diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 0000000..d20c0fe --- /dev/null +++ b/.codegraph/.gitignore @@ -0,0 +1,5 @@ +# CodeGraph data files — local to each machine, not for committing. +# Ignore everything in .codegraph/ except this file itself, so transient +# files (the database, daemon.pid, sockets, logs) never show up in git. +* +!.gitignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0aee37b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: test (py${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install project (dev extras) + run: uv pip install --system --python ${{ matrix.python-version }} -e ".[dev]" + + - name: Run tests + run: python -m pytest -q --cov=app --cov-report=term-missing + + lint: + name: lint (ruff) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + - name: Ruff check + run: uvx ruff check app benchmarks + + types: + # Non-blocking: HELIOS carries known typing debt outside the contracts/core + # boundary. This job surfaces regressions without gating merges. Remove + # `continue-on-error` once the debt is burned down. + name: types (mypy, informational) + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + - name: Set up Python + run: uv python install 3.11 + - name: Install project (dev extras) + run: uv pip install --system -e ".[dev]" + - name: Mypy + run: python -m mypy app diff --git a/.gitignore b/.gitignore index ad41998..e3089ec 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ data/orchestrator.db data/orchestrator.db-shm data/orchestrator.db-wal data/object_store/ +data/scientific_ledger/ .env # macOS @@ -36,6 +37,8 @@ docs/* !docs/adaptive_campaign_substrate.md !docs/development_progress.md !docs/agent_architecture.md +!docs/scientific_decision_ledger.md +!docs/scientific_evidence_loop.md !docs/plans/ docs/plans/* !docs/plans/2026-07-06-helios-experiment-planner-and-hybrid-switching-plan.md @@ -59,6 +62,9 @@ tests/**/__pycache__/ # Node modules node_modules/ +# Benchmark run artifacts (studies are reproducible from tracked configs) +/benchmark_results/ + # Runtime logs logs/ *.log @@ -98,3 +104,4 @@ ot2-nlp-agent/lab_automation/plugins/potentiostat/adapters/squidstat.py # git worktrees (isolated workspaces) .worktrees/ +.gstack/ diff --git a/README.md b/README.md index 354d599..394f696 100644 --- a/README.md +++ b/README.md @@ -53,29 +53,99 @@ The live path is conservative by design: rule-based, auditable, and bounded by e ## Core Capabilities - **Orchestrator-agnostic campaign decision layer** — keeps campaign-level decision authority separate from any downstream backend. -- **Context-aware policy** — uses objective hierarchy, proxy-gap state, failure attribution, backend memory, Nexus diagnostics, candidate/failure-zone memory, and bandit/learned-policy signals. +- **Context-aware policy** — uses objective hierarchy, proxy-gap state, failure attribution, backend memory, Nexus diagnostics, BO MCP availability, candidate/failure-zone memory, and bandit/learned-policy signals. - **Dynamic action vocabulary** — represents optimization, validation, calibration, failure diagnosis, context seeking, human observation, safety-constraint tightening, stopping, and future scale/fidelity choices. -- **Candidate and backend arbitration** — combines local baselines, Nexus signals, candidate pools, safety gates, and provenance into a traceable portfolio. +- **Candidate and backend arbitration** — combines local baselines, Nexus/BO MCP signals, candidate pools, safety gates, and provenance into a traceable portfolio. - **Failure-aware recovery** — separates scientific negative evidence from measurement, backend, constraint, and downstream tool failures. - **Trace, reward, and replay** — records `StrategyTrace`, `StrategyEvidence`, `StrategyOutcome`, `StrategyReward`, typed `FailureEvent`, and replay summaries. +- **Scientific evidence loop** — tracks falsifiable claims, updates posterior odds only from independent auditable likelihood ratios, ranks hypothesis-discrimination experiments by robust information gain, and blocks live promotion behind prospective evidence and explicit approval. +- **Scientific Decision Ledger** — projects every live campaign decision into deterministic, redacted Markdown Decision Cards with evidence, alternatives, outcome, reward, failures, recovery, policy/Nexus versions, exact-text memory search, and typed RLVR export. - **LLM boundary discipline** — LLMs can help translate intent, gather context, or generate review notes; they do not steer the live optimization loop. --- +## Scientific Decision Ledger + +HELIOS keeps two deliberately separate truths: + +- Typed DTOs and SQLite rows are the transactional runtime truth. +- Markdown and optional campaign-local Git are the readable, reviewable, auditable scientific truth. + +A decision is written before execution with `Outcome: Pending`, then finalized in place after analysis with its observed outcome, deterministic verifier scores, reward, failures, and recovery episode. A campaign is projected under `data/scientific_ledger/campaigns//`: + +```text +campaign.md # objective, metadata, decision index +index.md # navigable artifact index +summary.md # aggregate decisions and rewards +trajectory.md # Mermaid decision trajectory +policy.md # current decision policy snapshot +policy_versions/.md # immutable first snapshot per policy version +nexus.md # Nexus contract/version and diagnostics +training_dataset.md # human-reviewable RLVR projection +evidence/ + index.md # scientific claims and discrimination plans + claims/.md # posterior, falsifiers, evidence, promotion gate + plans/.md # robust information-gain ranking for review +rounds/001/ + objective.md + observations.md + decision_001.md + strategy.md + evidence.md + failure.md + recovery.md + summary.md +``` + +Decision Cards contain the question, scientific context, evidence, ranked candidate actions, selected action/backend, rationale, confidence/expected gain, outcome, reward/verifiers, failure/recovery counts, and reproducibility provenance. Values are deterministically rendered and recursively redacted before they reach Markdown. + +The read-only API exposes: + +- `GET /api/v1/memory/scientific/search?q=pipette%20offset` +- `GET /api/v1/memory/scientific/{campaign_id}/artifact?path=rounds/001/decision_001.md` +- `GET /api/v1/memory/scientific/{campaign_id}/rlvr` + +RLVR JSONL is generated from the typed `decision_trajectories` store, not by scraping Markdown. Optional Git history is one repository per campaign, stages exact Markdown paths only, and never pushes or modifies the HELIOS source repository. See [Scientific Decision Ledger](docs/scientific_decision_ledger.md) for lifecycle, schemas, safety properties, and operations. + +The scientific evidence loop is deliberately separate from the operational reward loop. A successful execution does not increase a scientific claim posterior. Only evidence carrying an auditable likelihood ratio can do that; descriptive evidence remains visible without being numerically counted. See [Scientific Evidence Loop](docs/scientific_evidence_loop.md). + +--- + ## Architecture | Surface | Responsibility | Representative modules | |---------|----------------|------------------------| | **Contract and context** | Typed campaign goal, objectives, constraints, budget, safety, and round context | `app/contracts/`, `app/services/round_context.py`, `app/services/objective_state.py` | | **Campaign policy** | Decide next campaign-level action and strategy mode | `app/services/strategy_selector.py`, `app/services/strategy_actions.py`, `app/services/decision_layer.py` | -| **Evidence and memory** | Attach diagnostics, prior-campaign evidence, failure history, and backend memory | `app/services/decision_trace.py`, `app/services/backend_memory.py`, `app/optimization/candidate_memory.py`, `app/optimization/failure_zone_memory.py` | +| **Evidence and memory** | Track scientific claims/posteriors, discrimination plans, diagnostics, prior-campaign evidence, failure history, and backend memory | `app/services/scientific_evidence.py`, `app/services/hypothesis_experiment_planner.py`, `app/services/scientific_ledger.py`, `app/services/backend_memory.py` | | **Candidate/backend arbitration** | Build, gate, score, and explain candidate/backend choices | `app/optimization/service.py`, `app/optimization/pool_service.py`, `app/optimization/decision_policy.py`, `app/optimization/provenance.py` | | **Adaptive substrate** | Shadow-only scientific activity mode, dynamic action space, and value-of-information assessment | `app/services/adaptive_campaign_substrate.py`, `app/services/campaign_mode.py`, `app/services/dynamic_action_space.py`, `app/services/value_of_information.py` | | **Outcome and replay** | Evaluate decision quality, reward components, and replay summaries | `app/services/decision_outcome.py`, `app/services/verifiable_reward.py`, `app/services/decision_replay.py`, `app/services/policy_evaluation.py` | ### Adaptive Campaign Decision Layer -HELIOS uses scientific context, objective hierarchy, typed failure attribution, backend performance memory, candidate/failure-zone memory, Nexus diagnostics and bandit/learned-policy signals to decide which campaign-level action should happen next. Today that includes `CampaignIntent`, `OptimizationMode`, and candidate-generation backend selection; the same layer owns validation, failure-aware recovery, context acquisition, human/LLM query, dynamic objective/constraint handling, and future scale/fidelity-aware decisions. +HELIOS uses scientific context, objective hierarchy, typed failure attribution, backend performance memory, candidate/failure-zone memory, Nexus diagnostics, BO MCP availability, and bandit/learned-policy signals to decide which campaign-level action should happen next. Today that includes `CampaignIntent`, `OptimizationMode`, and candidate-generation backend selection; the same layer owns validation, failure-aware recovery, context acquisition, human/LLM query, dynamic objective/constraint handling, and future scale/fidelity-aware decisions. + +The default runtime still records contextual campaign decisions in shadow mode. +When `CAMPAIGN_DECISION_AUTHORITY_ENABLED=true`, the orchestrator promotes the +decision envelope into a bounded pre-candidate gate: `STOP_CAMPAIGN` terminates +before more candidates, while validation, recovery, context, objective, and +constraint actions defer the current round, persist the requested campaign +state update, and leave candidate generation untouched for later rounds. The +gate never executes hardware or auto-applies objective/space changes. + +### Experimental-node active learning + +For campaigns whose alternatives are materially different experimental nodes +(for example, different synthesis routes), `experimental_route_graph` declares +the nodes, transitions, execution mapping, capability requirements, cost, and +safety metadata. Nexus `/api/experimental-routes/analyze` supplies versioned, +`advisory_only` evidence. HELIOS then scores every reachable option and enforces +local capability, safety, budget, operator-approval, and executable-protocol +gates. `NEXUS_EXPERIMENTAL_ROUTES_ENABLED` enables characterization in shadow; +the separate `EXPERIMENTAL_ROUTE_AUTHORITY_ENABLED` gate is required to change +the live node. Each decision and route-labelled outcome is checkpointed in the +campaign context and included in the Scientific Decision Ledger trajectory. ### Optimization Code Map @@ -141,14 +211,26 @@ pytest \ | `LLM_PROVIDER` | `mock` | LLM provider for language/knowledge-boundary tasks only | | `LLM_MODEL` | provider default | Model ID passed to the configured provider | | `CONTEXTUAL_DECISION_SHADOW_ENABLED` | `false` | Record the legacy contextual decision shadow trace per round | +| `CAMPAIGN_DECISION_AUTHORITY_ENABLED` | `false` | Promote contextual campaign decisions into a bounded live pre-candidate gate | | `ADAPTIVE_SUBSTRATE_SHADOW_ENABLED` | `false` | Record the adaptive campaign substrate shadow snapshot per round | | `ENABLE_CANDIDATE_ARBITRATION` | `false` | Enable deep candidate-pool arbitration instead of legacy generation fallback | +| `NEXUS_EXPERIMENTAL_ROUTES_ENABLED` | `false` | Request advisory experimental-route characterization from Nexus each round | +| `EXPERIMENTAL_ROUTE_AUTHORITY_ENABLED` | `false` | Allow HELIOS to apply a route selected by its local safety/budget/approval policy | +| `NEXUS_URL` | `http://localhost:8000/api` | Base URL for optional Nexus REST advisory endpoints | +| `NEXUS_API_KEY` | empty | Optional `X-API-Key` sent to Nexus REST endpoints | +| `NEXUS_TIMEOUT_SECONDS` | `10` | Nexus REST request timeout | +| `SCIENTIFIC_LEDGER_ENABLED` | `true` | Persist live Decision Cards and typed outcome/reward accounting; fail-open with respect to campaign routing | +| `SCIENTIFIC_LEDGER_ROOT` | `data/scientific_ledger` | Root for campaign Markdown artifacts | +| `SCIENTIFIC_LEDGER_GIT_ENABLED` | `false` | Commit changed Markdown artifacts to each campaign's local Git repository | +| `SCIENTIFIC_LEDGER_GIT_AUTO_INIT` | `true` | Initialize a missing campaign-local repository when Git recording is enabled | +| `SCIENTIFIC_LEDGER_GIT_AUTHOR_NAME` | `HELIOS Scientific Ledger` | Local ledger commit author name | +| `SCIENTIFIC_LEDGER_GIT_AUTHOR_EMAIL` | `helios-ledger@localhost` | Local ledger commit author email | --- ## Validation Evidence -HELIOS is framed as an orchestrator-agnostic adaptive campaign decision layer. The product boundary is campaign-level decision authority rather than ownership of downstream automation or presentation surfaces. The live campaign policy remains rule-based and auditable by default; Nexus is optimization advisor/backend/tool paths, not campaign decision authorities. Learned policy and self-evolution paths are offline, shadow, canary, and approval-gated. +HELIOS is framed as an orchestrator-agnostic adaptive campaign decision layer. The product boundary is campaign-level decision authority rather than ownership of downstream automation or presentation surfaces. The live campaign policy remains rule-based and auditable by default; Nexus and BO MCP are optimization advisor/backend/tool paths, not campaign decision authorities. Learned policy and self-evolution paths are offline, shadow, canary, and approval-gated; their metadata does not change default BO MCP/Nexus/backend behavior. The architecture validation report is version-controlled at [docs/HELIOS_ARCHITECTURE_VALIDATION.md](docs/HELIOS_ARCHITECTURE_VALIDATION.md). It is a static evidence pack for the current validation boundary. @@ -188,6 +270,7 @@ HELIOS/ ├── docs/ │ ├── HELIOS_ARCHITECTURE_VALIDATION.md │ ├── adaptive_campaign_substrate.md +│ ├── scientific_decision_ledger.md │ └── development_progress.md ├── tests/ # Pytest coverage for policy, replay, validation, and evidence layers ├── benchmarks/ # Offline method and policy evaluation harnesses @@ -198,6 +281,25 @@ HELIOS/ --- +## External Decision Inputs + +| Integration | Role in HELIOS | +|-------------|----------------| +| **Nexus** | Optimization diagnostics, profiling, and backend/candidate evidence | +| **BO MCP / Ax / local BO** | Optimization backend signals and candidate proposals | +| **Anthropic / OpenAI** | Language/knowledge-boundary tasks such as intent parsing, context requests, and review notes | +| **Campaign memory** | Similar-campaign priors, backend history, failure zones, and replay evidence | + +--- + +## Contributing + +1. Keep decision authority explicit: backends advise, HELIOS decides. +2. Preserve typed traces, evidence, outcomes, rewards, and replay records for every new decision path. +3. Keep learned policies gated by replay, shadow/canary evidence, and explicit promotion controls. +4. Add tests beside changes to policy, arbitration, reward, replay, or validation logic. + +--- ## License diff --git a/app/agents/orchestrator.py b/app/agents/orchestrator.py index 0f941e8..b3c0bbf 100644 --- a/app/agents/orchestrator.py +++ b/app/agents/orchestrator.py @@ -12,6 +12,7 @@ import asyncio import json import logging +import time import uuid from typing import Any @@ -30,6 +31,10 @@ failure_attribution_from_events, objective_state_from_input, ) +from app.services.campaign_decision_authority import ( + CampaignAuthorityVerdict, + evaluate_campaign_decision_authority, +) from app.services.decision_layer import CampaignDecisionLayer from app.services.decision_trace import CampaignDecisionTraceBuilder from app.services.primitives_registry import get_registry @@ -43,6 +48,23 @@ _ADAPTIVE_SUBSTRATE_ACTION_CAP = 24 +class RecoveryExecutionError(RuntimeError): + """Terminal recovery outcome carrying its audit episode across layers.""" + + def __init__( + self, + message: str, + *, + episode: dict[str, Any] | None = None, + failure_type: str = "recovery_abort", + chemical_safety: bool = False, + ) -> None: + super().__init__(message) + self.episode = dict(episode or {}) + self.failure_type = failure_type + self.chemical_safety = chemical_safety + + def _maybe_record_contextual_shadow_decision( *, campaign_id: str, @@ -66,7 +88,13 @@ def _maybe_record_contextual_shadow_decision( ) -> Any | None: """Record a contextual decision trace without affecting live routing.""" try: - if not get_settings().contextual_decision_shadow_enabled: + settings = get_settings() + shadow_enabled = getattr(settings, "contextual_decision_shadow_enabled", False) + ledger_enabled = getattr(settings, "scientific_ledger_enabled", False) + authority_enabled = getattr( + settings, "campaign_decision_authority_enabled", False + ) + if not shadow_enabled and not ledger_enabled and not authority_enabled: return None context = build_campaign_round_context( @@ -95,10 +123,11 @@ def _maybe_record_contextual_shadow_decision( actual_action=actual_action or "propose_candidates", metadata=metadata, ) - logger.info( - "contextual_shadow_decision_trace %s", - json.dumps(trace.model_dump(mode="json"), sort_keys=True), - ) + if shadow_enabled: + logger.info( + "contextual_shadow_decision_trace %s", + json.dumps(trace.model_dump(mode="json"), sort_keys=True), + ) return trace except Exception: logger.warning( @@ -108,6 +137,284 @@ def _maybe_record_contextual_shadow_decision( return None +def _maybe_record_pending_scientific_decision( + trace: Any | None, + *, + campaign_metadata: dict[str, Any] | None = None, + policy_snapshot: dict[str, Any] | None = None, +) -> Any | None: + """Best-effort pre-execution Decision Card projection.""" + if trace is None: + return None + try: + from app.services.scientific_ledger_runtime import ( + record_pending_scientific_decision, + ) + + return record_pending_scientific_decision( + trace, + campaign_metadata=campaign_metadata, + policy_snapshot=policy_snapshot, + ) + except Exception: + logger.warning( + "Scientific ledger pending-card hook failed; continuing live campaign", + exc_info=True, + ) + return None + + +def _maybe_finalize_scientific_decision( + trace: Any | None, + **outcome: Any, +) -> Any | None: + """Best-effort live Trace -> Outcome -> Reward -> Markdown closure.""" + if trace is None: + return None + try: + from app.services.scientific_ledger_runtime import finalize_scientific_decision + + return finalize_scientific_decision(trace, **outcome) + except Exception: + logger.warning( + "Scientific ledger accounting hook failed; continuing live campaign", + exc_info=True, + ) + return None + + +def _scientific_policy_snapshot( + strategy_trace: dict[str, Any] | None, + runtime_policy: dict[str, Any] | None, +) -> dict[str, Any]: + """Keep policy identity/governance separate from per-round scientific state.""" + trace = dict(strategy_trace or {}) + return { + "action_policy": trace.get("action_policy"), + "bandit_decision": trace.get("bandit_decision"), + "learned_policy_shadow": trace.get("learned_policy_shadow"), + "learned_policy_influence": trace.get("learned_policy_influence"), + "ranking_influences": trace.get("ranking_influences", []), + "runtime_policy_snapshot": dict(runtime_policy or {}), + } + + +def _recovery_events_from_steps(steps: list[dict[str, Any]]) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + seen: set[str] = set() + for step in steps: + if not isinstance(step, dict): + continue + episode = step.get("recovery_episode") + if not isinstance(episode, dict): + continue + episode_id = str(episode.get("episode_id") or "") + if episode_id and episode_id in seen: + continue + if episode_id: + seen.add(episode_id) + events.append(dict(episode)) + return events + + +def _planned_strategy_decision(strategy: str) -> dict[str, Any]: + """Describe a planner-selected route when no adaptive selector ran yet.""" + normalized = str(strategy or "adaptive") + if normalized in {"lhs", "random", "grid"}: + mode = "explore" + elif normalized == "prior_guided": + mode = "warm_start" + elif normalized in {"bayesian", "bo", "bo_mcp"}: + mode = "exploit" + else: + mode = "refine" + reason = "Campaign plan selected this candidate-generation route." + return { + "campaign_intent": "optimize", + "optimization_mode": mode, + "candidate_generation_backend": normalized, + "backend": normalized, + "phase": "planned", + "reason": reason, + "strategy_trace": { + "selected_intent": "optimize", + "selected_mode": mode, + "selected_backend": normalized, + "reason": reason, + }, + } + + +# Map the round's design strategy to a campaign mode for phase-aware rubric +# weighting (Phase B). Exploration-flavoured strategies value information; +# exploitation-flavoured ones value objective improvement. +_STRATEGY_TO_MODE = { + "explore": "calibration", + "lhs": "calibration", + "random": "calibration", + "prior_guided": "calibration", + "exploit": "bo_optimization", + "bayesian": "bo_optimization", + "bo_mcp": "bo_optimization", + "adaptive": "bo_optimization", +} + + +def _maybe_emit_verifiable_reward( + *, + emit, + campaign_id: str, + round_num: int, + round_strategy: str, + direction: str, + objective_kpi: str, + best_kpi: float | None, + prev_best: float | None, + round_batch_kpis: list, + dimensions: list, + max_rounds: int, +) -> None: + """Score the round with the verifiable reward, persist the trajectory, and + emit it to /lab — the inner evaluation loop made visible (Phases A/B/C). + + Best-effort: any failure is logged and the campaign continues untouched. + """ + try: + from app.services.campaign_mode import CampaignMode + from app.services.decision_trajectory import persist_loop_trajectory + from app.services.loop_engineering import LoopOutcome, calculate_loop_reward + from app.services.rubric import rescore, rubric_for_mode + + kpis = list(round_batch_kpis or []) + execution_success = any(k is not None for k in kpis) if kpis else None + failure_count = sum(1 for k in kpis if k is None) + if prev_best is None or best_kpi is None: + objective_delta: float | None = None + elif direction == "maximize": + objective_delta = best_kpi - prev_best + else: + objective_delta = prev_best - best_kpi + + outcome = LoopOutcome( + execution_success=execution_success, + objective_delta=objective_delta, + failure_count=failure_count, + ) + reward = calculate_loop_reward( + iteration_id=f"{campaign_id}-r{round_num}", outcome=outcome + ) + + # B: re-score under the round's phase-aware rubric. + mode_value = _STRATEGY_TO_MODE.get(round_strategy, "bo_optimization") + phase_rubric = rubric_for_mode(CampaignMode(mode_value)) + phase = rescore(reward.verifications, phase_rubric) + + try: + persist_loop_trajectory( + campaign_id, round_num, reward, + state={"strategy": round_strategy, "best_kpi": best_kpi}, + ) + except Exception: + logger.debug("trajectory persist skipped", exc_info=True) + + emit({ + "type": "decision_reward", + "round": round_num, + "reward": reward.reward, + "process_reward": reward.process_reward, + "outcome_reward": reward.outcome_reward, + "rubric_version": reward.rubric_version, + "phase_rubric_version": phase.rubric_version, + "phase_reward": phase.total, + "verifications": [ + {"name": v.name, "passed": v.passed, "score": v.score} + for v in reward.verifications + ], + "message": ( + f"reward {reward.reward} (process {reward.process_reward} / " + f"outcome {reward.outcome_reward}); phase[{mode_value}] {phase.total}" + ), + }) + + # C: plateau → propose reframing the objective / widening the space. + plateaued = objective_delta is not None and abs(objective_delta) < 1e-9 + if plateaued and round_num >= 2: + _maybe_emit_space_proposals( + emit=emit, campaign_id=campaign_id, round_num=round_num, + direction=direction, objective_kpi=objective_kpi, + dimensions=dimensions, max_rounds=max_rounds, + ) + except Exception: + logger.warning( + "Verifiable-reward hook failed; continuing live campaign", + exc_info=True, + ) + + +def _maybe_emit_space_proposals( + *, emit, campaign_id, round_num, direction, objective_kpi, dimensions, max_rounds +) -> None: + """Run the space-evolution advisor on a plateau and emit gated proposals.""" + try: + from app.contracts.task_contract import ( + DimensionDef, + ExplorationSpace, + HumanGatePolicy, + ObjectiveSpec, + SafetyEnvelope, + StopCondition, + TaskContract, + ) + from app.core.db import utcnow_iso + from app.services.space_evolution import SpaceEvolutionAdvisor + from app.services.space_overlay import review_space_change + + dims = [] + for d in dimensions or []: + name = d.get("name") or d.get("param_name") + if not name: + continue + dims.append(DimensionDef( + param_name=name, + param_type=d.get("param_type", "number"), + min_value=d.get("min_value", d.get("min")), + max_value=d.get("max_value", d.get("max")), + choices=d.get("choices"), + )) + if not dims: + return + contract = TaskContract( + contract_id=f"{campaign_id}-live", + created_at=utcnow_iso(), + created_by="orchestrator", + objective=ObjectiveSpec( + objective_type="single", primary_kpi=objective_kpi, direction=direction, + ), + exploration_space=ExplorationSpace(dimensions=dims), + stop_conditions=StopCondition(max_rounds=max_rounds), + safety_envelope=SafetyEnvelope(), + human_gate=HumanGatePolicy(), + protocol_pattern_id="live", + ) + proposals = SpaceEvolutionAdvisor().propose( + {"plateaued": True, "confidence": 0.75}, contract + ) + for p in proposals: + verdict = review_space_change(p, contract) + emit({ + "type": "space_proposal", + "round": round_num, + "proposal_id": p.proposal_id, + "reason": p.reason, + "confidence": p.confidence, + "verdict": verdict.status, + "verdict_reason": verdict.reason, + "message": f"space proposal [{verdict.status}]: {p.reason}", + }) + except Exception: + logger.debug("space-proposal hook skipped", exc_info=True) + + def _maybe_record_adaptive_campaign_substrate_snapshot( *, campaign_id: str, @@ -197,6 +504,12 @@ class OrchestratorInput(BaseModel): dimensions: list[dict[str, Any]] protocol_template: dict[str, Any] + # Optional graph of materially different experimental approaches. Nexus + # characterizes its evidence; HELIOS remains the only route-selection + # authority and maps the selected node to executable campaign inputs. + experimental_route_graph: dict[str, Any] = Field(default_factory=dict) + available_capabilities: list[str] | None = None + # Safety policy_snapshot: dict[str, Any] = Field(default_factory=dict) @@ -375,7 +688,17 @@ def _build_campaign_context(input_data: OrchestratorInput) -> dict[str, Any]: "batch_size": input_data.batch_size, }, "human_preferences": dict(input_data.policy_snapshot or {}), - "synthesis_routes": [], + "synthesis_routes": list( + (input_data.experimental_route_graph or {}).get("nodes", []) or [] + ), + "experimental_route_graph": dict( + input_data.experimental_route_graph or {} + ), + "active_experimental_node_id": ( + (input_data.experimental_route_graph or {}).get("active_node_id") + ), + "experimental_route_observations": [], + "experimental_route_decisions": [], "domain_hypotheses": [], "literature_priors": [], "human_observations": [], @@ -644,6 +967,7 @@ async def process( # --- Checkpoint: create campaign in DB --- from app.services.campaign_state import ( append_failure_event, + append_objective_transition, append_space_revision, checkpoint_kpi, complete_candidate, @@ -651,6 +975,7 @@ async def process( create_campaign, get_completed_rounds, is_candidate_done, + save_campaign_context, save_plan, start_candidate, start_round, @@ -673,6 +998,58 @@ def _note_failure_event(event: dict[str, Any]) -> None: except Exception: logger.debug("Failed to persist failure event", exc_info=True) + def _record_experimental_route_observation( + *, + round_number: int, + candidate_index: int, + parameters: dict[str, Any], + kpi: float | None = None, + qc_passed: bool = True, + is_failure: bool = False, + failure_reason: str | None = None, + run_id: str | None = None, + ) -> None: + """Append a route-labelled Nexus observation and checkpoint it.""" + node_id = campaign_context_dict.get("active_experimental_node_id") + if not node_id: + return + observation = { + "iteration": round_number, + "parameters": dict(parameters), + "kpi_values": ( + {input_data.objective_kpi: float(kpi)} if kpi is not None else {} + ), + "qc_passed": qc_passed, + "is_failure": is_failure, + "failure_reason": failure_reason, + "timestamp": time.time(), + "metadata": { + "experimental_node_id": str(node_id), + "round_number": round_number, + "candidate_index": candidate_index, + "run_id": run_id, + }, + } + from app.services.nexus_experimental_routes import ( + MAX_EXPERIMENTAL_ROUTE_OBSERVATIONS, + ) + + route_observations = campaign_context_dict.setdefault( + "experimental_route_observations", [] + ) + route_observations.append(observation) + if len(route_observations) > MAX_EXPERIMENTAL_ROUTE_OBSERVATIONS: + del route_observations[ + :-MAX_EXPERIMENTAL_ROUTE_OBSERVATIONS + ] + try: + save_campaign_context(campaign_id, campaign_context_dict) + except Exception: + logger.debug( + "Failed to checkpoint experimental-route observation", + exc_info=True, + ) + self._emit(campaign_id, { "type": "campaign_start", "campaign_id": campaign_id, @@ -998,6 +1375,8 @@ def _note_failure_event(event: dict[str, Any]) -> None: # The allocator tracks which destination well each (round, candidate) # pair is assigned to, preventing double-use across rounds. well_allocator = None + _well_allocator_route_id = "__campaign_default__" + _well_allocators_by_route: dict[str, Any] = {} try: _deck = plan_deck_layout( protocol_steps=input_data.protocol_template.get("steps", []), @@ -1005,6 +1384,7 @@ def _note_failure_event(event: dict[str, Any]) -> None: ) well_allocator = create_well_allocator_from_deck_plan(_deck, role="destination") if well_allocator: + _well_allocators_by_route[_well_allocator_route_id] = well_allocator self._emit(campaign_id, { "type": "well_allocator_init", "labware": well_allocator.labware_name, @@ -1026,6 +1406,317 @@ def _note_failure_event(event: dict[str, Any]) -> None: logger.info("Skipping completed round %d on resume", round_num) continue + # Resolve this round's experimental node before strategy selection. + # Nexus contributes evidence only; HELIOS performs all eligibility, + # approval, and live-authority checks locally. + round_dimensions = [dict(item) for item in input_data.dimensions] + round_protocol_template = dict(input_data.protocol_template or {}) + round_protocol_pattern_id = input_data.protocol_pattern_id + experimental_route_decision_payload: dict[str, Any] = {} + route_graph = dict( + (campaign_context_dict or {}).get("experimental_route_graph") + or input_data.experimental_route_graph + or {} + ) + active_experimental_node_id = ( + (campaign_context_dict or {}).get("active_experimental_node_id") + or route_graph.get("active_node_id") + ) + if active_experimental_node_id: + route_graph["active_node_id"] = active_experimental_node_id + + if route_graph.get("nodes"): + try: + from app.services.experimental_route_policy import ( + resolve_experimental_route_runtime, + select_experimental_route, + ) + from app.services.nexus_experimental_routes import ( + NexusExperimentalRouteClient, + build_experimental_route_payload, + ) + + _nodes_by_id = { + str(node.get("node_id")): node + for node in route_graph.get("nodes", []) or [] + if isinstance(node, dict) and node.get("node_id") + } + _active_node = _nodes_by_id.get( + str(active_experimental_node_id), {} + ) + _active_runtime = resolve_experimental_route_runtime( + node=_active_node, + is_current=True, + campaign_dimensions=input_data.dimensions, + campaign_protocol_template=input_data.protocol_template, + campaign_protocol_pattern_id=input_data.protocol_pattern_id, + ) + if active_experimental_node_id and _active_runtime is None: + _invalid_route_reason = ( + "Active experimental node has no executable HELIOS " + "parameter/protocol mapping." + ) + experimental_route_decision_payload = { + "round": round_num, + "active_node_id": active_experimental_node_id, + "selected_node_id": None, + "authority_enabled": get_settings().experimental_route_authority_enabled, + "execution_allowed": False, + "applied": False, + "changed": False, + "reason": _invalid_route_reason, + } + campaign_context_dict.setdefault( + "experimental_route_decisions", [] + ).append(experimental_route_decision_payload) + save_campaign_context(campaign_id, campaign_context_dict) + update_campaign_status( + campaign_id, + "failed", + error=_invalid_route_reason, + ) + self._emit(campaign_id, { + "type": "experimental_route_execution_blocked", + **experimental_route_decision_payload, + "message": _invalid_route_reason, + }) + return OrchestratorOutput( + campaign_id=campaign_id, + status="failed", + plan_summary=plan_summary, + rounds_completed=round_num - 1, + best_kpi=best_kpi, + stop_reason="experimental_route_execution_blocked", + agent_trace=agent_trace, + errors=[_invalid_route_reason], + ) + if _active_runtime is not None: + round_dimensions = [ + dict(item) for item in _active_runtime.dimensions + ] + round_protocol_template = dict( + _active_runtime.protocol_template + ) + round_protocol_pattern_id = ( + _active_runtime.protocol_pattern_id + ) + + _route_settings = get_settings() + if _route_settings.nexus_experimental_routes_enabled: + _route_payload = build_experimental_route_payload( + campaign_id=campaign_id, + graph=route_graph, + observations=list( + (campaign_context_dict or {}).get( + "experimental_route_observations", [] + ) + or [] + ), + objective=input_data.objective_kpi, + direction=input_data.direction, + available_capabilities=input_data.available_capabilities, + ) + _nexus_route_response = await asyncio.to_thread( + NexusExperimentalRouteClient().analyze, + _route_payload, + ) + if _nexus_route_response.ok and _nexus_route_response.report: + _route_decision = select_experimental_route( + report=_nexus_route_response.report, + execution_graph=route_graph, + campaign_dimensions=input_data.dimensions, + campaign_protocol_template=input_data.protocol_template, + campaign_protocol_pattern_id=input_data.protocol_pattern_id, + direction=input_data.direction, + authority_enabled=( + _route_settings.experimental_route_authority_enabled + ), + available_capabilities=( + input_data.available_capabilities + ), + policy_snapshot=input_data.policy_snapshot, + ) + experimental_route_decision_payload = ( + _route_decision.to_dict() + ) + experimental_route_decision_payload["round"] = round_num + experimental_route_decision_payload[ + "nexus_report_confidence" + ] = _nexus_route_response.report.get("confidence") + experimental_route_decision_payload[ + "nexus_risk_flags" + ] = list( + _nexus_route_response.report.get("risk_flags", []) + or [] + ) + campaign_context_dict["nexus_experimental_route_report"] = dict( + _nexus_route_response.report + ) + if _route_decision.applied: + _selected = _route_decision.selected_option + if _selected is not None and _selected.runtime is not None: + active_experimental_node_id = _selected.node_id + route_graph["active_node_id"] = _selected.node_id + round_dimensions = [ + dict(item) + for item in _selected.runtime.dimensions + ] + round_protocol_template = dict( + _selected.runtime.protocol_template + ) + round_protocol_pattern_id = ( + _selected.runtime.protocol_pattern_id + ) + else: + experimental_route_decision_payload = { + "round": round_num, + "active_node_id": active_experimental_node_id, + "selected_node_id": active_experimental_node_id, + "authority_enabled": ( + _route_settings.experimental_route_authority_enabled + ), + "applied": False, + "changed": False, + "reason": ( + "Nexus route characterization unavailable; " + "HELIOS retained the current route." + ), + "error_type": ( + _nexus_route_response.error_type.value + if _nexus_route_response.error_type + else None + ), + "error_message": _nexus_route_response.error_message, + } + else: + experimental_route_decision_payload = { + "round": round_num, + "active_node_id": active_experimental_node_id, + "selected_node_id": active_experimental_node_id, + "authority_enabled": ( + _route_settings.experimental_route_authority_enabled + ), + "applied": False, + "changed": False, + "reason": "Nexus experimental-route characterization is disabled.", + } + + campaign_context_dict["experimental_route_graph"] = route_graph + campaign_context_dict["active_experimental_node_id"] = ( + active_experimental_node_id + ) + campaign_context_dict.setdefault( + "experimental_route_decisions", [] + ).append(experimental_route_decision_payload) + save_campaign_context(campaign_id, campaign_context_dict) + self._emit(campaign_id, { + "type": "experimental_route_decision", + **experimental_route_decision_payload, + "message": experimental_route_decision_payload.get("reason"), + }) + + if ( + experimental_route_decision_payload.get("execution_allowed") + is False + and "error_type" not in experimental_route_decision_payload + and _route_settings.nexus_experimental_routes_enabled + ): + _blocked_reason = str( + experimental_route_decision_payload.get("reason") + or "No experimental route passed HELIOS execution gates." + ) + update_campaign_status( + campaign_id, + "failed", + error=_blocked_reason, + ) + self._emit(campaign_id, { + "type": "experimental_route_execution_blocked", + **experimental_route_decision_payload, + "message": _blocked_reason, + }) + return OrchestratorOutput( + campaign_id=campaign_id, + status="failed", + plan_summary=plan_summary, + rounds_completed=round_num - 1, + best_kpi=best_kpi, + stop_reason="experimental_route_execution_blocked", + agent_trace=agent_trace, + errors=[_blocked_reason], + ) + + # A route may use a different deck. Start a separate + # allocator when the active node changes; same-route rounds + # continue sharing the allocator to prevent well reuse. + _route_allocator_id = str( + active_experimental_node_id or "__campaign_default__" + ) + if _route_allocator_id != _well_allocator_route_id: + if _route_allocator_id in _well_allocators_by_route: + well_allocator = _well_allocators_by_route[ + _route_allocator_id + ] + _well_allocator_route_id = _route_allocator_id + else: + try: + _route_deck = plan_deck_layout( + protocol_steps=round_protocol_template.get( + "steps", [] + ), + batch_size=input_data.batch_size, + ) + well_allocator = create_well_allocator_from_deck_plan( + _route_deck, role="destination" + ) + _well_allocator_route_id = _route_allocator_id + if well_allocator is not None: + _well_allocators_by_route[ + _route_allocator_id + ] = well_allocator + except Exception: + logger.debug( + "Could not initialise route-specific well allocator", + exc_info=True, + ) + except Exception: + logger.warning( + "Experimental-route policy failed; retaining current route", + exc_info=True, + ) + experimental_route_decision_payload = { + "round": round_num, + "active_node_id": active_experimental_node_id, + "selected_node_id": active_experimental_node_id, + "authority_enabled": get_settings().experimental_route_authority_enabled, + "applied": False, + "changed": False, + "reason": ( + "Experimental-route policy raised an internal error; " + "HELIOS retained the current route." + ), + "error_type": "internal_error", + } + campaign_context_dict.setdefault( + "experimental_route_decisions", [] + ).append(experimental_route_decision_payload) + try: + save_campaign_context(campaign_id, campaign_context_dict) + except Exception: + logger.debug( + "Failed to checkpoint route-policy fallback", + exc_info=True, + ) + self._emit(campaign_id, { + "type": "experimental_route_decision", + **experimental_route_decision_payload, + "message": experimental_route_decision_payload["reason"], + }) + + # Bound the cumulative failure/step histories to this decision card. + _round_failure_start = len(failure_event_dicts) + _round_step_history_start = len(step_history) + self._emit(campaign_id, { "type": "round_start", "round": round_num, @@ -1034,11 +1725,15 @@ def _note_failure_event(event: dict[str, Any]) -> None: "message": f"Starting round {round_num}/{len(plan.planned_rounds)} (strategy: {planned_round.strategy})", }) + # Running best before this round runs — lets the verifiable-reward + # hook (below) measure this round's improvement (0 == plateau). + _best_kpi_at_round_start = best_kpi + # 2a. Design parameters — if "adaptive", re-select strategy # using real-time KPI history AND batch-level data for # data-driven switching. round_strategy = planned_round.strategy - strategy_decision_info: dict[str, Any] = {} + strategy_decision_info = _planned_strategy_decision(round_strategy) strategy_decision: Any = None # holds StrategyDecision if adaptive if round_strategy == "adaptive" and kpi_history: @@ -1059,14 +1754,14 @@ def _note_failure_event(event: dict[str, Any]) -> None: round_number=round_num, max_rounds=input_data.max_rounds, n_observations=total_runs, - n_dimensions=len(input_data.dimensions), + n_dimensions=len(round_dimensions), has_categorical=any( d.get("choices") is not None - for d in input_data.dimensions + for d in round_dimensions ), has_log_scale=any( d.get("log_scale", False) - for d in input_data.dimensions + for d in round_dimensions ), kpi_history=tuple(kpi_history), direction=input_data.direction, @@ -1123,14 +1818,22 @@ def _note_failure_event(event: dict[str, Any]) -> None: if round_strategy not in _BACKEND_PASSTHROUGH: round_strategy = "adaptive" # use adaptive path in candidate_gen + _strategy_trace_payload = strategy_trace_to_dict( + decision.strategy_trace + ) strategy_decision_info = { + "campaign_intent": _strategy_trace_payload.get( + "selected_intent" + ), + "optimization_mode": _strategy_trace_payload.get( + "selected_mode" + ), + "candidate_generation_backend": decision.backend_name, "backend": decision.backend_name, "phase": decision.phase, "reason": decision.reason, "confidence": decision.confidence, - "strategy_trace": strategy_trace_to_dict( - decision.strategy_trace - ), + "strategy_trace": _strategy_trace_payload, } latest_strategy_trace = strategy_decision_info.get( "strategy_trace" @@ -1242,11 +1945,11 @@ def _note_failure_event(event: dict[str, Any]) -> None: exc_info=True, ) - _maybe_record_contextual_shadow_decision( - campaign_id=campaign_id, - round_index=round_num, - strategy_selection_result=strategy_decision_info, - failure_summary={ + decision_context_kwargs = { + "campaign_id": campaign_id, + "round_index": round_num, + "strategy_selection_result": strategy_decision_info, + "failure_summary": { "events": list(failure_event_dicts), "requires_recovery": any( event.get("failure_type") in {"hardware", "backend"} @@ -1254,36 +1957,94 @@ def _note_failure_event(event: dict[str, Any]) -> None: if isinstance(event, dict) ), }, - safety_summary=dict(input_data.policy_snapshot or {}), - objective_summary={ + "safety_summary": dict(input_data.policy_snapshot or {}), + "objective_summary": { "objective_kpi": input_data.objective_kpi, "direction": input_data.direction, "target_value": input_data.target_value, "max_rounds": input_data.max_rounds, }, - constraint_summary={ - "dimensions": list(input_data.dimensions), + "constraint_summary": { + "dimensions": list(round_dimensions), "policy_snapshot": dict(input_data.policy_snapshot or {}), }, - backend_memory_summary=dict(backend_performance_records), - bo_mcp_summary={ + "backend_memory_summary": dict(backend_performance_records), + "bo_mcp_summary": { "backend_state": bomcp_backend_state, }, - human_observations=list( + "nexus_diagnostics": dict( + (campaign_context_dict or {}).get("nexus_diagnostics") + or (campaign_context_dict or {}).get("nexus_early_stage_report") + or {} + ), + "learning_policy_summary": dict( + ((strategy_decision_info or {}).get("strategy_trace") or {}).get( + "learned_policy_shadow" + ) + or {} + ), + "validation_summary": dict( + (campaign_context_dict or {}).get("validation_summary") or {} + ), + "human_observations": list( (campaign_context_dict or {}).get("human_observations", []) or [] ), - literature_summary={ + "literature_summary": { "literature_priors": list( (campaign_context_dict or {}).get("literature_priors", []) or [] ), }, - metadata={ + "metadata": { "round_strategy": round_strategy, "planned_strategy": planned_round.strategy, "shadow_only": True, + "experimental_route_decision": dict( + experimental_route_decision_payload + ), }, + } + + if experimental_route_decision_payload: + _strategy_trace = strategy_decision_info.setdefault( + "strategy_trace", {} + ) + _strategy_trace["experimental_route_decision"] = dict( + experimental_route_decision_payload + ) + decision_context_kwargs["nexus_diagnostics"] = { + **decision_context_kwargs["nexus_diagnostics"], + "experimental_route_report": dict( + (campaign_context_dict or {}).get( + "nexus_experimental_route_report", {} + ) + ), + "helios_route_decision": dict( + experimental_route_decision_payload + ), + } + + round_decision_trace = _maybe_record_contextual_shadow_decision( + **decision_context_kwargs, actual_stage="candidate_generation", ) + scientific_campaign_metadata = { + "objective": decision_context_kwargs["objective_summary"], + "constraints": decision_context_kwargs["constraint_summary"], + "campaign_context": dict(campaign_context_dict or {}), + "protocol_template": dict(round_protocol_template or {}), + "experimental_route_decision": dict( + experimental_route_decision_payload + ), + } + scientific_policy_snapshot = _scientific_policy_snapshot( + (strategy_decision_info or {}).get("strategy_trace"), + dict(input_data.policy_snapshot or {}), + ) + _maybe_record_pending_scientific_decision( + round_decision_trace, + campaign_metadata=scientific_campaign_metadata, + policy_snapshot=scientific_policy_snapshot, + ) # Parallel shadow track: adaptive campaign substrate snapshot. # Independent of the contextual decision trace above; observational @@ -1294,10 +2055,209 @@ def _note_failure_event(event: dict[str, Any]) -> None: objective_kpi=input_data.objective_kpi, max_rounds=input_data.max_rounds, failure_event_dicts=failure_event_dicts, - protocol_template=input_data.protocol_template, + protocol_template=round_protocol_template, safety_summary=dict(input_data.policy_snapshot or {}), ) + # Optional live campaign-decision authority. This is the promotion + # boundary for the contextual decision layer: default-off, explicit, + # auditable, and consumed before candidate generation. + authority_verdict: CampaignAuthorityVerdict | None = None + try: + decision_context = build_campaign_round_context( + **decision_context_kwargs + ) + authority_plan = CampaignDecisionLayer().decide(decision_context) + authority_verdict = evaluate_campaign_decision_authority( + authority_plan, + enabled=get_settings().campaign_decision_authority_enabled, + ) + if authority_verdict.enabled: + self._emit(campaign_id, { + "type": "campaign_decision_authority", + "round": round_num, + "consumed": authority_verdict.consumed, + "proceed_to_candidates": authority_verdict.proceed_to_candidates, + "terminal": authority_verdict.terminal, + "round_status": authority_verdict.round_status, + "reason": authority_verdict.reason, + "state_updates": [ + { + "update_type": update.update_type, + "payload": update.payload, + } + for update in authority_verdict.state_updates + ], + **authority_verdict.event_payload, + }) + except Exception: + logger.warning( + "Campaign decision authority failed; continuing candidate generation", + exc_info=True, + ) + authority_verdict = None + + if authority_verdict is not None and authority_verdict.consumed: + _context_changed = False + for update in authority_verdict.state_updates: + try: + if update.update_type == "objective_transition": + append_objective_transition(campaign_id, update.payload) + elif update.update_type == "space_revision": + append_space_revision(campaign_id, update.payload) + elif update.update_type == "context_request": + campaign_context_dict.setdefault( + "pending_context_requests", [] + ).append(update.payload) + _context_changed = True + elif update.update_type in { + "recovery_request", + "validation_request", + }: + campaign_context_dict.setdefault( + "pending_campaign_actions", [] + ).append(update.payload) + _context_changed = True + except Exception: + logger.debug( + "Failed to persist campaign authority update", + exc_info=True, + ) + if _context_changed: + try: + save_campaign_context(campaign_id, campaign_context_dict) + except Exception: + logger.debug( + "Failed to persist campaign authority context", + exc_info=True, + ) + + agent_trace.append({ + "agent": "campaign_decision_authority", + "round": round_num, + "action": authority_verdict.action_type.value, + "terminal": authority_verdict.terminal, + "round_status": authority_verdict.round_status, + }) + + if authority_verdict.terminal: + _authority_failures = list( + failure_event_dicts[_round_failure_start:] + ) + _maybe_finalize_scientific_decision( + round_decision_trace, + observed_action=authority_verdict.action_type.value, + observed_backend=None, + candidate_count=0, + execution_success=None, + failure_count=len(_authority_failures), + safety_incident_count=sum( + 1 + for event in _authority_failures + if event.get("failure_type") in {"safety", "chemical_safety"} + ), + metadata={ + "authority_reason": authority_verdict.reason, + "round_status": authority_verdict.round_status, + "terminal": True, + }, + campaign_metadata=scientific_campaign_metadata, + policy_snapshot=scientific_policy_snapshot, + failures=_authority_failures, + ) + top_k = self._compute_top_k_ranking( + all_params, all_kpis, all_rounds, input_data.direction, + ) + try: + update_campaign_status( + campaign_id, + "completed", + stop_reason=authority_verdict.stop_reason, + best_kpi=best_kpi, + ) + except Exception: + logger.debug( + "Failed to checkpoint campaign authority stop", + exc_info=True, + ) + self._emit(campaign_id, { + "type": "campaign_complete", + "campaign_id": campaign_id, + "status": "completed", + "rounds_completed": round_num - 1, + "best_kpi": best_kpi, + "stop_reason": authority_verdict.stop_reason, + "top_k_recipes": [r.model_dump() for r in top_k], + "message": ( + "Campaign completed by campaign decision authority " + f"({authority_verdict.action_type.value})" + ), + }) + return OrchestratorOutput( + campaign_id=campaign_id, + status="completed", + plan_summary=plan_summary, + rounds_completed=round_num - 1, + best_kpi=best_kpi, + stop_reason=authority_verdict.stop_reason, + agent_trace=agent_trace, + top_k_recipes=top_k, + ) + + if not authority_verdict.proceed_to_candidates: + _authority_failures = list( + failure_event_dicts[_round_failure_start:] + ) + _maybe_finalize_scientific_decision( + round_decision_trace, + observed_action=authority_verdict.action_type.value, + observed_backend=None, + candidate_count=0, + execution_success=None, + failure_count=len(_authority_failures), + safety_incident_count=sum( + 1 + for event in _authority_failures + if event.get("failure_type") in {"safety", "chemical_safety"} + ), + metadata={ + "authority_reason": authority_verdict.reason, + "round_status": authority_verdict.round_status, + "terminal": False, + }, + campaign_metadata=scientific_campaign_metadata, + policy_snapshot=scientific_policy_snapshot, + failures=_authority_failures, + ) + try: + start_round( + campaign_id, + round_num, + f"decision_authority:{authority_verdict.action_type.value}", + n_candidates=0, + strategy_decision={ + "authority_action": authority_verdict.action_type.value, + "reason": authority_verdict.reason, + "round_status": authority_verdict.round_status, + }, + ) + complete_round(campaign_id, round_num, [], []) + except Exception: + logger.debug( + "Failed to checkpoint authority-deferred round", + exc_info=True, + ) + self._emit(campaign_id, { + "type": "round_deferred", + "round": round_num, + "action": authority_verdict.action_type.value, + "message": ( + "Candidate generation deferred by campaign decision " + f"authority: {authority_verdict.reason}" + ), + }) + continue + # Reset per-round batch collectors round_batch_kpis: list[float] = [] round_batch_params: list[dict[str, Any]] = [] @@ -1359,8 +2319,6 @@ def _note_failure_event(event: dict[str, Any]) -> None: arbitration_candidates: list[dict[str, Any]] | None = None if stabilize_candidates is None and strategy_decision is not None: try: - from app.core.config import get_settings - if get_settings().enable_candidate_arbitration: from app.optimization.loop_integration import ( arbitrate_round_if_enabled, @@ -1369,8 +2327,8 @@ def _note_failure_event(event: dict[str, Any]) -> None: arb_request = build_optimization_request( campaign_id=campaign_id, - dimensions=input_data.dimensions, - protocol_template=input_data.protocol_template, + dimensions=round_dimensions, + protocol_template=round_protocol_template, all_params=list(all_params), all_kpis=list(all_kpis), objective_name=input_data.objective_kpi, @@ -1425,8 +2383,8 @@ def _note_failure_event(event: dict[str, Any]) -> None: else: # Normal path: generate candidates via DesignAgent design_input = DesignInput( - dimensions=input_data.dimensions, - protocol_template=input_data.protocol_template, + dimensions=round_dimensions, + protocol_template=round_protocol_template, strategy=round_strategy, batch_size=planned_round.batch_size, seed=round_num, @@ -1473,6 +2431,28 @@ def _note_failure_event(event: dict[str, Any]) -> None: logger.warning( "Round %d: design failed: %s", round_num, design_result.errors ) + _design_failure = { + "failure_type": "design", + "round": round_num, + "errors": list(design_result.errors), + "reason": "Candidate design failed before execution.", + } + _note_failure_event(_design_failure) + _round_failures = list(failure_event_dicts[_round_failure_start:]) + _maybe_finalize_scientific_decision( + round_decision_trace, + observed_action="propose_candidates", + observed_backend=( + strategy_decision_info.get("backend") or round_strategy + ), + candidate_count=0, + execution_success=False, + failure_count=len(_round_failures), + metadata={"design_failed": True}, + campaign_metadata=scientific_campaign_metadata, + policy_snapshot=scientific_policy_snapshot, + failures=_round_failures, + ) continue design_candidates = list(design_result.output.candidates) # (c) carry the bomcp TuRBO trust region into the next round. @@ -1491,8 +2471,8 @@ def _note_failure_event(event: dict[str, Any]) -> None: _round_evidence = evidence_for_candidates( campaign_id, design_candidates, - input_data.dimensions, - input_data.protocol_template, + round_dimensions, + round_protocol_template, ) _evidence_trace = (strategy_decision_info or {}).get("strategy_trace") if _round_evidence is not None and isinstance(_evidence_trace, dict): @@ -1534,10 +2514,10 @@ def _note_failure_event(event: dict[str, Any]) -> None: # Build protocol with candidate params from app.services.protocol_patterns import get_pattern - pattern = get_pattern(input_data.protocol_pattern_id) + pattern = get_pattern(round_protocol_pattern_id) if pattern is None: # Use the template as-is - protocol = input_data.protocol_template + protocol = round_protocol_template else: protocol = pattern.to_protocol_json(candidate_params) @@ -1588,6 +2568,14 @@ def _note_failure_event(event: dict[str, Any]) -> None: "params": candidate_params, "penalize_backend": False, }) + _record_experimental_route_observation( + round_number=round_num, + candidate_index=i, + parameters=candidate_params, + qc_passed=False, + is_failure=True, + failure_reason="compilation_failed", + ) continue # --- Idempotent skip: check graph_hash --- @@ -1650,6 +2638,14 @@ def _note_failure_event(event: dict[str, Any]) -> None: "params": candidate_params, "penalize_backend": True, }) + _record_experimental_route_observation( + round_number=round_num, + candidate_index=i, + parameters=candidate_params, + qc_passed=False, + is_failure=True, + failure_reason="safety_veto", + ) continue self._emit(campaign_id, { @@ -1728,6 +2724,14 @@ def _note_failure_event(event: dict[str, Any]) -> None: "params": candidate_params, "penalize_backend": False, }) + _record_experimental_route_observation( + round_number=round_num, + candidate_index=i, + parameters=candidate_params, + qc_passed=False, + is_failure=True, + failure_reason="simulation_fail", + ) continue else: logger.warning( @@ -1821,17 +2825,116 @@ def _note_failure_event(event: dict[str, Any]) -> None: else: # Real execution with recovery: create run → dispatch to worker → collect results # RecoveryAgent provides retry/abort/degrade strategies on failure - run_kpi, run_step_result = await self._execute_candidate_with_recovery( - campaign_id=campaign_id, - protocol=protocol, - inputs={"candidate_index": i, "round": round_num}, - policy_snapshot=input_data.policy_snapshot, - objective_kpi=input_data.objective_kpi, - candidate_params=candidate_params, - agent_trace=agent_trace, - round_num=round_num, - candidate_idx=i, - ) + try: + run_kpi, run_step_result = ( + await self._execute_candidate_with_recovery( + campaign_id=campaign_id, + protocol=protocol, + inputs={"candidate_index": i, "round": round_num}, + policy_snapshot=input_data.policy_snapshot, + objective_kpi=input_data.objective_kpi, + candidate_params=candidate_params, + agent_trace=agent_trace, + round_num=round_num, + candidate_idx=i, + ) + ) + except RecoveryExecutionError as exc: + terminal_failure = { + "failure_type": exc.failure_type, + "reason": str(exc), + "backend_name": ( + strategy_decision.backend_name + if strategy_decision is not None + else strategy_decision_info.get("backend") + ), + "round_number": round_num, + "candidate_index": i, + "params": candidate_params, + "recovery_episode_id": exc.episode.get("episode_id"), + "penalize_backend": not exc.chemical_safety, + } + _note_failure_event(terminal_failure) + _record_experimental_route_observation( + round_number=round_num, + candidate_index=i, + parameters=candidate_params, + qc_passed=False, + is_failure=True, + failure_reason=str(exc), + ) + terminal_failures = list( + failure_event_dicts[_round_failure_start:] + ) + recovery_events = [exc.episode] if exc.episode else [] + terminal_scientific_result = ( + _maybe_finalize_scientific_decision( + round_decision_trace, + observed_action="recover_failure", + observed_backend=( + strategy_decision_info.get("backend") + or round_strategy + ), + candidate_count=len(design_candidates), + execution_success=False, + failure_count=len(terminal_failures), + safety_incident_count=( + 1 if exc.chemical_safety else 0 + ), + recovery_attempted=bool(recovery_events), + recovery_success=( + False if recovery_events else None + ), + metadata={ + "terminal_recovery": True, + "candidate_index": i, + }, + campaign_metadata=scientific_campaign_metadata, + policy_snapshot=scientific_policy_snapshot, + observations=[{"execution_error": str(exc)}], + failures=terminal_failures, + recovery_events=recovery_events, + ) + ) + if terminal_scientific_result is not None: + terminal_ledger = terminal_scientific_result.ledger_result + self._emit(campaign_id, { + "type": "scientific_decision_recorded", + "round": round_num, + "trace_id": round_decision_trace.trace_id, + "trajectory_id": ( + terminal_scientific_result.trajectory_id + ), + "ledger_directory": ( + terminal_ledger.campaign_directory + if terminal_ledger + else None + ), + "git_commit": ( + terminal_ledger.git_commit.commit_sha + if terminal_ledger + and terminal_ledger.git_commit + else None + ), + "terminal_recovery": True, + "message": ( + "Scientific Decision Card finalized before " + "terminal recovery exit." + ), + }) + try: + update_campaign_status( + campaign_id, + "failed", + stop_reason=exc.failure_type, + best_kpi=best_kpi, + ) + except Exception: + logger.debug( + "Failed to checkpoint terminal recovery", + exc_info=True, + ) + raise self._emit(campaign_id, { "type": "agent_result", @@ -1978,6 +3081,15 @@ def _note_failure_event(event: dict[str, Any]) -> None: "params": candidate_params, "penalize_backend": False, }) + _record_experimental_route_observation( + round_number=round_num, + candidate_index=i, + parameters=candidate_params, + kpi=run_kpi, + qc_passed=False, + is_failure=True, + failure_reason=f"qc_abort:{qc_quality}", + ) continue # Record KPI (after QC pass) @@ -2002,6 +3114,17 @@ def _note_failure_event(event: dict[str, Any]) -> None: all_params.append(candidate_params) all_rounds.append(round_num) + _record_experimental_route_observation( + round_number=round_num, + candidate_index=i, + parameters=candidate_params, + kpi=run_kpi, + qc_passed=True, + is_failure=run_kpi is None, + failure_reason=("missing_kpi" if run_kpi is None else None), + run_id=_candidate_run_id, + ) + # --- Checkpoint: candidate completion + KPI snapshot --- try: complete_candidate( @@ -2063,6 +3186,23 @@ def _note_failure_event(event: dict[str, Any]) -> None: except Exception: logger.debug("RL post-round hook failed", exc_info=True) + # --- Verifiable reward (Phases A/B/C): score this round, persist the + # trajectory, and stream it to /lab so the inner evaluation loop is + # visible. Best-effort — never breaks the campaign. --- + _maybe_emit_verifiable_reward( + emit=lambda evt: self._emit(campaign_id, evt), + campaign_id=campaign_id, + round_num=round_num, + round_strategy=round_strategy, + direction=input_data.direction, + objective_kpi=input_data.objective_kpi, + best_kpi=best_kpi, + prev_best=_best_kpi_at_round_start, + round_batch_kpis=round_batch_kpis, + dimensions=round_dimensions, + max_rounds=input_data.max_rounds, + ) + # --- Per-backend recent-failure history (feeds rank_backends) --- # Attribute this round's execution outcome (any QC failure) to the # backend the strategy selector chose for it. Only adaptive rounds @@ -2127,12 +3267,12 @@ def _note_failure_event(event: dict[str, Any]) -> None: all_rounds=list(all_rounds), qc_fail_rate=_round_qc_fail_rate, max_rounds=input_data.max_rounds, - n_dimensions=len(input_data.dimensions), + n_dimensions=len(round_dimensions), has_categorical=any( - d.get("choices") is not None for d in input_data.dimensions + d.get("choices") is not None for d in round_dimensions ), has_log_scale=any( - d.get("log_scale", False) for d in input_data.dimensions + d.get("log_scale", False) for d in round_dimensions ), step_history=list(step_history), emit=lambda event: self._emit(campaign_id, event), @@ -2210,6 +3350,94 @@ def _note_failure_event(event: dict[str, Any]) -> None: "message": f"Stop decision: {decision}" + (f" (best KPI: {best_kpi})" if best_kpi is not None else ""), }) + # Close the full campaign decision accounting after analysis and + # stop evaluation, while every round-local observation is available. + _round_failures = list(failure_event_dicts[_round_failure_start:]) + _round_steps = list(step_history[_round_step_history_start:]) + _round_recovery_events = _recovery_events_from_steps(_round_steps) + _round_execution_success = ( + any(kpi is not None for kpi in round_batch_kpis) + if design_candidates + else None + ) + if _best_kpi_at_round_start is None or best_kpi is None: + _round_objective_delta = None + elif input_data.direction == "maximize": + _round_objective_delta = best_kpi - _best_kpi_at_round_start + else: + _round_objective_delta = _best_kpi_at_round_start - best_kpi + _analysis_observations: list[dict[str, Any]] = [ + { + "round_kpis": list(round_batch_kpis), + "round_parameters": list(round_batch_params), + "best_kpi": best_kpi, + "stop_decision": decision, + "experimental_node_id": active_experimental_node_id, + "experimental_route_decision": dict( + experimental_route_decision_payload + ), + } + ] + if analyzer_result.success: + _analysis_observations.append( + { + "analysis": analyzer_result.output.narrative, + "convergence": analyzer_result.output.convergence_status, + "decision_nodes": list(analyzer_result.output.decision_nodes or []), + } + ) + _scientific_result = _maybe_finalize_scientific_decision( + round_decision_trace, + observed_action="propose_candidates", + observed_backend=(strategy_decision_info.get("backend") or round_strategy), + candidate_count=len(design_candidates), + execution_success=_round_execution_success, + failure_count=len(_round_failures), + safety_incident_count=sum( + 1 + for event in _round_failures + if event.get("failure_type") in {"safety", "chemical_safety"} + ), + objective_delta=_round_objective_delta, + validation_success=( + True if decision == "target_reached" else None + ), + recovery_attempted=bool(_round_recovery_events), + recovery_success=( + _round_execution_success if _round_recovery_events else None + ), + metadata={ + "best_kpi_before": _best_kpi_at_round_start, + "best_kpi_after": best_kpi, + "stop_decision": decision, + "experimental_route_decision": dict( + experimental_route_decision_payload + ), + }, + campaign_metadata=scientific_campaign_metadata, + policy_snapshot=scientific_policy_snapshot, + observations=_analysis_observations, + failures=_round_failures, + recovery_events=_round_recovery_events, + ) + if _scientific_result is not None: + ledger_result = _scientific_result.ledger_result + self._emit(campaign_id, { + "type": "scientific_decision_recorded", + "round": round_num, + "trace_id": round_decision_trace.trace_id, + "trajectory_id": _scientific_result.trajectory_id, + "ledger_directory": ( + ledger_result.campaign_directory if ledger_result else None + ), + "git_commit": ( + ledger_result.git_commit.commit_sha + if ledger_result and ledger_result.git_commit + else None + ), + "message": "Scientific Decision Card finalized.", + }) + if stop_result.success and stop_result.output.decision != "continue": top_k = self._compute_top_k_ranking( all_params, all_kpis, all_rounds, input_data.direction, @@ -2771,7 +3999,11 @@ async def _execute_candidate_with_recovery( "Recovery agent failed: %s", recovery_result.errors, ) - raise Exception(error_msg) + raise RecoveryExecutionError( + error_msg, + episode=recovery_episode, + failure_type="recovery_agent_failure", + ) decision = recovery_result.output.decision rationale = recovery_result.output.rationale @@ -2816,7 +4048,12 @@ async def _execute_candidate_with_recovery( "message": "Chemical safety event detected - SafetyAgent veto active", }) # Force abort on chemical safety - raise Exception(f"Chemical safety event: {error_msg}") + raise RecoveryExecutionError( + f"Chemical safety event: {error_msg}", + episode=recovery_episode, + failure_type="chemical_safety", + chemical_safety=True, + ) # Execute recovery decision if decision == "retry": @@ -2856,7 +4093,10 @@ async def _execute_candidate_with_recovery( "Recovery: abort execution (rationale: %s)", rationale[:100], ) - raise Exception(f"Recovery abort: {error_msg}") + raise RecoveryExecutionError( + f"Recovery abort: {error_msg}", + episode=recovery_episode, + ) elif decision == "skip": logger.info( @@ -2867,6 +4107,7 @@ async def _execute_candidate_with_recovery( "status": "skipped", "reason": "recovery_skip", "rationale": rationale, + "recovery_episode": recovery_episode, } elif decision == "degrade": @@ -2877,6 +4118,7 @@ async def _execute_candidate_with_recovery( # Mark as degraded but return results step_result["degraded"] = True step_result["recovery_rationale"] = rationale + step_result["recovery_episode"] = recovery_episode return kpi_value, step_result # Success path @@ -2894,9 +4136,20 @@ async def _execute_candidate_with_recovery( "episode_id": recovery_episode.get("episode_id") if recovery_episode else None, "message": f"Execution succeeded after {retry_count} retries", }) + if recovery_episode is not None: + recovery_episode["phase"] = "exit" + attempts = recovery_episode.get("attempts") or [] + if attempts and isinstance(attempts[-1], dict): + attempts[-1]["result"] = "success" + step_result = { + **step_result, + "recovery_episode": recovery_episode, + } return kpi_value, step_result + except RecoveryExecutionError: + raise except Exception as exc: # Exception during execution error_type = map_exception_to_error_type(exc) @@ -2939,7 +4192,11 @@ async def _execute_candidate_with_recovery( "Recovery agent failed: %s", recovery_result.errors, ) - raise exc + raise RecoveryExecutionError( + str(exc), + episode=recovery_episode, + failure_type="recovery_agent_failure", + ) from exc decision = recovery_result.output.decision rationale = recovery_result.output.rationale @@ -2983,7 +4240,12 @@ async def _execute_candidate_with_recovery( "error_type": error_type, "message": "Chemical safety event detected - SafetyAgent veto active", }) - raise exc + raise RecoveryExecutionError( + str(exc), + episode=recovery_episode, + failure_type="chemical_safety", + chemical_safety=True, + ) from exc # Execute recovery decision if decision == "retry": @@ -3024,7 +4286,10 @@ async def _execute_candidate_with_recovery( "Recovery: abort execution (rationale: %s)", rationale[:100], ) - raise exc + raise RecoveryExecutionError( + str(exc), + episode=recovery_episode, + ) from exc elif decision == "skip": logger.info( @@ -3035,6 +4300,7 @@ async def _execute_candidate_with_recovery( "status": "skipped", "reason": "recovery_skip", "rationale": rationale, + "recovery_episode": recovery_episode, } elif decision == "degrade": @@ -3048,6 +4314,7 @@ async def _execute_candidate_with_recovery( "reason": "recovery_degrade", "rationale": rationale, "error": str(exc), + "recovery_episode": recovery_episode, } # Max retries exceeded diff --git a/app/agents/recovery_agent.py b/app/agents/recovery_agent.py index 9b6717d..793d837 100644 --- a/app/agents/recovery_agent.py +++ b/app/agents/recovery_agent.py @@ -77,7 +77,7 @@ class RecoveryInput(BaseModel): stage: str | None = None retry_count: int = 0 safety_packet: dict[str, Any] | None = None - episode: "RecoveryEpisode | None" = None + episode: RecoveryEpisode | None = None last_attempt_result: dict[str, Any] | None = None diff --git a/app/api/v1/endpoints/memory.py b/app/api/v1/endpoints/memory.py index 229910c..b5b0a6b 100644 --- a/app/api/v1/endpoints/memory.py +++ b/app/api/v1/endpoints/memory.py @@ -10,15 +10,88 @@ """ from __future__ import annotations +from pathlib import Path, PurePosixPath from typing import Any -from fastapi import APIRouter, Query +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import PlainTextResponse from app.core.db import parse_json, run_txn router = APIRouter(prefix="/memory", tags=["memory"]) +@router.get("/scientific/search") +async def scientific_memory_search( + q: str = Query(..., min_length=1, max_length=500), + campaign_id: str | None = Query(None), + limit: int = Query(50, ge=1, le=200), +) -> dict[str, Any]: + """Search Decision Cards, failures, recoveries, and evidence as Markdown.""" + from app.services.scientific_ledger import get_scientific_ledger + + try: + hits = get_scientific_ledger().search( + q, + campaign_id=campaign_id, + limit=limit, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return { + "query": q, + "campaign_id": campaign_id, + "count": len(hits), + "hits": [ + { + "campaign_id": hit.campaign_id, + "path": hit.path, + "title": hit.title, + "line_number": hit.line_number, + "snippet": hit.snippet, + } + for hit in hits + ], + } + + +@router.get("/scientific/{campaign_id}/artifact", response_class=PlainTextResponse) +async def scientific_memory_artifact( + campaign_id: str, + path: str = Query("index.md", min_length=1, max_length=500), +) -> PlainTextResponse: + """Read one campaign Markdown artifact with traversal protection.""" + from app.services.scientific_ledger import get_scientific_ledger + + ledger = get_scientific_ledger() + campaign_dir = ledger.campaign_directory(campaign_id) + pure = PurePosixPath(path) + if ( + pure.is_absolute() + or ".." in pure.parts + or ".git" in pure.parts + or pure.suffix.casefold() != ".md" + ): + raise HTTPException(status_code=400, detail="Artifact path must be campaign-local Markdown") + target = (campaign_dir / Path(*pure.parts)).resolve() + try: + target.relative_to(campaign_dir) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Artifact path escapes campaign directory") from exc + if not target.is_file(): + raise HTTPException(status_code=404, detail="Scientific artifact not found") + return PlainTextResponse(target.read_text(encoding="utf-8"), media_type="text/markdown") + + +@router.get("/scientific/{campaign_id}/rlvr", response_class=PlainTextResponse) +async def scientific_memory_rlvr(campaign_id: str) -> PlainTextResponse: + """Export deterministic JSONL from the typed decision trajectory store.""" + from app.services.scientific_ledger import get_scientific_ledger + + text = get_scientific_ledger().export_rlvr_jsonl(campaign_id) + return PlainTextResponse(text, media_type="application/x-ndjson") + + @router.get("/snapshot") async def memory_snapshot( episodes_limit: int = Query(20, ge=1, le=200), diff --git a/app/contracts/scientific_evidence.py b/app/contracts/scientific_evidence.py new file mode 100644 index 0000000..fd3cef8 --- /dev/null +++ b/app/contracts/scientific_evidence.py @@ -0,0 +1,468 @@ +"""Typed advisory scientific-evidence contract consumed by HELIOS. + +The contract carries literature and validated experimental evidence into the +campaign decision layer. It deliberately excludes executable protocols, +commands, and workflow mappings: external evidence may inform a HELIOS +decision, but it never owns execution. +""" + +from __future__ import annotations + +import math +from datetime import datetime +from enum import StrEnum +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +SCIENTIFIC_EVIDENCE_CONTRACT_VERSION = "scientific_evidence_bundle.v1" + +MAX_CLAIMS = 32 +MAX_PATHS = 64 +MAX_CONFLICTS = 32 +MAX_SOURCE_REFS_PER_CLAIM = 8 +MAX_CHUNKS_PER_SOURCE = 16 +MAX_METADATA_ITEMS = 32 +MAX_METADATA_DEPTH = 3 + +_EXECUTION_KEYS = { + "command", + "commands", + "execution_graph", + "execution_mapping", + "hardware_command", + "primitive", + "protocol", + "protocol_template", + "steps", + "workflow", +} +_EXECUTION_KEYS_COMPACT = { + "".join(character for character in key if character.isalnum()) + for key in _EXECUTION_KEYS +} +_SENSITIVE_KEYS_COMPACT = { + "accesstoken", + "apikey", + "authtoken", + "authorization", + "bearertoken", + "cookie", + "credential", + "password", + "privatekey", + "refreshtoken", + "secret", + "setcookie", + "token", +} + + +class EvidenceNamespace(StrEnum): + PUBLISHED = "published_evidence" + LOCAL_EXPERIMENTAL = "local_experimental_evidence" + HYPOTHESIS = "hypothesis" + DERIVED_INFERENCE = "derived_inference" + REFUTED = "refuted" + UNCERTAIN = "uncertain" + + +class ScientificSourceType(StrEnum): + PAPER = "paper" + EXPERIMENT = "experiment" + DATASET = "dataset" + OTHER = "other" + + +class EvidencePolarity(StrEnum): + POSITIVE = "positive" + NEGATIVE = "negative" + + +class EvidenceCentrality(StrEnum): + CORE_CONTRIBUTION = "core_contribution" + SUPPORTING_METHOD = "supporting_method" + BACKGROUND_ONLY = "background_only" + INCIDENTAL_MENTION = "incidental_mention" + UNRELATED = "unrelated" + + +class ApplicabilityStatus(StrEnum): + APPLICABLE = "applicable" + PARTIAL = "partial" + MISMATCH = "mismatch" + UNKNOWN = "unknown" + + +class ScientificEvidenceStatus(StrEnum): + USABLE = "usable" + INSUFFICIENT = "insufficient_evidence" + CONFLICTING = "conflicting_evidence" + APPLICABILITY_MISMATCH = "applicability_mismatch" + STALE = "stale" + INVALID = "invalid" + UNAVAILABLE = "unavailable" + + +class ScientificEvidencePolicyMode(StrEnum): + OFF = "off" + SHADOW = "shadow" + BOUNDED = "bounded" + + +class ScientificEvidenceRecommendedAction(StrEnum): + NONE = "none" + QUERY_LITERATURE = "query_literature" + RUN_VALIDATION = "run_validation" + REQUEST_HUMAN_OBSERVATION = "request_human_observation" + + +class ScientificSourceRef(BaseModel): + """Traceable source for one scientific claim.""" + + model_config = ConfigDict(extra="forbid") + + ref_id: str = Field(min_length=1, max_length=160) + source_type: ScientificSourceType = ScientificSourceType.PAPER + source_id: str = Field(min_length=1, max_length=160) + paper_id: str | None = Field(default=None, min_length=1, max_length=160) + experiment_id: str | None = Field(default=None, min_length=1, max_length=160) + chunk_ids: list[str] = Field( + min_length=1, + max_length=MAX_CHUNKS_PER_SOURCE, + ) + title: str | None = Field(default=None, max_length=512) + doi: str | None = Field(default=None, max_length=256) + pages: list[str] = Field(default_factory=list, max_length=16) + + @field_validator("chunk_ids", "pages") + @classmethod + def _unique_non_empty_values(cls, values: list[str]) -> list[str]: + normalized = [value.strip() for value in values] + if any(not value for value in normalized): + raise ValueError("source reference values must be non-empty") + if len(set(normalized)) != len(normalized): + raise ValueError("source reference values must be unique") + return normalized + + @model_validator(mode="after") + def _type_specific_identifier(self) -> ScientificSourceRef: + if self.source_type == ScientificSourceType.PAPER and not self.paper_id: + raise ValueError("paper source references require paper_id") + if ( + self.source_type == ScientificSourceType.EXPERIMENT + and not self.experiment_id + ): + raise ValueError("experiment source references require experiment_id") + return self + + +class ApplicabilityContext(BaseModel): + """Conditions under which a claim can be reused for the current campaign.""" + + model_config = ConfigDict(extra="forbid") + + status: ApplicabilityStatus = ApplicabilityStatus.UNKNOWN + material_families: list[str] = Field(default_factory=list, max_length=16) + methods: list[str] = Field(default_factory=list, max_length=16) + instruments: list[str] = Field(default_factory=list, max_length=16) + protocols: list[str] = Field(default_factory=list, max_length=16) + conditions: dict[str, str | int | float | bool] = Field(default_factory=dict) + mismatches: list[str] = Field(default_factory=list, max_length=16) + + @field_validator( + "material_families", + "methods", + "instruments", + "protocols", + "mismatches", + ) + @classmethod + def _bounded_strings(cls, values: list[str]) -> list[str]: + normalized = [value.strip() for value in values] + if any(not value or len(value) > 256 for value in normalized): + raise ValueError( + "applicability values must be non-empty and <=256 characters" + ) + return normalized + + @field_validator("conditions") + @classmethod + def _bounded_conditions( + cls, + value: dict[str, str | int | float | bool], + ) -> dict[str, str | int | float | bool]: + if len(value) > 16: + raise ValueError("applicability conditions exceed the 16-item limit") + for key, item in value.items(): + if not key or len(key) > 128: + raise ValueError( + "applicability condition keys must be 1-128 characters" + ) + if isinstance(item, float) and not math.isfinite(item): + raise ValueError("applicability condition numbers must be finite") + if isinstance(item, str) and len(item) > 512: + raise ValueError( + "applicability condition strings must be <=512 characters" + ) + return value + + +class ScientificClaim(BaseModel): + """One source-grounded claim in a scientific evidence bundle.""" + + model_config = ConfigDict(extra="forbid") + + claim_id: str = Field(min_length=1, max_length=160) + claim_type: str = Field(min_length=1, max_length=160) + statement: str = Field(min_length=1, max_length=2000) + namespace: EvidenceNamespace + polarity: EvidencePolarity + centrality: EvidenceCentrality + confidence: float = Field(ge=0.0, le=1.0) + applicability: ApplicabilityContext = Field(default_factory=ApplicabilityContext) + source_refs: list[ScientificSourceRef] = Field( + min_length=1, + max_length=MAX_SOURCE_REFS_PER_CLAIM, + ) + tags: list[str] = Field(default_factory=list, max_length=16) + metadata: dict[str, Any] = Field(default_factory=dict) + + @field_validator("tags") + @classmethod + def _bounded_tags(cls, values: list[str]) -> list[str]: + normalized = [value.strip() for value in values] + if any(not value or len(value) > 128 for value in normalized): + raise ValueError("claim tags must be non-empty and <=128 characters") + return normalized + + @field_validator("metadata") + @classmethod + def _safe_metadata(cls, value: dict[str, Any]) -> dict[str, Any]: + _validate_bounded_json(value) + return value + + @model_validator(mode="after") + def _source_refs_are_unique(self) -> ScientificClaim: + ref_ids = [ref.ref_id for ref in self.source_refs] + if len(set(ref_ids)) != len(ref_ids): + raise ValueError(f"claim {self.claim_id} has duplicate source ref ids") + if self.namespace == EvidenceNamespace.PUBLISHED and any( + ref.source_type != ScientificSourceType.PAPER + for ref in self.source_refs + ): + raise ValueError("published evidence claims require paper sources") + if self.namespace == EvidenceNamespace.LOCAL_EXPERIMENTAL and not any( + ref.source_type == ScientificSourceType.EXPERIMENT + for ref in self.source_refs + ): + raise ValueError( + "local experimental claims require an experiment source" + ) + return self + + +class ScientificEvidencePath(BaseModel): + """Auditable path connecting a claim to graph relations and source chunks.""" + + model_config = ConfigDict(extra="forbid") + + path_id: str = Field(min_length=1, max_length=160) + claim_id: str = Field(min_length=1, max_length=160) + relation_types: list[str] = Field(min_length=1, max_length=16) + source_ref_ids: list[str] = Field(min_length=1, max_length=16) + summary: str = Field(min_length=1, max_length=2000) + confidence: float = Field(ge=0.0, le=1.0) + + @field_validator("relation_types", "source_ref_ids") + @classmethod + def _unique_non_empty_values(cls, values: list[str]) -> list[str]: + normalized = [value.strip() for value in values] + if any(not value or len(value) > 160 for value in normalized): + raise ValueError( + "evidence path values must be non-empty and <=160 characters" + ) + if len(set(normalized)) != len(normalized): + raise ValueError("evidence path values must be unique") + return normalized + + +class EvidenceConflict(BaseModel): + """Explicitly preserved disagreement between source-grounded claims.""" + + model_config = ConfigDict(extra="forbid") + + conflict_id: str = Field(min_length=1, max_length=160) + claim_ids: list[str] = Field(min_length=2, max_length=8) + reason: str = Field(min_length=1, max_length=2000) + confidence: float = Field(ge=0.0, le=1.0) + + @field_validator("claim_ids") + @classmethod + def _unique_claim_ids(cls, values: list[str]) -> list[str]: + normalized = [value.strip() for value in values] + if any(not value or len(value) > 160 for value in normalized): + raise ValueError( + "conflict claim ids must be non-empty and <=160 characters" + ) + if len(set(normalized)) != len(normalized): + raise ValueError("conflict claim ids must be unique") + return normalized + + +class ScientificEvidenceBundle(BaseModel): + """Versioned, bounded, advisory-only evidence package.""" + + model_config = ConfigDict(extra="forbid") + + contract_version: Literal["scientific_evidence_bundle.v1"] + authority: Literal["advisory_only"] = "advisory_only" + bundle_id: str = Field(min_length=1, max_length=160) + query_id: str | None = Field(default=None, max_length=160) + claims: list[ScientificClaim] = Field(default_factory=list, max_length=MAX_CLAIMS) + evidence_paths: list[ScientificEvidencePath] = Field( + default_factory=list, + max_length=MAX_PATHS, + ) + conflicts: list[EvidenceConflict] = Field( + default_factory=list, + max_length=MAX_CONFLICTS, + ) + corpus_version: str = Field(min_length=1, max_length=160) + ontology_version: str = Field(min_length=1, max_length=160) + created_at: datetime + expires_at: datetime | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + @field_validator("created_at", "expires_at") + @classmethod + def _timezone_aware(cls, value: datetime | None) -> datetime | None: + if value is not None and ( + value.tzinfo is None or value.tzinfo.utcoffset(value) is None + ): + raise ValueError("scientific evidence timestamps must be timezone-aware") + return value + + @field_validator("metadata") + @classmethod + def _safe_metadata(cls, value: dict[str, Any]) -> dict[str, Any]: + _validate_bounded_json(value) + return value + + @model_validator(mode="after") + def _validate_graph_references(self) -> ScientificEvidenceBundle: + if self.expires_at is not None and self.expires_at <= self.created_at: + raise ValueError("expires_at must be later than created_at") + + claim_ids = [claim.claim_id for claim in self.claims] + if len(set(claim_ids)) != len(claim_ids): + raise ValueError("claim ids must be unique") + path_ids = [path.path_id for path in self.evidence_paths] + if len(set(path_ids)) != len(path_ids): + raise ValueError("evidence path ids must be unique") + conflict_ids = [conflict.conflict_id for conflict in self.conflicts] + if len(set(conflict_ids)) != len(conflict_ids): + raise ValueError("conflict ids must be unique") + + claims_by_id = {claim.claim_id: claim for claim in self.claims} + for path in self.evidence_paths: + claim = claims_by_id.get(path.claim_id) + if claim is None: + raise ValueError(f"path {path.path_id} references unknown claim") + available_refs = {ref.ref_id for ref in claim.source_refs} + if not set(path.source_ref_ids).issubset(available_refs): + raise ValueError( + f"path {path.path_id} references sources outside claim {path.claim_id}" + ) + for conflict in self.conflicts: + if not set(conflict.claim_ids).issubset(claims_by_id): + raise ValueError( + f"conflict {conflict.conflict_id} references unknown claims" + ) + if not self.claims and (self.evidence_paths or self.conflicts): + raise ValueError("paths and conflicts require at least one claim") + return self + + +class ScientificEvidenceAssessment(BaseModel): + """Deterministic HELIOS assessment of one external evidence bundle.""" + + model_config = ConfigDict(extra="forbid") + + bundle_id: str | None = None + status: ScientificEvidenceStatus + policy_mode: ScientificEvidencePolicyMode = ScientificEvidencePolicyMode.OFF + support_strength: float = Field(default=0.0, ge=0.0, le=1.0) + contradiction_strength: float = Field(default=0.0, ge=0.0, le=1.0) + applicability_score: float = Field(default=0.0, ge=0.0, le=1.0) + source_coverage: float = Field(default=0.0, ge=0.0, le=1.0) + claim_count: int = Field(default=0, ge=0, le=MAX_CLAIMS) + evidence_path_count: int = Field(default=0, ge=0, le=MAX_PATHS) + conflict_count: int = Field(default=0, ge=0, le=MAX_CONFLICTS) + stale: bool = False + requires_human_review: bool = False + recommended_action: ScientificEvidenceRecommendedAction = ( + ScientificEvidenceRecommendedAction.NONE + ) + reasons: list[str] = Field(default_factory=list, max_length=16) + metadata: dict[str, Any] = Field(default_factory=dict) + + @field_validator("metadata") + @classmethod + def _safe_metadata(cls, value: dict[str, Any]) -> dict[str, Any]: + _validate_bounded_json(value) + return value + + @field_validator("reasons") + @classmethod + def _bounded_reasons(cls, values: list[str]) -> list[str]: + normalized = [value.strip() for value in values] + if any(not value or len(value) > 2000 for value in normalized): + raise ValueError( + "assessment reasons must be non-empty and <=2000 characters" + ) + return normalized + + +def _validate_bounded_json( + value: Any, + *, + depth: int = 0, +) -> None: + if depth > MAX_METADATA_DEPTH: + raise ValueError(f"metadata exceeds depth limit {MAX_METADATA_DEPTH}") + if isinstance(value, dict): + if len(value) > MAX_METADATA_ITEMS: + raise ValueError(f"metadata exceeds {MAX_METADATA_ITEMS} items") + for key, item in value.items(): + normalized = str(key).strip().lower() + compact = "".join( + character for character in normalized if character.isalnum() + ) + if not normalized or len(normalized) > 128: + raise ValueError("metadata keys must be 1-128 characters") + if ( + normalized in _EXECUTION_KEYS + or compact in _EXECUTION_KEYS_COMPACT + ): + raise ValueError( + f"external scientific evidence cannot include executable field {key!r}" + ) + if compact in _SENSITIVE_KEYS_COMPACT: + raise ValueError( + f"external scientific evidence cannot include sensitive field {key!r}" + ) + _validate_bounded_json(item, depth=depth + 1) + return + if isinstance(value, list | tuple): + if len(value) > MAX_METADATA_ITEMS: + raise ValueError(f"metadata list exceeds {MAX_METADATA_ITEMS} items") + for item in value: + _validate_bounded_json(item, depth=depth + 1) + return + if isinstance(value, float) and not math.isfinite(value): + raise ValueError("metadata numbers must be finite") + if isinstance(value, str) and len(value) > 2000: + raise ValueError("metadata strings must be <=2000 characters") + if value is not None and not isinstance(value, str | int | float | bool): + raise ValueError(f"unsupported metadata type {type(value).__name__}") diff --git a/app/core/config.py b/app/core/config.py index 41b6e08..7563483 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -15,6 +15,30 @@ def __init__(self) -> None: self.object_store_dir = Path( os.getenv("OBJECT_STORE_DIR", str(self.data_dir / "object_store")) ) + # Human-readable scientific provenance. Markdown projection is enabled + # by default because it is reporting-only and never changes live routes. + # Git history is separately opt-in and always campaign-local. + self.scientific_ledger_root = Path( + os.getenv( + "SCIENTIFIC_LEDGER_ROOT", + str(self.data_dir / "scientific_ledger"), + ) + ) + self.scientific_ledger_enabled: bool = os.getenv( + "SCIENTIFIC_LEDGER_ENABLED", "true" + ).lower() in ("true", "1", "yes") + self.scientific_ledger_git_enabled: bool = os.getenv( + "SCIENTIFIC_LEDGER_GIT_ENABLED", "false" + ).lower() in ("true", "1", "yes") + self.scientific_ledger_git_auto_init: bool = os.getenv( + "SCIENTIFIC_LEDGER_GIT_AUTO_INIT", "true" + ).lower() in ("true", "1", "yes") + self.scientific_ledger_git_author_name: str = os.getenv( + "SCIENTIFIC_LEDGER_GIT_AUTHOR_NAME", "HELIOS Scientific Ledger" + ) + self.scientific_ledger_git_author_email: str = os.getenv( + "SCIENTIFIC_LEDGER_GIT_AUTHOR_EMAIL", "helios-ledger@localhost" + ) self.scheduler_poll_seconds = float(os.getenv("SCHEDULER_POLL_SECONDS", "2")) # Upper bound on concurrently-executing worker threads. Defaults to the # CPU count clamped to [2, 8] so a busy queue cannot spawn unbounded @@ -92,6 +116,49 @@ def __init__(self) -> None: self.nexus_advisor_enabled: bool = os.getenv( "NEXUS_ADVISOR_ENABLED", "false" ).lower() in ("true", "1", "yes") + self.nexus_url: str = self._normalize_nexus_api_url( + os.getenv("NEXUS_URL", "http://localhost:8000/api") + ) + self.nexus_timeout_seconds: float = float(os.getenv("NEXUS_TIMEOUT_SECONDS", "10")) + self.nexus_api_key: str = os.getenv("NEXUS_API_KEY", "") + + # Nexus supplies advisory evidence for cross-route characterization. + # Calling the endpoint and applying a route are intentionally separate + # gates so operators can run a shadow campaign before promoting it. + + # ---- Optional Paper Attribution System evidence settings ---- + # Fetch, shadow use, and bounded policy influence are deliberately + # separate. All are default-off; PAS remains an advisory evidence source. + self.pas_evidence_fetch_enabled: bool = os.getenv( + "PAS_EVIDENCE_FETCH_ENABLED", "false" + ).lower() in ("true", "1", "yes") + self.pas_evidence_shadow_enabled: bool = os.getenv( + "PAS_EVIDENCE_SHADOW_ENABLED", "false" + ).lower() in ("true", "1", "yes") + self.pas_evidence_influence_enabled: bool = os.getenv( + "PAS_EVIDENCE_INFLUENCE_ENABLED", "false" + ).lower() in ("true", "1", "yes") + self.pas_evidence_url: str = os.getenv( + "PAS_EVIDENCE_URL", "http://localhost:8001/api/v1" + ).rstrip("/") + self.pas_evidence_api_key: str = os.getenv("PAS_EVIDENCE_API_KEY", "") + self.pas_evidence_timeout_seconds: float = float( + os.getenv("PAS_EVIDENCE_TIMEOUT_SECONDS", "10") + ) + self.pas_evidence_max_bundle_bytes: int = int( + os.getenv("PAS_EVIDENCE_MAX_BUNDLE_BYTES", "262144") + ) + if self.pas_evidence_timeout_seconds <= 0: + raise ValueError("PAS_EVIDENCE_TIMEOUT_SECONDS must be positive") + if self.pas_evidence_max_bundle_bytes < 1024: + raise ValueError("PAS_EVIDENCE_MAX_BUNDLE_BYTES must be at least 1024") + + self.nexus_experimental_routes_enabled: bool = os.getenv( + "NEXUS_EXPERIMENTAL_ROUTES_ENABLED", "false" + ).lower() in ("true", "1", "yes") + self.experimental_route_authority_enabled: bool = os.getenv( + "EXPERIMENTAL_ROUTE_AUTHORITY_ENABLED", "false" + ).lower() in ("true", "1", "yes") # ---- Contextual SDL decision layer ---- # Shadow-only by default. When enabled, orchestrator records contextual @@ -100,6 +167,23 @@ def __init__(self) -> None: "CONTEXTUAL_DECISION_SHADOW_ENABLED", "false" ).lower() in ("true", "1", "yes") + # ---- Campaign decision authority ---- + # Explicit promotion gate for the contextual decision layer. Off by + # default: existing live routing stays unchanged. When enabled, the + # orchestrator consumes non-candidate campaign decisions before candidate + # generation and records the action as auditable state. + self.campaign_decision_authority_enabled: bool = os.getenv( + "CAMPAIGN_DECISION_AUTHORITY_ENABLED", "false" + ).lower() in ("true", "1", "yes") + + + # Reporting and next-round context enrichment are enabled by default. + # The monitor cannot mutate live routes on its own; any defer/stop still + # requires the separate campaign-decision authority gate above. + self.closed_loop_drift_monitor_enabled: bool = os.getenv( + "CLOSED_LOOP_DRIFT_MONITOR_ENABLED", "true" + ).lower() in ("true", "1", "yes") + # ---- Adaptive campaign substrate (Phase 1-5) shadow logging ---- # Independent, shadow-only track recorded in parallel with the # contextual decision trace. Default off; never affects routing. @@ -127,6 +211,11 @@ def _load_int_set(raw: str) -> set[int]: values.add(int(value)) return values + @staticmethod + def _normalize_nexus_api_url(raw: str) -> str: + value = raw.rstrip("/") + return value if value.endswith("/api") else f"{value}/api" + @lru_cache(maxsize=1) def get_settings() -> Settings: diff --git a/app/core/startup.py b/app/core/startup.py index 725f94e..90dfe0d 100644 --- a/app/core/startup.py +++ b/app/core/startup.py @@ -107,10 +107,16 @@ def check_data_directories() -> CheckResult: settings = get_settings() issues: list[str] = [] - for label, dirpath in [ + directories = [ ("data_dir", settings.data_dir), ("object_store_dir", settings.object_store_dir), - ]: + ] + if settings.scientific_ledger_enabled: + directories.append( + ("scientific_ledger_root", settings.scientific_ledger_root) + ) + + for label, dirpath in directories: try: dirpath.mkdir(parents=True, exist_ok=True) # Verify write access via a temp file @@ -136,6 +142,11 @@ def check_data_directories() -> CheckResult: details={ "data_dir": str(settings.data_dir), "object_store_dir": str(settings.object_store_dir), + **( + {"scientific_ledger_root": str(settings.scientific_ledger_root)} + if settings.scientific_ledger_enabled + else {} + ), }, ) diff --git a/app/optimization/pool_service.py b/app/optimization/pool_service.py index 94ca670..ac0f2ae 100644 --- a/app/optimization/pool_service.py +++ b/app/optimization/pool_service.py @@ -205,21 +205,40 @@ def build_pool( @staticmethod def _apply_failure_penalty(pool: CandidatePool, request: OptimizationRequest) -> CandidatePool: - """Drop candidates inside the learned failure region; never strand empty.""" + """Drop candidates inside the learned failure region; never strand empty. + + E3 (soft re-rank): previously, when *every* candidate scored as + failure-prone the method kept the whole pool unchanged, so in a + failure-dominated campaign (e.g. a bottleneck drug making most of the + space infeasible) the failure-zone learning was silently a no-op. + Now the survivors are always re-ranked by failure proximity (least + failure-prone first) and the pool is capped to the requested size, so + even a fully failure-prone pool yields the *least-bad* candidates + instead of an arbitrary subset. + """ failed = request.context.get("failed_params") or () if not failed or not pool.candidates: return pool from app.services.failure_region import FailureRegionModel model = FailureRegionModel.fit(failed=list(failed), space=request.space) - kept = tuple(c for c in pool.candidates if model.predicted_feasible(c.params)) - if not kept or len(kept) == len(pool.candidates): - return pool # all-failure-prone (keep the round alive) or nothing dropped + scored = sorted( + ((model.failure_score(c.params), c) for c in pool.candidates), + key=lambda pair: pair[0], + ) + n = int(getattr(request, "n", 0) or len(pool.candidates)) + kept = tuple(c for _, c in scored[:n]) + if not kept: + return pool dropped = len(pool.candidates) - len(kept) + notes = ["failure-zone re-rank: candidates ordered by failure proximity"] + if dropped: + notes.append( + f"failure-zone penalty: dropped {dropped} failure-prone candidate(s)" + ) return CandidatePool( candidates=kept, sources_used=pool.sources_used, sources_dropped=pool.sources_dropped, - construction_trace=pool.construction_trace - + (f"failure-zone penalty: dropped {dropped} failure-prone candidate(s)",), + construction_trace=pool.construction_trace + tuple(notes), ) diff --git a/app/services/campaign_decision_authority.py b/app/services/campaign_decision_authority.py new file mode 100644 index 0000000..d8b0260 --- /dev/null +++ b/app/services/campaign_decision_authority.py @@ -0,0 +1,260 @@ +"""Bounded live authority for campaign-level decision plans. + +The contextual decision layer originally produced shadow-only envelopes. This +module is the narrow promotion boundary: when explicitly enabled, it converts a +``CampaignDecisionPlan`` into a deterministic verdict that the orchestrator can +consume before candidate generation. + +The module is intentionally pure. It does not mutate campaign state, write to +the database, call agents, or execute tools. The orchestrator owns all effects +and records every consumed verdict. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from app.services.decision_models import ( + CampaignContextRequest, + CampaignDecisionAction, + CampaignDecisionPlan, +) + + +@dataclass(frozen=True) +class CampaignAuthorityStateUpdate: + """A state update the orchestrator should persist if the verdict is consumed.""" + + update_type: str + payload: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class CampaignAuthorityVerdict: + """Live-routing verdict derived from a campaign decision plan.""" + + enabled: bool + consumed: bool + proceed_to_candidates: bool + terminal: bool + action_type: CampaignDecisionAction + stop_reason: str | None = None + round_status: str = "continue" + reason: str = "" + state_updates: tuple[CampaignAuthorityStateUpdate, ...] = () + event_payload: dict[str, Any] = field(default_factory=dict) + + +_NON_CANDIDATE_ACTIONS = { + CampaignDecisionAction.RECOVER_FAILURE, + CampaignDecisionAction.RUN_VALIDATION, + CampaignDecisionAction.QUERY_LITERATURE, + CampaignDecisionAction.REQUEST_HUMAN_OBSERVATION, + CampaignDecisionAction.REVISE_OBJECTIVE, + CampaignDecisionAction.TIGHTEN_CONSTRAINTS, +} + + +def evaluate_campaign_decision_authority( + plan: CampaignDecisionPlan, + *, + enabled: bool, +) -> CampaignAuthorityVerdict: + """Return the live authority verdict for a decision-layer plan. + + With ``enabled=False`` this is a no-op and candidate generation continues. + With ``enabled=True``: + + * ``PROPOSE_CANDIDATES`` continues normally. + * ``STOP_CAMPAIGN`` terminates the campaign before more candidates. + * validation/recovery/context/objective/constraint decisions block candidate + generation for the current round and return explicit state updates. + """ + if not enabled: + return CampaignAuthorityVerdict( + enabled=False, + consumed=False, + proceed_to_candidates=True, + terminal=False, + action_type=plan.action_type, + reason="campaign decision authority disabled", + event_payload=_event_payload(plan, consumed=False, enabled=False), + ) + + if plan.action_type == CampaignDecisionAction.PROPOSE_CANDIDATES: + return CampaignAuthorityVerdict( + enabled=True, + consumed=False, + proceed_to_candidates=True, + terminal=False, + action_type=plan.action_type, + reason="decision layer selected candidate proposal path", + event_payload=_event_payload(plan, consumed=False, enabled=True), + ) + + updates = _state_updates_for_plan(plan) + if plan.action_type == CampaignDecisionAction.STOP_CAMPAIGN: + return CampaignAuthorityVerdict( + enabled=True, + consumed=True, + proceed_to_candidates=False, + terminal=True, + action_type=plan.action_type, + stop_reason="campaign_decision_authority_stop", + round_status="completed", + reason=plan.rationale, + state_updates=updates, + event_payload=_event_payload(plan, consumed=True, enabled=True), + ) + + if plan.action_type in _NON_CANDIDATE_ACTIONS: + return CampaignAuthorityVerdict( + enabled=True, + consumed=True, + proceed_to_candidates=False, + terminal=False, + action_type=plan.action_type, + round_status="deferred", + reason=plan.rationale, + state_updates=updates, + event_payload=_event_payload(plan, consumed=True, enabled=True), + ) + + return CampaignAuthorityVerdict( + enabled=True, + consumed=False, + proceed_to_candidates=True, + terminal=False, + action_type=plan.action_type, + reason=f"unknown authority action {plan.action_type}; continuing safely", + event_payload=_event_payload(plan, consumed=False, enabled=True), + ) + + +def _state_updates_for_plan( + plan: CampaignDecisionPlan, +) -> tuple[CampaignAuthorityStateUpdate, ...]: + updates: list[CampaignAuthorityStateUpdate] = [] + + if plan.objective_patch is not None: + updates.append( + CampaignAuthorityStateUpdate( + update_type="objective_transition", + payload={ + "reason": plan.objective_patch.reason, + "proposed_changes": dict(plan.objective_patch.proposed_changes), + "source_action": plan.action_type.value, + "confidence": plan.confidence, + "auto_applied": False, + }, + ) + ) + + if plan.constraint_patch is not None: + updates.append( + CampaignAuthorityStateUpdate( + update_type="space_revision", + payload={ + "revision_type": "constraint_update", + "reason": plan.constraint_patch.reason, + "proposed_changes": dict(plan.constraint_patch.proposed_changes), + "source_action": plan.action_type.value, + "confidence": plan.confidence, + "approval_required": True, + "auto_applied": False, + }, + ) + ) + + requests = tuple(plan.context_requests) + if not requests: + synthetic = _synthetic_context_request(plan) + requests = (synthetic,) if synthetic is not None else () + + for request in requests: + updates.append( + CampaignAuthorityStateUpdate( + update_type="context_request", + payload={ + "request_type": request.request_type, + "reason": request.reason, + "priority": request.priority, + "target": request.target, + "payload": dict(request.payload), + "source_action": plan.action_type.value, + "confidence": plan.confidence, + }, + ) + ) + + if plan.action_type == CampaignDecisionAction.RECOVER_FAILURE: + updates.append( + CampaignAuthorityStateUpdate( + update_type="recovery_request", + payload={ + "reason": plan.rationale, + "route_target": plan.route_target or "recovery", + "source_action": plan.action_type.value, + "confidence": plan.confidence, + }, + ) + ) + + if plan.action_type == CampaignDecisionAction.RUN_VALIDATION: + updates.append( + CampaignAuthorityStateUpdate( + update_type="validation_request", + payload={ + "reason": plan.rationale, + "route_target": plan.route_target or "validation", + "source_action": plan.action_type.value, + "confidence": plan.confidence, + }, + ) + ) + + return tuple(updates) + + +def _synthetic_context_request( + plan: CampaignDecisionPlan, +) -> CampaignContextRequest | None: + if plan.action_type == CampaignDecisionAction.QUERY_LITERATURE: + return CampaignContextRequest( + request_type="literature_context", + reason=plan.rationale, + priority="high", + target=plan.route_target or "literature", + ) + if plan.action_type == CampaignDecisionAction.REQUEST_HUMAN_OBSERVATION: + return CampaignContextRequest( + request_type="human_observation", + reason=plan.rationale, + priority="high", + target=plan.route_target or "human_observation", + ) + return None + + +def _event_payload( + plan: CampaignDecisionPlan, + *, + consumed: bool, + enabled: bool, +) -> dict[str, Any]: + return { + "enabled": enabled, + "consumed": consumed, + "action_type": plan.action_type.value, + "rationale": plan.rationale, + "confidence": plan.confidence, + "shadow_only_plan": plan.shadow_only, + "route_target": plan.route_target, + "fallback_action": ( + plan.fallback_action.value if plan.fallback_action is not None else None + ), + "context_requests": [ + request.model_dump(mode="json") for request in plan.context_requests + ], + "metadata": dict(plan.metadata), + } diff --git a/app/services/campaign_mode.py b/app/services/campaign_mode.py index 712fd06..88756aa 100644 --- a/app/services/campaign_mode.py +++ b/app/services/campaign_mode.py @@ -53,6 +53,11 @@ class CampaignMode(StrEnum): """Scientific-activity mode proposed for the next round.""" BO_OPTIMIZATION = "bo_optimization" + EARLY_STAGE_SYSTEM_CHARACTERIZATION = "early_stage_system_characterization" + HARDWARE_FEASIBILITY_DISCOVERY = "hardware_feasibility_discovery" + CONTROLLABILITY_MAPPING = "controllability_mapping" + DATA_QUALITY_DIAGNOSTIC = "data_quality_diagnostic" + OBJECTIVE_DISCOVERY = "objective_discovery" VALIDATION = "validation" CALIBRATION = "calibration" FAILURE_DIAGNOSIS = "failure_diagnosis" diff --git a/app/services/closed_loop_drift.py b/app/services/closed_loop_drift.py new file mode 100644 index 0000000..e37d64c --- /dev/null +++ b/app/services/closed_loop_drift.py @@ -0,0 +1,969 @@ +"""Typed, replayable monitoring for long-horizon closed-loop drift. + +The monitor is deliberately pure: callers provide persisted campaign state and +decision trajectories, and receive a report. It never changes a strategy, +objective, parameter space, or hardware route. The orchestrator may feed the +report into the next round's decision context; the existing campaign-authority +gate remains the only live promotion boundary. +""" + +from __future__ import annotations + +import math +from collections import defaultdict +from datetime import UTC, datetime +from enum import StrEnum +from statistics import fmean, median, pstdev +from typing import Any +from uuid import uuid4 + +from pydantic import BaseModel, Field + +__all__ = [ + "CLOSED_LOOP_DRIFT_SCHEMA_VERSION", + "ClosedLoopDriftMonitor", + "ClosedLoopDriftReport", + "DriftSignal", + "DriftStatus", + "assess_closed_loop_drift", + "build_candidate_applicability_context", + "build_next_round_decision_memory", +] + + +CLOSED_LOOP_DRIFT_SCHEMA_VERSION = "closed_loop_drift.v1" +_MIN_WINDOW = 2 +_RECENT_ROUNDS = 2 +_MAX_MEMORY_RECORDS = 8 + + +class DriftStatus(StrEnum): + """Evidence strength for one monitored quantity.""" + + INSUFFICIENT = "insufficient" + STABLE = "stable" + WATCH = "watch" + DRIFT = "drift" + + +class DriftSignal(BaseModel): + """One auditable drift metric and the evidence used to calculate it.""" + + name: str + drift_type: str + status: DriftStatus + score: float | None = Field(default=None, ge=0.0, le=1.0) + sample_count: int = Field(default=0, ge=0) + baseline_count: int = Field(default=0, ge=0) + recent_count: int = Field(default=0, ge=0) + threshold: float | None = None + baseline_value: float | None = None + current_value: float | None = None + trend: float | None = None + evidence: list[str] = Field(default_factory=list) + metadata: dict[str, Any] = Field(default_factory=dict) + + +class ClosedLoopDriftReport(BaseModel): + """Round-scoped report threaded into the next decision context.""" + + report_id: str = Field(default_factory=lambda: f"cld-{uuid4().hex}") + schema_version: str = CLOSED_LOOP_DRIFT_SCHEMA_VERSION + campaign_id: str + round_index: int = Field(ge=0) + overall_status: DriftStatus + signals: list[DriftSignal] + requires_validation: bool = False + requires_objective_review: bool = False + requires_context_review: bool = False + safe_for_memory_reuse: bool = True + recommended_actions: list[str] = Field(default_factory=list) + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + metadata: dict[str, Any] = Field(default_factory=dict) + + +class ClosedLoopDriftMonitor: + """Assess the six closed-loop drift modes from four primary quantities.""" + + def assess( + self, + *, + campaign_id: str, + round_index: int, + parameters: list[dict[str, Any]] | None = None, + parameter_rounds: list[int] | None = None, + dimensions: list[dict[str, Any]] | None = None, + trajectories: list[dict[str, Any]] | None = None, + campaign_context: dict[str, Any] | None = None, + candidate_records: list[dict[str, Any]] | None = None, + decision_memory: dict[str, Any] | None = None, + ) -> ClosedLoopDriftReport: + context = dict(campaign_context or {}) + candidates = list(candidate_records or []) + recorded_parameters = [dict(row["params"]) for row in candidates if isinstance(row.get("params"), dict)] + recorded_rounds = [ + int(row["round_number"]) + for row in candidates + if isinstance(row.get("params"), dict) and isinstance(row.get("round_number"), int) + ] + if len(recorded_parameters) == len(recorded_rounds) and recorded_parameters: + observed_parameters = recorded_parameters + observed_rounds = recorded_rounds + else: + observed_parameters = list(parameters or []) + observed_rounds = list(parameter_rounds or []) + rows = _campaign_trajectories(trajectories or []) + memory = dict(decision_memory or build_next_round_decision_memory(rows)) + signals = [ + _observation_distribution_signal(observed_parameters, observed_rounds, dimensions or []), + _prediction_residual_signal(rows, context), + _objective_proxy_gap_signal(context, rows), + _replay_policy_signal(rows), + _measurement_drift_signal(context), + _context_drift_signal(memory), + _memory_applicability_signal(candidates, context), + ] + overall = _overall_status(signals) + by_name = {signal.name: signal for signal in signals} + + direct_validation_names = { + "prediction_outcome_residual", + "replay_policy_performance", + "measurement_telemetry", + } + requires_validation = any( + signal.status == DriftStatus.DRIFT and signal.name in direct_validation_names for signal in signals + ) + requires_validation = requires_validation or ( + by_name["observation_distribution"].status == DriftStatus.DRIFT + and any( + by_name[name].status in {DriftStatus.WATCH, DriftStatus.DRIFT} + for name in ( + "prediction_outcome_residual", + "replay_policy_performance", + ) + ) + ) + requires_objective_review = by_name["objective_proxy_gap"].status == DriftStatus.DRIFT + requires_context_review = by_name["decision_context_completeness"].status == DriftStatus.DRIFT + safe_for_memory_reuse = by_name["candidate_memory_applicability"].status not in { + DriftStatus.WATCH, + DriftStatus.DRIFT, + } + recommended_actions: list[str] = [] + if by_name["measurement_telemetry"].status == DriftStatus.DRIFT: + recommended_actions.append("validate_instrument_calibration") + if requires_validation: + recommended_actions.append("run_validation_before_more_candidates") + if requires_objective_review: + recommended_actions.append("review_proxy_against_scientific_objective") + if requires_context_review: + recommended_actions.append("complete_missing_decision_context") + if not safe_for_memory_reuse: + recommended_actions.append("block_unqualified_candidate_memory_reuse") + + return ClosedLoopDriftReport( + campaign_id=campaign_id, + round_index=round_index, + overall_status=overall, + signals=signals, + requires_validation=requires_validation, + requires_objective_review=requires_objective_review, + requires_context_review=requires_context_review, + safe_for_memory_reuse=safe_for_memory_reuse, + recommended_actions=list(dict.fromkeys(recommended_actions)), + metadata={ + "trajectory_count": len(rows), + "parameter_count": len(observed_parameters), + "decision_memory_count": int(memory.get("record_count", 0) or 0), + }, + ) + + +def assess_closed_loop_drift(**kwargs: Any) -> ClosedLoopDriftReport: + """Assess drift with the default monitor.""" + return ClosedLoopDriftMonitor().assess(**kwargs) + + +def build_next_round_decision_memory( + trajectories: list[dict[str, Any]], *, limit: int = _MAX_MEMORY_RECORDS +) -> dict[str, Any]: + """Project recent outcomes into bounded, next-round decision context. + + The projection keeps the reasons that are commonly lost in a closed loop: + strategy rationale, human-override reason, failure reasons, route changes, + and the applicability context under which the decision was made. + """ + rows = _campaign_trajectories(trajectories)[-max(1, limit) :] + records: list[dict[str, Any]] = [] + omissions: list[dict[str, Any]] = [] + for row in rows: + trajectory = _mapping(row.get("trajectory")) + trace = _mapping(trajectory.get("trace")) + plan = _mapping(trace.get("decision_plan")) + outcome = _mapping(trajectory.get("outcome")) + outcome_metadata = _mapping(outcome.get("metadata")) + trace_context = _mapping(trace.get("context")) + failure_count = int(outcome.get("failure_count", 0) or 0) + failure_reasons = _bounded_text_list( + outcome_metadata.get("failure_reasons"), limit=8, max_length=500 + ) + record = { + "trace_id": row.get("trace_id") or trace.get("trace_id"), + "round_index": row.get("round_index", trace.get("round_index")), + "selected_action": _bounded_text(plan.get("action_type"), 120), + "selected_backend": _bounded_text(plan.get("candidate_generation_backend"), 120), + "strategy_change_reason": _bounded_text(plan.get("rationale"), 1000), + "observed_action": _bounded_text(outcome.get("observed_action"), 120), + "observed_backend": _bounded_text(outcome.get("observed_backend"), 120), + "objective_delta": outcome.get("objective_delta"), + "proxy_gap_delta": outcome.get("proxy_gap_delta"), + "reward": row.get("reward"), + "failure_count": failure_count, + "failure_reasons": failure_reasons, + "human_override": outcome.get("human_override"), + "human_override_reason": _bounded_text(outcome_metadata.get("human_override_reason"), 500), + "route_changed": bool(trace.get("would_change_route", False)), + "context_requests": _bounded_context_requests(plan.get("context_requests", [])), + "applicability_context": _applicability_from_trace_context(trace_context), + } + trace_id = str(record["trace_id"] or "unknown") + if record["human_override"] is True and not record["human_override_reason"]: + omissions.append({"trace_id": trace_id, "missing": "human_override_reason"}) + if failure_count > 0 and not failure_reasons: + omissions.append({"trace_id": trace_id, "missing": "failure_reasons"}) + if record["route_changed"] and not record["strategy_change_reason"]: + omissions.append({"trace_id": trace_id, "missing": "strategy_change_reason"}) + records.append(record) + return { + "schema_version": "decision_memory_context.v1", + "record_count": len(records), + "records": records, + "omissions": omissions, + "latest_round": records[-1]["round_index"] if records else None, + } + + +def _bounded_context_requests(value: Any) -> list[dict[str, Any]]: + """Retain request intent without recursively copying prior context payloads.""" + if not isinstance(value, list): + return [] + requests: list[dict[str, Any]] = [] + for item in value[:8]: + if not isinstance(item, dict): + continue + request = _drop_empty( + { + "request_type": item.get("request_type"), + "reason": str(item.get("reason") or "")[:500], + "priority": item.get("priority"), + "target": item.get("target"), + } + ) + requests.append(request) + return requests + + +def build_candidate_applicability_context( + *, + objective_kpi: str, + direction: str, + campaign_context: dict[str, Any] | None = None, + protocol_pattern_id: str | None = None, + strategy: str | None = None, + backend: str | None = None, +) -> dict[str, Any]: + """Build a compact context fingerprint stored beside a candidate outcome.""" + context = dict(campaign_context or {}) + instrument_state = _mapping(context.get("instrument_state")) + drift = _mapping(context.get("closed_loop_drift_report")) + return _drop_empty( + { + "schema_version": "candidate_applicability.v1", + "objective_kpi": objective_kpi, + "direction": direction, + "current_objective_level": context.get("current_objective_level"), + "material_family": context.get("material_family"), + "active_experimental_node_id": context.get("active_experimental_node_id"), + "protocol_pattern_id": protocol_pattern_id, + "strategy": strategy, + "backend": backend, + "instrument_id": instrument_state.get("instrument_id"), + "calibration_id": instrument_state.get("calibration_id"), + "drift_status": drift.get("overall_status"), + "drift_report_id": drift.get("report_id"), + } + ) + + +def _observation_distribution_signal( + parameters: list[dict[str, Any]], + rounds: list[int], + dimensions: list[dict[str, Any]], +) -> DriftSignal: + if len(parameters) != len(rounds) or not rounds: + return _insufficient( + "observation_distribution", + "strategy_drift", + len(parameters), + "Aligned parameter and round histories are required.", + ) + latest_round = max(rounds) + cutoff = latest_round - _RECENT_ROUNDS + 1 + baseline = [params for params, round_no in zip(parameters, rounds, strict=True) if round_no < cutoff] + recent = [params for params, round_no in zip(parameters, rounds, strict=True) if round_no >= cutoff] + if len(baseline) < _MIN_WINDOW or len(recent) < _MIN_WINDOW: + return _insufficient_windows( + "observation_distribution", + "strategy_drift", + baseline, + recent, + "Need at least two baseline and two recent candidates.", + ) + + scores: list[float] = [] + evidence: list[str] = [] + for dim in dimensions: + name = str(dim.get("param_name") or dim.get("name") or "") + if not name: + continue + base_values = [item[name] for item in baseline if name in item] + recent_values = [item[name] for item in recent if name in item] + if not base_values or not recent_values: + continue + if _all_numeric(base_values + recent_values): + low = _as_float(dim.get("min_value", dim.get("min"))) + high = _as_float(dim.get("max_value", dim.get("max"))) + span = abs(high - low) if low is not None and high is not None else None + if not span: + combined = [float(value) for value in base_values + recent_values] + span = max(combined) - min(combined) + if not span: + score = 0.0 + else: + mean_shift = ( + abs(fmean(float(value) for value in recent_values) - fmean(float(value) for value in base_values)) + / span + ) + spread_shift = ( + abs(pstdev(float(value) for value in recent_values) - pstdev(float(value) for value in base_values)) + / span + ) + score = _clamp(mean_shift + 0.5 * spread_shift) + else: + score = _categorical_total_variation(base_values, recent_values) + scores.append(score) + if len(evidence) < 16: + evidence.append(f"{name} distribution shift={score:.3g}") + if not scores: + return _insufficient_windows( + "observation_distribution", + "strategy_drift", + baseline, + recent, + "No comparable parameter dimensions were available.", + ) + score = round(fmean(scores), 10) + return _scored_signal( + name="observation_distribution", + drift_type="strategy_drift", + score=score, + sample_count=len(parameters), + baseline_count=len(baseline), + recent_count=len(recent), + watch_threshold=0.2, + drift_threshold=0.4, + evidence=evidence, + metadata={"latest_round": latest_round, "recent_round_cutoff": cutoff}, + ) + + +def _prediction_residual_signal(rows: list[dict[str, Any]], context: dict[str, Any]) -> DriftSignal: + runtime_pairs = [ + (predicted, outcome) + for observation in context.get("closed_loop_observations", []) or [] + if isinstance(observation, dict) + for predicted, outcome in [ + ( + _as_float(observation.get("predicted_value", observation.get("predicted_kpi"))), + _as_float(observation.get("outcome_value")), + ) + ] + if predicted is not None and outcome is not None + ] + if len(runtime_pairs) >= _MIN_WINDOW * 2: + nonzero_outcomes = [abs(outcome) for _, outcome in runtime_pairs if outcome] + outcome_scale = median(nonzero_outcomes) if nonzero_outcomes else 1.0 + values = [_clamp(abs(predicted - outcome) / outcome_scale) for predicted, outcome in runtime_pairs] + return _residual_shift_signal( + values, + scale=outcome_scale, + source="runtime_prediction", + ) + + residuals: list[tuple[float, float]] = [] + for row in rows: + trajectory = _mapping(row.get("trajectory")) + trace = _mapping(trajectory.get("trace")) + plan = _mapping(trace.get("decision_plan")) + strategy_trace = _mapping(plan.get("strategy_trace")) + outcome = _mapping(trajectory.get("outcome")) + expected = _selected_expected_improvement( + strategy_trace, + selected_backend=plan.get("candidate_generation_backend"), + ) + delta = _as_float(outcome.get("objective_delta")) + if expected is None or delta is None: + continue + residuals.append((expected, delta)) + if len(residuals) < _MIN_WINDOW * 2: + return _insufficient( + "prediction_outcome_residual", + "model_drift", + len(residuals), + "Need four decisions with expected improvement and final objective delta.", + ) + nonzero_deltas = [abs(delta) for _expected, delta in residuals if delta != 0] + delta_scale = median(nonzero_deltas) if nonzero_deltas else 1.0 + values = [abs(expected - _clamp(max(delta, 0.0) / delta_scale)) for expected, delta in residuals] + return _residual_shift_signal( + values, + scale=delta_scale, + source="strategy_expected_improvement", + ) + + +def _residual_shift_signal(values: list[float], *, scale: float, source: str) -> DriftSignal: + baseline, recent = _split_series(values) + baseline_mean = fmean(baseline) + recent_mean = fmean(recent) + degradation = max(0.0, recent_mean - baseline_mean) + score = _clamp(max(recent_mean, degradation * 2.0)) + return _scored_signal( + name="prediction_outcome_residual", + drift_type="model_drift", + score=score, + sample_count=len(values), + baseline_count=len(baseline), + recent_count=len(recent), + watch_threshold=0.3, + drift_threshold=0.5, + baseline_value=_round(baseline_mean), + current_value=_round(recent_mean), + trend=_round(recent_mean - baseline_mean), + evidence=[ + f"recent mean normalized residual={recent_mean:.3g}", + f"historical mean normalized residual={baseline_mean:.3g}", + ], + metadata={"residual_scale": _round(scale), "source": source}, + ) + + +def _objective_proxy_gap_signal(context: dict[str, Any], rows: list[dict[str, Any]]) -> DriftSignal: + current = _proxy_gap_score(context) + history = [ + value + for value in ( + _as_float(item.get("score")) for item in context.get("proxy_gap_history", []) if isinstance(item, dict) + ) + if value is not None + ] + divergences = _proxy_scientific_divergences(context) + if divergences: + current = fmean(divergences[-_MIN_WINDOW:]) + history = divergences[:-_MIN_WINDOW] + if current is None: + proxy_deltas = [ + _as_float(_mapping(_mapping(row.get("trajectory")).get("outcome")).get("proxy_gap_delta")) for row in rows + ] + observed = [value for value in proxy_deltas if value is not None] + if observed: + current = _clamp(0.5 + fmean(observed[-_MIN_WINDOW:])) + history = [_clamp(0.5 + value) for value in observed[:-_MIN_WINDOW]] + if current is None: + return _insufficient( + "objective_proxy_gap", + "target_drift", + 0, + "No proxy-gap assessment or paired proxy/scientific outcomes were recorded.", + ) + baseline = fmean(history) if history else None + trend = current - baseline if baseline is not None else None + score = _clamp(max(current, (trend or 0.0) * 2.0)) + return _scored_signal( + name="objective_proxy_gap", + drift_type="target_drift", + score=score, + sample_count=len(history) + 1, + baseline_count=len(history), + recent_count=1, + watch_threshold=0.35, + drift_threshold=0.6, + baseline_value=_round(baseline) if baseline is not None else None, + current_value=_round(current), + trend=_round(trend) if trend is not None else None, + evidence=[ + f"current proxy-to-scientific gap={current:.3g}", + "high gap or an expanding gap indicates target drift", + ], + ) + + +def _replay_policy_signal(rows: list[dict[str, Any]]) -> DriftSignal: + scored = [row for row in rows if _as_float(row.get("reward")) is not None] + if len(scored) < 6: + return _insufficient( + "replay_policy_performance", + "strategy_drift", + len(scored), + "Need at least six scored trajectories for recent-versus-history replay.", + ) + split = max(3, len(scored) - 3) + historical = scored[:split] + recent = scored[split:] + current_policy = _policy_key(recent[-1]) + current_rows = [row for row in recent if _policy_key(row) == current_policy] + if len(current_rows) < _MIN_WINDOW: + return _insufficient_windows( + "replay_policy_performance", + "strategy_drift", + historical, + current_rows, + "Need two recent scored outcomes from the same current policy.", + ) + historical_by_policy: dict[str, list[float]] = defaultdict(list) + for row in historical: + reward = _as_float(row.get("reward")) + if reward is not None: + historical_by_policy[_policy_key(row)].append(reward) + eligible = {policy: rewards for policy, rewards in historical_by_policy.items() if len(rewards) >= _MIN_WINDOW} + if not eligible: + return _insufficient_windows( + "replay_policy_performance", + "strategy_drift", + historical, + current_rows, + "Historical policies lack two comparable outcomes.", + ) + best_policy, best_rewards = max(eligible.items(), key=lambda item: fmean(item[1])) + current_mean = fmean(float(row["reward"]) for row in current_rows) + historical_mean = fmean(best_rewards) + underperformance = max(0.0, historical_mean - current_mean) + score = _clamp(underperformance / 2.0) + return _scored_signal( + name="replay_policy_performance", + drift_type="strategy_drift", + score=score, + sample_count=len(scored), + baseline_count=len(best_rewards), + recent_count=len(current_rows), + watch_threshold=0.15, + drift_threshold=0.3, + baseline_value=_round(historical_mean), + current_value=_round(current_mean), + trend=_round(current_mean - historical_mean), + evidence=[ + f"recent policy={current_policy} mean reward={current_mean:.3g}", + f"historical policy={best_policy} mean reward={historical_mean:.3g}", + "This is replay evidence, not a causal counterfactual.", + ], + metadata={"current_policy": current_policy, "historical_policy": best_policy}, + ) + + +def _measurement_drift_signal(context: dict[str, Any]) -> DriftSignal: + observations = [item for item in context.get("closed_loop_observations", []) if isinstance(item, dict)] + numeric: dict[str, list[float]] = defaultdict(list) + for observation in observations: + telemetry = _mapping(observation.get("telemetry")) + for key, value in _flatten_numeric(telemetry).items(): + numeric[key].append(value) + confidence = _calibration_confidence(context, observations) + field_scores: list[tuple[str, float, float, float]] = [] + for key, values in numeric.items(): + if len(values) < _MIN_WINDOW * 2: + continue + baseline, recent = _split_series(values) + base_mean = fmean(baseline) + recent_mean = fmean(recent) + scale = max(pstdev(baseline), abs(base_mean) * 0.05, 1e-9) + score = _clamp(abs(recent_mean - base_mean) / (4.0 * scale)) + field_scores.append((key, score, base_mean, recent_mean)) + if confidence is None and not field_scores: + return _insufficient( + "measurement_telemetry", + "measurement_drift", + len(observations), + "No calibration confidence or repeated numeric telemetry was recorded.", + ) + score = max((item[1] for item in field_scores), default=0.0) + if confidence is not None: + score = max(score, _clamp(1.0 - confidence)) + evidence = [ + f"{key}: baseline={baseline:.3g}, recent={recent:.3g}, shift={field_score:.3g}" + for key, field_score, baseline, recent in sorted(field_scores, key=lambda item: item[1], reverse=True)[:5] + ] + if confidence is not None: + evidence.append(f"current calibration confidence={confidence:.3g}") + return _scored_signal( + name="measurement_telemetry", + drift_type="measurement_drift", + score=score, + sample_count=len(observations), + baseline_count=max(0, len(observations) - _MIN_WINDOW), + recent_count=min(_MIN_WINDOW, len(observations)), + watch_threshold=0.3, + drift_threshold=0.5, + current_value=_round(confidence) if confidence is not None else None, + evidence=evidence, + ) + + +def _context_drift_signal(memory: dict[str, Any]) -> DriftSignal: + omissions = [item for item in memory.get("omissions", []) if isinstance(item, dict)] + record_count = int(memory.get("record_count", 0) or 0) + if record_count == 0: + return _insufficient( + "decision_context_completeness", + "context_drift", + 0, + "No prior decisions exist yet.", + ) + ratio = len(omissions) / max(record_count, 1) + score = _clamp(ratio) + return _scored_signal( + name="decision_context_completeness", + drift_type="context_drift", + score=score, + sample_count=record_count, + baseline_count=record_count, + recent_count=1, + watch_threshold=0.1, + drift_threshold=0.3, + current_value=_round(ratio), + evidence=[f"{item.get('trace_id')}: missing {item.get('missing')}" for item in omissions[:8]] + or ["Recent decision records retain required reasons and failure context."], + metadata={"omission_count": len(omissions)}, + ) + + +def _memory_applicability_signal(records: list[dict[str, Any]], context: dict[str, Any]) -> DriftSignal: + successful = [row for row in records if row.get("status") == "completed" and row.get("kpi_value") is not None] + if not successful: + return _insufficient( + "candidate_memory_applicability", + "memory_eviction_error", + 0, + "No successful candidate-memory records exist yet.", + ) + current = _current_applicability_context(context) + missing: list[dict[str, Any]] = [] + mismatched: list[dict[str, Any]] = [] + for row in successful: + stored = _mapping(row.get("applicability_context")) + if not stored: + missing.append(row) + continue + if any( + key in current and key in stored and current[key] != stored[key] for key in _APPLICABILITY_COMPARISON_KEYS + ): + mismatched.append(row) + unsafe_count = len(missing) + len(mismatched) + ratio = unsafe_count / len(successful) + return _scored_signal( + name="candidate_memory_applicability", + drift_type="memory_eviction_error", + score=ratio, + sample_count=len(successful), + baseline_count=len(successful), + recent_count=len(successful), + watch_threshold=0.01, + drift_threshold=0.5, + current_value=_round(ratio), + evidence=[ + f"{len(missing)} of {len(successful)} successful candidates lack applicability context.", + f"{len(mismatched)} of {len(successful)} successful candidates mismatch the current context.", + ], + metadata={ + "unqualified_candidate_count": unsafe_count, + "missing_context_count": len(missing), + "mismatched_context_count": len(mismatched), + }, + ) + + +_APPLICABILITY_COMPARISON_KEYS = ( + "objective_kpi", + "direction", + "current_objective_level", + "material_family", + "active_experimental_node_id", + "instrument_id", + "calibration_id", +) + + +def _current_applicability_context(context: dict[str, Any]) -> dict[str, Any]: + hierarchy = [item for item in context.get("objective_hierarchy", []) if isinstance(item, dict)] + objective = hierarchy[0] if hierarchy else {} + instrument = _mapping(context.get("instrument_state")) + return _drop_empty( + { + "objective_kpi": objective.get("metric") or context.get("scientific_goal"), + "direction": objective.get("direction"), + "current_objective_level": context.get("current_objective_level"), + "material_family": context.get("material_family"), + "active_experimental_node_id": context.get("active_experimental_node_id"), + "instrument_id": instrument.get("instrument_id"), + "calibration_id": instrument.get("calibration_id"), + } + ) + + +def _selected_expected_improvement(strategy_trace: dict[str, Any], *, selected_backend: Any = None) -> float | None: + selected = str(strategy_trace.get("selected_mode") or strategy_trace.get("selected_intent") or "") + actions = [action for action in strategy_trace.get("available_actions", []) or [] if isinstance(action, dict)] + for action in actions: + if selected and str(action.get("name")) != selected: + continue + value = _as_float(action.get("expected_improvement")) + if value is not None: + return _clamp(value) + for action in actions: + if selected_backend and str(action.get("backend_name")) != str(selected_backend): + continue + value = _as_float(action.get("expected_improvement")) + if value is not None: + return _clamp(value) + return None + + +def _proxy_gap_score(context: dict[str, Any]) -> float | None: + candidates = [ + context.get("objective_proxy_gap"), + context.get("proxy_gap_score"), + _mapping(context.get("proxy_gap_assessment")).get("score"), + _mapping(context.get("objective_summary")).get("proxy_gap_score"), + _mapping(_mapping(context.get("objective_summary")).get("proxy_gap_assessment")).get("score"), + ] + for value in candidates: + parsed = _as_float(value) + if parsed is not None: + return _clamp(parsed) + return None + + +def _proxy_scientific_divergences(context: dict[str, Any]) -> list[float]: + divergences: list[float] = [] + for item in context.get("closed_loop_observations", []) or []: + if not isinstance(item, dict): + continue + proxy = _as_float(item.get("proxy_value")) + scientific = _as_float(item.get("scientific_value")) + if proxy is None or scientific is None: + continue + scale = max(abs(proxy), abs(scientific), 1e-9) + divergences.append(_clamp(abs(proxy - scientific) / scale)) + return divergences + + +def _calibration_confidence(context: dict[str, Any], observations: list[dict[str, Any]]) -> float | None: + instrument_state = _mapping(context.get("instrument_state")) + candidates: list[Any] = [] + for observation in reversed(observations): + candidates.extend( + [ + observation.get("calibration_confidence"), + _mapping(observation.get("telemetry")).get("calibration_confidence"), + ] + ) + candidates.extend( + [ + instrument_state.get("calibration_confidence"), + context.get("calibration_confidence"), + ] + ) + for value in candidates: + parsed = _as_float(value) + if parsed is not None: + return _clamp(parsed) + return None + + +def _policy_key(row: dict[str, Any]) -> str: + trajectory = _mapping(row.get("trajectory")) + outcome = _mapping(trajectory.get("outcome")) + trace = _mapping(trajectory.get("trace")) + plan = _mapping(trace.get("decision_plan")) + return str( + outcome.get("observed_backend") + or plan.get("candidate_generation_backend") + or plan.get("action_type") + or "unknown" + ) + + +def _campaign_trajectories(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [row for row in rows if row.get("layer", "campaign") == "campaign"] + + +def _applicability_from_trace_context(context: dict[str, Any]) -> dict[str, Any]: + objective = _mapping(context.get("objective_summary")) + metadata = _mapping(context.get("metadata")) + return _drop_empty( + { + "objective_kpi": objective.get("objective_kpi"), + "direction": objective.get("direction"), + "target_value": objective.get("target_value"), + "round_strategy": metadata.get("round_strategy"), + "planned_strategy": metadata.get("planned_strategy"), + "drift_status": _mapping(context.get("drift_summary")).get("overall_status"), + } + ) + + +def _flatten_numeric(value: dict[str, Any], prefix: str = "") -> dict[str, float]: + result: dict[str, float] = {} + for key, item in value.items(): + name = f"{prefix}.{key}" if prefix else str(key) + if isinstance(item, bool): + continue + if isinstance(item, int | float) and math.isfinite(float(item)): + result[name] = float(item) + elif isinstance(item, dict): + result.update(_flatten_numeric(item, name)) + return result + + +def _categorical_total_variation(baseline: list[Any], recent: list[Any]) -> float: + values = {str(value) for value in baseline + recent} + return 0.5 * sum( + abs( + sum(1 for item in baseline if str(item) == value) / len(baseline) + - sum(1 for item in recent if str(item) == value) / len(recent) + ) + for value in values + ) + + +def _split_series(values: list[float]) -> tuple[list[float], list[float]]: + recent_count = max(_MIN_WINDOW, min(3, len(values) // 2)) + return values[:-recent_count], values[-recent_count:] + + +def _scored_signal( + *, + name: str, + drift_type: str, + score: float, + sample_count: int, + baseline_count: int, + recent_count: int, + watch_threshold: float, + drift_threshold: float, + baseline_value: float | None = None, + current_value: float | None = None, + trend: float | None = None, + evidence: list[str] | None = None, + metadata: dict[str, Any] | None = None, +) -> DriftSignal: + score = _clamp(score) + if score >= drift_threshold: + status = DriftStatus.DRIFT + elif score >= watch_threshold: + status = DriftStatus.WATCH + else: + status = DriftStatus.STABLE + return DriftSignal( + name=name, + drift_type=drift_type, + status=status, + score=_round(score), + sample_count=sample_count, + baseline_count=baseline_count, + recent_count=recent_count, + threshold=drift_threshold, + baseline_value=baseline_value, + current_value=current_value, + trend=trend, + evidence=list(evidence or []), + metadata=dict(metadata or {}), + ) + + +def _insufficient(name: str, drift_type: str, sample_count: int, reason: str) -> DriftSignal: + return DriftSignal( + name=name, + drift_type=drift_type, + status=DriftStatus.INSUFFICIENT, + sample_count=sample_count, + evidence=[reason], + ) + + +def _insufficient_windows( + name: str, + drift_type: str, + baseline: list[Any], + recent: list[Any], + reason: str, +) -> DriftSignal: + signal = _insufficient(name, drift_type, len(baseline) + len(recent), reason) + signal.baseline_count = len(baseline) + signal.recent_count = len(recent) + return signal + + +def _overall_status(signals: list[DriftSignal]) -> DriftStatus: + if any(signal.status == DriftStatus.DRIFT for signal in signals): + return DriftStatus.DRIFT + if any(signal.status == DriftStatus.WATCH for signal in signals): + return DriftStatus.WATCH + if any(signal.status == DriftStatus.STABLE for signal in signals): + return DriftStatus.STABLE + return DriftStatus.INSUFFICIENT + + +def _mapping(value: Any) -> dict[str, Any]: + return dict(value) if isinstance(value, dict) else {} + + +def _drop_empty(value: dict[str, Any]) -> dict[str, Any]: + return {key: item for key, item in value.items() if item not in (None, "", [], {})} + + +def _bounded_text(value: Any, max_length: int) -> str | None: + if value is None: + return None + return str(value)[:max_length] + + +def _bounded_text_list(value: Any, *, limit: int, max_length: int) -> list[str]: + if not isinstance(value, list | tuple): + return [] + return [str(item)[:max_length] for item in value[:limit]] + + +def _all_numeric(values: list[Any]) -> bool: + return all( + not isinstance(value, bool) and isinstance(value, int | float) and math.isfinite(float(value)) + for value in values + ) + + +def _as_float(value: Any) -> float | None: + if isinstance(value, bool) or value is None: + return None + try: + parsed = float(value) + except (TypeError, ValueError): + return None + return parsed if math.isfinite(parsed) else None + + +def _clamp(value: float) -> float: + return max(0.0, min(1.0, value)) + + +def _round(value: float | None) -> float | None: + return None if value is None else round(float(value), 10) diff --git a/app/services/closed_loop_runtime.py b/app/services/closed_loop_runtime.py new file mode 100644 index 0000000..563243b --- /dev/null +++ b/app/services/closed_loop_runtime.py @@ -0,0 +1,383 @@ +"""Runtime adapter for closed-loop drift monitoring. + +This module owns the bounded translation between orchestrator/runtime payloads +and the pure drift monitor. It may persist campaign context and emit a report, +but it never changes campaign strategy, objectives, parameter space, or routes. +""" + +from __future__ import annotations + +import logging +import math +import time +from collections.abc import Callable +from typing import Any + +from app.core.config import get_settings + +logger = logging.getLogger(__name__) + +__all__ = [ + "assess_and_persist_closed_loop_drift", + "bounded_proxy_gap", + "bounded_runtime_state", + "current_proxy_gap_delta", + "extract_closed_loop_runtime_signals", + "human_override_from_steps", + "record_closed_loop_observation", + "sanitize_closed_loop_observation", +] + + +def human_override_from_steps( + steps: list[dict[str, Any]], +) -> tuple[bool | None, str | None]: + """Return an auditable operator-override signal from round step results.""" + reasons: list[str] = [] + for step in steps: + if not isinstance(step, dict): + continue + status = str(step.get("status") or "") + reason = str(step.get("human_override_reason") or step.get("reason") or step.get("rejection_reason") or "") + explicit_override = step.get("human_override") is True + human_reason = any(token in reason.lower() for token in ("operator", "human", "manual", "user")) + if explicit_override or (status == "rejected" and human_reason): + reasons.append(reason or "operator rejected candidate") + elif status == "approval_timeout": + reasons.append("operator approval timed out") + if not reasons: + return None, None + return True, "; ".join(dict.fromkeys(reasons)) + + +def current_proxy_gap_delta(campaign_context: dict[str, Any], prior_summary: dict[str, Any]) -> float | None: + """Calculate the current observed proxy-gap change for outcome accounting.""" + gaps: list[float] = [] + for observation in campaign_context.get("closed_loop_observations", []) or []: + if not isinstance(observation, dict): + continue + proxy = observation.get("proxy_value", observation.get("proxy_kpi")) + scientific = observation.get( + "scientific_value", + observation.get( + "scientific_objective_value", + observation.get("functional_outcome"), + ), + ) + if ( + isinstance(proxy, int | float) + and not isinstance(proxy, bool) + and isinstance(scientific, int | float) + and not isinstance(scientific, bool) + and math.isfinite(float(proxy)) + and math.isfinite(float(scientific)) + ): + scale = max(abs(float(proxy)), abs(float(scientific)), 1e-9) + gaps.append(min(1.0, abs(float(proxy) - float(scientific)) / scale)) + + current: float | None = None + if gaps: + current = sum(gaps[-2:]) / min(len(gaps), 2) + else: + assessment = campaign_context.get("proxy_gap_assessment") + score = assessment.get("score") if isinstance(assessment, dict) else None + if isinstance(score, int | float) and not isinstance(score, bool): + current = max(0.0, min(1.0, float(score))) + if current is None: + return None + + previous: float | None = None + for signal in prior_summary.get("signals", []) or []: + if not isinstance(signal, dict) or signal.get("name") != "objective_proxy_gap": + continue + value = signal.get("current_value") + if isinstance(value, int | float) and not isinstance(value, bool): + previous = float(value) + break + if previous is None and len(gaps) > 2: + previous = sum(gaps[:-2]) / len(gaps[:-2]) + return current - previous if previous is not None else None + + +def assess_and_persist_closed_loop_drift( + *, + campaign_id: str, + round_index: int, + campaign_context: dict[str, Any], + parameters: list[dict[str, Any]], + parameter_rounds: list[int], + dimensions: list[dict[str, Any]], + emit: Callable[[dict[str, Any]], None], + force: bool = False, +) -> tuple[dict[str, Any], dict[str, Any]]: + """Persist and emit a best-effort report for the next decision context.""" + existing_report = dict(campaign_context.get("closed_loop_drift_report") or {}) + existing_memory = dict(campaign_context.get("decision_memory") or {}) + try: + if not getattr(get_settings(), "closed_loop_drift_monitor_enabled", True): + return {}, existing_memory + if not force and existing_report and existing_report.get("round_index") == round_index: + return existing_report, existing_memory + + from app.services.campaign_state import ( + load_all_candidates, + save_campaign_context, + ) + from app.services.closed_loop_drift import ( + assess_closed_loop_drift, + build_next_round_decision_memory, + ) + from app.services.decision_trajectory import load_trajectories + + trajectories = load_trajectories(campaign_id) + decision_memory = build_next_round_decision_memory(trajectories) + report = assess_closed_loop_drift( + campaign_id=campaign_id, + round_index=round_index, + parameters=parameters, + parameter_rounds=parameter_rounds, + dimensions=dimensions, + trajectories=trajectories, + campaign_context=campaign_context, + candidate_records=load_all_candidates(campaign_id), + decision_memory=decision_memory, + ) + report_payload = report.model_dump(mode="json") + campaign_context["decision_memory"] = decision_memory + campaign_context["closed_loop_drift_report"] = report_payload + history = list(campaign_context.get("closed_loop_drift_history", []) or []) + history.append( + { + "report_id": report.report_id, + "round_index": report.round_index, + "overall_status": report.overall_status.value, + "requires_validation": report.requires_validation, + "requires_objective_review": report.requires_objective_review, + "requires_context_review": report.requires_context_review, + "safe_for_memory_reuse": report.safe_for_memory_reuse, + } + ) + campaign_context["closed_loop_drift_history"] = history[-50:] + save_campaign_context(campaign_id, campaign_context) + emit( + { + "type": "closed_loop_drift_report", + "round": round_index, + "report_id": report.report_id, + "status": report.overall_status.value, + "requires_validation": report.requires_validation, + "requires_objective_review": report.requires_objective_review, + "requires_context_review": report.requires_context_review, + "safe_for_memory_reuse": report.safe_for_memory_reuse, + "signals": [ + { + "name": signal.name, + "drift_type": signal.drift_type, + "status": signal.status.value, + "score": signal.score, + } + for signal in report.signals + ], + "message": ( + f"Closed-loop drift: {report.overall_status.value}; " + f"actions={','.join(report.recommended_actions) or 'none'}" + ), + } + ) + return report_payload, decision_memory + except Exception: + logger.warning( + "Closed-loop drift monitor failed; preserving prior context", + exc_info=True, + ) + return existing_report, existing_memory + + +def extract_closed_loop_runtime_signals(outputs: Any) -> dict[str, Any]: + """Extract a bounded, explicit drift contract from worker outputs.""" + if not isinstance(outputs, dict): + return {} + nested = outputs.get("closed_loop_signals") + nested = dict(nested) if isinstance(nested, dict) else {} + result: dict[str, Any] = {} + telemetry = _bounded_numeric_runtime_mapping(nested.get("telemetry", outputs.get("telemetry"))) + calibration = bounded_runtime_state(nested.get("calibration", outputs.get("calibration"))) + instrument_state = bounded_runtime_state(nested.get("instrument_state", outputs.get("instrument_state"))) + for key in ("calibration_id", "calibration_confidence", "calibrated_at"): + if key in calibration and key not in instrument_state: + instrument_state[key] = calibration[key] + if telemetry: + result["telemetry"] = telemetry + if calibration: + result["calibration"] = calibration + if instrument_state: + result["instrument_state"] = instrument_state + for key in ( + "predicted_value", + "predicted_kpi", + "proxy_value", + "proxy_kpi", + "scientific_value", + "scientific_objective_value", + "functional_outcome", + "calibration_confidence", + ): + value = nested.get(key, outputs.get(key)) + if isinstance(value, int | float) and not isinstance(value, bool) and math.isfinite(float(value)): + result[key] = float(value) + return result + + +_RUNTIME_STATE_KEYS = { + "instrument_id", + "calibration_id", + "calibration_confidence", + "calibrated_at", + "firmware_version", +} + + +def bounded_runtime_state(value: Any) -> dict[str, Any]: + """Keep only small scalar instrument/calibration identity fields.""" + if not isinstance(value, dict): + return {} + result: dict[str, Any] = {} + for key in _RUNTIME_STATE_KEYS: + item = value.get(key) + if isinstance(item, bool) or item is None: + continue + if isinstance(item, int | float) and math.isfinite(float(item)): + result[key] = float(item) + elif isinstance(item, str): + result[key] = item[:256] + return result + + +def _bounded_numeric_runtime_mapping(value: Any) -> dict[str, float]: + """Flatten at most 32 finite numeric telemetry fields to a depth of three.""" + if not isinstance(value, dict): + return {} + result: dict[str, float] = {} + + def _walk(node: dict[str, Any], prefix: str, depth: int) -> None: + if depth > 3 or len(result) >= 32: + return + for raw_key, item in node.items(): + if len(result) >= 32: + break + key = str(raw_key)[:80] + name = f"{prefix}.{key}" if prefix else key + if isinstance(item, bool): + continue + if isinstance(item, int | float) and math.isfinite(float(item)): + result[name] = float(item) + elif isinstance(item, dict): + _walk(item, name, depth + 1) + + _walk(value, "", 0) + return result + + +def sanitize_closed_loop_observation(value: Any) -> dict[str, Any]: + """Normalize user-seeded observations to the bounded runtime contract.""" + if not isinstance(value, dict): + return {} + result: dict[str, Any] = {} + for key in ( + "round_index", + "candidate_index", + "outcome_value", + "predicted_value", + "predicted_kpi", + "proxy_value", + "proxy_kpi", + "scientific_value", + "scientific_objective_value", + "functional_outcome", + "calibration_confidence", + ): + item = value.get(key) + if isinstance(item, int | float) and not isinstance(item, bool) and math.isfinite(float(item)): + result[key] = float(item) + for key in ("strategy", "backend", "failure_reason", "run_id", "recorded_at"): + item = value.get(key) + if isinstance(item, str): + result[key] = item[:500] + telemetry = _bounded_numeric_runtime_mapping(value.get("telemetry")) + if telemetry: + result["telemetry"] = telemetry + return result + + +def bounded_proxy_gap(value: Any) -> dict[str, Any]: + """Normalize an explicit proxy-gap assessment to a compact safe shape.""" + if not isinstance(value, dict): + return {} + result: dict[str, Any] = {} + score = value.get("score") + if isinstance(score, int | float) and not isinstance(score, bool) and math.isfinite(float(score)): + result["score"] = max(0.0, min(1.0, float(score))) + for key in ("level", "source", "reason", "recorded_at"): + item = value.get(key) + if isinstance(item, str): + result[key] = item[:500] + return result + + +def record_closed_loop_observation( + *, + campaign_id: str, + campaign_context: dict[str, Any], + round_number: int, + candidate_index: int, + parameters: dict[str, Any], + kpi: float | None, + step_result: dict[str, Any], + strategy: str, + backend: str | None, + failure_reason: str | None = None, +) -> None: + """Persist one bounded, explicitly recognized closed-loop observation.""" + runtime = dict(step_result.get("closed_loop_signals") or {}) + telemetry: dict[str, Any] = {} + for source in (runtime.get("telemetry"), runtime.get("calibration")): + if isinstance(source, dict): + telemetry.update(source) + observation = { + "round_index": round_number, + "candidate_index": candidate_index, + "parameters": dict(parameters), + "outcome_value": kpi, + "predicted_value": runtime.get("predicted_value", runtime.get("predicted_kpi")), + "proxy_value": runtime.get("proxy_value", runtime.get("proxy_kpi")), + "scientific_value": runtime.get( + "scientific_value", + runtime.get("scientific_objective_value", runtime.get("functional_outcome")), + ), + "calibration_confidence": runtime.get("calibration_confidence"), + "telemetry": telemetry, + "strategy": strategy, + "backend": backend, + "failure_reason": failure_reason, + "run_id": step_result.get("run_id"), + "recorded_at": time.time(), + } + observations = campaign_context.setdefault("closed_loop_observations", []) + observations.append({key: value for key, value in observation.items() if value not in (None, {}, [])}) + if len(observations) > 200: + del observations[:-200] + instrument_state = runtime.get("instrument_state") + if isinstance(instrument_state, dict): + campaign_context["instrument_state"] = { + **dict(campaign_context.get("instrument_state") or {}), + **bounded_runtime_state(instrument_state), + } + try: + from app.services.campaign_state import save_campaign_context + + save_campaign_context(campaign_id, campaign_context) + except Exception: + logger.debug( + "Failed to checkpoint closed-loop drift observation", + exc_info=True, + ) diff --git a/app/services/decision_markdown.py b/app/services/decision_markdown.py new file mode 100644 index 0000000..fb95e63 --- /dev/null +++ b/app/services/decision_markdown.py @@ -0,0 +1,867 @@ +"""Deterministic Markdown projections for HELIOS scientific decisions. + +The runtime DTOs and SQLite rows remain the transactional source of truth. +This module produces a human-readable, Git-friendly projection of the same +decision accounting bundle without calling databases, Git, or live services. +""" +from __future__ import annotations + +import hashlib +import json +import re +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from datetime import date, datetime +from enum import Enum +from typing import Any + +import yaml + +from app.services.decision_outcome import CampaignDecisionAccounting +from app.services.decision_trace import CampaignDecisionTrace + +DECISION_CARD_SCHEMA_VERSION = "helios.decision-card/v1" +LEDGER_RENDERER_VERSION = "1" +REDACTED = "[REDACTED]" + +_SECRET_KEY_PARTS = ( + "api_key", + "apikey", + "authorization", + "cookie", + "credential", + "password", + "private_key", + "secret", + "set_cookie", + "token", +) +_BEARER_RE = re.compile(r"(?i)\bbearer\s+[a-z0-9._~+/=-]{8,}") +_OPENAI_KEY_RE = re.compile(r"\bsk-[A-Za-z0-9_-]{12,}\b") +_JWT_RE = re.compile(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b") + + +@dataclass(frozen=True) +class LedgerProvenance: + """Version identities attached to every rendered scientific artifact.""" + + code_commit: str | None = None + policy_id: str | None = None + policy_version: str | None = None + nexus_contract_version: str | None = None + rubric_version: str | None = None + renderer_version: str = LEDGER_RENDERER_VERSION + extra: Mapping[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class RenderedDecisionBundle: + """A complete set of relative Markdown files for one decision lifecycle.""" + + campaign_id: str + trace_id: str + round_index: int + status: str + source_sha256: str + files: Mapping[str, str] + + +def redact_sensitive(value: Any, *, key: str | None = None) -> Any: + """Recursively redact credentials while preserving scientific structure.""" + if key is not None and _is_secret_key(key): + return REDACTED + if isinstance(value, Mapping): + return { + str(item_key): redact_sensitive(item_value, key=str(item_key)) + for item_key, item_value in sorted(value.items(), key=lambda item: str(item[0])) + } + if isinstance(value, (list, tuple, set, frozenset)): + return [redact_sensitive(item) for item in value] + if isinstance(value, Enum): + return redact_sensitive(value.value) + if isinstance(value, (datetime, date)): + return value.isoformat() + if hasattr(value, "model_dump"): + return redact_sensitive(value.model_dump(mode="json")) + if isinstance(value, str): + return _redact_text(value) + if value is None or isinstance(value, (bool, int, float)): + return value + return _redact_text(str(value)) + + +def render_pending_decision( + trace: CampaignDecisionTrace, + *, + provenance: LedgerProvenance | None = None, + campaign_metadata: Mapping[str, Any] | None = None, +) -> RenderedDecisionBundle: + """Render a decision immediately, before its experimental outcome exists.""" + return _render_bundle( + trace=trace, + accounting=None, + provenance=provenance or LedgerProvenance(), + campaign_metadata=campaign_metadata or {}, + observations=(), + failures=(), + recovery_events=(), + ) + + +def render_completed_decision( + accounting: CampaignDecisionAccounting, + *, + provenance: LedgerProvenance | None = None, + campaign_metadata: Mapping[str, Any] | None = None, + observations: Sequence[Any] | None = None, + failures: Sequence[Any] | None = None, + recovery_events: Sequence[Any] | None = None, +) -> RenderedDecisionBundle: + """Render the completed trace, outcome, verifiers, reward, and recovery.""" + return _render_bundle( + trace=accounting.trace, + accounting=accounting, + provenance=provenance or LedgerProvenance(), + campaign_metadata=campaign_metadata or {}, + observations=observations or (), + failures=failures or (), + recovery_events=recovery_events or (), + ) + + +def _render_bundle( + *, + trace: CampaignDecisionTrace, + accounting: CampaignDecisionAccounting | None, + provenance: LedgerProvenance, + campaign_metadata: Mapping[str, Any], + observations: Sequence[Any], + failures: Sequence[Any], + recovery_events: Sequence[Any], +) -> RenderedDecisionBundle: + status = "completed" if accounting is not None else "pending" + source = { + "trace": trace.model_dump(mode="json"), + "outcome": accounting.outcome.model_dump(mode="json") if accounting else None, + "reward": accounting.reward.model_dump(mode="json") if accounting else None, + "observations": list(observations), + "failures": list(failures), + "recovery_events": list(recovery_events), + "campaign_metadata": dict(campaign_metadata), + "provenance": _provenance_dict(provenance), + } + redacted_source = redact_sensitive(source) + source_hash = _stable_hash(redacted_source) + prefix = f"rounds/{trace.round_index:03d}" + metadata = _card_metadata(trace, status, source_hash, provenance) + + files = { + f"{prefix}/objective.md": _render_objective(trace, campaign_metadata, metadata), + f"{prefix}/observations.md": _render_observations(trace, observations, metadata), + f"{prefix}/decision_{trace.round_index:03d}.md": _render_decision_card( + trace, + accounting, + provenance, + source_hash, + failures, + recovery_events, + ), + f"{prefix}/strategy.md": _render_strategy(trace, metadata), + f"{prefix}/evidence.md": _render_evidence(trace, metadata), + f"{prefix}/failure.md": _render_failures(failures, metadata), + f"{prefix}/recovery.md": _render_recovery(recovery_events, metadata), + f"{prefix}/summary.md": _render_round_summary( + trace, accounting, failures, recovery_events, metadata + ), + } + return RenderedDecisionBundle( + campaign_id=trace.campaign_id, + trace_id=trace.trace_id, + round_index=trace.round_index, + status=status, + source_sha256=source_hash, + files=files, + ) + + +def _render_decision_card( + trace: CampaignDecisionTrace, + accounting: CampaignDecisionAccounting | None, + provenance: LedgerProvenance, + source_hash: str, + failures: Sequence[Any], + recovery_events: Sequence[Any], +) -> str: + plan = trace.decision_plan + metadata = _card_metadata( + trace, + "completed" if accounting else "pending", + source_hash, + provenance, + ) + metadata.update( + { + "confidence": plan.confidence, + "reward": accounting.reward.reward if accounting else None, + "selected_action": plan.action_type.value, + "selected_backend": plan.candidate_generation_backend or "unspecified", + } + ) + strategy = redact_sensitive(plan.strategy_trace) + actions = _candidate_actions(strategy) + selected = plan.action_type.value + if plan.candidate_generation_backend: + selected = f"{selected} via {plan.candidate_generation_backend}" + + lines = [ + _front_matter(metadata), + f"# Decision Card {trace.round_index:03d}", + "", + "## Question", + "", + "What should happen next in this scientific campaign?", + "", + "## Context", + "", + _mapping_markdown(redact_sensitive(trace.context.model_dump(mode="json"))), + "", + "## Evidence", + "", + _evidence_table(trace), + "", + "## Candidate Actions", + "", + _action_table(actions, plan), + "", + "## Chosen", + "", + f"**{_safe_inline(selected)}**", + "", + "## Decision Rationale", + "", + _safe_paragraph(plan.rationale), + "", + "## Confidence and Expected Gain", + "", + f"- Confidence: {_format_scalar(plan.confidence)}", + f"- Expected gain: {_expected_gain(actions, plan)}", + f"- Fallback: {_safe_inline(plan.fallback_action.value if plan.fallback_action else 'None')}", + f"- Shadow only: {'yes' if plan.shadow_only else 'no'}", + "", + "## Outcome", + "", + ] + if accounting is None: + lines.extend(["**Pending** — the decision has been recorded before execution.", ""]) + else: + lines.extend([_outcome_table(accounting), ""]) + lines.extend(["## Reward and Verification", ""]) + if accounting is None: + lines.extend(["Pending outcome evaluation.", ""]) + else: + lines.extend([_reward_section(accounting), ""]) + lines.extend( + [ + "## Failure and Recovery", + "", + f"- Failure events: {len(failures)}", + f"- Recovery events: {len(recovery_events)}", + "", + "## Reproducibility", + "", + _mapping_markdown(_provenance_dict(provenance)), + "", + ] + ) + return _finish(lines) + + +def _render_objective( + trace: CampaignDecisionTrace, + campaign_metadata: Mapping[str, Any], + metadata: Mapping[str, Any], +) -> str: + objective: Any = trace.context.objective_summary + if not objective: + objective = campaign_metadata.get("objective", {}) + if not isinstance(objective, Mapping): + objective = {"value": objective} + return _finish( + [ + _front_matter({**metadata, "artifact_type": "objective"}), + f"# Objective — Round {trace.round_index}", + "", + _mapping_markdown(redact_sensitive(objective)), + "", + ] + ) + + +def _render_observations( + trace: CampaignDecisionTrace, + observations: Sequence[Any], + metadata: Mapping[str, Any], +) -> str: + combined = [*trace.context.human_observations, *list(observations)] + lines = [ + _front_matter({**metadata, "artifact_type": "observations"}), + f"# Observations — Round {trace.round_index}", + "", + ] + if not combined: + lines.extend(["No observations were recorded.", ""]) + else: + for index, observation in enumerate(combined, 1): + lines.extend([f"## Observation {index}", "", _value_markdown(redact_sensitive(observation)), ""]) + return _finish(lines) + + +def _render_strategy(trace: CampaignDecisionTrace, metadata: Mapping[str, Any]) -> str: + plan = trace.decision_plan + strategy = redact_sensitive(plan.strategy_trace) + actions = _candidate_actions(strategy) + lines = [ + _front_matter({**metadata, "artifact_type": "strategy"}), + f"# Strategy — Round {trace.round_index}", + "", + "## Selected Route", + "", + f"- Campaign intent: {_safe_inline(plan.campaign_intent or 'Unspecified')}", + f"- Optimization mode: {_safe_inline(plan.optimization_mode or 'Unspecified')}", + f"- Backend: {_safe_inline(plan.candidate_generation_backend or 'Unspecified')}", + f"- Confidence: {_format_scalar(plan.confidence)}", + "", + "## Ranked Actions", + "", + _action_table(actions, plan), + "", + "## Strategy Trace", + "", + _mapping_markdown(strategy), + "", + ] + return _finish(lines) + + +def _render_evidence(trace: CampaignDecisionTrace, metadata: Mapping[str, Any]) -> str: + return _finish( + [ + _front_matter({**metadata, "artifact_type": "evidence"}), + f"# Evidence — Round {trace.round_index}", + "", + _evidence_table(trace), + "", + ] + ) + + +def _render_failures(failures: Sequence[Any], metadata: Mapping[str, Any]) -> str: + lines = [ + _front_matter({**metadata, "artifact_type": "failure"}), + "# Failure Record", + "", + ] + if not failures: + lines.extend(["No failure events were recorded.", ""]) + else: + for index, failure in enumerate(failures, 1): + payload = redact_sensitive(failure) + lines.extend( + [ + f"## Failure {index}", + "", + f"**Problem:** {_failure_label(payload)}", + "", + "### Evidence and Attribution", + "", + _value_markdown(payload), + "", + ] + ) + return _finish(lines) + + +def _render_recovery(recovery_events: Sequence[Any], metadata: Mapping[str, Any]) -> str: + lines = [ + _front_matter({**metadata, "artifact_type": "recovery"}), + "# Recovery Record", + "", + ] + if not recovery_events: + lines.extend(["No recovery action was recorded.", ""]) + else: + for index, event in enumerate(recovery_events, 1): + lines.extend( + [ + f"## Recovery {index}", + "", + _value_markdown(redact_sensitive(event)), + "", + ] + ) + return _finish(lines) + + +def _render_round_summary( + trace: CampaignDecisionTrace, + accounting: CampaignDecisionAccounting | None, + failures: Sequence[Any], + recovery_events: Sequence[Any], + metadata: Mapping[str, Any], +) -> str: + plan = trace.decision_plan + lines = [ + _front_matter({**metadata, "artifact_type": "round_summary"}), + f"# Round {trace.round_index} Summary", + "", + f"- Decision: {_safe_inline(plan.action_type.value)}", + f"- Backend: {_safe_inline(plan.candidate_generation_backend or 'Unspecified')}", + f"- Confidence: {_format_scalar(plan.confidence)}", + f"- Failures: {len(failures)}", + f"- Recoveries: {len(recovery_events)}", + ] + if accounting is None: + lines.extend(["- Outcome: Pending", ""]) + else: + outcome = accounting.outcome + lines.extend( + [ + f"- Outcome: {'Success' if outcome.execution_success else 'Failure' if outcome.execution_success is False else 'Unknown'}", + f"- Objective delta: {_format_scalar(outcome.objective_delta)}", + f"- Reward: {_format_scalar(accounting.reward.reward)}", + "", + ] + ) + return _finish(lines) + + +def render_campaign_overview( + *, + campaign_id: str, + decision_rows: Sequence[Mapping[str, Any]], + campaign_metadata: Mapping[str, Any] | None = None, +) -> str: + """Render the campaign-level index and current scientific state.""" + safe_rows = [redact_sensitive(row) for row in decision_rows] + metadata = redact_sensitive(campaign_metadata or {}) + lines = [ + _front_matter( + { + "artifact_type": "campaign", + "campaign_id": campaign_id, + "decision_count": len(safe_rows), + "schema_version": DECISION_CARD_SCHEMA_VERSION, + } + ), + f"# Campaign {_safe_inline(campaign_id)}", + "", + "## Objective and Metadata", + "", + _mapping_markdown(metadata), + "", + "## Decision Index", + "", + "| Round | Trace | Action | Backend | Confidence | Status | Reward |", + "|---:|---|---|---|---:|---|---:|", + ] + for row in sorted(safe_rows, key=lambda item: (int(item.get("round_index", 0)), str(item.get("trace_id", "")))): + lines.append( + "| {round_index} | {trace_id} | {action} | {backend} | {confidence} | {status} | {reward} |".format( + round_index=_safe_table(row.get("round_index")), + trace_id=_safe_table(row.get("trace_id")), + action=_safe_table(row.get("selected_action") or row.get("action")), + backend=_safe_table(row.get("selected_backend") or row.get("backend")), + confidence=_safe_table(_format_scalar(row.get("confidence"))), + status=_safe_table(row.get("status")), + reward=_safe_table(_format_scalar(row.get("reward"))), + ) + ) + if not safe_rows: + lines.append("| — | — | — | — | — | — | — |") + lines.append("") + return _finish(lines) + + +def render_policy_snapshot( + *, + campaign_id: str, + policy: Mapping[str, Any], + provenance: LedgerProvenance, +) -> str: + safe_policy = redact_sensitive(policy) + return _finish( + [ + _front_matter( + { + "artifact_type": "policy", + "campaign_id": campaign_id, + "policy_id": provenance.policy_id or "unversioned", + "policy_version": provenance.policy_version or "unversioned", + "source_sha256": _stable_hash(safe_policy), + } + ), + "# Decision Policy", + "", + "## Version", + "", + f"- Policy ID: {_safe_inline(provenance.policy_id or 'unversioned')}", + f"- Policy version: {_safe_inline(provenance.policy_version or 'unversioned')}", + f"- Code commit: {_safe_inline(provenance.code_commit or 'unknown')}", + "", + "## Policy State", + "", + _mapping_markdown(safe_policy), + "", + ] + ) + + +def render_nexus_snapshot( + *, + campaign_id: str, + diagnostics: Mapping[str, Any], + provenance: LedgerProvenance, +) -> str: + safe_diagnostics = redact_sensitive(diagnostics) + return _finish( + [ + _front_matter( + { + "artifact_type": "nexus", + "campaign_id": campaign_id, + "contract_version": provenance.nexus_contract_version or "unknown", + "source_sha256": _stable_hash(safe_diagnostics), + } + ), + "# Nexus Optimization Health", + "", + f"- Contract version: {_safe_inline(provenance.nexus_contract_version or 'unknown')}", + "", + "## Diagnostics", + "", + _mapping_markdown(safe_diagnostics), + "", + ] + ) + + +def render_trajectory_figure( + *, campaign_id: str, decision_rows: Sequence[Mapping[str, Any]] +) -> str: + """Render a Mermaid trajectory suitable for review and paper-figure export.""" + rows = sorted( + (redact_sensitive(row) for row in decision_rows), + key=lambda item: (int(item.get("round_index", 0)), str(item.get("trace_id", ""))), + ) + lines = [ + _front_matter( + { + "artifact_type": "trajectory_figure", + "campaign_id": campaign_id, + "decision_count": len(rows), + } + ), + "# Decision Trajectory", + "", + "```mermaid", + "flowchart LR", + ] + if not rows: + lines.append(' empty["No decisions recorded"]') + for index, row in enumerate(rows): + node = f"d{index}" + label = ( + f"R{row.get('round_index', '?')} · " + f"{row.get('selected_action') or row.get('action', 'unknown')}" + f" · {row.get('status', 'unknown')} · reward={_format_scalar(row.get('reward'))}" + ) + lines.append(f' {node}["{_mermaid_text(label)}"]') + if index: + lines.append(f" d{index - 1} --> {node}") + lines.extend(["```", ""]) + return _finish(lines) + + +def _card_metadata( + trace: CampaignDecisionTrace, + status: str, + source_hash: str, + provenance: LedgerProvenance, +) -> dict[str, Any]: + return { + "artifact_type": "decision_card", + "campaign_id": trace.campaign_id, + "code_commit": provenance.code_commit or "unknown", + "created_at": trace.created_at.isoformat(), + "nexus_contract_version": provenance.nexus_contract_version or "unknown", + "policy_id": provenance.policy_id or "unversioned", + "policy_version": provenance.policy_version or "unversioned", + "renderer_version": provenance.renderer_version, + "round_index": trace.round_index, + "schema_version": DECISION_CARD_SCHEMA_VERSION, + "source_sha256": source_hash, + "status": status, + "trace_id": trace.trace_id, + } + + +def _provenance_dict(provenance: LedgerProvenance) -> dict[str, Any]: + return redact_sensitive( + { + "code_commit": provenance.code_commit or "unknown", + "policy_id": provenance.policy_id or "unversioned", + "policy_version": provenance.policy_version or "unversioned", + "nexus_contract_version": provenance.nexus_contract_version or "unknown", + "rubric_version": provenance.rubric_version or "unknown", + "renderer_version": provenance.renderer_version, + **dict(provenance.extra), + } + ) + + +def _candidate_actions(strategy: Any) -> list[Mapping[str, Any]]: + if not isinstance(strategy, Mapping): + return [] + for key in ("available_actions", "actions", "actions_considered"): + value = strategy.get(key) + if isinstance(value, list | tuple): + return [item for item in value if isinstance(item, Mapping)] + return [] + + +def _action_table(actions: Sequence[Mapping[str, Any]], plan: Any) -> str: + lines = [ + "| Rank | Action | Backend | Expected improvement | Information gain | Risk | Utility | Reason |", + "|---:|---|---|---:|---:|---:|---:|---|", + ] + ranked = sorted( + actions, + key=lambda action: float(action.get("utility", 0.0) or 0.0), + reverse=True, + ) + for index, action in enumerate(ranked, 1): + lines.append( + "| {rank} | {action} | {backend} | {improvement} | {info} | {risk} | {utility} | {reason} |".format( + rank=index, + action=_safe_table(action.get("name") or action.get("action") or "unknown"), + backend=_safe_table(action.get("backend_name") or action.get("backend") or "—"), + improvement=_safe_table(_format_scalar(action.get("expected_improvement"))), + info=_safe_table(_format_scalar(action.get("expected_info_gain"))), + risk=_safe_table(_format_scalar(action.get("risk"))), + utility=_safe_table(_format_scalar(action.get("utility"))), + reason=_safe_table(action.get("reason") or ""), + ) + ) + if not ranked: + lines.append( + "| 1 | {action} | {backend} | — | — | — | — | {reason} |".format( + action=_safe_table(plan.action_type.value), + backend=_safe_table(plan.candidate_generation_backend or "—"), + reason=_safe_table(plan.rationale), + ) + ) + return "\n".join(lines) + + +def _evidence_table(trace: CampaignDecisionTrace) -> str: + lines = [ + "| Source | Kind | Summary | Weight |", + "|---|---|---|---:|", + ] + for evidence in trace.evidence: + lines.append( + f"| {_safe_table(evidence.source)} | {_safe_table(evidence.kind)} | " + f"{_safe_table(evidence.summary)} | {_safe_table(_format_scalar(evidence.weight))} |" + ) + if not trace.evidence: + lines.append("| — | — | No structured evidence was recorded. | — |") + return "\n".join(lines) + + +def _outcome_table(accounting: CampaignDecisionAccounting) -> str: + outcome = accounting.outcome + rows = [ + ("Observed action", outcome.observed_action), + ("Observed backend", outcome.observed_backend), + ("Candidate count", outcome.candidate_count), + ("Execution success", outcome.execution_success), + ("Failure count", outcome.failure_count), + ("Safety incidents", outcome.safety_incident_count), + ("Objective delta", outcome.objective_delta), + ("Proxy-gap delta", outcome.proxy_gap_delta), + ("Validation success", outcome.validation_success), + ("Recovery attempted", outcome.recovery_attempted), + ("Recovery success", outcome.recovery_success), + ("Context fulfilled", outcome.context_request_fulfilled), + ("Human override", outcome.human_override), + ] + return "\n".join( + ["| Field | Value |", "|---|---|"] + + [f"| {_safe_table(name)} | {_safe_table(_format_scalar(value))} |" for name, value in rows] + ) + + +def _reward_section(accounting: CampaignDecisionAccounting) -> str: + reward = accounting.reward + lines = [ + f"- Total reward: {_format_scalar(reward.reward)}", + f"- Process reward: {_format_scalar(reward.process_reward)}", + f"- Outcome reward: {_format_scalar(reward.outcome_reward)}", + f"- Regret: {_format_scalar(reward.regret)}", + f"- Rubric version: {_safe_inline(reward.rubric_version)}", + f"- Rationale: {_safe_paragraph(reward.rationale)}", + "", + "| Verifier | Passed | Score | Evidence |", + "|---|---|---:|---|", + ] + for verification in reward.verifications: + payload = verification.model_dump(mode="json") + lines.append( + f"| {_safe_table(payload.get('name'))} | {_safe_table(payload.get('passed'))} | " + f"{_safe_table(_format_scalar(payload.get('score')))} | " + f"{_safe_table(payload.get('rationale') or payload.get('evidence') or '')} |" + ) + return "\n".join(lines) + + +def _expected_gain(actions: Sequence[Mapping[str, Any]], plan: Any) -> str: + backend = plan.candidate_generation_backend + selected = next( + ( + action + for action in actions + if action.get("backend_name") == backend or action.get("backend") == backend + ), + actions[0] if actions else None, + ) + if not selected: + return "not quantified" + improvement = _format_scalar(selected.get("expected_improvement")) + information = _format_scalar(selected.get("expected_info_gain")) + utility = _format_scalar(selected.get("utility")) + return f"improvement={improvement}, information_gain={information}, utility={utility}" + + +def _mapping_markdown(value: Any) -> str: + if not isinstance(value, Mapping) or not value: + return "No structured data was recorded." + lines = [] + for path, item in _flatten_mapping(value): + lines.append(f"- **{_safe_inline(path)}:** {_safe_inline(_format_scalar(item))}") + return "\n".join(lines) if lines else "No structured data was recorded." + + +def _value_markdown(value: Any) -> str: + if isinstance(value, Mapping): + return _mapping_markdown(value) + if isinstance(value, list): + if not value: + return "No entries." + return "\n".join(f"- {_safe_inline(_format_scalar(item))}" for item in value) + return _safe_paragraph(_format_scalar(value)) + + +def _flatten_mapping(value: Mapping[str, Any], prefix: str = "") -> list[tuple[str, Any]]: + flattened: list[tuple[str, Any]] = [] + for key in sorted(value, key=str): + path = f"{prefix}.{key}" if prefix else str(key) + item = value[key] + if isinstance(item, Mapping): + flattened.extend(_flatten_mapping(item, path)) + elif isinstance(item, list) and item and all(isinstance(entry, Mapping) for entry in item): + for index, entry in enumerate(item): + flattened.extend(_flatten_mapping(entry, f"{path}[{index}]")) + else: + flattened.append((path, item)) + return flattened + + +def _front_matter(metadata: Mapping[str, Any]) -> str: + safe = redact_sensitive(metadata) + dumped = yaml.safe_dump( + safe, + allow_unicode=True, + default_flow_style=False, + sort_keys=True, + ).strip() + return f"---\n{dumped}\n---" + + +def _stable_hash(value: Any) -> str: + encoded = json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + default=str, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _is_secret_key(key: str) -> bool: + normalized = re.sub(r"[^a-z0-9]+", "_", key.casefold()).strip("_") + return any(part in normalized for part in _SECRET_KEY_PARTS) + + +def _redact_text(text: str) -> str: + redacted = _BEARER_RE.sub(REDACTED, text) + redacted = _OPENAI_KEY_RE.sub(REDACTED, redacted) + return _JWT_RE.sub(REDACTED, redacted) + + +def _failure_label(payload: Any) -> str: + if isinstance(payload, Mapping): + for key in ("problem", "failure_type", "error_type", "error", "message", "reason"): + value = payload.get(key) + if value: + return _safe_inline(_format_scalar(value)) + return _safe_inline(_format_scalar(payload)) + + +def _format_scalar(value: Any) -> str: + if value is None: + return "—" + if isinstance(value, bool): + return "yes" if value else "no" + if isinstance(value, float): + return f"{value:.6g}" + if isinstance(value, Mapping): + return "; ".join(f"{key}={_format_scalar(item)}" for key, item in sorted(value.items())) + if isinstance(value, (list, tuple, set, frozenset)): + return ", ".join(_format_scalar(item) for item in value) if value else "—" + return _redact_text(str(value)) + + +def _safe_inline(value: Any) -> str: + return _redact_text(str(value)).replace("\n", " ").replace("\r", " ").replace("`", "'") + + +def _safe_paragraph(value: Any) -> str: + return _redact_text(str(value)).replace("\r\n", "\n").replace("\r", "\n") + + +def _safe_table(value: Any) -> str: + return _safe_inline(value).replace("|", "\\|") + + +def _mermaid_text(value: Any) -> str: + return _safe_inline(value).replace('"', "'").replace("[", "(").replace("]", ")") + + +def _finish(lines: Sequence[str]) -> str: + return "\n".join(str(line).rstrip() for line in lines).rstrip() + "\n" + + +__all__ = [ + "DECISION_CARD_SCHEMA_VERSION", + "LEDGER_RENDERER_VERSION", + "LedgerProvenance", + "RenderedDecisionBundle", + "redact_sensitive", + "render_campaign_overview", + "render_completed_decision", + "render_nexus_snapshot", + "render_pending_decision", + "render_policy_snapshot", + "render_trajectory_figure", +] diff --git a/app/services/decision_outcome.py b/app/services/decision_outcome.py index e4b25cc..1c725c0 100644 --- a/app/services/decision_outcome.py +++ b/app/services/decision_outcome.py @@ -23,6 +23,7 @@ verify_failure, verify_objective, verify_proxy_gap, + verify_recovery, verify_safety, verify_validation, ) @@ -73,6 +74,8 @@ class CampaignDecisionOutcome(BaseModel): objective_delta: float | None = None proxy_gap_delta: float | None = None validation_success: bool | None = None + recovery_attempted: bool = False + recovery_success: bool | None = None context_request_fulfilled: bool | None = None human_override: bool | None = None created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) @@ -97,6 +100,7 @@ class CampaignDecisionReward(BaseModel): objective_reward: float = 0.0 proxy_gap_reward: float = 0.0 validation_reward: float = 0.0 + recovery_reward: float = 0.0 context_reward: float = 0.0 # Phase A (RLVR wedge): version the rubric, split process vs outcome credit, # and carry the per-signal verifiable records. Defaults keep older callers @@ -142,6 +146,8 @@ def build( objective_delta: float | None = None, proxy_gap_delta: float | None = None, validation_success: bool | None = None, + recovery_attempted: bool = False, + recovery_success: bool | None = None, context_request_fulfilled: bool | None = None, human_override: bool | None = None, metadata: dict[str, Any] | None = None, @@ -159,6 +165,8 @@ def build( objective_delta=objective_delta, proxy_gap_delta=proxy_gap_delta, validation_success=validation_success, + recovery_attempted=recovery_attempted, + recovery_success=recovery_success, context_request_fulfilled=context_request_fulfilled, human_override=human_override, metadata=deepcopy(dict(metadata or {})), @@ -179,6 +187,10 @@ def calculate(self, outcome: CampaignDecisionOutcome) -> CampaignDecisionReward: verify_objective(outcome.objective_delta), verify_proxy_gap(outcome.proxy_gap_delta), verify_validation(outcome.validation_success), + verify_recovery( + attempted=outcome.recovery_attempted, + success=outcome.recovery_success, + ), verify_context(outcome.context_request_fulfilled), ] scores = {v.name: v.score for v in verifications} @@ -188,6 +200,7 @@ def calculate(self, outcome: CampaignDecisionOutcome) -> CampaignDecisionReward: objective_reward = scores["objective"] proxy_gap_reward = scores["proxy_gap"] validation_reward = scores["validation"] + recovery_reward = scores["recovery"] context_reward = scores["context"] raw_reward = sum(v.score for v in verifications) reward = _clamp(raw_reward) @@ -202,6 +215,7 @@ def calculate(self, outcome: CampaignDecisionOutcome) -> CampaignDecisionReward: objective_reward=objective_reward, proxy_gap_reward=proxy_gap_reward, validation_reward=validation_reward, + recovery_reward=recovery_reward, context_reward=context_reward, rubric_version=RUBRIC_VERSION_DEFAULT, process_reward=process_reward, @@ -214,6 +228,7 @@ def calculate(self, outcome: CampaignDecisionOutcome) -> CampaignDecisionReward: objective_reward=objective_reward, proxy_gap_reward=proxy_gap_reward, validation_reward=validation_reward, + recovery_reward=recovery_reward, context_reward=context_reward, raw_reward=raw_reward, reward=reward, @@ -289,6 +304,7 @@ def _reward_rationale( objective_reward: float, proxy_gap_reward: float, validation_reward: float, + recovery_reward: float, context_reward: float, raw_reward: float, reward: float, @@ -300,6 +316,7 @@ def _reward_rationale( "objective": objective_reward, "proxy_gap": proxy_gap_reward, "validation": validation_reward, + "recovery": recovery_reward, "context": context_reward, } nonzero = [ diff --git a/app/services/decision_replay.py b/app/services/decision_replay.py index 8c0412b..79bc673 100644 --- a/app/services/decision_replay.py +++ b/app/services/decision_replay.py @@ -41,9 +41,11 @@ class CampaignDecisionReplaySummary(BaseModel): average_objective_reward: float = 0.0 average_proxy_gap_reward: float = 0.0 average_validation_reward: float = 0.0 + average_recovery_reward: float = 0.0 average_context_reward: float = 0.0 context_request_fulfillment_rate: float | None = None validation_success_rate: float | None = None + recovery_success_rate: float | None = None human_override_rate: float | None = None rationale: str created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) @@ -118,6 +120,9 @@ def analyze( average_validation_reward=_mean( [accounting.reward.validation_reward for accounting in accountings] ), + average_recovery_reward=_mean( + [accounting.reward.recovery_reward for accounting in accountings] + ), average_context_reward=_mean( [accounting.reward.context_reward for accounting in accountings] ), @@ -130,6 +135,13 @@ def analyze( validation_success_rate=_optional_bool_rate( [accounting.outcome.validation_success for accounting in accountings] ), + recovery_success_rate=_optional_bool_rate( + [ + accounting.outcome.recovery_success + for accounting in accountings + if accounting.outcome.recovery_attempted + ] + ), human_override_rate=_optional_bool_rate( [accounting.outcome.human_override for accounting in accountings] ), diff --git a/app/services/decision_trajectory.py b/app/services/decision_trajectory.py index b88c5f9..28e6d16 100644 --- a/app/services/decision_trajectory.py +++ b/app/services/decision_trajectory.py @@ -23,6 +23,7 @@ __all__ = [ "TRAJECTORY_SCHEMA_VERSION", "persist_campaign_trajectory", + "persist_loop_trajectory", "load_trajectories", "export_trajectories_jsonl", ] @@ -30,22 +31,18 @@ TRAJECTORY_SCHEMA_VERSION = "1" -def persist_campaign_trajectory( - accounting: CampaignDecisionAccounting, *, layer: str = "campaign" +def _insert_row( + *, + campaign_id: str, + trace_id: str | None, + round_index: int | None, + layer: str, + reward: Any, + trajectory: dict[str, Any], ) -> str: - """Insert one append-only trajectory row from a decision accounting bundle. - - Returns the new row id. Never updates or deletes. - """ - outcome = accounting.outcome - reward = accounting.reward + """Insert one append-only trajectory row. Returns the new row id.""" row_id = f"traj-{uuid4().hex}" - verifier_report = [v.model_dump() for v in reward.verifications] - trajectory = { - "trace": accounting.trace.model_dump(mode="json"), - "outcome": outcome.model_dump(mode="json"), - "reward": reward.model_dump(mode="json"), - } + verifier_report = [v.model_dump() for v in getattr(reward, "verifications", []) or []] def _insert(conn: Any) -> None: conn.execute( @@ -59,14 +56,14 @@ def _insert(conn: Any) -> None: """, ( row_id, - outcome.campaign_id, - outcome.trace_id, - outcome.round_index, + campaign_id, + trace_id, + round_index, layer, - reward.rubric_version, + getattr(reward, "rubric_version", "v0.1_static"), reward.reward, - reward.process_reward, - reward.outcome_reward, + getattr(reward, "process_reward", 0.0), + getattr(reward, "outcome_reward", 0.0), db.json_dumps(verifier_report), db.json_dumps(trajectory), TRAJECTORY_SCHEMA_VERSION, @@ -78,6 +75,45 @@ def _insert(conn: Any) -> None: return row_id +def persist_loop_trajectory( + campaign_id: str, round_index: int, reward: Any, *, state: dict | None = None +) -> str: + """Persist a live loop-layer decision (LoopReward) as a trajectory row.""" + return _insert_row( + campaign_id=campaign_id, + trace_id=getattr(reward, "iteration_id", None), + round_index=round_index, + layer="loop", + reward=reward, + trajectory={ + "reward": reward.model_dump(mode="json"), + "state": state or {}, + }, + ) + + +def persist_campaign_trajectory( + accounting: CampaignDecisionAccounting, *, layer: str = "campaign" +) -> str: + """Insert one append-only trajectory row from a decision accounting bundle. + + Returns the new row id. Never updates or deletes. + """ + outcome = accounting.outcome + return _insert_row( + campaign_id=outcome.campaign_id, + trace_id=outcome.trace_id, + round_index=outcome.round_index, + layer=layer, + reward=accounting.reward, + trajectory={ + "trace": accounting.trace.model_dump(mode="json"), + "outcome": outcome.model_dump(mode="json"), + "reward": accounting.reward.model_dump(mode="json"), + }, + ) + + def load_trajectories(campaign_id: str | None = None) -> list[dict[str, Any]]: """Return trajectory rows (optionally scoped to one campaign), oldest first.""" with db.connection() as conn: diff --git a/app/services/dynamic_action_space.py b/app/services/dynamic_action_space.py index 79a4e5d..e629caa 100644 --- a/app/services/dynamic_action_space.py +++ b/app/services/dynamic_action_space.py @@ -324,6 +324,74 @@ def _label_for_mode( f"Action '{action.name}' may proceed while awaiting human observation." ) + if mode == CampaignMode.OBJECTIVE_DISCOVERY: + if kind in {"objective_discovery", "kpi_discovery", "diagnostic"}: + return ActionShadowLabel.PREFERRED, ( + f"Objective-discovery mode prefers action '{action.name}'." + ) + if kind in {"optimization", "experiment"}: + return ActionShadowLabel.RISKY, ( + f"Objective-discovery mode holds '{kind}' action '{action.name}' " + "until the campaign objective is clarified." + ) + return ActionShadowLabel.NEUTRAL, ( + f"Action '{action.name}' does not clarify the campaign objective." + ) + + if mode == CampaignMode.CONTROLLABILITY_MAPPING: + if kind in {"controllability_mapping", "control_probe", "calibration", "diagnostic"}: + return ActionShadowLabel.PREFERRED, ( + f"Controllability-mapping mode prefers action '{action.name}'." + ) + if kind == "optimization": + return ActionShadowLabel.RISKY, ( + f"Controllability-mapping mode gates optimization action '{action.name}'." + ) + return ActionShadowLabel.NEUTRAL, ( + f"Action '{action.name}' does not map target-vs-actual control." + ) + + if mode == CampaignMode.HARDWARE_FEASIBILITY_DISCOVERY: + if kind in {"hardware_feasibility", "feasibility_mapping", "diagnostic"}: + return ActionShadowLabel.PREFERRED, ( + f"Hardware-feasibility mode prefers action '{action.name}'." + ) + if kind in {"optimization", "experiment"} and touches_implicated: + return ActionShadowLabel.RISKY, ( + f"Action '{action.name}' touches implicated hardware before " + "feasibility is mapped." + ) + return ActionShadowLabel.NEUTRAL, ( + f"Action '{action.name}' does not map hardware feasibility." + ) + + if mode == CampaignMode.DATA_QUALITY_DIAGNOSTIC: + if kind in {"data_quality_diagnostic", "replicate", "sensor_qc", "diagnostic"}: + return ActionShadowLabel.PREFERRED, ( + f"Data-quality diagnostic mode prefers action '{action.name}'." + ) + if kind == "optimization": + return ActionShadowLabel.RISKY, ( + f"Data-quality diagnostic mode gates optimization action '{action.name}'." + ) + return ActionShadowLabel.NEUTRAL, ( + f"Action '{action.name}' does not diagnose data quality." + ) + + if mode == CampaignMode.EARLY_STAGE_SYSTEM_CHARACTERIZATION: + if kind in {"diagnostic", "characterization", "replicate", "control_probe"}: + return ActionShadowLabel.PREFERRED, ( + f"Early-stage characterization mode prefers action '{action.name}'." + ) + if kind == "optimization": + return ActionShadowLabel.RISKY, ( + f"Early-stage characterization mode delays optimization action " + f"'{action.name}'." + ) + return ActionShadowLabel.NEUTRAL, ( + f"Action '{action.name}' is neutral for early-stage characterization." + ) + # Default: BO_OPTIMIZATION. if kind in {"experiment", "optimization"}: floor = _SAFETY_RISK_FLOOR.get((action.safety_class or "").lower(), 0.0) diff --git a/app/services/experimental_route_policy.py b/app/services/experimental_route_policy.py new file mode 100644 index 0000000..67fd82b --- /dev/null +++ b/app/services/experimental_route_policy.py @@ -0,0 +1,466 @@ +"""HELIOS-owned policy and execution gates for experimental-route selection. + +The Nexus report is evidence, never an instruction. HELIOS scores all +reachable alternatives, applies local capability/safety/budget/approval gates, +and only mutates the live route behind an explicit authority flag. +""" +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from math import isfinite +from typing import Any + + +@dataclass(frozen=True) +class ExperimentalRouteRuntime: + node_id: str + dimensions: tuple[dict[str, Any], ...] + protocol_template: dict[str, Any] + protocol_pattern_id: str + uses_campaign_default: bool = False + + +@dataclass(frozen=True) +class ExperimentalRouteOption: + node_id: str + score: float | None + eligible: bool + is_current: bool + transition_id: str | None = None + rejection_reasons: tuple[str, ...] = () + score_components: dict[str, float] = field(default_factory=dict) + runtime: ExperimentalRouteRuntime | None = None + + def to_dict(self) -> dict[str, Any]: + data = asdict(self) + if self.runtime is not None: + data["runtime"]["dimensions"] = list(self.runtime.dimensions) + return data + + +@dataclass(frozen=True) +class ExperimentalRouteDecision: + active_node_id: str | None + selected_node_id: str | None + authority_enabled: bool + execution_allowed: bool + applied: bool + changed: bool + reason: str + options: tuple[ExperimentalRouteOption, ...] + nexus_contract_version: str | None + nexus_authority: str | None + + @property + def selected_option(self) -> ExperimentalRouteOption | None: + return next( + (option for option in self.options if option.node_id == self.selected_node_id), + None, + ) + + def to_dict(self) -> dict[str, Any]: + return { + "active_node_id": self.active_node_id, + "selected_node_id": self.selected_node_id, + "authority_enabled": self.authority_enabled, + "execution_allowed": self.execution_allowed, + "applied": self.applied, + "changed": self.changed, + "reason": self.reason, + "options": [option.to_dict() for option in self.options], + "nexus_contract_version": self.nexus_contract_version, + "nexus_authority": self.nexus_authority, + } + + +DEFAULT_WEIGHTS = { + "objective": 0.35, + "information_gain": 0.25, + "prior": 0.12, + "evidence": 0.12, + "failure": 0.20, + "safety": 0.22, + "cost": 0.08, + "duration": 0.05, + "switch_cost": 0.08, + "switch_duration": 0.05, +} + + +def select_experimental_route( + *, + report: dict[str, Any], + execution_graph: dict[str, Any], + campaign_dimensions: list[dict[str, Any]], + campaign_protocol_template: dict[str, Any], + campaign_protocol_pattern_id: str, + direction: str, + authority_enabled: bool, + available_capabilities: list[str] | None, + policy_snapshot: dict[str, Any] | None = None, +) -> ExperimentalRouteDecision: + """Evaluate a Nexus report under HELIOS policy and return an audit bundle.""" + policy = dict(policy_snapshot or {}) + # The report graph is external evidence and must never supply executable + # protocol content. Only the campaign graph accepted by HELIOS can do so. + graph = dict(execution_graph or {}) + active_node_id = _text(graph.get("active_node_id")) + contract = _text(report.get("contract_version")) + nexus_authority = _text(report.get("authority")) + if contract != "experimental_route_intelligence.v1" or nexus_authority != "advisory_only": + return ExperimentalRouteDecision( + active_node_id=active_node_id, + selected_node_id=active_node_id, + authority_enabled=authority_enabled, + execution_allowed=False, + applied=False, + changed=False, + reason="Nexus route evidence contract or authority is invalid; current route retained.", + options=(), + nexus_contract_version=contract, + nexus_authority=nexus_authority, + ) + + nodes = { + str(node.get("node_id")): node + for node in graph.get("nodes", []) or [] + if isinstance(node, dict) and node.get("node_id") + } + assessments = { + str(item.get("node_id")): item + for item in report.get("node_assessments", []) or [] + if isinstance(item, dict) and item.get("node_id") + } + nexus_transitions = { + str(item.get("target_id")): item + for item in report.get("available_transitions", []) or [] + if isinstance(item, dict) and item.get("target_id") + } + local_transitions = { + str(item.get("target_id")): item + for item in graph.get("transitions", []) or [] + if ( + isinstance(item, dict) + and item.get("target_id") + and str(item.get("source_id")) == active_node_id + ) + } + transitions = { + target_id: { + **local_transition, + "transition_id": f"{active_node_id}->{target_id}", + "target_status": nexus_transitions.get(target_id, {}).get( + "target_status" + ), + "target_evidence_strength": nexus_transitions.get(target_id, {}).get( + "target_evidence_strength" + ), + "target_missing_capabilities": nexus_transitions.get(target_id, {}).get( + "target_missing_capabilities", [] + ), + "target_capability_status": nexus_transitions.get(target_id, {}).get( + "target_capability_status", "unknown" + ), + } + for target_id, local_transition in local_transitions.items() + if target_id in nexus_transitions + } + candidate_ids = set(transitions) + if active_node_id: + candidate_ids.add(active_node_id) + + weights = dict(DEFAULT_WEIGHTS) + supplied_weights = policy.get("experimental_route_weights") + if isinstance(supplied_weights, dict): + for name in weights: + value = supplied_weights.get(name) + if ( + isinstance(value, int | float) + and isfinite(float(value)) + and value >= 0 + ): + weights[name] = float(value) + + objective_utilities = _objective_utilities(assessments, direction) + max_safety = _number(policy.get("experimental_route_max_safety_risk"), 0.6) + max_cost = _optional_number(policy.get("experimental_route_max_expected_cost")) + max_duration = _optional_number( + policy.get("experimental_route_max_expected_duration_s") + ) + approved = { + str(item) + for item in policy.get("approved_experimental_route_transitions", []) or [] + } + capability_inventory_supplied = available_capabilities is not None + local_capabilities = set(available_capabilities or []) + + options: list[ExperimentalRouteOption] = [] + for node_id in sorted(candidate_ids): + node = nodes.get(node_id, {}) + assessment = assessments.get(node_id, {}) + transition = transitions.get(node_id) + is_current = node_id == active_node_id + transition_id = _text((transition or {}).get("transition_id")) + rejection_reasons: list[str] = [] + + capability_status = _text((transition or {}).get("target_capability_status")) + required_capabilities = { + str(item) for item in node.get("required_capabilities", []) or [] + } + locally_missing_caps = sorted(required_capabilities - local_capabilities) + missing_caps = list( + (transition or {}).get("target_missing_capabilities") + or assessment.get("missing_capabilities") + or [] + ) + if not is_current and not capability_inventory_supplied: + rejection_reasons.append("capability_inventory_unknown") + if not is_current and capability_status == "unknown": + rejection_reasons.append("target_capability_status_unknown") + if not is_current and ( + capability_status == "missing" + or missing_caps + or locally_missing_caps + ): + rejection_reasons.append("missing_required_capabilities") + if assessment.get("status") == "capability_blocked": + rejection_reasons.append("capability_blocked") + + safety_risk = _number(node.get("safety_risk"), 0.0) + expected_cost = _number(node.get("expected_cost"), 1.0) + expected_duration = _number(node.get("expected_duration_s"), 0.0) + if safety_risk > max_safety: + rejection_reasons.append("safety_risk_above_policy") + if max_cost is not None and expected_cost > max_cost: + rejection_reasons.append("expected_cost_above_budget") + if max_duration is not None and expected_duration > max_duration: + rejection_reasons.append("expected_duration_above_budget") + + runtime = resolve_experimental_route_runtime( + node=node, + is_current=is_current, + campaign_dimensions=campaign_dimensions, + campaign_protocol_template=campaign_protocol_template, + campaign_protocol_pattern_id=campaign_protocol_pattern_id, + ) + if runtime is None: + rejection_reasons.append("route_has_no_executable_helios_mapping") + + if ( + not is_current + and bool((transition or {}).get("approval_required", True)) + and transition_id not in approved + and f"{active_node_id}->{node_id}" not in approved + ): + rejection_reasons.append("operator_approval_required") + + failure_rate = _number(assessment.get("failure_rate"), 0.0) + info_gap = _number(assessment.get("information_gap"), 1.0) + prior = _number(assessment.get("normalized_prior"), 0.0) + evidence = _number(assessment.get("evidence_strength"), 0.0) + switch_cost = _number((transition or {}).get("switch_cost"), 0.0) + switch_duration = _number((transition or {}).get("switch_duration_s"), 0.0) + components = { + "objective": weights["objective"] * objective_utilities.get(node_id, 0.5), + "information_gain": weights["information_gain"] * info_gap, + "prior": weights["prior"] * prior, + "evidence": weights["evidence"] * evidence, + "failure_penalty": -weights["failure"] * failure_rate, + "safety_penalty": -weights["safety"] * safety_risk, + "cost_penalty": -weights["cost"] * _bounded_cost(expected_cost), + "duration_penalty": -weights["duration"] * _bounded_cost(expected_duration / 3600.0), + "switch_cost_penalty": -weights["switch_cost"] * _bounded_cost(switch_cost), + "switch_duration_penalty": -weights["switch_duration"] + * _bounded_cost(switch_duration / 3600.0), + } + score = round(sum(components.values()), 9) + options.append( + ExperimentalRouteOption( + node_id=node_id, + score=score, + eligible=not rejection_reasons, + is_current=is_current, + transition_id=transition_id, + rejection_reasons=tuple(sorted(set(rejection_reasons))), + score_components=components, + runtime=runtime, + ) + ) + + eligible = [option for option in options if option.eligible] + selected = ( + max( + eligible, + key=lambda item: ( + item.score if item.score is not None else float("-inf"), + item.node_id, + ), + ) + if eligible + else None + ) + selected_id = selected.node_id if selected is not None else None + changed = selected_id is not None and selected_id != active_node_id + applied = bool(authority_enabled and selected is not None and changed) + current_option = next( + (option for option in options if option.node_id == active_node_id), + None, + ) + execution_allowed = bool( + (selected is not None and selected.eligible) + if applied + else (current_option is not None and current_option.eligible) + ) + if selected is None: + reason = "No route passed HELIOS capability, safety, budget, approval, and execution gates." + elif not authority_enabled and changed: + reason = f"Shadow policy prefers {selected_id}; live route authority is disabled." + elif applied: + reason = f"HELIOS selected and applied experimental route {selected_id}." + else: + reason = f"HELIOS retained experimental route {selected_id}." + return ExperimentalRouteDecision( + active_node_id=active_node_id, + selected_node_id=selected_id, + authority_enabled=authority_enabled, + execution_allowed=execution_allowed, + applied=applied, + changed=applied, + reason=reason, + options=tuple(options), + nexus_contract_version=contract, + nexus_authority=nexus_authority, + ) + + +def resolve_experimental_route_runtime( + *, + node: dict[str, Any], + is_current: bool, + campaign_dimensions: list[dict[str, Any]], + campaign_protocol_template: dict[str, Any], + campaign_protocol_pattern_id: str, +) -> ExperimentalRouteRuntime | None: + """Resolve a graph node into concrete HELIOS design/compile inputs.""" + node_id = _text(node.get("node_id")) + if node_id is None: + return None + raw_dimensions = node.get("parameter_space") + dimensions = normalize_route_dimensions(raw_dimensions) if raw_dimensions else [] + protocol_ref = node.get("protocol_ref") if isinstance(node.get("protocol_ref"), dict) else {} + template = protocol_ref.get("protocol_template", protocol_ref.get("template")) + pattern_id = _text(protocol_ref.get("protocol_pattern_id")) or "" + use_default = bool(protocol_ref.get("use_campaign_default")) or ( + is_current and not raw_dimensions and not protocol_ref + ) + if not dimensions and use_default: + dimensions = [dict(item) for item in campaign_dimensions] + if not isinstance(template, dict) and use_default: + template = dict(campaign_protocol_template) + if not pattern_id and use_default: + pattern_id = campaign_protocol_pattern_id + if ( + not dimensions + or not all(_dimension_is_executable(item) for item in dimensions) + or (not isinstance(template, dict) and not pattern_id) + ): + return None + if pattern_id: + from app.services.protocol_patterns import get_pattern + + if get_pattern(pattern_id) is None: + return None + return ExperimentalRouteRuntime( + node_id=node_id, + dimensions=tuple(dimensions), + protocol_template=dict(template or {}), + protocol_pattern_id=pattern_id, + uses_campaign_default=use_default and not raw_dimensions, + ) + + +def normalize_route_dimensions(raw_dimensions: Any) -> list[dict[str, Any]]: + """Accept Nexus-style or HELIOS-style parameter-space dictionaries.""" + normalized: list[dict[str, Any]] = [] + if not isinstance(raw_dimensions, list): + return normalized + for raw in raw_dimensions: + if not isinstance(raw, dict): + continue + name = raw.get("param_name", raw.get("name")) + if not name: + continue + item = { + "param_name": str(name), + "param_type": raw.get("param_type", raw.get("type", "number")), + "min_value": raw.get("min_value", raw.get("min", raw.get("lower"))), + "max_value": raw.get("max_value", raw.get("max", raw.get("upper"))), + "log_scale": bool(raw.get("log_scale", False)), + } + for key in ("choices", "step_key", "primitive"): + if key in raw: + item[key] = raw[key] + normalized.append(item) + return normalized + + +def _objective_utilities( + assessments: dict[str, dict[str, Any]], direction: str +) -> dict[str, float]: + best_by_node: dict[str, float] = {} + for node_id, assessment in assessments.items(): + summaries = assessment.get("objective_summaries", []) or [] + if summaries and isinstance(summaries[0], dict): + value = summaries[0].get("best") + if isinstance(value, int | float): + best_by_node[node_id] = float(value) + if not best_by_node: + return {} + low, high = min(best_by_node.values()), max(best_by_node.values()) + if high == low: + return {node_id: 0.5 for node_id in best_by_node} + utilities = { + node_id: (value - low) / (high - low) + for node_id, value in best_by_node.items() + } + if direction == "minimize": + utilities = {node_id: 1.0 - value for node_id, value in utilities.items()} + return utilities + + +def _bounded_cost(value: float) -> float: + value = max(0.0, value) + return value / (1.0 + value) + + +def _dimension_is_executable(dimension: dict[str, Any]) -> bool: + choices = dimension.get("choices") + if choices is not None: + return isinstance(choices, list | tuple) and bool(choices) + if dimension.get("param_type") == "boolean": + return True + low = dimension.get("min_value") + high = dimension.get("max_value") + return ( + isinstance(low, int | float) + and isinstance(high, int | float) + and isfinite(float(low)) + and isfinite(float(high)) + and float(low) <= float(high) + ) + + +def _number(value: Any, default: float) -> float: + if isinstance(value, int | float) and isfinite(float(value)): + return float(value) + return default + + +def _optional_number(value: Any) -> float | None: + if isinstance(value, int | float) and isfinite(float(value)): + return float(value) + return None + + +def _text(value: Any) -> str | None: + return str(value) if value is not None and str(value) else None diff --git a/app/services/failure_region.py b/app/services/failure_region.py index 2c5fec4..aaa4f89 100644 --- a/app/services/failure_region.py +++ b/app/services/failure_region.py @@ -15,6 +15,90 @@ _DEFAULT_BANDWIDTH = 0.15 _DEFAULT_THRESHOLD = 0.5 +# --------------------------------------------------------------------------- +# Continuous failure-objective helpers (shared across experiments) +# --------------------------------------------------------------------------- +# +# Integrations historically encoded a failure as a flat penalty (e.g. -2.0), +# which collapses every failed trial to an identical scalar. A surrogate +# trained on those objectives cannot learn *how far* a trial was from the +# feasibility boundary, so optimization keeps re-sampling near known-success +# regions instead of steering away from failure zones (observed in the +# drug-solubilization campaign: 61% of trials had zero gradient; the GLV +# bottleneck dominated failures but got no dedicated signal). +# +# The helpers below turn a per-target *margin* (signed distance to threshold) +# into a continuous penalty, and produce a higher-is-better success objective +# for minimize-style problems. Integrations attach per-target values to +# ``Observation.objectives`` and call :func:`continuous_failure_penalty`. + + +def _isfinite(value: float) -> bool: + try: + return math.isfinite(float(value)) + except (TypeError, ValueError): + return False + + +def worst_margin( + values: dict[str, Any], + thresholds: dict[str, float], + *, + keys: tuple[str, ...] | None = None, + higher_is_better: bool = True, +) -> float | None: + """Return the worst (most negative) margin over the requested keys. + + ``margin`` is ``threshold - value`` when ``higher_is_better`` (the common + case: a value must stay below a threshold, e.g. absorbance). ``>= 0`` + means feasible; ``< 0`` means failed, more negative = further from + feasibility. Returns ``None`` when no requested key is present. + """ + margins: list[float] = [] + for key in keys or tuple(thresholds): + if key not in values: + continue + val = float(values[key]) + if not _isfinite(val): + continue + th = float(thresholds.get(key, 0.0)) + margins.append((th - val) if higher_is_better else (val - th)) + return min(margins) if margins else None + + +def continuous_failure_penalty( + margin: float | None, + *, + floor: float = -2.0, + ceiling: float = -1.0, + width: float = 1.0, +) -> float: + """Map a worst margin to a continuous failure penalty (higher = better). + + - ``margin >= 0`` -> ``0.0`` (feasible; success objective is the + caller's job). + - ``margin`` in ``[-width, 0)`` -> linear from ``ceiling`` (just below + threshold) up to ``floor`` (at the window edge): the learning gradient. + - ``margin < -width`` -> ``floor``. + + Defaults keep the drug campaign's old ``-2.0`` floor while adding a + ``-2.0 -> -1.0`` ramp over the first unit of failure distance. + """ + if margin is None or not _isfinite(margin): + return floor + if margin >= 0.0: + return 0.0 + if margin <= -width: + return floor + t = (margin + width) / width # 0 at -width .. 1 at 0 + return floor + (ceiling - floor) * t + + +def success_objective(total: float, max_total: float) -> float: + """Minimize-style success objective expressed as higher-is-better.""" + return -total / max_total if max_total > 0 else -1.0 + + def _dim_distance(dim: SearchDimension, a: Any, b: Any) -> float: """Normalized per-dimension distance in [0, 1+].""" diff --git a/app/services/hypothesis_experiment_planner.py b/app/services/hypothesis_experiment_planner.py new file mode 100644 index 0000000..b1aa99c --- /dev/null +++ b/app/services/hypothesis_experiment_planner.py @@ -0,0 +1,240 @@ +"""Shadow planner for experiments that discriminate competing hypotheses. + +Unlike an optimization acquisition function, this planner does not reward a +candidate for a predicted objective value. It ranks predeclared experiments +by the expected reduction in uncertainty over competing hypotheses. The +robust score is the minimum expected information gain across supplied prior +scenarios, which makes prior sensitivity visible instead of hiding it behind a +single subjective prior. + +The module is deterministic, pure, and never executes an experiment. +""" + +from __future__ import annotations + +import math +from collections.abc import Iterable +from statistics import fmean +from typing import Any + +from pydantic import BaseModel, Field, model_validator + +__all__ = [ + "DiscriminationExperiment", + "ExperimentPlan", + "ExperimentPrediction", + "ExperimentScore", + "HypothesisPriorScenario", + "rank_discrimination_experiments", +] + + +_PROBABILITY_TOLERANCE = 1e-9 + + +class HypothesisPriorScenario(BaseModel): + """One plausible prior distribution over mutually exclusive hypotheses.""" + + scenario_id: str = Field(min_length=1) + probabilities: dict[str, float] = Field(min_length=2) + rationale: str | None = None + + @model_validator(mode="after") + def _probabilities_form_a_distribution(self) -> HypothesisPriorScenario: + _validate_distribution(self.probabilities, label="hypothesis prior") + return self + + +class ExperimentPrediction(BaseModel): + """Predicted categorical outcome distribution under one hypothesis.""" + + hypothesis_id: str = Field(min_length=1) + outcome_probabilities: dict[str, float] = Field(min_length=2) + rationale: str | None = None + + @model_validator(mode="after") + def _outcomes_form_a_distribution(self) -> ExperimentPrediction: + _validate_distribution( + self.outcome_probabilities, + label=f"outcome likelihood for {self.hypothesis_id}", + ) + return self + + +class DiscriminationExperiment(BaseModel): + """A reviewed experiment with likelihoods under competing hypotheses.""" + + experiment_id: str = Field(min_length=1) + description: str = Field(min_length=1) + predictions: list[ExperimentPrediction] = Field(min_length=2) + cost: float = Field(default=1.0, gt=0.0) + replicate_count: int = Field(default=1, ge=1) + safety_approved: bool = False + parameters: dict[str, Any] = Field(default_factory=dict) + metadata: dict[str, Any] = Field(default_factory=dict) + + @model_validator(mode="after") + def _predictions_share_hypotheses_and_outcomes(self) -> DiscriminationExperiment: + hypothesis_ids = [prediction.hypothesis_id for prediction in self.predictions] + if len(hypothesis_ids) != len(set(hypothesis_ids)): + raise ValueError("experiment predictions must have unique hypothesis_id values") + outcome_sets = [set(prediction.outcome_probabilities) for prediction in self.predictions] + if any(outcomes != outcome_sets[0] for outcomes in outcome_sets[1:]): + raise ValueError("all hypotheses for an experiment must declare the same outcomes") + return self + + +class ExperimentScore(BaseModel): + """Prior-sensitive information score for one proposed experiment.""" + + experiment_id: str + eligible: bool + robust_expected_information_gain: float = Field(ge=0.0) + mean_expected_information_gain: float = Field(ge=0.0) + information_gain_per_cost: float = Field(ge=0.0) + expected_information_gain_by_scenario: dict[str, float] = Field(default_factory=dict) + reasons: list[str] = Field(default_factory=list) + rank: int | None = Field(default=None, ge=1) + + +class ExperimentPlan(BaseModel): + """Ranked, operator-reviewable hypothesis-discrimination plan.""" + + plan_id: str = Field(min_length=1) + ranked_experiments: list[ExperimentScore] = Field(default_factory=list) + excluded_experiments: list[ExperimentScore] = Field(default_factory=list) + prior_scenario_ids: list[str] = Field(default_factory=list) + objective: str = "robust_expected_information_gain_per_cost" + operator_approval_required: bool = True + shadow_only: bool = True + metadata: dict[str, Any] = Field(default_factory=dict) + + +def rank_discrimination_experiments( + experiments: list[DiscriminationExperiment], + prior_scenarios: list[HypothesisPriorScenario], + *, + plan_id: str, +) -> ExperimentPlan: + """Rank safe experiments by worst-case expected information gain per cost. + + Every experiment must cover exactly the hypothesis set declared by every + prior scenario. Unsafe or not-yet-reviewed experiments remain visible in + ``excluded_experiments`` with a zero actionable utility. + """ + + if not experiments: + raise ValueError("at least one discrimination experiment is required") + if not prior_scenarios: + raise ValueError("at least one hypothesis prior scenario is required") + scenario_ids = [scenario.scenario_id for scenario in prior_scenarios] + if len(scenario_ids) != len(set(scenario_ids)): + raise ValueError("prior scenario ids must be unique") + hypothesis_set = set(prior_scenarios[0].probabilities) + if any(set(scenario.probabilities) != hypothesis_set for scenario in prior_scenarios[1:]): + raise ValueError("all prior scenarios must cover the same hypotheses") + + experiment_ids = [experiment.experiment_id for experiment in experiments] + if len(experiment_ids) != len(set(experiment_ids)): + raise ValueError("experiment ids must be unique") + + eligible: list[ExperimentScore] = [] + excluded: list[ExperimentScore] = [] + for experiment in experiments: + prediction_map = { + prediction.hypothesis_id: prediction.outcome_probabilities + for prediction in experiment.predictions + } + if set(prediction_map) != hypothesis_set: + missing = sorted(hypothesis_set - set(prediction_map)) + extra = sorted(set(prediction_map) - hypothesis_set) + raise ValueError( + f"experiment {experiment.experiment_id!r} hypothesis mismatch; " + f"missing={missing}, extra={extra}" + ) + + by_scenario = { + scenario.scenario_id: _expected_information_gain( + scenario.probabilities, + prediction_map, + ) + for scenario in prior_scenarios + } + robust_eig = min(by_scenario.values()) + mean_eig = fmean(by_scenario.values()) + actionable = experiment.safety_approved + reasons = [] if actionable else ["source-backed safety approval is required"] + score = ExperimentScore( + experiment_id=experiment.experiment_id, + eligible=actionable, + robust_expected_information_gain=round(robust_eig, 12), + mean_expected_information_gain=round(mean_eig, 12), + information_gain_per_cost=round(robust_eig / experiment.cost, 12) if actionable else 0.0, + expected_information_gain_by_scenario={ + scenario_id: round(value, 12) for scenario_id, value in by_scenario.items() + }, + reasons=reasons, + ) + (eligible if actionable else excluded).append(score) + + eligible.sort( + key=lambda score: ( + -score.information_gain_per_cost, + -score.robust_expected_information_gain, + score.experiment_id, + ) + ) + ranked = [score.model_copy(update={"rank": rank}) for rank, score in enumerate(eligible, 1)] + excluded.sort(key=lambda score: score.experiment_id) + return ExperimentPlan( + plan_id=plan_id, + ranked_experiments=ranked, + excluded_experiments=excluded, + prior_scenario_ids=scenario_ids, + metadata={ + "hypothesis_ids": sorted(hypothesis_set), + "experiment_count": len(experiments), + "eligible_experiment_count": len(ranked), + }, + ) + + +def _expected_information_gain( + prior: dict[str, float], + likelihoods: dict[str, dict[str, float]], +) -> float: + prior_entropy = _entropy(prior.values()) + outcomes = next(iter(likelihoods.values())).keys() + expected_posterior_entropy = 0.0 + for outcome in outcomes: + outcome_probability = sum( + prior[hypothesis_id] * likelihoods[hypothesis_id][outcome] + for hypothesis_id in prior + ) + if outcome_probability <= 0.0: + continue + posterior = [ + prior[hypothesis_id] + * likelihoods[hypothesis_id][outcome] + / outcome_probability + for hypothesis_id in prior + ] + expected_posterior_entropy += outcome_probability * _entropy(posterior) + return max(0.0, prior_entropy - expected_posterior_entropy) + + +def _entropy(probabilities: Iterable[float]) -> float: + return -sum( + (probability * math.log(probability) for probability in probabilities if probability > 0.0), + 0.0, + ) + + +def _validate_distribution(probabilities: dict[str, float], *, label: str) -> None: + if not probabilities: + raise ValueError(f"{label} must not be empty") + if any(not math.isfinite(value) or value < 0.0 or value > 1.0 for value in probabilities.values()): + raise ValueError(f"{label} probabilities must be finite and between 0 and 1") + total = sum(probabilities.values()) + if not math.isclose(total, 1.0, rel_tol=0.0, abs_tol=_PROBABILITY_TOLERANCE): + raise ValueError(f"{label} probabilities must sum to 1 (got {total})") diff --git a/app/services/nexus_early_stage.py b/app/services/nexus_early_stage.py new file mode 100644 index 0000000..03fa922 --- /dev/null +++ b/app/services/nexus_early_stage.py @@ -0,0 +1,588 @@ +"""Nexus early-stage advisory adapter for imperfect campaign data. + +Nexus owns messy-data intake and early-stage system characterization. HELIOS +keeps campaign authority by adapting Nexus reports into local evidence, mode +hints, action-space adjustments, and audit metadata. +""" +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import quote +from urllib.request import Request, urlopen + +from app.core.config import get_settings +from app.services.campaign_mode import CampaignMode +from app.services.strategy_models import EvidenceItem + +logger = logging.getLogger(__name__) + +SUPPORTED_CONTRACT_VERSIONS = {"early_stage_system_characterization.v1"} + + +class NexusEarlyStageErrorType(StrEnum): + """Typed Nexus early-stage failure classes for safe fallback decisions.""" + + BAD_REQUEST = "bad_request" + NOT_FOUND = "not_found" + TIMEOUT = "timeout" + UNSUPPORTED_CONTRACT_VERSION = "unsupported_contract_version" + UNAVAILABLE = "unavailable" + INVALID_RESPONSE = "invalid_response" + + +@dataclass(frozen=True) +class NexusEarlyStageResponse: + """Typed result from a Nexus early-stage endpoint. + + ``ok=False`` is expected for fallback paths. Callers should inspect + ``error_type`` and continue with local HELIOS policy when automation is not + safe. + """ + + ok: bool + endpoint: str + status_code: int | None = None + campaign_id: str | None = None + report: dict[str, Any] | None = None + intake_report: dict[str, Any] | None = None + observations: tuple[dict[str, Any], ...] = () + parameter_specs: tuple[dict[str, Any], ...] = () + error_type: NexusEarlyStageErrorType | None = None + error_message: str = "" + raw: dict[str, Any] = field(default_factory=dict) + + @property + def contract_version(self) -> str | None: + return _string_or_none((self.report or {}).get("contract_version")) + + @property + def recommended_campaign_mode(self) -> str | None: + return _string_or_none((self.report or {}).get("recommended_campaign_mode")) + + @property + def confidence(self) -> float | None: + value = (self.report or {}).get("confidence") + return float(value) if isinstance(value, int | float) else None + + @property + def risk_flags(self) -> tuple[str, ...]: + return _string_tuple((self.report or {}).get("risk_flags")) + + +class NexusEarlyStageClient: + """Small REST wrapper around Nexus early-stage endpoints.""" + + def __init__(self, base_url: str | None = None, timeout_seconds: float | None = None) -> None: + settings = get_settings() + self.base_url = (base_url or settings.nexus_url).rstrip("/") + self.timeout_seconds = ( + float(timeout_seconds) + if timeout_seconds is not None + else settings.nexus_timeout_seconds + ) + + def intake(self, payload: dict[str, Any]) -> NexusEarlyStageResponse: + """Call ``POST /early-stage/intake``.""" + return self._post("/early-stage/intake", payload) + + def analyze(self, payload: dict[str, Any]) -> NexusEarlyStageResponse: + """Call ``POST /early-stage/analyze``.""" + return self._post("/early-stage/analyze", payload) + + def report(self, campaign_id: str) -> NexusEarlyStageResponse: + """Call ``GET /campaigns/{campaign_id}/early-stage-report``.""" + escaped = quote(campaign_id, safe="") + return self._request( + "GET", + f"/campaigns/{escaped}/early-stage-report", + campaign_id=campaign_id, + ) + + def _post(self, path: str, payload: dict[str, Any]) -> NexusEarlyStageResponse: + campaign_id = _string_or_none(payload.get("campaign_id")) + return self._request("POST", path, payload=payload, campaign_id=campaign_id) + + def _request( + self, + method: str, + path: str, + *, + payload: dict[str, Any] | None = None, + campaign_id: str | None = None, + ) -> NexusEarlyStageResponse: + endpoint = f"{self.base_url}{path}" + body = json.dumps(payload).encode("utf-8") if payload is not None else None + request = Request( + endpoint, + data=body, + method=method, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + ) + try: + with urlopen(request, timeout=self.timeout_seconds) as response: + raw_body = response.read().decode("utf-8") + decoded = json.loads(raw_body) if raw_body else {} + if not isinstance(decoded, dict): + raise TypeError("Nexus early-stage response must be a JSON object.") + return self._build_response( + endpoint=endpoint, + status_code=response.status, + raw=decoded, + campaign_id=campaign_id, + ) + except HTTPError as exc: + raw = _decode_http_error(exc) + error_type = ( + NexusEarlyStageErrorType.BAD_REQUEST + if exc.code == 400 + else NexusEarlyStageErrorType.NOT_FOUND + if exc.code == 404 + else NexusEarlyStageErrorType.UNAVAILABLE + ) + response = NexusEarlyStageResponse( + ok=False, + endpoint=endpoint, + status_code=exc.code, + campaign_id=campaign_id, + error_type=error_type, + error_message=_error_message(raw, exc.reason), + raw=raw, + ) + self._log_response(response) + return response + except TimeoutError: + response = NexusEarlyStageResponse( + ok=False, + endpoint=endpoint, + campaign_id=campaign_id, + error_type=NexusEarlyStageErrorType.TIMEOUT, + error_message=f"Nexus early-stage request timed out after {self.timeout_seconds}s.", + ) + self._log_response(response) + return response + except URLError as exc: + reason = getattr(exc, "reason", exc) + error_type = ( + NexusEarlyStageErrorType.TIMEOUT + if isinstance(reason, TimeoutError) + else NexusEarlyStageErrorType.UNAVAILABLE + ) + response = NexusEarlyStageResponse( + ok=False, + endpoint=endpoint, + campaign_id=campaign_id, + error_type=error_type, + error_message=str(reason), + ) + self._log_response(response) + return response + except (json.JSONDecodeError, TypeError) as exc: + response = NexusEarlyStageResponse( + ok=False, + endpoint=endpoint, + campaign_id=campaign_id, + error_type=NexusEarlyStageErrorType.INVALID_RESPONSE, + error_message=str(exc), + ) + self._log_response(response) + return response + + def _build_response( + self, + *, + endpoint: str, + status_code: int, + raw: dict[str, Any], + campaign_id: str | None, + ) -> NexusEarlyStageResponse: + report = _report_from_raw(raw) + contract_version = _string_or_none((report or {}).get("contract_version")) + error_type = None + error_message = "" + ok = True + if contract_version and contract_version not in SUPPORTED_CONTRACT_VERSIONS: + ok = False + error_type = NexusEarlyStageErrorType.UNSUPPORTED_CONTRACT_VERSION + error_message = f"Unsupported Nexus early-stage contract: {contract_version}." + + response = NexusEarlyStageResponse( + ok=ok, + endpoint=endpoint, + status_code=status_code, + campaign_id=campaign_id or _string_or_none(raw.get("campaign_id")), + report=report, + intake_report=_dict_or_none(raw.get("intake_report")), + observations=_dict_tuple(raw.get("observations")), + parameter_specs=_dict_tuple(raw.get("parameter_specs")), + error_type=error_type, + error_message=error_message, + raw=raw, + ) + self._log_response(response) + return response + + def _log_response(self, response: NexusEarlyStageResponse) -> None: + logger.info( + "Nexus early-stage response: campaign_id=%s contract=%s mode=%s risks=%s ok=%s error=%s", + response.campaign_id, + response.contract_version, + response.recommended_campaign_mode, + list(response.risk_flags)[:5], + response.ok, + response.error_type, + ) + + +@dataclass(frozen=True) +class ActionSpaceAdjustment: + """Advisory adjustment derived from Nexus early-stage characterization.""" + + adjustment_type: str + source_field: str + reason: str + payload: dict[str, Any] = field(default_factory=dict) + reject_by_default: bool = False + + +@dataclass(frozen=True) +class NexusEarlyStageAdvice: + """HELIOS-native view of a Nexus early-stage report.""" + + evidence: tuple[EvidenceItem, ...] = () + campaign_mode_hint: CampaignMode | None = None + nexus_campaign_mode: str | None = None + action_space_adjustments: tuple[ActionSpaceAdjustment, ...] = () + objective_candidates: tuple[dict[str, Any], ...] = () + operator_messages: tuple[str, ...] = () + audit_metadata: dict[str, Any] = field(default_factory=dict) + ordinary_bo_allowed: bool = True + requires_operator_approval: bool = False + + +class NexusEarlyStageAdapter: + """Convert Nexus characterization reports into HELIOS advisory artifacts.""" + + def adapt( + self, + report: dict[str, Any] | NexusEarlyStageResponse | None, + *, + endpoint_used: str | None = None, + campaign_id: str | None = None, + ) -> NexusEarlyStageAdvice: + response = report if isinstance(report, NexusEarlyStageResponse) else None + raw_report = response.report if response is not None else report + if not isinstance(raw_report, dict): + return NexusEarlyStageAdvice( + requires_operator_approval=True, + audit_metadata={ + "nexus_endpoint_used": endpoint_used or getattr(response, "endpoint", None), + "campaign_id": campaign_id or getattr(response, "campaign_id", None), + "error_type": getattr(response, "error_type", None), + "error_message": getattr(response, "error_message", ""), + }, + ) + + contract_version = _string_or_none(raw_report.get("contract_version")) + if contract_version and contract_version not in SUPPORTED_CONTRACT_VERSIONS: + return NexusEarlyStageAdvice( + requires_operator_approval=True, + audit_metadata={ + "nexus_endpoint_used": endpoint_used or getattr(response, "endpoint", None), + "contract_version": contract_version, + "campaign_id": campaign_id or getattr(response, "campaign_id", None), + "error_type": NexusEarlyStageErrorType.UNSUPPORTED_CONTRACT_VERSION, + "error_message": ( + f"Unsupported Nexus early-stage contract: {contract_version}." + ), + }, + ) + risk_flags = _string_tuple(raw_report.get("risk_flags")) + recommendations = _dict_tuple(raw_report.get("diagnostic_recommendations")) + mode = _select_campaign_mode(raw_report) + evidence = _build_strategy_evidence(raw_report, mode, risk_flags, recommendations) + adjustments = _build_action_space_adjustments(raw_report, risk_flags) + objective_candidates = _dict_tuple(raw_report.get("candidate_kpis")) + confidence = _confidence(raw_report) + ordinary_bo_allowed = _ordinary_bo_allowed(raw_report, risk_flags, confidence) + + operator_messages = tuple( + str(item) + for item in raw_report.get("insights", ()) + if isinstance(item, str) and item.strip() + ) + if not ordinary_bo_allowed: + operator_messages = ( + *operator_messages, + "Nexus early-stage characterization gates ordinary final-KPI BO.", + ) + if confidence < 0.5: + operator_messages = ( + *operator_messages, + "Nexus confidence is low; treat recommendations as advisory only.", + ) + + return NexusEarlyStageAdvice( + evidence=evidence, + campaign_mode_hint=mode, + nexus_campaign_mode=_string_or_none(raw_report.get("recommended_campaign_mode")), + action_space_adjustments=adjustments, + objective_candidates=objective_candidates, + operator_messages=operator_messages, + audit_metadata={ + "nexus_endpoint_used": endpoint_used or getattr(response, "endpoint", None), + "contract_version": contract_version, + "campaign_id": campaign_id or getattr(response, "campaign_id", None), + "recommended_campaign_mode": raw_report.get("recommended_campaign_mode"), + "confidence": confidence, + "risk_flags": list(risk_flags), + "top_diagnostic_recommendations": [ + rec for rec in recommendations[:3] + ], + "action_space_adjustments": [ + { + "adjustment_type": adj.adjustment_type, + "source_field": adj.source_field, + "reject_by_default": adj.reject_by_default, + "payload": adj.payload, + } + for adj in adjustments + ], + }, + ordinary_bo_allowed=ordinary_bo_allowed, + requires_operator_approval=(confidence < 0.5 or not ordinary_bo_allowed), + ) + + +def _select_campaign_mode(report: dict[str, Any]) -> CampaignMode: + mode = _string_or_none(report.get("recommended_campaign_mode")) + risk_flags = set(_string_tuple(report.get("risk_flags"))) + + if "objective_missing" in risk_flags or "low_confidence_objective_candidates" in risk_flags: + return CampaignMode.OBJECTIVE_DISCOVERY + if "poor_controllability" in risk_flags or "target_reachability_low" in risk_flags: + return CampaignMode.CONTROLLABILITY_MAPPING + if "hardware_failures_dominate" in risk_flags or "hardware_design_changed" in risk_flags: + return CampaignMode.HARDWARE_FEASIBILITY_DISCOVERY + if risk_flags & {"low_data_quality", "batch_effect_detected", "instrument_drift_detected"}: + return CampaignMode.DATA_QUALITY_DIAGNOSTIC + + mapping = { + "optimization_ready": CampaignMode.BO_OPTIMIZATION, + "early_stage_system_characterization": ( + CampaignMode.EARLY_STAGE_SYSTEM_CHARACTERIZATION + ), + "hardware_feasibility_discovery": CampaignMode.HARDWARE_FEASIBILITY_DISCOVERY, + "controllability_mapping": CampaignMode.CONTROLLABILITY_MAPPING, + "data_quality_diagnostic": CampaignMode.DATA_QUALITY_DIAGNOSTIC, + "objective_discovery": CampaignMode.OBJECTIVE_DISCOVERY, + } + return mapping.get(mode or "", CampaignMode.EARLY_STAGE_SYSTEM_CHARACTERIZATION) + + +def _build_strategy_evidence( + report: dict[str, Any], + mode: CampaignMode, + risk_flags: tuple[str, ...], + recommendations: tuple[dict[str, Any], ...], +) -> tuple[EvidenceItem, ...]: + evidence: list[EvidenceItem] = [] + confidence = _confidence(report) + for flag in risk_flags: + evidence.append( + EvidenceItem( + signal_name=f"nexus_early_stage_risk_{flag}", + signal_value=confidence, + target_action=_risk_flag_target_action(flag), + contribution=_risk_flag_contribution(flag, confidence), + description=f"Nexus early-stage risk flag '{flag}' supports {mode.value}.", + ) + ) + for rec in recommendations[:3]: + action_type = _string_or_none(rec.get("action_type")) or "diagnostic" + priority = _priority(rec) + evidence.append( + EvidenceItem( + signal_name=f"nexus_early_stage_recommendation_{action_type}", + signal_value=priority, + target_action=_recommendation_target_action(action_type), + contribution=round(min(0.2, 0.05 + priority * 0.1), 4), + description=f"Nexus recommends '{action_type}' for early-stage characterization.", + ) + ) + if not evidence and mode == CampaignMode.BO_OPTIMIZATION: + evidence.append( + EvidenceItem( + signal_name="nexus_early_stage_optimization_ready", + signal_value=confidence, + target_action="exploit", + contribution=round(0.05 * confidence, 4), + description="Nexus reports optimization_ready; HELIOS may continue guarded BO.", + ) + ) + return tuple(evidence) + + +def _build_action_space_adjustments( + report: dict[str, Any], + risk_flags: tuple[str, ...], +) -> tuple[ActionSpaceAdjustment, ...]: + adjustments: list[ActionSpaceAdjustment] = [] + feasibility = _dict_or_none(report.get("feasibility_summary")) or {} + for zone in _dict_tuple(feasibility.get("danger_zones")): + adjustments.append( + ActionSpaceAdjustment( + adjustment_type="reject_or_annotate_danger_zone", + source_field="feasibility_summary.danger_zones", + reason="Nexus identified an early-stage failure danger zone.", + payload=zone, + reject_by_default=True, + ) + ) + + target_feasibility = report.get("target_feasibility_summary") + target_rows = ( + _dict_tuple(target_feasibility) + if isinstance(target_feasibility, list | tuple) + else _dict_tuple([target_feasibility] if isinstance(target_feasibility, dict) else []) + ) + for row in target_rows: + lower = row.get("infeasible_target_min") + upper = row.get("infeasible_target_max") + if lower is None and upper is None: + continue + adjustments.append( + ActionSpaceAdjustment( + adjustment_type="narrow_target_range", + source_field="target_feasibility_summary", + reason="Nexus marked part of the target range as infeasible.", + payload=row, + reject_by_default="target_reachability_low" in risk_flags, + ) + ) + + hardware = _dict_or_none(report.get("hardware_summary")) or {} + worst_design_id = _string_or_none(hardware.get("worst_design_id")) + if worst_design_id: + adjustments.append( + ActionSpaceAdjustment( + adjustment_type="route_by_hardware_design", + source_field="hardware_summary.worst_design_id", + reason="Nexus identified a weak hardware or reactor design.", + payload={"worst_design_id": worst_design_id}, + reject_by_default="hardware_failures_dominate" in risk_flags, + ) + ) + return tuple(adjustments) + + +def _ordinary_bo_allowed( + report: dict[str, Any], + risk_flags: tuple[str, ...], + confidence: float, +) -> bool: + mode = _string_or_none(report.get("recommended_campaign_mode")) + blockers = { + "poor_controllability", + "objective_missing", + "target_reachability_low", + "hardware_failures_dominate", + } + if blockers & set(risk_flags): + return False + if mode and mode != "optimization_ready" and confidence >= 0.5: + return False + return True + + +def _risk_flag_target_action(flag: str) -> str: + if flag in {"objective_missing", "low_confidence_objective_candidates"}: + return "objective_discovery" + if flag in {"poor_controllability", "target_reachability_low"}: + return "controllability_mapping" + if flag in {"hardware_failures_dominate", "hardware_design_changed"}: + return "hardware_feasibility" + if flag in {"low_data_quality", "batch_effect_detected", "instrument_drift_detected"}: + return "data_quality_diagnostic" + return "diagnose" + + +def _risk_flag_contribution(flag: str, confidence: float) -> float: + high_impact = { + "poor_controllability", + "objective_missing", + "target_reachability_low", + "hardware_failures_dominate", + } + base = 0.18 if flag in high_impact else 0.1 + return round(base * max(0.2, confidence), 4) + + +def _recommendation_target_action(action_type: str) -> str: + mapping = { + "run_controllability_mapping": "controllability_mapping", + "map_hardware_feasibility": "hardware_feasibility", + "run_data_quality_diagnostic": "data_quality_diagnostic", + "run_objective_discovery": "objective_discovery", + "shrink_or_annotate_action_space": "revise_space", + "validate_target_reachability": "controllability_mapping", + "proceed_with_guarded_optimization": "exploit", + } + return mapping.get(action_type, "diagnose") + + +def _priority(recommendation: dict[str, Any]) -> float: + raw = recommendation.get("priority", recommendation.get("score", 0.5)) + if isinstance(raw, int | float): + return max(0.0, min(1.0, float(raw))) + if isinstance(raw, str): + return {"high": 0.9, "medium": 0.6, "low": 0.3}.get(raw.lower(), 0.5) + return 0.5 + + +def _report_from_raw(raw: dict[str, Any]) -> dict[str, Any] | None: + report = raw.get("analysis_report", raw.get("report")) + return _dict_or_none(report) + + +def _decode_http_error(exc: HTTPError) -> dict[str, Any]: + try: + raw_body = exc.read().decode("utf-8") + decoded = json.loads(raw_body) if raw_body else {} + return decoded if isinstance(decoded, dict) else {"detail": decoded} + except (json.JSONDecodeError, OSError, UnicodeDecodeError): + return {} + + +def _error_message(raw: dict[str, Any], fallback: str) -> str: + detail = raw.get("detail") or raw.get("error") or raw.get("message") + return str(detail or fallback) + + +def _confidence(report: dict[str, Any]) -> float: + value = report.get("confidence", 0.5) + return max(0.0, min(1.0, float(value))) if isinstance(value, int | float) else 0.5 + + +def _dict_or_none(value: Any) -> dict[str, Any] | None: + return value if isinstance(value, dict) else None + + +def _dict_tuple(value: Any) -> tuple[dict[str, Any], ...]: + if not isinstance(value, list | tuple): + return () + return tuple(item for item in value if isinstance(item, dict)) + + +def _string_tuple(value: Any) -> tuple[str, ...]: + if not isinstance(value, list | tuple): + return () + return tuple(str(item) for item in value if item is not None) + + +def _string_or_none(value: Any) -> str | None: + return str(value) if value is not None else None diff --git a/app/services/nexus_experimental_routes.py b/app/services/nexus_experimental_routes.py new file mode 100644 index 0000000..35c13e7 --- /dev/null +++ b/app/services/nexus_experimental_routes.py @@ -0,0 +1,314 @@ +"""Typed REST boundary for Nexus experimental-route intelligence. + +Nexus characterizes route evidence and returns advisory-only reports. This +module deliberately stops at that boundary: route selection and live campaign +mutation belong to HELIOS (see :mod:`experimental_route_policy`). +""" +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from app.core.config import get_settings + +logger = logging.getLogger(__name__) + +SUPPORTED_CONTRACT_VERSIONS = {"experimental_route_intelligence.v1"} +REQUIRED_AUTHORITY = "advisory_only" +MAX_EXPERIMENTAL_ROUTE_OBSERVATIONS = 10_000 +MAX_NEXUS_RESPONSE_BYTES = 8 * 1024 * 1024 + + +class NexusExperimentalRouteErrorType(StrEnum): + BAD_REQUEST = "bad_request" + UNAUTHORIZED = "unauthorized" + PAYLOAD_TOO_LARGE = "payload_too_large" + RATE_LIMITED = "rate_limited" + TIMEOUT = "timeout" + UNSUPPORTED_CONTRACT_VERSION = "unsupported_contract_version" + INVALID_AUTHORITY = "invalid_authority" + INVALID_RESPONSE = "invalid_response" + UNAVAILABLE = "unavailable" + + +@dataclass(frozen=True) +class NexusExperimentalRouteResponse: + ok: bool + endpoint: str + status_code: int | None = None + campaign_id: str | None = None + report: dict[str, Any] | None = None + error_type: NexusExperimentalRouteErrorType | None = None + error_message: str = "" + raw: dict[str, Any] = field(default_factory=dict) + + @property + def contract_version(self) -> str | None: + value = (self.report or {}).get("contract_version") + return str(value) if value is not None else None + + @property + def authority(self) -> str | None: + value = (self.report or {}).get("authority") + return str(value) if value is not None else None + + +class NexusExperimentalRouteClient: + """Small fail-closed client for ``POST /experimental-routes/analyze``.""" + + def __init__( + self, + base_url: str | None = None, + timeout_seconds: float | None = None, + api_key: str | None = None, + ) -> None: + settings = get_settings() + self.base_url = (base_url or settings.nexus_url).rstrip("/") + self.timeout_seconds = float( + timeout_seconds + if timeout_seconds is not None + else settings.nexus_timeout_seconds + ) + self.api_key = api_key if api_key is not None else settings.nexus_api_key + + def analyze(self, payload: dict[str, Any]) -> NexusExperimentalRouteResponse: + endpoint = f"{self.base_url}/experimental-routes/analyze" + headers = {"Content-Type": "application/json", "Accept": "application/json"} + if self.api_key: + headers["X-API-Key"] = self.api_key + request = Request( + endpoint, + data=json.dumps(payload).encode("utf-8"), + method="POST", + headers=headers, + ) + campaign_id = _text(payload.get("campaign_id")) + try: + with urlopen(request, timeout=self.timeout_seconds) as response: + raw_bytes = response.read(MAX_NEXUS_RESPONSE_BYTES + 1) + if len(raw_bytes) > MAX_NEXUS_RESPONSE_BYTES: + raise TypeError("Nexus experimental-route response exceeded 8 MiB.") + raw_body = raw_bytes.decode("utf-8") + decoded = json.loads(raw_body) if raw_body else {} + if not isinstance(decoded, dict): + raise TypeError("Nexus experimental-route response must be a JSON object.") + return self._build_response( + endpoint=endpoint, + status_code=response.status, + raw=decoded, + campaign_id=campaign_id, + ) + except HTTPError as exc: + raw = _decode_http_error(exc) + error_type = { + 400: NexusExperimentalRouteErrorType.BAD_REQUEST, + 401: NexusExperimentalRouteErrorType.UNAUTHORIZED, + 413: NexusExperimentalRouteErrorType.PAYLOAD_TOO_LARGE, + 422: NexusExperimentalRouteErrorType.BAD_REQUEST, + 429: NexusExperimentalRouteErrorType.RATE_LIMITED, + }.get(exc.code, NexusExperimentalRouteErrorType.UNAVAILABLE) + return self._failed( + endpoint, + campaign_id, + error_type, + _error_message(raw, exc.reason), + status_code=exc.code, + raw=raw, + ) + except TimeoutError: + return self._failed( + endpoint, + campaign_id, + NexusExperimentalRouteErrorType.TIMEOUT, + f"Nexus experimental-route request timed out after {self.timeout_seconds}s.", + ) + except URLError as exc: + reason = getattr(exc, "reason", exc) + error_type = ( + NexusExperimentalRouteErrorType.TIMEOUT + if isinstance(reason, TimeoutError) + else NexusExperimentalRouteErrorType.UNAVAILABLE + ) + return self._failed(endpoint, campaign_id, error_type, str(reason)) + except (json.JSONDecodeError, TypeError, UnicodeDecodeError) as exc: + return self._failed( + endpoint, + campaign_id, + NexusExperimentalRouteErrorType.INVALID_RESPONSE, + str(exc), + ) + + def _build_response( + self, + *, + endpoint: str, + status_code: int, + raw: dict[str, Any], + campaign_id: str | None, + ) -> NexusExperimentalRouteResponse: + report = raw.get("report") + if not isinstance(report, dict): + return self._failed( + endpoint, + campaign_id, + NexusExperimentalRouteErrorType.INVALID_RESPONSE, + "Nexus response is missing a report object.", + status_code=status_code, + raw=raw, + ) + contract = _text(report.get("contract_version")) + if contract not in SUPPORTED_CONTRACT_VERSIONS: + return self._failed( + endpoint, + campaign_id, + NexusExperimentalRouteErrorType.UNSUPPORTED_CONTRACT_VERSION, + f"Unsupported Nexus experimental-route contract: {contract!r}.", + status_code=status_code, + raw=raw, + ) + authority = _text(report.get("authority")) + if authority != REQUIRED_AUTHORITY: + return self._failed( + endpoint, + campaign_id, + NexusExperimentalRouteErrorType.INVALID_AUTHORITY, + f"Nexus report authority must be {REQUIRED_AUTHORITY!r}, got {authority!r}.", + status_code=status_code, + raw=raw, + ) + result = NexusExperimentalRouteResponse( + ok=True, + endpoint=endpoint, + status_code=status_code, + campaign_id=campaign_id or _text(report.get("campaign_id")), + report=dict(report), + raw=raw, + ) + self._log(result) + return result + + def _failed( + self, + endpoint: str, + campaign_id: str | None, + error_type: NexusExperimentalRouteErrorType, + message: str, + *, + status_code: int | None = None, + raw: dict[str, Any] | None = None, + ) -> NexusExperimentalRouteResponse: + result = NexusExperimentalRouteResponse( + ok=False, + endpoint=endpoint, + status_code=status_code, + campaign_id=campaign_id, + error_type=error_type, + error_message=message, + raw=dict(raw or {}), + ) + self._log(result) + return result + + @staticmethod + def _log(response: NexusExperimentalRouteResponse) -> None: + logger.info( + "Nexus experimental-route response: campaign=%s ok=%s contract=%s authority=%s error=%s", + response.campaign_id, + response.ok, + response.contract_version, + response.authority, + response.error_type, + ) + + +def build_experimental_route_payload( + *, + campaign_id: str, + graph: dict[str, Any], + observations: list[dict[str, Any]], + objective: str, + direction: str, + available_capabilities: list[str] | None, +) -> dict[str, Any]: + """Build only fields accepted by Nexus's strict v1 request contract.""" + nodes = [] + for raw in graph.get("nodes", []) or []: + if not isinstance(raw, dict): + continue + nodes.append({ + key: raw[key] + for key in ( + "node_id", "label", "kind", "parameter_space", "protocol_ref", + "required_capabilities", "expected_cost", "expected_duration_s", + "safety_risk", "prior_weight", "prior_evidence", "metadata", + ) + if key in raw + }) + transitions = [] + for raw in graph.get("transitions", []) or []: + if not isinstance(raw, dict): + continue + transitions.append({ + key: raw[key] + for key in ( + "source_id", "target_id", "switch_cost", "switch_duration_s", + "approval_required", "constraints", "evidence", "metadata", + ) + if key in raw + }) + clean_observations = [] + for raw in observations[-MAX_EXPERIMENTAL_ROUTE_OBSERVATIONS:]: + if not isinstance(raw, dict): + continue + clean_observations.append({ + key: raw[key] + for key in ( + "iteration", "parameters", "kpi_values", "qc_passed", "is_failure", + "failure_reason", "timestamp", "metadata", + ) + if key in raw + }) + clean_graph: dict[str, Any] = { + "graph_id": graph.get("graph_id", "experimental-routes"), + "nodes": nodes, + "transitions": transitions, + "metadata": dict(graph.get("metadata") or {}), + } + if graph.get("active_node_id"): + clean_graph["active_node_id"] = graph["active_node_id"] + return { + "campaign_id": campaign_id, + "graph": clean_graph, + "observations": clean_observations, + "objectives": [objective], + "objective_directions": [direction], + "available_capabilities": available_capabilities, + } + + +def _decode_http_error(exc: HTTPError) -> dict[str, Any]: + try: + decoded = json.loads( + exc.read(MAX_NEXUS_RESPONSE_BYTES + 1)[ + :MAX_NEXUS_RESPONSE_BYTES + ].decode("utf-8") + ) + return decoded if isinstance(decoded, dict) else {} + except (json.JSONDecodeError, UnicodeDecodeError): + return {} + + +def _error_message(raw: dict[str, Any], fallback: Any) -> str: + detail = raw.get("detail") + if isinstance(detail, str): + return detail + return str(fallback) + + +def _text(value: Any) -> str | None: + return str(value) if value is not None and str(value) else None diff --git a/app/services/objective_state.py b/app/services/objective_state.py index 590f520..86af16a 100644 --- a/app/services/objective_state.py +++ b/app/services/objective_state.py @@ -16,22 +16,33 @@ from __future__ import annotations from datetime import UTC, datetime +from enum import StrEnum from typing import Any from pydantic import BaseModel, Field, field_validator from app.services.decision_outcome import CampaignDecisionOutcome from app.services.objective_models import ProxyGapAssessment +from app.services.scientific_evidence import ClaimAssessment, PromotionDecision __all__ = [ "ObjectiveRevision", "ObjectiveState", "ObjectiveStateUpdater", + "ObjectiveConfidenceMethod", "StoppingCriteria", + "apply_evidence_to_objective_state", "apply_outcome_to_objective_state", ] +class ObjectiveConfidenceMethod(StrEnum): + """How ``objective_confidence`` was most recently updated.""" + + HEURISTIC_OUTCOME_DELTA = "heuristic_outcome_delta" + SCIENTIFIC_EVIDENCE_POSTERIOR = "scientific_evidence_posterior" + + class StoppingCriteria(BaseModel): """Deterministic, evaluable stopping conditions for a campaign objective.""" @@ -68,6 +79,12 @@ class ObjectiveState(BaseModel): scientific_question: str | None = None proxy_objective_names: list[str] = Field(default_factory=list) objective_confidence: float = Field(default=0.5, ge=0.0, le=1.0) + objective_confidence_method: ObjectiveConfidenceMethod = ( + ObjectiveConfidenceMethod.HEURISTIC_OUTCOME_DELTA + ) + evidence_claim_id: str | None = None + evidence_assessment: ClaimAssessment | None = None + promotion_decision: PromotionDecision | None = None proxy_gap: ProxyGapAssessment | None = None failure_constraints: list[str] = Field(default_factory=list) validation_requirements: list[str] = Field(default_factory=list) @@ -107,7 +124,13 @@ def apply_outcome( ) -> ObjectiveState: timestamp = now or datetime.now(UTC) - delta, evidence = _confidence_delta(outcome) + if state.objective_confidence_method == ObjectiveConfidenceMethod.SCIENTIFIC_EVIDENCE_POSTERIOR: + delta = 0.0 + evidence = [ + "heuristic confidence update skipped because the objective is bound to a scientific evidence posterior" + ] + else: + delta, evidence = _confidence_delta(outcome) old_confidence = state.objective_confidence new_confidence = _clamp_unit(_round(old_confidence + delta)) @@ -169,6 +192,124 @@ def apply_outcome( } ) + def apply_evidence_assessment( + self, + state: ObjectiveState, + assessment: ClaimAssessment, + *, + promotion_decision: PromotionDecision | None = None, + trace_id: str | None = None, + now: datetime | None = None, + ) -> ObjectiveState: + """Bind an auditable claim posterior to objective state. + + This replaces the objective confidence with the assessed posterior but + does not apply a promoted objective, constraint, or search-space + change. A promotion decision is stored solely as shadow evidence. + """ + + if state.evidence_claim_id is not None and state.evidence_claim_id != assessment.claim_id: + raise ValueError( + "objective state is already bound to another scientific claim: " + f"{state.evidence_claim_id!r}" + ) + if state.evidence_assessment is not None and ( + state.evidence_assessment.prior_probability != assessment.prior_probability + or state.evidence_assessment.prior_version != assessment.prior_version + ): + raise ValueError( + "scientific claim prior changed after binding; create a new versioned claim " + "instead of silently rewriting prior odds" + ) + if promotion_decision is not None and promotion_decision.claim_id != assessment.claim_id: + raise ValueError("promotion decision and assessment must target the same claim") + timestamp = now or datetime.now(UTC) + old_confidence = state.objective_confidence + old_method = state.objective_confidence_method + new_confidence = assessment.posterior_probability + stop_recommended, stop_reason = _evaluate_stopping( + criteria=state.stopping_criteria, + confidence=new_confidence, + consecutive_failures=state.consecutive_failure_count, + rounds_observed=state.rounds_observed, + ) + changes: dict[str, Any] = { + "objective_confidence": {"from": old_confidence, "to": new_confidence}, + "objective_confidence_method": { + "from": old_method.value, + "to": ObjectiveConfidenceMethod.SCIENTIFIC_EVIDENCE_POSTERIOR.value, + }, + "evidence_claim_id": { + "from": state.evidence_claim_id, + "to": assessment.claim_id, + }, + "evidence_status": { + "from": state.evidence_assessment.status.value if state.evidence_assessment else None, + "to": assessment.status.value, + }, + } + if promotion_decision is not None: + changes["promotion_allowed"] = { + "from": ( + state.promotion_decision.promotion_allowed + if state.promotion_decision is not None + else None + ), + "to": promotion_decision.promotion_allowed, + } + if (stop_recommended, stop_reason) != (state.stop_recommended, state.stop_reason): + changes["stop_recommended"] = { + "from": state.stop_recommended, + "to": stop_recommended, + } + changes["stop_reason"] = {"from": state.stop_reason, "to": stop_reason} + + revision = ObjectiveRevision( + revision=state.revision + 1, + reason=( + "Objective confidence replaced by an auditable scientific evidence posterior; " + "no live objective change was auto-applied." + ), + source="scientific_evidence_posterior", + trace_id=trace_id, + changes=changes, + evidence=[ + f"claim_id={assessment.claim_id}", + f"status={assessment.status.value}", + f"posterior_probability={assessment.posterior_probability:.6g}", + f"scored_evidence_count={assessment.scored_evidence_count}", + f"prospective_evidence_count={assessment.prospective_evidence_count}", + f"independent_block_count={assessment.independent_block_count}", + ( + f"promotion_allowed={promotion_decision.promotion_allowed}" + if promotion_decision is not None + else "promotion_not_evaluated" + ), + ], + created_at=timestamp, + metadata={"shadow_only": True, "auto_applied": False}, + ) + return state.model_copy( + update={ + "objective_confidence": new_confidence, + "objective_confidence_method": ( + ObjectiveConfidenceMethod.SCIENTIFIC_EVIDENCE_POSTERIOR + ), + "evidence_claim_id": assessment.claim_id, + "evidence_assessment": assessment.model_copy(deep=True), + "promotion_decision": ( + promotion_decision.model_copy(deep=True) + if promotion_decision is not None + else None + ), + "stop_recommended": stop_recommended, + "stop_reason": stop_reason, + "revision": state.revision + 1, + "revision_history": [*state.revision_history, revision], + "updated_at": timestamp, + } + ) + def apply_outcome_to_objective_state( state: ObjectiveState, @@ -183,6 +324,25 @@ def apply_outcome_to_objective_state( ) +def apply_evidence_to_objective_state( + state: ObjectiveState, + assessment: ClaimAssessment, + *, + promotion_decision: PromotionDecision | None = None, + trace_id: str | None = None, + now: datetime | None = None, +) -> ObjectiveState: + """Bind a scientific evidence posterior with the default updater.""" + + return ObjectiveStateUpdater().apply_evidence_assessment( + state, + assessment, + promotion_decision=promotion_decision, + trace_id=trace_id, + now=now, + ) + + def _confidence_delta(outcome: CampaignDecisionOutcome) -> tuple[float, list[str]]: delta = 0.0 evidence: list[str] = [] diff --git a/app/services/pas_scientific_evidence.py b/app/services/pas_scientific_evidence.py new file mode 100644 index 0000000..4448227 --- /dev/null +++ b/app/services/pas_scientific_evidence.py @@ -0,0 +1,527 @@ +"""Fail-closed Paper Attribution System scientific-evidence adapter. + +PAS is an external evidence provider. This module validates its versioned +payload, derives deterministic HELIOS assessment signals, and exposes bounded +decision evidence. It never creates executable routes or hardware commands. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass, field +from datetime import UTC, datetime +from enum import StrEnum +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import urlsplit +from urllib.request import HTTPRedirectHandler, Request, build_opener + +from pydantic import ValidationError + +from app.contracts.scientific_evidence import ( + SCIENTIFIC_EVIDENCE_CONTRACT_VERSION, + ApplicabilityStatus, + EvidenceCentrality, + EvidenceNamespace, + EvidencePolarity, + ScientificEvidenceAssessment, + ScientificEvidenceBundle, + ScientificEvidencePolicyMode, + ScientificEvidenceRecommendedAction, + ScientificEvidenceStatus, +) +from app.core.config import get_settings + +logger = logging.getLogger(__name__) + +DEFAULT_MAX_BUNDLE_BYTES = 262_144 + +_CENTRALITY_WEIGHT = { + EvidenceCentrality.CORE_CONTRIBUTION: 1.0, + EvidenceCentrality.SUPPORTING_METHOD: 0.75, + EvidenceCentrality.BACKGROUND_ONLY: 0.25, + EvidenceCentrality.INCIDENTAL_MENTION: 0.1, + EvidenceCentrality.UNRELATED: 0.0, +} +_APPLICABILITY_SCORE = { + ApplicabilityStatus.APPLICABLE: 1.0, + ApplicabilityStatus.PARTIAL: 0.5, + ApplicabilityStatus.MISMATCH: 0.0, + ApplicabilityStatus.UNKNOWN: 0.25, +} + + +class _NoRedirectHandler(HTTPRedirectHandler): + def redirect_request(self, *_args: Any, **_kwargs: Any) -> None: + return None + + +_PAS_OPENER = build_opener(_NoRedirectHandler()) + + +def _open_pas_request(request: Request, *, timeout: float): + return _PAS_OPENER.open(request, timeout=timeout) + + +class PasScientificEvidenceErrorType(StrEnum): + BAD_REQUEST = "bad_request" + NOT_FOUND = "not_found" + UNAVAILABLE = "unavailable" + TIMEOUT = "timeout" + INVALID_RESPONSE = "invalid_response" + UNSUPPORTED_CONTRACT_VERSION = "unsupported_contract_version" + OVERSIZED_PAYLOAD = "oversized_payload" + + +@dataclass(frozen=True) +class PasScientificEvidenceResponse: + """Typed response from the PAS evidence endpoint.""" + + ok: bool + endpoint: str + status_code: int | None = None + bundle: ScientificEvidenceBundle | None = None + error_type: PasScientificEvidenceErrorType | None = None + error_message: str = "" + + +@dataclass(frozen=True) +class PasScientificEvidenceAdvice: + """HELIOS-native advisory projection of one PAS response.""" + + bundle: ScientificEvidenceBundle | None + assessment: ScientificEvidenceAssessment + requires_operator_approval: bool = False + audit_metadata: dict[str, Any] = field(default_factory=dict) + + +class PasScientificEvidenceClient: + """Small synchronous REST client used only behind explicit config gates.""" + + def __init__( + self, + base_url: str | None = None, + timeout_seconds: float | None = None, + api_key: str | None = None, + max_bundle_bytes: int | None = None, + ) -> None: + settings = get_settings() + self.base_url = (base_url or settings.pas_evidence_url).rstrip("/") + self.timeout_seconds = ( + float(timeout_seconds) + if timeout_seconds is not None + else settings.pas_evidence_timeout_seconds + ) + self.api_key = api_key if api_key is not None else settings.pas_evidence_api_key + self.max_bundle_bytes = ( + int(max_bundle_bytes) + if max_bundle_bytes is not None + else settings.pas_evidence_max_bundle_bytes + ) + parsed_url = urlsplit(self.base_url) + if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc: + raise ValueError("PAS evidence URL must be an absolute HTTP(S) URL") + if parsed_url.username is not None or parsed_url.password is not None: + raise ValueError("PAS evidence URL cannot contain user information") + loopback_hosts = {"localhost", "127.0.0.1", "::1"} + if ( + parsed_url.scheme != "https" + and parsed_url.hostname not in loopback_hosts + ): + raise ValueError( + "PAS evidence URL must use HTTPS except for loopback hosts" + ) + if self.timeout_seconds <= 0: + raise ValueError("PAS evidence timeout must be positive") + if self.max_bundle_bytes < 1024: + raise ValueError("PAS evidence byte limit must be at least 1024") + if "\r" in self.api_key or "\n" in self.api_key: + raise ValueError("PAS evidence API key cannot contain header newlines") + + def query(self, payload: dict[str, Any]) -> PasScientificEvidenceResponse: + endpoint = f"{self.base_url}/scientific-evidence/query" + try: + body = json.dumps( + payload, + allow_nan=False, + separators=(",", ":"), + ).encode("utf-8") + except (TypeError, ValueError) as exc: + return self._failure( + endpoint, + PasScientificEvidenceErrorType.BAD_REQUEST, + f"PAS query payload is not valid bounded JSON: {exc}", + ) + if len(body) > self.max_bundle_bytes: + return self._failure( + endpoint, + PasScientificEvidenceErrorType.OVERSIZED_PAYLOAD, + "PAS query payload exceeds configured byte limit.", + ) + headers = {"Content-Type": "application/json", "Accept": "application/json"} + if self.api_key: + headers["X-API-Key"] = self.api_key + request = Request(endpoint, data=body, method="POST", headers=headers) + try: + with _open_pas_request( + request, + timeout=self.timeout_seconds, + ) as response: + raw_body = response.read(self.max_bundle_bytes + 1) + if len(raw_body) > self.max_bundle_bytes: + return self._failure( + endpoint, + PasScientificEvidenceErrorType.OVERSIZED_PAYLOAD, + "PAS response exceeds configured byte limit.", + status_code=getattr(response, "status", None), + ) + decoded = json.loads(raw_body.decode("utf-8")) if raw_body else {} + return self._build_response( + endpoint=endpoint, + status_code=getattr(response, "status", 200), + decoded=decoded, + ) + except HTTPError as exc: + error_type = ( + PasScientificEvidenceErrorType.BAD_REQUEST + if exc.code == 400 + else PasScientificEvidenceErrorType.NOT_FOUND + if exc.code == 404 + else PasScientificEvidenceErrorType.UNAVAILABLE + ) + return self._failure( + endpoint, + error_type, + f"PAS returned HTTP {exc.code}.", + status_code=exc.code, + ) + except TimeoutError: + return self._failure( + endpoint, + PasScientificEvidenceErrorType.TIMEOUT, + f"PAS evidence request timed out after {self.timeout_seconds}s.", + ) + except URLError as exc: + reason = getattr(exc, "reason", exc) + error_type = ( + PasScientificEvidenceErrorType.TIMEOUT + if isinstance(reason, TimeoutError) + else PasScientificEvidenceErrorType.UNAVAILABLE + ) + return self._failure( + endpoint, + error_type, + "PAS evidence endpoint timed out." + if error_type == PasScientificEvidenceErrorType.TIMEOUT + else "PAS evidence endpoint is unavailable.", + ) + except (UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError) as exc: + return self._failure( + endpoint, + PasScientificEvidenceErrorType.INVALID_RESPONSE, + f"PAS returned an invalid response ({type(exc).__name__}).", + ) + + def _build_response( + self, + *, + endpoint: str, + status_code: int, + decoded: Any, + ) -> PasScientificEvidenceResponse: + if not isinstance(decoded, dict): + return self._failure( + endpoint, + PasScientificEvidenceErrorType.INVALID_RESPONSE, + "PAS response must be a JSON object.", + status_code=status_code, + ) + candidate = decoded.get( + "bundle", decoded.get("scientific_evidence_bundle", decoded) + ) + if not isinstance(candidate, dict): + return self._failure( + endpoint, + PasScientificEvidenceErrorType.INVALID_RESPONSE, + "PAS response does not contain a scientific evidence bundle.", + status_code=status_code, + ) + contract_version = candidate.get("contract_version") + if contract_version != SCIENTIFIC_EVIDENCE_CONTRACT_VERSION: + return self._failure( + endpoint, + PasScientificEvidenceErrorType.UNSUPPORTED_CONTRACT_VERSION, + f"Unsupported PAS evidence contract: {contract_version!r}.", + status_code=status_code, + ) + try: + bundle = ScientificEvidenceBundle.model_validate(candidate) + except ValidationError as exc: + return self._failure( + endpoint, + PasScientificEvidenceErrorType.INVALID_RESPONSE, + _validation_error_message(exc), + status_code=status_code, + ) + response = PasScientificEvidenceResponse( + ok=True, + endpoint=endpoint, + status_code=status_code, + bundle=bundle, + ) + self._log_response(response) + return response + + def _failure( + self, + endpoint: str, + error_type: PasScientificEvidenceErrorType, + error_message: str, + *, + status_code: int | None = None, + ) -> PasScientificEvidenceResponse: + response = PasScientificEvidenceResponse( + ok=False, + endpoint=endpoint, + status_code=status_code, + error_type=error_type, + error_message=error_message[:2000], + ) + self._log_response(response) + return response + + @staticmethod + def _log_response(response: PasScientificEvidenceResponse) -> None: + logger.info( + "PAS scientific evidence response: bundle=%s contract=%s ok=%s error=%s", + response.bundle.bundle_id if response.bundle else None, + response.bundle.contract_version if response.bundle else None, + response.ok, + response.error_type, + ) + + +class PasScientificEvidenceAdapter: + """Validate PAS input and derive bounded HELIOS decision evidence.""" + + def adapt( + self, + value: ( + dict[str, Any] + | ScientificEvidenceBundle + | PasScientificEvidenceResponse + | None + ), + *, + policy_mode: ScientificEvidencePolicyMode = ScientificEvidencePolicyMode.OFF, + now: datetime | None = None, + ) -> PasScientificEvidenceAdvice: + response = value if isinstance(value, PasScientificEvidenceResponse) else None + if response is not None and (not response.ok or response.bundle is None): + assessment = unavailable_evidence_assessment( + policy_mode=policy_mode, + reason=response.error_message or "PAS evidence unavailable.", + error_type=response.error_type, + ) + return PasScientificEvidenceAdvice( + bundle=None, + assessment=assessment, + audit_metadata={ + "endpoint": response.endpoint, + "status_code": response.status_code, + "error_type": response.error_type, + "error_message": response.error_message, + }, + ) + + try: + bundle = ( + response.bundle + if response is not None + else value + if isinstance(value, ScientificEvidenceBundle) + else ScientificEvidenceBundle.model_validate( + value.get("bundle", value) if isinstance(value, dict) else value + ) + ) + except (ValidationError, TypeError, AttributeError) as exc: + error_message = ( + _validation_error_message(exc) + if isinstance(exc, ValidationError) + else f"Invalid PAS evidence bundle ({type(exc).__name__})." + ) + assessment = unavailable_evidence_assessment( + policy_mode=policy_mode, + reason=error_message, + status=ScientificEvidenceStatus.INVALID, + error_type=PasScientificEvidenceErrorType.INVALID_RESPONSE, + ) + return PasScientificEvidenceAdvice( + bundle=None, + assessment=assessment, + audit_metadata={ + "error_type": PasScientificEvidenceErrorType.INVALID_RESPONSE, + "error_message": error_message, + }, + ) + + assessment = assess_scientific_evidence( + bundle, + policy_mode=policy_mode, + now=now, + ) + return PasScientificEvidenceAdvice( + bundle=bundle, + assessment=assessment, + requires_operator_approval=assessment.requires_human_review, + audit_metadata={ + "bundle_id": bundle.bundle_id, + "contract_version": bundle.contract_version, + "corpus_version": bundle.corpus_version, + "ontology_version": bundle.ontology_version, + "policy_mode": policy_mode.value, + }, + ) + + +def assess_scientific_evidence( + bundle: ScientificEvidenceBundle, + *, + policy_mode: ScientificEvidencePolicyMode = ScientificEvidencePolicyMode.OFF, + now: datetime | None = None, +) -> ScientificEvidenceAssessment: + """Derive deterministic evidence strength, applicability, and next action.""" + timestamp = now or datetime.now(UTC) + if timestamp.tzinfo is None or timestamp.tzinfo.utcoffset(timestamp) is None: + raise ValueError("assessment time must be timezone-aware") + + stale = bundle.expires_at is not None and bundle.expires_at <= timestamp + claim_count = len(bundle.claims) + source_grounded = sum( + 1 + for claim in bundle.claims + if claim.source_refs + and all(source.source_id and source.chunk_ids for source in claim.source_refs) + ) + source_coverage = source_grounded / claim_count if claim_count else 0.0 + applicability_score = ( + _mean( + [ + _APPLICABILITY_SCORE[claim.applicability.status] + for claim in bundle.claims + ] + ) + if claim_count + else 0.0 + ) + support_strength = max( + ( + claim.confidence * _CENTRALITY_WEIGHT[claim.centrality] + for claim in bundle.claims + if claim.polarity == EvidencePolarity.POSITIVE + and claim.namespace != EvidenceNamespace.REFUTED + ), + default=0.0, + ) + negative_claim_strength = max( + ( + claim.confidence * _CENTRALITY_WEIGHT[claim.centrality] + for claim in bundle.claims + if claim.polarity == EvidencePolarity.NEGATIVE + or claim.namespace == EvidenceNamespace.REFUTED + ), + default=0.0, + ) + conflict_strength = max( + (conflict.confidence for conflict in bundle.conflicts), + default=0.0, + ) + contradiction_strength = max(negative_claim_strength, conflict_strength) + has_mismatch = any( + claim.applicability.status == ApplicabilityStatus.MISMATCH + for claim in bundle.claims + ) + has_unknown_applicability = any( + claim.applicability.status == ApplicabilityStatus.UNKNOWN + for claim in bundle.claims + ) + + reasons: list[str] = [] + requires_human_review = False + if stale: + status = ScientificEvidenceStatus.STALE + action = ScientificEvidenceRecommendedAction.QUERY_LITERATURE + reasons.append("Evidence bundle is expired.") + elif not bundle.claims: + status = ScientificEvidenceStatus.INSUFFICIENT + action = ScientificEvidenceRecommendedAction.QUERY_LITERATURE + reasons.append("Evidence bundle contains no claims.") + elif bundle.conflicts or (support_strength > 0.0 and contradiction_strength >= 0.5): + status = ScientificEvidenceStatus.CONFLICTING + action = ScientificEvidenceRecommendedAction.RUN_VALIDATION + requires_human_review = True + reasons.append("Source-grounded claims contain material conflict.") + elif has_mismatch: + status = ScientificEvidenceStatus.APPLICABILITY_MISMATCH + action = ScientificEvidenceRecommendedAction.REQUEST_HUMAN_OBSERVATION + requires_human_review = True + reasons.append("At least one claim is not applicable to the current context.") + else: + status = ScientificEvidenceStatus.USABLE + if has_unknown_applicability: + action = ( + ScientificEvidenceRecommendedAction.REQUEST_HUMAN_OBSERVATION + ) + reasons.append("Some applicability conditions remain unknown.") + requires_human_review = True + else: + action = ScientificEvidenceRecommendedAction.NONE + + return ScientificEvidenceAssessment( + bundle_id=bundle.bundle_id, + status=status, + policy_mode=policy_mode, + support_strength=round(support_strength, 10), + contradiction_strength=round(contradiction_strength, 10), + applicability_score=round(applicability_score, 10), + source_coverage=round(source_coverage, 10), + claim_count=claim_count, + evidence_path_count=len(bundle.evidence_paths), + conflict_count=len(bundle.conflicts), + stale=stale, + requires_human_review=requires_human_review, + recommended_action=action, + reasons=reasons, + metadata={ + "contract_version": bundle.contract_version, + "corpus_version": bundle.corpus_version, + "ontology_version": bundle.ontology_version, + }, + ) + + +def unavailable_evidence_assessment( + *, + policy_mode: ScientificEvidencePolicyMode, + reason: str, + status: ScientificEvidenceStatus = ScientificEvidenceStatus.UNAVAILABLE, + error_type: PasScientificEvidenceErrorType | None = None, +) -> ScientificEvidenceAssessment: + """Create a non-influencing assessment for unavailable or invalid evidence.""" + return ScientificEvidenceAssessment( + status=status, + policy_mode=policy_mode, + recommended_action=ScientificEvidenceRecommendedAction.NONE, + reasons=[reason[:2000]], + metadata={"error_type": error_type.value if error_type else None}, + ) + + +def _validation_error_message(exc: ValidationError) -> str: + errors = exc.errors(include_url=False, include_context=False, include_input=False) + return json.dumps(errors, separators=(",", ":"))[:2000] + + +def _mean(values: list[float]) -> float: + return sum(values) / len(values) if values else 0.0 diff --git a/app/services/rl_strategy_selector.py b/app/services/rl_strategy_selector.py index 81b80bb..9d3a342 100644 --- a/app/services/rl_strategy_selector.py +++ b/app/services/rl_strategy_selector.py @@ -32,6 +32,8 @@ import numpy as np +from app.services.decision_models import CampaignDecisionAction +from app.services.strategy_models import FailureType, ObjectiveLevel from app.services.strategy_selector import ( CampaignSnapshot, DiagnosticSignals, @@ -91,6 +93,33 @@ class RLState: local_smoothness: float # 0-1; 0 if None batch_param_spread: float # 0-1; 0 if None + # --- Scientific campaign context (v6 science features) --- + # Objective level (one-hot over ObjectiveLevel order) + objective_feasibility: float = 0.0 + objective_data_quality: float = 0.0 + objective_baseline: float = 0.0 + objective_performance: float = 0.0 + objective_mechanism: float = 0.0 + objective_generalization: float = 0.0 + # Failure-type counts (normalized, cap 1.0 at 3 events) + failure_hardware: float = 0.0 + failure_protocol: float = 0.0 + failure_constraint: float = 0.0 + failure_measurement: float = 0.0 + failure_model: float = 0.0 + failure_backend: float = 0.0 + failure_scientific_negative: float = 0.0 + # Governance / context signals + qc_fail_rate: float = 0.0 + requires_revision: float = 0.0 + requires_route_switch: float = 0.0 + requires_calibration: float = 0.0 + n_hypotheses: float = 0.0 + n_literature_priors: float = 0.0 + warm_start_available: float = 0.0 + budget_pressure_high: float = 0.0 + drift_score: float = 0.0 + # Total: 16 features @classmethod @@ -114,6 +143,28 @@ def norm(x: float | None, default: float = 0.0) -> float: return default return max(0.0, min(1.0, x)) + context = snapshot.campaign_context + level = "performance" + if context is not None: + level = str( + getattr(context.current_objective_level, "value", context.current_objective_level) + or "performance" + ) + levels = ("feasibility", "data_quality", "baseline", "performance", "mechanism", "generalization") + objective_onehot = {lvl: 1.0 if level == lvl else 0.0 for lvl in levels} + + failure_counts = {ft.value: 0.0 for ft in FailureType} + for event in snapshot.failure_events: + ftype = str(getattr(event.failure_type, "value", event.failure_type)) + if ftype in failure_counts: + failure_counts[ftype] = min(1.0, failure_counts[ftype] + 1.0 / 3.0) + + space_health = context.parameter_space_health if context is not None else None + route_ctx = context.route_context if context is not None else None + dq_ctx = context.data_quality_context if context is not None else None + budget_ctx = context.budget_context if context is not None else None + prior_ctx = context.prior_campaign_context if context is not None else None + return cls( progress=progress, n_obs_ratio=n_obs_ratio, @@ -130,6 +181,29 @@ def norm(x: float | None, default: float = 0.0) -> float: convergence_plateau=convergence_plateau, local_smoothness=norm(diagnostics.local_smoothness, 0.0), batch_param_spread=norm(diagnostics.batch_param_spread, 0.0), + # Scientific campaign context + objective_feasibility=objective_onehot["feasibility"], + objective_data_quality=objective_onehot["data_quality"], + objective_baseline=objective_onehot["baseline"], + objective_performance=objective_onehot["performance"], + objective_mechanism=objective_onehot["mechanism"], + objective_generalization=objective_onehot["generalization"], + failure_hardware=failure_counts["hardware"], + failure_protocol=failure_counts["protocol"], + failure_constraint=failure_counts["constraint"], + failure_measurement=failure_counts["measurement"], + failure_model=failure_counts["model"], + failure_backend=failure_counts["backend"], + failure_scientific_negative=failure_counts["scientific_negative"], + qc_fail_rate=min(1.0, snapshot.qc_fail_rate), + requires_revision=float(bool(space_health and space_health.requires_revision)), + requires_route_switch=float(bool(route_ctx and route_ctx.requires_route_switch)), + requires_calibration=float(bool(dq_ctx and dq_ctx.requires_calibration)), + n_hypotheses=min(1.0, len(context.domain_hypotheses) / 3.0) if context is not None else 0.0, + n_literature_priors=min(1.0, len(context.literature_priors) / 3.0) if context is not None else 0.0, + warm_start_available=float(bool(prior_ctx and prior_ctx.warm_start_available)), + budget_pressure_high=float(bool(budget_ctx and budget_ctx.pressure == "high")), + drift_score=norm(diagnostics.drift_score, 0.0), ) def to_array(self) -> np.ndarray: @@ -150,8 +224,35 @@ def to_array(self) -> np.ndarray: self.convergence_plateau, self.local_smoothness, self.batch_param_spread, + self.objective_feasibility, + self.objective_data_quality, + self.objective_baseline, + self.objective_performance, + self.objective_mechanism, + self.objective_generalization, + self.failure_hardware, + self.failure_protocol, + self.failure_constraint, + self.failure_measurement, + self.failure_model, + self.failure_backend, + self.failure_scientific_negative, + self.qc_fail_rate, + self.requires_revision, + self.requires_route_switch, + self.requires_calibration, + self.n_hypotheses, + self.n_literature_priors, + self.warm_start_available, + self.budget_pressure_high, + self.drift_score, ], dtype=np.float32) + @classmethod + def n_features(cls) -> int: + """Number of RL state features (network input dim).""" + return len(cls().to_array()) + # --------------------------------------------------------------------------- # Action Space @@ -173,6 +274,80 @@ def to_array(self) -> np.ndarray: } +# --------------------------------------------------------------------------- +# Science Action Space (v6) — campaign-level actions above backend selection +# --------------------------------------------------------------------------- + +# Ordered list of campaign decision actions (mirrors CampaignDecisionAction). +SCIENCE_ACTIONS = [ + CampaignDecisionAction.PROPOSE_CANDIDATES.value, + CampaignDecisionAction.REVISE_OBJECTIVE.value, + CampaignDecisionAction.RUN_VALIDATION.value, + CampaignDecisionAction.RECOVER_FAILURE.value, + CampaignDecisionAction.REQUEST_HUMAN_OBSERVATION.value, + CampaignDecisionAction.TIGHTEN_CONSTRAINTS.value, + CampaignDecisionAction.QUERY_LITERATURE.value, + CampaignDecisionAction.STOP_CAMPAIGN.value, +] + + +def suggest_science_action( + snapshot: CampaignSnapshot, + diagnostics: DiagnosticSignals, +) -> str: + """Ground-truth science action for a campaign state (reward shaping). + + Mirrors the rule-based decision layer priorities: blocking failures first, + then safety/validation, then objective/proxy and plateau-context moves, + defaulting to candidate proposal. + """ + context = snapshot.campaign_context + fail_types = { + str(getattr(event.failure_type, "value", event.failure_type)) + for event in snapshot.failure_events + } + if fail_types & {"hardware", "backend"}: + return CampaignDecisionAction.RECOVER_FAILURE.value + if "constraint" in fail_types: + return CampaignDecisionAction.TIGHTEN_CONSTRAINTS.value + if fail_types & {"measurement", "scientific_negative"}: + return CampaignDecisionAction.RUN_VALIDATION.value + + level = "performance" + if context is not None: + level = str( + getattr(context.current_objective_level, "value", context.current_objective_level) + or "performance" + ) + dq_ctx = context.data_quality_context if context is not None else None + if dq_ctx is not None and dq_ctx.requires_calibration: + return CampaignDecisionAction.RUN_VALIDATION.value + space_health = context.parameter_space_health if context is not None else None + if space_health is not None and space_health.requires_revision: + return CampaignDecisionAction.REVISE_OBJECTIVE.value + if level in {"feasibility", "data_quality"}: + return CampaignDecisionAction.REVISE_OBJECTIVE.value + route_ctx = context.route_context if context is not None else None + if route_ctx is not None and route_ctx.requires_route_switch: + return CampaignDecisionAction.QUERY_LITERATURE.value + if level == "mechanism" and context is not None and context.domain_hypotheses: + return CampaignDecisionAction.RUN_VALIDATION.value + if ( + diagnostics.convergence_status == "plateau" + and context is not None + and not context.literature_priors + ): + return CampaignDecisionAction.QUERY_LITERATURE.value + progress = snapshot.round_number / max(snapshot.max_rounds, 1) + if ( + diagnostics.convergence_status == "plateau" + and diagnostics.convergence_confidence > 0.75 + and progress > 0.8 + ): + return CampaignDecisionAction.STOP_CAMPAIGN.value + return CampaignDecisionAction.PROPOSE_CANDIDATES.value + + # --------------------------------------------------------------------------- # Experience Replay Buffer # --------------------------------------------------------------------------- diff --git a/app/services/scientific_evidence.py b/app/services/scientific_evidence.py new file mode 100644 index 0000000..a379ee3 --- /dev/null +++ b/app/services/scientific_evidence.py @@ -0,0 +1,449 @@ +"""Typed scientific claims, evidence accounting, and promotion gates. + +This module is the evidence boundary between descriptive campaign signals and +scientific claims. It deliberately does not manufacture confidence from +generic success/failure events. Posterior claim probabilities move only when +an evidence item supplies an auditable likelihood ratio (stored as a log Bayes +factor), and dependent evidence cannot be double counted because every item +must have a unique ``independence_key``. + +All APIs are pure and shadow-only. A positive promotion decision means that +predeclared evidence requirements were met; it never mutates a live objective, +search space, constraint, or hardware workflow. +""" + +from __future__ import annotations + +import math +from datetime import UTC, datetime +from enum import StrEnum +from typing import Any + +from pydantic import BaseModel, Field, field_validator, model_validator + +__all__ = [ + "ClaimAssessment", + "ClaimStatus", + "EvidenceAssessmentPolicy", + "EvidenceDesign", + "EvidenceItem", + "EvidenceSet", + "PromotionCriteria", + "PromotionDecision", + "ScientificClaim", + "ValidationCheck", + "assess_claim_evidence", + "evaluate_claim_promotion", +] + + +class ClaimStatus(StrEnum): + """Evidence state for one scientific claim.""" + + PROPOSED = "proposed" + INCONCLUSIVE = "inconclusive" + SUPPORTED = "supported" + REFUTED = "refuted" + BLOCKED = "blocked" + + +class EvidenceDesign(StrEnum): + """Study design that produced an evidence item.""" + + RETROSPECTIVE = "retrospective" + PROSPECTIVE_OBSERVATIONAL = "prospective_observational" + PROSPECTIVE_INTERVENTIONAL = "prospective_interventional" + INDEPENDENT_REPLICATION = "independent_replication" + EXTERNAL_VALIDATION = "external_validation" + + +_PROSPECTIVE_DESIGNS = { + EvidenceDesign.PROSPECTIVE_OBSERVATIONAL, + EvidenceDesign.PROSPECTIVE_INTERVENTIONAL, + EvidenceDesign.INDEPENDENT_REPLICATION, + EvidenceDesign.EXTERNAL_VALIDATION, +} + +_INTERVENTIONAL_DESIGNS = { + EvidenceDesign.PROSPECTIVE_INTERVENTIONAL, + EvidenceDesign.INDEPENDENT_REPLICATION, +} + + +class ScientificClaim(BaseModel): + """A falsifiable claim whose evidence is tracked independently of prose.""" + + claim_id: str = Field(min_length=1) + statement: str = Field(min_length=1) + scope: str = Field(min_length=1) + prior_probability: float = Field(default=0.5, gt=0.0, lt=1.0) + prior_version: str = Field(default="v1", min_length=1) + prior_rationale: str | None = None + competing_claim_ids: list[str] = Field(default_factory=list) + assumptions: list[str] = Field(default_factory=list) + falsifying_observations: list[str] = Field(default_factory=list) + required_evidence: list[str] = Field(default_factory=list) + blocked_reason: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + @field_validator("competing_claim_ids") + @classmethod + def _claim_must_not_compete_with_itself(cls, values: list[str], info: Any) -> list[str]: + claim_id = info.data.get("claim_id") + if claim_id in values: + raise ValueError("a scientific claim cannot compete with itself") + if len(values) != len(set(values)): + raise ValueError("competing_claim_ids must be unique") + return values + + +class EvidenceItem(BaseModel): + """One independently interpretable unit of evidence for a claim. + + ``log_bayes_factor`` is positive when the observed data are more likely + under the claim than its declared alternative, negative when they favor + the alternative, and zero when they do not discriminate. ``None`` keeps + descriptive evidence in the ledger without pretending that it updates a + posterior. + """ + + evidence_id: str = Field(min_length=1) + claim_id: str = Field(min_length=1) + independence_key: str = Field(min_length=1) + design: EvidenceDesign + source: str = Field(min_length=1) + log_bayes_factor: float | None = None + analysis_method: str | None = None + dataset_hash: str | None = None + protocol_version: str | None = None + registered_before_observation: bool = False + replicate_count: int = Field(default=1, ge=1) + block_ids: list[str] = Field(default_factory=list) + effect_estimate: float | None = None + standard_error: float | None = Field(default=None, gt=0.0) + falsifier_triggered: bool = False + safety_incident_count: int = Field(default=0, ge=0) + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + metadata: dict[str, Any] = Field(default_factory=dict) + + @field_validator("created_at") + @classmethod + def _created_at_must_be_timezone_aware(cls, value: datetime) -> datetime: + if value.tzinfo is None or value.tzinfo.utcoffset(value) is None: + raise ValueError("created_at must be timezone-aware") + return value + + @field_validator("block_ids") + @classmethod + def _block_ids_must_be_unique(cls, values: list[str]) -> list[str]: + if len(values) != len(set(values)): + raise ValueError("block_ids must be unique") + return values + + @model_validator(mode="after") + def _scored_evidence_requires_an_auditable_method(self) -> EvidenceItem: + if self.log_bayes_factor is not None: + if not math.isfinite(self.log_bayes_factor): + raise ValueError("log_bayes_factor must be finite") + if not self.analysis_method: + raise ValueError("scored evidence requires analysis_method") + return self + + +class EvidenceSet(BaseModel): + """Evidence for one claim with independence and identity invariants.""" + + claim_id: str = Field(min_length=1) + items: list[EvidenceItem] = Field(default_factory=list) + + @model_validator(mode="after") + def _items_must_be_aligned_and_independent(self) -> EvidenceSet: + evidence_ids = [item.evidence_id for item in self.items] + if len(evidence_ids) != len(set(evidence_ids)): + raise ValueError("evidence_id values must be unique") + independence_keys = [item.independence_key for item in self.items] + if len(independence_keys) != len(set(independence_keys)): + raise ValueError( + "independence_key values must be unique; aggregate dependent observations " + "into one evidence item before posterior updating" + ) + mismatched = [item.evidence_id for item in self.items if item.claim_id != self.claim_id] + if mismatched: + raise ValueError(f"evidence items target another claim: {mismatched}") + return self + + +class EvidenceAssessmentPolicy(BaseModel): + """Predeclared thresholds for interpreting a claim posterior.""" + + support_probability: float = Field(default=0.95, gt=0.5, lt=1.0) + refute_probability: float = Field(default=0.05, gt=0.0, lt=0.5) + min_scored_evidence: int = Field(default=1, ge=1) + min_prospective_evidence: int = Field(default=1, ge=0) + min_independent_blocks: int = Field(default=1, ge=0) + require_interventional_evidence: bool = False + + +class ClaimAssessment(BaseModel): + """Posterior and design-quality summary for a scientific claim.""" + + claim_id: str + status: ClaimStatus + prior_probability: float = Field(gt=0.0, lt=1.0) + prior_version: str + prior_rationale_recorded: bool + posterior_probability: float = Field(ge=0.0, le=1.0) + cumulative_log_bayes_factor: float + scored_evidence_count: int = Field(ge=0) + unscored_evidence_count: int = Field(ge=0) + prospective_evidence_count: int = Field(ge=0) + interventional_evidence_count: int = Field(ge=0) + preregistered_evidence_count: int = Field(ge=0) + independent_block_count: int = Field(ge=0) + safety_incident_count: int = Field(ge=0) + falsifier_triggered: bool = False + evidence_ids: list[str] = Field(default_factory=list) + unmet_requirements: list[str] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + method: str = "posterior_odds_from_independent_log_bayes_factors" + shadow_only: bool = True + + +class ValidationCheck(BaseModel): + """One predeclared non-posterior condition for promotion.""" + + name: str = Field(min_length=1) + passed: bool + required: bool = True + evidence_ids: list[str] = Field(default_factory=list) + rationale: str | None = None + + +class PromotionCriteria(BaseModel): + """Evidence and governance requirements for considering a claim promotable.""" + + min_posterior_probability: float = Field(default=0.95, gt=0.5, lt=1.0) + min_scored_evidence: int = Field(default=2, ge=1) + min_prospective_evidence: int = Field(default=2, ge=0) + min_interventional_evidence: int = Field(default=0, ge=0) + min_independent_blocks: int = Field(default=2, ge=0) + min_preregistered_evidence: int = Field(default=1, ge=0) + max_safety_incidents: int = Field(default=0, ge=0) + require_supported_status: bool = True + require_prior_rationale: bool = True + require_human_approval: bool = True + + +class PromotionDecision(BaseModel): + """Shadow-only decision about whether evidence justifies human promotion review.""" + + claim_id: str + evidence_criteria_satisfied: bool + human_approval_required: bool + human_approved: bool + promotion_allowed: bool + reasons: list[str] = Field(default_factory=list) + checks: list[ValidationCheck] = Field(default_factory=list) + auto_applied: bool = False + shadow_only: bool = True + + @model_validator(mode="after") + def _live_mutation_is_forbidden(self) -> PromotionDecision: + if self.auto_applied: + raise ValueError("scientific evidence promotion cannot be auto-applied") + return self + + +def assess_claim_evidence( + claim: ScientificClaim, + evidence: EvidenceSet, + *, + policy: EvidenceAssessmentPolicy | None = None, +) -> ClaimAssessment: + """Update claim odds from independent, auditable likelihood ratios.""" + + if evidence.claim_id != claim.claim_id: + raise ValueError("claim and evidence set must have the same claim_id") + effective_policy = policy or EvidenceAssessmentPolicy() + scored_values = [ + item.log_bayes_factor + for item in evidence.items + if item.log_bayes_factor is not None + ] + scored = [item for item in evidence.items if item.log_bayes_factor is not None] + unscored = [item for item in evidence.items if item.log_bayes_factor is None] + cumulative_log_bf = sum(scored_values, 0.0) + log_prior_odds = math.log(claim.prior_probability) - math.log1p(-claim.prior_probability) + posterior = _logistic(log_prior_odds + cumulative_log_bf) + prospective = [item for item in scored if item.design in _PROSPECTIVE_DESIGNS] + interventional = [item for item in scored if item.design in _INTERVENTIONAL_DESIGNS] + preregistered = [item for item in prospective if item.registered_before_observation] + block_ids = {block_id for item in scored for block_id in item.block_ids} + falsifier_triggered = any(item.falsifier_triggered for item in evidence.items) + safety_incidents = sum(item.safety_incident_count for item in evidence.items) + + unmet = _assessment_requirements( + effective_policy, + scored_count=len(scored), + prospective_count=len(prospective), + interventional_count=len(interventional), + block_count=len(block_ids), + ) + warnings: list[str] = [] + if unscored: + warnings.append( + f"{len(unscored)} evidence item(s) were recorded descriptively and did not update the posterior" + ) + if any(item.dataset_hash is None for item in scored): + warnings.append("one or more scored evidence items lack a dataset_hash") + if any(item.design in _PROSPECTIVE_DESIGNS and not item.registered_before_observation for item in scored): + warnings.append("one or more prospective evidence items were not preregistered") + if not claim.prior_rationale: + warnings.append("claim prior has no recorded rationale") + + if claim.blocked_reason: + status = ClaimStatus.BLOCKED + unmet = [*unmet, f"claim blocked: {claim.blocked_reason}"] + elif falsifier_triggered or posterior <= effective_policy.refute_probability: + status = ClaimStatus.REFUTED + elif posterior >= effective_policy.support_probability and not unmet: + status = ClaimStatus.SUPPORTED + elif not evidence.items: + status = ClaimStatus.PROPOSED + else: + status = ClaimStatus.INCONCLUSIVE + + return ClaimAssessment( + claim_id=claim.claim_id, + status=status, + prior_probability=claim.prior_probability, + prior_version=claim.prior_version, + prior_rationale_recorded=bool(claim.prior_rationale), + posterior_probability=round(posterior, 12), + cumulative_log_bayes_factor=round(cumulative_log_bf, 12), + scored_evidence_count=len(scored), + unscored_evidence_count=len(unscored), + prospective_evidence_count=len(prospective), + interventional_evidence_count=len(interventional), + preregistered_evidence_count=len(preregistered), + independent_block_count=len(block_ids), + safety_incident_count=safety_incidents, + falsifier_triggered=falsifier_triggered, + evidence_ids=[item.evidence_id for item in evidence.items], + unmet_requirements=unmet, + warnings=warnings, + ) + + +def evaluate_claim_promotion( + assessment: ClaimAssessment, + *, + criteria: PromotionCriteria | None = None, + validation_checks: list[ValidationCheck] | None = None, + human_approved: bool = False, +) -> PromotionDecision: + """Evaluate a promotion gate without applying any live change.""" + + effective = criteria or PromotionCriteria() + checks = list(validation_checks or []) + reasons: list[str] = [] + + if effective.require_supported_status and assessment.status != ClaimStatus.SUPPORTED: + reasons.append(f"claim status is {assessment.status.value}, not supported") + if effective.require_prior_rationale and not assessment.prior_rationale_recorded: + reasons.append("claim prior has no recorded rationale") + if assessment.posterior_probability < effective.min_posterior_probability: + reasons.append( + "posterior probability " + f"{assessment.posterior_probability:.4f} < {effective.min_posterior_probability:.4f}" + ) + _append_count_failure( + reasons, + "scored evidence", + assessment.scored_evidence_count, + effective.min_scored_evidence, + ) + _append_count_failure( + reasons, + "prospective evidence", + assessment.prospective_evidence_count, + effective.min_prospective_evidence, + ) + _append_count_failure( + reasons, + "interventional evidence", + assessment.interventional_evidence_count, + effective.min_interventional_evidence, + ) + _append_count_failure( + reasons, + "independent blocks", + assessment.independent_block_count, + effective.min_independent_blocks, + ) + _append_count_failure( + reasons, + "preregistered evidence", + assessment.preregistered_evidence_count, + effective.min_preregistered_evidence, + ) + if assessment.safety_incident_count > effective.max_safety_incidents: + reasons.append( + f"safety incidents {assessment.safety_incident_count} > {effective.max_safety_incidents}" + ) + if assessment.falsifier_triggered: + reasons.append("a predeclared falsifier was triggered") + for check in checks: + if check.required and not check.passed: + reasons.append(f"required validation check failed: {check.name}") + + evidence_criteria_satisfied = not reasons + promotion_allowed = evidence_criteria_satisfied and ( + human_approved or not effective.require_human_approval + ) + if evidence_criteria_satisfied and effective.require_human_approval and not human_approved: + reasons.append("explicit human approval is required") + + return PromotionDecision( + claim_id=assessment.claim_id, + evidence_criteria_satisfied=evidence_criteria_satisfied, + human_approval_required=effective.require_human_approval, + human_approved=human_approved, + promotion_allowed=promotion_allowed, + reasons=reasons, + checks=checks, + ) + + +def _assessment_requirements( + policy: EvidenceAssessmentPolicy, + *, + scored_count: int, + prospective_count: int, + interventional_count: int, + block_count: int, +) -> list[str]: + unmet: list[str] = [] + _append_count_failure(unmet, "scored evidence", scored_count, policy.min_scored_evidence) + _append_count_failure( + unmet, + "prospective evidence", + prospective_count, + policy.min_prospective_evidence, + ) + _append_count_failure(unmet, "independent blocks", block_count, policy.min_independent_blocks) + if policy.require_interventional_evidence and interventional_count < 1: + unmet.append("interventional evidence 0 < 1") + return unmet + + +def _append_count_failure(reasons: list[str], name: str, observed: int, required: int) -> None: + if observed < required: + reasons.append(f"{name} {observed} < {required}") + + +def _logistic(value: float) -> float: + if value >= 0: + return 1.0 / (1.0 + math.exp(-value)) + exp_value = math.exp(value) + return exp_value / (1.0 + exp_value) diff --git a/app/services/scientific_ledger.py b/app/services/scientific_ledger.py new file mode 100644 index 0000000..3bb69bc --- /dev/null +++ b/app/services/scientific_ledger.py @@ -0,0 +1,974 @@ +"""Git-trackable Markdown scientific memory for HELIOS campaigns. + +This service projects typed decision accounting into an atomic Markdown tree, +maintains campaign indexes and policy/Nexus snapshots, supports exact-text +scientific-memory retrieval, and exposes deterministic RLVR training exports. +It is reporting-only: failures never change live campaign behavior. +""" +from __future__ import annotations + +import hashlib +import json +import os +import re +import subprocess +import tempfile +import threading +from collections.abc import Iterator, Mapping, Sequence +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Any + +import yaml + +from app.services.decision_markdown import ( + LedgerProvenance, + RenderedDecisionBundle, + redact_sensitive, + render_campaign_overview, + render_completed_decision, + render_nexus_snapshot, + render_pending_decision, + render_policy_snapshot, + render_trajectory_figure, +) +from app.services.decision_outcome import CampaignDecisionAccounting +from app.services.decision_trace import CampaignDecisionTrace +from app.services.hypothesis_experiment_planner import ExperimentPlan +from app.services.scientific_evidence import ( + ClaimAssessment, + EvidenceSet, + PromotionDecision, + ScientificClaim, +) +from app.services.scientific_ledger_git import LedgerGitCommit, ScientificLedgerGit + +try: # Unix process lock; HELIOS production targets Linux/macOS. + import fcntl +except ImportError: # pragma: no cover - Windows fallback still has thread locking. + fcntl = None # type: ignore[assignment] + + +RLVR_EXPORT_SCHEMA_VERSION = "helios.rlvr/v1" +_SAFE_COMPONENT_RE = re.compile(r"[^A-Za-z0-9._-]+") +_THREAD_LOCKS: dict[str, threading.RLock] = {} +_THREAD_LOCKS_GUARD = threading.Lock() + + +@dataclass(frozen=True) +class LedgerSearchHit: + campaign_id: str + path: str + title: str + line_number: int + snippet: str + + +@dataclass(frozen=True) +class LedgerWriteResult: + campaign_id: str + campaign_directory: str + status: str + changed_paths: tuple[str, ...] + unchanged_paths: tuple[str, ...] + git_commit: LedgerGitCommit | None = None + + +class ScientificLedger: + """Atomic campaign Markdown storage with optional campaign-local Git.""" + + def __init__( + self, + root: str | Path, + *, + workspace_root: str | Path | None = None, + git_enabled: bool = False, + git_auto_init: bool = False, + git_author_name: str = "HELIOS Scientific Ledger", + git_author_email: str = "helios-ledger@localhost", + ) -> None: + self.root = Path(root).resolve() + self.workspace_root = Path(workspace_root).resolve() if workspace_root else None + self.git_enabled = git_enabled + self.git_auto_init = git_auto_init + self.git_author_name = git_author_name + self.git_author_email = git_author_email + + def record_pending( + self, + trace: CampaignDecisionTrace, + *, + campaign_metadata: Mapping[str, Any] | None = None, + policy_snapshot: Mapping[str, Any] | None = None, + provenance: LedgerProvenance | None = None, + ) -> LedgerWriteResult: + """Write the decision before execution with Outcome=Pending.""" + effective_provenance = provenance or self.provenance_for(trace) + bundle = render_pending_decision( + trace, + provenance=effective_provenance, + campaign_metadata=campaign_metadata, + ) + return self._record_bundle( + bundle, + trace=trace, + provenance=effective_provenance, + campaign_metadata=campaign_metadata or {}, + policy_snapshot=policy_snapshot or trace.decision_plan.strategy_trace, + git_message=f"decision: record round {trace.round_index:03d} pending", + ) + + def record_completed( + self, + accounting: CampaignDecisionAccounting, + *, + campaign_metadata: Mapping[str, Any] | None = None, + policy_snapshot: Mapping[str, Any] | None = None, + observations: Sequence[Any] | None = None, + failures: Sequence[Any] | None = None, + recovery_events: Sequence[Any] | None = None, + provenance: LedgerProvenance | None = None, + ) -> LedgerWriteResult: + """Finalize one card with its outcome, reward, failures, and recovery.""" + effective_provenance = provenance or self.provenance_for( + accounting.trace, accounting=accounting + ) + bundle = render_completed_decision( + accounting, + provenance=effective_provenance, + campaign_metadata=campaign_metadata, + observations=observations, + failures=failures, + recovery_events=recovery_events, + ) + return self._record_bundle( + bundle, + trace=accounting.trace, + provenance=effective_provenance, + campaign_metadata=campaign_metadata or {}, + policy_snapshot=policy_snapshot or accounting.trace.decision_plan.strategy_trace, + git_message=f"outcome: finalize round {accounting.trace.round_index:03d}", + ) + + def record_claim_evidence( + self, + *, + campaign_id: str, + claim: ScientificClaim, + evidence: EvidenceSet, + assessment: ClaimAssessment, + promotion_decision: PromotionDecision | None = None, + ) -> LedgerWriteResult: + """Persist a typed claim posterior as a reviewable Markdown artifact.""" + + ids = {claim.claim_id, evidence.claim_id, assessment.claim_id} + if promotion_decision is not None: + ids.add(promotion_decision.claim_id) + if len(ids) != 1: + raise ValueError("claim, evidence, assessment, and promotion decision must align") + campaign_dir = self.campaign_directory(campaign_id) + relative = f"evidence/claims/{safe_path_component(claim.claim_id)}.md" + content = _render_claim_evidence(claim, evidence, assessment, promotion_decision) + with self._campaign_lock(campaign_id): + path = _validated_markdown_path(campaign_dir, relative) + changed: list[Path] = [] + unchanged: list[Path] = [] + (changed if _atomic_write_markdown(path, content, campaign_dir) else unchanged).append(path) + index_path = campaign_dir / "evidence" / "index.md" + index_content = _render_scientific_evidence_index(campaign_id, campaign_dir) + (changed if _atomic_write_markdown( + index_path, index_content, campaign_dir + ) else unchanged).append(index_path) + git_commit = self._commit( + campaign_dir, + changed, + f"evidence: assess claim {claim.claim_id}", + ) + return LedgerWriteResult( + campaign_id=campaign_id, + campaign_directory=str(campaign_dir), + status="claim_evidence", + changed_paths=tuple(item.relative_to(campaign_dir).as_posix() for item in changed), + unchanged_paths=tuple(item.relative_to(campaign_dir).as_posix() for item in unchanged), + git_commit=git_commit, + ) + + def record_experiment_plan( + self, + *, + campaign_id: str, + plan: ExperimentPlan, + ) -> LedgerWriteResult: + """Persist a shadow hypothesis-discrimination plan for operator review.""" + + campaign_dir = self.campaign_directory(campaign_id) + relative = f"evidence/plans/{safe_path_component(plan.plan_id)}.md" + content = _render_experiment_plan(plan) + with self._campaign_lock(campaign_id): + path = _validated_markdown_path(campaign_dir, relative) + changed: list[Path] = [] + unchanged: list[Path] = [] + (changed if _atomic_write_markdown(path, content, campaign_dir) else unchanged).append(path) + index_path = campaign_dir / "evidence" / "index.md" + index_content = _render_scientific_evidence_index(campaign_id, campaign_dir) + (changed if _atomic_write_markdown( + index_path, index_content, campaign_dir + ) else unchanged).append(index_path) + git_commit = self._commit( + campaign_dir, + changed, + f"evidence: record experiment plan {plan.plan_id}", + ) + return LedgerWriteResult( + campaign_id=campaign_id, + campaign_directory=str(campaign_dir), + status="experiment_plan", + changed_paths=tuple(item.relative_to(campaign_dir).as_posix() for item in changed), + unchanged_paths=tuple(item.relative_to(campaign_dir).as_posix() for item in unchanged), + git_commit=git_commit, + ) + + def search( + self, + query: str, + *, + campaign_id: str | None = None, + limit: int = 50, + ) -> list[LedgerSearchHit]: + """Case-insensitive exact-text retrieval over Markdown; no embeddings.""" + needle = query.strip().casefold() + if not needle: + raise ValueError("Scientific memory query must not be empty") + if limit < 1 or limit > 1000: + raise ValueError("Scientific memory result limit must be between 1 and 1000") + roots = [self.campaign_directory(campaign_id)] if campaign_id else self._campaign_directories() + hits: list[LedgerSearchHit] = [] + for campaign_dir in roots: + if not campaign_dir.is_dir(): + continue + for path in sorted(campaign_dir.rglob("*.md")): + if ".git" in path.parts or path.is_symlink(): + continue + try: + path.resolve().relative_to(campaign_dir.resolve()) + except ValueError: + continue + text = path.read_text(encoding="utf-8") + title = _first_title(text) or path.stem + for line_number, line in enumerate(text.splitlines(), 1): + if needle in line.casefold(): + hits.append( + LedgerSearchHit( + campaign_id=_campaign_id_from_dir(campaign_dir), + path=path.relative_to(campaign_dir).as_posix(), + title=title, + line_number=line_number, + snippet=line.strip()[:500], + ) + ) + if len(hits) >= limit: + return hits + return hits + + def export_rlvr_records(self, campaign_id: str | None = None) -> list[dict[str, Any]]: + """Build deterministic machine records from the typed trajectory store.""" + from app.services.decision_trajectory import load_trajectories + + records: list[dict[str, Any]] = [] + for row in load_trajectories(campaign_id): + if row.get("layer") != "campaign": + continue + trajectory = redact_sensitive(row.get("trajectory") or {}) + if not isinstance(trajectory, Mapping): + continue + trace = trajectory.get("trace") or {} + outcome = trajectory.get("outcome") or {} + reward = trajectory.get("reward") or {} + if not isinstance(trace, Mapping): + continue + plan = trace.get("decision_plan") or {} + context = trace.get("context") or {} + strategy = plan.get("strategy_trace") or {} if isinstance(plan, Mapping) else {} + candidates = _candidate_actions(strategy) + record = { + "schema_version": RLVR_EXPORT_SCHEMA_VERSION, + "decision_id": row.get("trace_id"), + "campaign_id": row.get("campaign_id"), + "round_index": row.get("round_index"), + "question": "What should happen next in this scientific campaign?", + "context": context, + "candidate_actions": candidates, + "chosen_action": plan.get("action_type") if isinstance(plan, Mapping) else None, + "chosen_backend": ( + plan.get("candidate_generation_backend") if isinstance(plan, Mapping) else None + ), + "rationale": plan.get("rationale") if isinstance(plan, Mapping) else None, + "confidence": plan.get("confidence") if isinstance(plan, Mapping) else None, + "outcome": outcome, + "reward": reward, + "verifier_report": row.get("verifier_report") or [], + "rubric_version": row.get("rubric_version"), + "trajectory_schema_version": row.get("trajectory_schema_version"), + "created_at": row.get("created_at"), + } + records.append(redact_sensitive(record)) + return sorted( + records, + key=lambda item: ( + str(item.get("campaign_id") or ""), + int(item.get("round_index") or 0), + str(item.get("decision_id") or ""), + ), + ) + + def export_rlvr_jsonl(self, campaign_id: str | None = None) -> str: + """Return one canonical RLVR JSON object per line for downstream training.""" + return "\n".join( + json.dumps(record, ensure_ascii=False, sort_keys=True) + for record in self.export_rlvr_records(campaign_id) + ) + + def write_training_dataset_markdown(self, campaign_id: str) -> Path: + """Refresh the human-reviewable view of the RLVR training rows.""" + campaign_dir = self.campaign_directory(campaign_id) + records = self.export_rlvr_records(campaign_id) + content = _render_training_dataset(campaign_id, records) + with self._campaign_lock(campaign_id): + path = campaign_dir / "training_dataset.md" + _atomic_write_markdown(path, content, campaign_dir) + return path + + def provenance_for( + self, + trace: CampaignDecisionTrace, + *, + accounting: CampaignDecisionAccounting | None = None, + ) -> LedgerProvenance: + strategy = trace.decision_plan.strategy_trace + policy_id, policy_version = _policy_identity(strategy) + nexus_version = _first_nested_value( + trace.context.nexus_diagnostics, + ("contract_version", "schema_version", "version"), + ) + code_commit, code_dirty = _code_identity(self.workspace_root) + return LedgerProvenance( + code_commit=code_commit, + policy_id=policy_id, + policy_version=policy_version, + nexus_contract_version=nexus_version, + rubric_version=accounting.reward.rubric_version if accounting else None, + extra={"code_dirty": code_dirty}, + ) + + def campaign_directory(self, campaign_id: str) -> Path: + if not str(campaign_id).strip(): + raise ValueError("campaign_id must not be empty") + campaigns_root = (self.root / "campaigns").resolve() + campaign_dir = (campaigns_root / safe_path_component(campaign_id)).resolve() + try: + campaign_dir.relative_to(campaigns_root) + except ValueError as exc: + raise ValueError("Campaign ledger directory escapes the ledger root") from exc + return campaign_dir + + def _record_bundle( + self, + bundle: RenderedDecisionBundle, + *, + trace: CampaignDecisionTrace, + provenance: LedgerProvenance, + campaign_metadata: Mapping[str, Any], + policy_snapshot: Mapping[str, Any], + git_message: str, + ) -> LedgerWriteResult: + campaign_dir = self.campaign_directory(bundle.campaign_id) + changed: list[Path] = [] + unchanged: list[Path] = [] + with self._campaign_lock(bundle.campaign_id): + for relative, content in sorted(bundle.files.items()): + path = _validated_markdown_path(campaign_dir, relative) + (changed if _atomic_write_markdown(path, content, campaign_dir) else unchanged).append(path) + + policy_content = render_policy_snapshot( + campaign_id=bundle.campaign_id, + policy=policy_snapshot, + provenance=provenance, + ) + policy_path = campaign_dir / "policy.md" + (changed if _atomic_write_markdown(policy_path, policy_content, campaign_dir) else unchanged).append( + policy_path + ) + policy_version_path = ( + campaign_dir + / "policy_versions" + / f"{safe_path_component(provenance.policy_version or 'unversioned')}.md" + ) + if not policy_version_path.exists(): + (changed if _atomic_write_markdown( + policy_version_path, policy_content, campaign_dir + ) else unchanged).append(policy_version_path) + + nexus_content = render_nexus_snapshot( + campaign_id=bundle.campaign_id, + diagnostics=trace.context.nexus_diagnostics, + provenance=provenance, + ) + nexus_path = campaign_dir / "nexus.md" + (changed if _atomic_write_markdown(nexus_path, nexus_content, campaign_dir) else unchanged).append( + nexus_path + ) + + decision_rows = _read_decision_rows(campaign_dir) + overview = render_campaign_overview( + campaign_id=bundle.campaign_id, + decision_rows=decision_rows, + campaign_metadata=campaign_metadata, + ) + for name, content in ( + ("campaign.md", overview), + ("index.md", _render_navigation(bundle.campaign_id, decision_rows)), + ("summary.md", _render_campaign_summary(bundle.campaign_id, decision_rows)), + ("trajectory.md", render_trajectory_figure( + campaign_id=bundle.campaign_id, decision_rows=decision_rows + )), + ): + path = campaign_dir / name + (changed if _atomic_write_markdown(path, content, campaign_dir) else unchanged).append(path) + + training_records = self.export_rlvr_records(bundle.campaign_id) + training_path = campaign_dir / "training_dataset.md" + training_content = _render_training_dataset(bundle.campaign_id, training_records) + (changed if _atomic_write_markdown( + training_path, training_content, campaign_dir + ) else unchanged).append(training_path) + + git_commit = self._commit(campaign_dir, changed, git_message) + + return LedgerWriteResult( + campaign_id=bundle.campaign_id, + campaign_directory=str(campaign_dir), + status=bundle.status, + changed_paths=tuple(path.relative_to(campaign_dir).as_posix() for path in changed), + unchanged_paths=tuple(path.relative_to(campaign_dir).as_posix() for path in unchanged), + git_commit=git_commit, + ) + + def _commit( + self, campaign_dir: Path, changed: Sequence[Path], message: str + ) -> LedgerGitCommit | None: + if not self.git_enabled: + return None + return ScientificLedgerGit( + campaign_dir, + auto_init=self.git_auto_init, + author_name=self.git_author_name, + author_email=self.git_author_email, + ).commit(changed, message) + + def _campaign_directories(self) -> list[Path]: + campaigns_root = self.root / "campaigns" + if not campaigns_root.is_dir(): + return [] + return sorted(path for path in campaigns_root.iterdir() if path.is_dir()) + + @contextmanager + def _campaign_lock(self, campaign_id: str) -> Iterator[None]: + safe_id = safe_path_component(campaign_id) + key = str((self.root / safe_id).resolve()) + with _THREAD_LOCKS_GUARD: + thread_lock = _THREAD_LOCKS.setdefault(key, threading.RLock()) + with thread_lock: + lock_dir = self.root / ".locks" + lock_dir.mkdir(parents=True, exist_ok=True) + lock_path = lock_dir / f"{safe_id}.lock" + with lock_path.open("a+", encoding="utf-8") as handle: + if fcntl is not None: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + if fcntl is not None: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + + +def safe_path_component(value: Any) -> str: + """Return a traversal-safe, collision-resistant filesystem component.""" + original = str(value).strip() + if not original: + raise ValueError("Ledger path component must not be empty") + normalized = _SAFE_COMPONENT_RE.sub("-", original).strip(" .-_")[:80] + normalized = normalized or "item" + if normalized != original or original in {".", ".."}: + digest = hashlib.sha256(original.encode("utf-8")).hexdigest()[:10] + normalized = f"{normalized}-{digest}" + return normalized + + +def get_scientific_ledger() -> ScientificLedger: + """Build a ledger from current settings without module-level mutable state.""" + from app.core.config import get_settings + + settings = get_settings() + return ScientificLedger( + settings.scientific_ledger_root, + workspace_root=settings.workspace_root, + git_enabled=settings.scientific_ledger_git_enabled, + git_auto_init=settings.scientific_ledger_git_auto_init, + git_author_name=settings.scientific_ledger_git_author_name, + git_author_email=settings.scientific_ledger_git_author_email, + ) + + +def _validated_markdown_path(campaign_dir: Path, relative: str) -> Path: + pure = PurePosixPath(relative) + if ( + pure.is_absolute() + or ".." in pure.parts + or ".git" in pure.parts + or pure.suffix.casefold() != ".md" + ): + raise ValueError(f"Invalid scientific ledger Markdown path: {relative!r}") + path = (campaign_dir / Path(*pure.parts)).resolve() + try: + path.relative_to(campaign_dir) + except ValueError as exc: + raise ValueError("Scientific ledger path escapes the campaign directory") from exc + return path + + +def _atomic_write_markdown(path: Path, content: str, campaign_dir: Path) -> bool: + path = path.resolve() + try: + path.relative_to(campaign_dir.resolve()) + except ValueError as exc: + raise ValueError("Scientific ledger write escapes the campaign directory") from exc + if path.suffix.casefold() != ".md": + raise ValueError("Scientific ledger artifacts must be Markdown files") + encoded = content.encode("utf-8") + if path.exists() and path.read_bytes() == encoded: + return False + path.parent.mkdir(parents=True, exist_ok=True) + temp_name: str | None = None + try: + with tempfile.NamedTemporaryFile( + mode="wb", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as handle: + temp_name = handle.name + handle.write(encoded) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp_name, path) + _fsync_directory(path.parent) + return True + finally: + if temp_name and os.path.exists(temp_name): + os.unlink(temp_name) + + +def _fsync_directory(directory: Path) -> None: + """Best-effort durability for the rename that publishes an artifact.""" + try: + descriptor = os.open(directory, os.O_RDONLY) + except OSError: # pragma: no cover - platform/filesystem dependent. + return + try: + os.fsync(descriptor) + except OSError: # pragma: no cover - some filesystems reject directory fsync. + pass + finally: + os.close(descriptor) + + +def _read_decision_rows(campaign_dir: Path) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for path in sorted((campaign_dir / "rounds").glob("*/decision_*.md")): + metadata = _front_matter(path) + if not metadata: + continue + metadata["path"] = path.relative_to(campaign_dir).as_posix() + rows.append(metadata) + return rows + + +def _front_matter(path: Path) -> dict[str, Any]: + text = path.read_text(encoding="utf-8") + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + return {} + try: + end = lines[1:].index("---") + 1 + except ValueError: + return {} + decoded = yaml.safe_load("\n".join(lines[1:end])) or {} + return decoded if isinstance(decoded, dict) else {} + + +def _render_navigation(campaign_id: str, rows: Sequence[Mapping[str, Any]]) -> str: + lines = [ + "---", + "artifact_type: index", + f"campaign_id: {json.dumps(campaign_id, ensure_ascii=False)}", + "---", + f"# Campaign {campaign_id} Artifact Index", + "", + "- [Campaign overview](campaign.md)", + "- [Campaign summary](summary.md)", + "- [Decision trajectory](trajectory.md)", + "- [Decision policy](policy.md)", + "- [Nexus health](nexus.md)", + "- [RLVR training dataset](training_dataset.md)", + "", + "## Rounds", + "", + ] + for row in sorted(rows, key=lambda item: int(item.get("round_index", 0))): + path = str(row.get("path", "")) + lines.append( + f"- [Round {row.get('round_index')} — {row.get('selected_action', 'decision')}]({path})" + ) + if not rows: + lines.append("- No rounds recorded.") + return "\n".join(lines).rstrip() + "\n" + + +def _render_campaign_summary(campaign_id: str, rows: Sequence[Mapping[str, Any]]) -> str: + completed = [row for row in rows if row.get("status") == "completed"] + rewards = [float(row["reward"]) for row in completed if isinstance(row.get("reward"), int | float)] + action_counts: dict[str, int] = {} + for row in rows: + action = str(row.get("selected_action") or "unknown") + action_counts[action] = action_counts.get(action, 0) + 1 + lines = [ + "---", + "artifact_type: campaign_summary", + f"campaign_id: {json.dumps(campaign_id, ensure_ascii=False)}", + f"decision_count: {len(rows)}", + "---", + "# Campaign Summary", + "", + f"- Decisions: {len(rows)}", + f"- Completed decisions: {len(completed)}", + f"- Pending decisions: {len(rows) - len(completed)}", + f"- Mean reward: {sum(rewards) / len(rewards):.6g}" if rewards else "- Mean reward: —", + "", + "## Action Distribution", + "", + ] + lines.extend(f"- {action}: {count}" for action, count in sorted(action_counts.items())) + if not action_counts: + lines.append("- No actions recorded.") + return "\n".join(lines).rstrip() + "\n" + + +def _render_training_dataset(campaign_id: str, records: Sequence[Mapping[str, Any]]) -> str: + lines = [ + "---", + "artifact_type: rlvr_training_dataset", + f"campaign_id: {json.dumps(campaign_id, ensure_ascii=False)}", + f"record_count: {len(records)}", + f"schema_version: {RLVR_EXPORT_SCHEMA_VERSION}", + "---", + "# RLVR Training Dataset", + "", + "This is the human-reviewable projection. Machine consumers should use the deterministic JSONL export API.", + "", + "| Round | Decision | Chosen action | Backend | Reward | Rubric |", + "|---:|---|---|---|---:|---|", + ] + for record in records: + reward = record.get("reward") or {} + total = reward.get("reward") if isinstance(reward, Mapping) else None + lines.append( + "| {round} | {decision} | {action} | {backend} | {reward} | {rubric} |".format( + round=_table(record.get("round_index")), + decision=_table(record.get("decision_id")), + action=_table(record.get("chosen_action")), + backend=_table(record.get("chosen_backend")), + reward=_table("—" if total is None else f"{float(total):.6g}"), + rubric=_table(record.get("rubric_version")), + ) + ) + if not records: + lines.append("| — | — | — | — | — | — |") + return "\n".join(lines).rstrip() + "\n" + + +def _policy_identity(strategy: Any) -> tuple[str, str]: + if isinstance(strategy, Mapping): + policy_id = _first_nested_value(strategy, ("policy_id",)) + version = _first_nested_value(strategy, ("policy_version", "version")) + return policy_id or "helios-strategy-selector", version or "unversioned" + return "helios-strategy-selector", "unversioned" + + +def _first_nested_value(value: Any, keys: Sequence[str]) -> str | None: + if isinstance(value, Mapping): + for key in keys: + candidate = value.get(key) + if candidate not in (None, "") and not isinstance(candidate, Mapping | list | tuple): + return str(candidate) + for item in value.values(): + nested = _first_nested_value(item, keys) + if nested: + return nested + elif isinstance(value, list | tuple): + for item in value: + nested = _first_nested_value(item, keys) + if nested: + return nested + return None + + +def _code_identity(workspace_root: Path | None) -> tuple[str | None, bool]: + if workspace_root is None or not workspace_root.is_dir(): + return None, False + try: + sha = subprocess.run( + ["git", "-C", str(workspace_root), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + timeout=10, + ).stdout.strip() + dirty = bool( + subprocess.run( + ["git", "-C", str(workspace_root), "status", "--porcelain", "--untracked-files=no"], + check=True, + capture_output=True, + text=True, + timeout=10, + ).stdout.strip() + ) + return sha, dirty + except (OSError, subprocess.SubprocessError): + return None, False + + +def _candidate_actions(strategy: Any) -> list[Any]: + if not isinstance(strategy, Mapping): + return [] + for key in ("available_actions", "actions", "actions_considered"): + value = strategy.get(key) + if isinstance(value, list | tuple): + return list(value) + return [] + + +def _campaign_id_from_dir(campaign_dir: Path) -> str: + metadata = _front_matter(campaign_dir / "campaign.md") if (campaign_dir / "campaign.md").exists() else {} + return str(metadata.get("campaign_id") or campaign_dir.name) + + +def _first_title(text: str) -> str | None: + for line in text.splitlines(): + if line.startswith("# "): + return line[2:].strip() + return None + + +def _table(value: Any) -> str: + if value is None: + return "—" + return str(value).replace("\n", " ").replace("\r", " ").replace("|", "\\|") + + +def _render_claim_evidence( + claim: ScientificClaim, + evidence: EvidenceSet, + assessment: ClaimAssessment, + promotion: PromotionDecision | None, +) -> str: + promotion_status = "not_evaluated" if promotion is None else str(promotion.promotion_allowed).lower() + lines = [ + "---", + "artifact_type: scientific_claim_evidence", + f"claim_id: {json.dumps(claim.claim_id, ensure_ascii=False)}", + f"claim_status: {assessment.status.value}", + f"posterior_probability: {assessment.posterior_probability:.12g}", + f"promotion_allowed: {promotion_status}", + "shadow_only: true", + "---", + f"# Scientific Claim — {_table(redact_sensitive(claim.claim_id))}", + "", + "## Claim", + "", + _table(redact_sensitive(claim.statement)), + "", + f"- Scope: {_table(redact_sensitive(claim.scope))}", + f"- Prior probability: {claim.prior_probability:.6g}", + f"- Prior version: {_table(redact_sensitive(claim.prior_version))}", + f"- Prior rationale: {_table(redact_sensitive(claim.prior_rationale or 'not recorded'))}", + f"- Posterior probability: {assessment.posterior_probability:.6g}", + f"- Status: {assessment.status.value}", + f"- Cumulative log Bayes factor: {assessment.cumulative_log_bayes_factor:.6g}", + f"- Method: {_table(assessment.method)}", + "", + "## Falsifiability", + "", + ] + lines.extend( + f"- {_table(redact_sensitive(item))}" for item in claim.falsifying_observations + ) + if not claim.falsifying_observations: + lines.append("- No falsifying observation has been declared; promotion should remain blocked.") + lines.extend( + [ + "", + "## Evidence", + "", + "| Evidence | Design | Log BF | Preregistered | Replicates | Blocks | Falsifier | Source |", + "|---|---|---:|---|---:|---|---|---|", + ] + ) + for item in evidence.items: + lines.append( + "| {evidence_id} | {design} | {log_bf} | {registered} | {replicates} | " + "{blocks} | {falsifier} | {source} |".format( + evidence_id=_table(redact_sensitive(item.evidence_id)), + design=item.design.value, + log_bf="—" if item.log_bayes_factor is None else f"{item.log_bayes_factor:.6g}", + registered="yes" if item.registered_before_observation else "no", + replicates=item.replicate_count, + blocks=_table(", ".join(item.block_ids) or "—"), + falsifier="yes" if item.falsifier_triggered else "no", + source=_table(redact_sensitive(item.source)), + ) + ) + if not evidence.items: + lines.append("| — | — | — | — | — | — | — | No evidence recorded |") + lines.extend( + [ + "", + "## Evidence Quality", + "", + f"- Scored evidence: {assessment.scored_evidence_count}", + f"- Descriptive/unscored evidence: {assessment.unscored_evidence_count}", + f"- Prospective evidence: {assessment.prospective_evidence_count}", + f"- Interventional evidence: {assessment.interventional_evidence_count}", + f"- Preregistered evidence: {assessment.preregistered_evidence_count}", + f"- Independent blocks: {assessment.independent_block_count}", + f"- Safety incidents: {assessment.safety_incident_count}", + f"- Falsifier triggered: {'yes' if assessment.falsifier_triggered else 'no'}", + "", + "## Unmet Requirements", + "", + ] + ) + lines.extend(f"- {_table(item)}" for item in assessment.unmet_requirements) + if not assessment.unmet_requirements: + lines.append("- None") + lines.extend(["", "## Warnings", ""]) + lines.extend(f"- {_table(item)}" for item in assessment.warnings) + if not assessment.warnings: + lines.append("- None") + lines.extend(["", "## Promotion Gate", ""]) + if promotion is None: + lines.append("- Not evaluated") + else: + lines.extend( + [ + f"- Evidence criteria satisfied: {'yes' if promotion.evidence_criteria_satisfied else 'no'}", + f"- Human approval required: {'yes' if promotion.human_approval_required else 'no'}", + f"- Human approved: {'yes' if promotion.human_approved else 'no'}", + f"- Promotion allowed: {'yes' if promotion.promotion_allowed else 'no'}", + "- Auto-applied: no", + ] + ) + lines.extend(f"- Reason: {_table(item)}" for item in promotion.reasons) + return "\n".join(lines).rstrip() + "\n" + + +def _render_experiment_plan(plan: ExperimentPlan) -> str: + lines = [ + "---", + "artifact_type: hypothesis_discrimination_plan", + f"plan_id: {json.dumps(plan.plan_id, ensure_ascii=False)}", + f"eligible_experiment_count: {len(plan.ranked_experiments)}", + f"excluded_experiment_count: {len(plan.excluded_experiments)}", + "operator_approval_required: true", + "shadow_only: true", + "---", + f"# Hypothesis-Discrimination Plan — {_table(redact_sensitive(plan.plan_id))}", + "", + f"- Objective: {_table(plan.objective)}", + f"- Prior scenarios: {_table(', '.join(plan.prior_scenario_ids))}", + "- This artifact is advisory and cannot execute experiments.", + "", + "## Ranked Experiments", + "", + "| Rank | Experiment | Robust EIG | Mean EIG | Robust EIG / Cost |", + "|---:|---|---:|---:|---:|", + ] + for score in plan.ranked_experiments: + lines.append( + f"| {score.rank} | {_table(redact_sensitive(score.experiment_id))} | " + f"{score.robust_expected_information_gain:.6g} | " + f"{score.mean_expected_information_gain:.6g} | " + f"{score.information_gain_per_cost:.6g} |" + ) + if not plan.ranked_experiments: + lines.append("| — | — | — | — | — |") + lines.extend( + [ + "", + "## Excluded Experiments", + "", + "| Experiment | Reason |", + "|---|---|", + ] + ) + for score in plan.excluded_experiments: + lines.append( + f"| {_table(redact_sensitive(score.experiment_id))} | " + f"{_table('; '.join(score.reasons))} |" + ) + if not plan.excluded_experiments: + lines.append("| — | None |") + return "\n".join(lines).rstrip() + "\n" + + +def _render_scientific_evidence_index(campaign_id: str, campaign_dir: Path) -> str: + lines = [ + "---", + "artifact_type: scientific_evidence_index", + f"campaign_id: {json.dumps(campaign_id, ensure_ascii=False)}", + "---", + "# Scientific Evidence", + "", + "## Claims", + "", + ] + claim_paths = sorted((campaign_dir / "evidence" / "claims").glob("*.md")) + for path in claim_paths: + metadata = _front_matter(path) + relative = path.relative_to(campaign_dir / "evidence").as_posix() + label = metadata.get("claim_id") or path.stem + status = metadata.get("claim_status") or "unknown" + lines.append(f"- [{_table(label)}]({relative}) — {status}") + if not claim_paths: + lines.append("- No scientific claims recorded.") + lines.extend(["", "## Experiment Plans", ""]) + plan_paths = sorted((campaign_dir / "evidence" / "plans").glob("*.md")) + for path in plan_paths: + metadata = _front_matter(path) + relative = path.relative_to(campaign_dir / "evidence").as_posix() + label = metadata.get("plan_id") or path.stem + lines.append(f"- [{_table(label)}]({relative})") + if not plan_paths: + lines.append("- No hypothesis-discrimination plans recorded.") + return "\n".join(lines).rstrip() + "\n" + + +__all__ = [ + "LedgerSearchHit", + "LedgerWriteResult", + "RLVR_EXPORT_SCHEMA_VERSION", + "ScientificLedger", + "get_scientific_ledger", + "safe_path_component", +] diff --git a/app/services/scientific_ledger_git.py b/app/services/scientific_ledger_git.py new file mode 100644 index 0000000..bfad6fe --- /dev/null +++ b/app/services/scientific_ledger_git.py @@ -0,0 +1,128 @@ +"""Optional local Git history for one HELIOS campaign ledger. + +Each campaign directory is its own repository. This prevents experiment +commits from dirtying or rewriting the HELIOS source repository and avoids +branch-switch races between concurrent campaigns. The backend never pushes. +""" +from __future__ import annotations + +import os +import re +import subprocess +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class LedgerGitCommit: + committed: bool + commit_sha: str | None = None + message: str = "" + reason: str = "" + + +class ScientificLedgerGit: + """Stage exact Markdown paths and commit them in a campaign-local repo.""" + + def __init__( + self, + campaign_dir: str | Path, + *, + auto_init: bool = False, + author_name: str = "HELIOS Scientific Ledger", + author_email: str = "helios-ledger@localhost", + git_executable: str = "git", + ) -> None: + self.campaign_dir = Path(campaign_dir).resolve() + self.auto_init = auto_init + self.author_name = ( + _single_line(author_name, limit=100) or "HELIOS Scientific Ledger" + ) + self.author_email = _safe_email(author_email) + self.git_executable = git_executable + + def commit(self, paths: Sequence[str | Path], message: str) -> LedgerGitCommit: + """Commit exact paths if they changed; never add unrelated files.""" + if not paths: + return LedgerGitCommit(committed=False, reason="no_paths") + self.campaign_dir.mkdir(parents=True, exist_ok=True) + if not self._ensure_repository(): + return LedgerGitCommit(committed=False, reason="git_repository_unavailable") + + relative_paths = sorted({self._relative_markdown_path(path) for path in paths}) + self._run(["add", "--", *relative_paths]) + staged = self._run(["diff", "--cached", "--quiet"], check=False) + if staged.returncode == 0: + return LedgerGitCommit(committed=False, reason="no_changes") + if staged.returncode != 1: + raise RuntimeError(staged.stderr.strip() or "Unable to inspect staged ledger changes") + + safe_message = _single_line(message, limit=160) or "Record HELIOS scientific decision" + env = { + **os.environ, + "GIT_AUTHOR_NAME": self.author_name, + "GIT_AUTHOR_EMAIL": self.author_email, + "GIT_COMMITTER_NAME": self.author_name, + "GIT_COMMITTER_EMAIL": self.author_email, + } + self._run(["commit", "-m", safe_message, "--no-gpg-sign"], env=env) + sha = self._run(["rev-parse", "HEAD"]).stdout.strip() + return LedgerGitCommit(committed=True, commit_sha=sha, message=safe_message) + + def _ensure_repository(self) -> bool: + git_dir = self.campaign_dir / ".git" + if git_dir.is_dir(): + top = Path(self._run(["rev-parse", "--show-toplevel"]).stdout.strip()).resolve() + if top != self.campaign_dir: + raise RuntimeError("Campaign Git repository resolves outside its ledger directory") + return True + if not self.auto_init: + return False + self._run(["init"]) + self._run(["config", "user.name", self.author_name]) + self._run(["config", "user.email", self.author_email]) + return True + + def _relative_markdown_path(self, path: str | Path) -> str: + candidate = Path(path) + absolute = candidate.resolve() if candidate.is_absolute() else (self.campaign_dir / candidate).resolve() + try: + relative = absolute.relative_to(self.campaign_dir) + except ValueError as exc: + raise ValueError("Ledger Git paths must remain inside the campaign directory") from exc + if absolute.suffix.casefold() != ".md": + raise ValueError("Scientific ledger Git commits may only stage Markdown artifacts") + if ".git" in relative.parts: + raise ValueError("Scientific ledger Git commits may not stage Git metadata") + return relative.as_posix() + + def _run( + self, + args: Sequence[str], + *, + check: bool = True, + env: dict[str, str] | None = None, + ) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [self.git_executable, "-C", str(self.campaign_dir), *args], + check=check, + capture_output=True, + text=True, + timeout=30, + env=env, + ) + + +def _single_line(value: str, *, limit: int) -> str: + return re.sub(r"\s+", " ", str(value)).strip()[:limit] + + +def _safe_email(value: str) -> str: + candidate = _single_line(value, limit=200) + if not re.fullmatch(r"[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9.-]+", candidate): + return "helios-ledger@localhost" + return candidate + + +__all__ = ["LedgerGitCommit", "ScientificLedgerGit"] diff --git a/app/services/scientific_ledger_runtime.py b/app/services/scientific_ledger_runtime.py new file mode 100644 index 0000000..3fd066e --- /dev/null +++ b/app/services/scientific_ledger_runtime.py @@ -0,0 +1,155 @@ +"""Thin runtime bridge from campaign decisions to the Scientific Ledger.""" +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Any + +from app.services.decision_outcome import ( + CampaignDecisionAccounting, + CampaignDecisionAccountingBuilder, + CampaignDecisionOutcomeBuilder, +) +from app.services.decision_trace import CampaignDecisionTrace +from app.services.scientific_ledger import LedgerWriteResult, get_scientific_ledger + + +@dataclass(frozen=True) +class RuntimeDecisionAccountingResult: + accounting: CampaignDecisionAccounting + trajectory_id: str + ledger_result: LedgerWriteResult | None = None + + +def record_pending_scientific_decision( + trace: CampaignDecisionTrace, + *, + campaign_metadata: Mapping[str, Any] | None = None, + policy_snapshot: Mapping[str, Any] | None = None, +) -> LedgerWriteResult | None: + """Record the pre-execution card when Markdown projection is enabled.""" + if not _ledger_enabled(): + return None + return get_scientific_ledger().record_pending( + trace, + campaign_metadata=campaign_metadata, + policy_snapshot=policy_snapshot, + ) + + +def finalize_scientific_decision( + trace: CampaignDecisionTrace, + *, + observed_action: str | None = None, + observed_backend: str | None = None, + candidate_count: int | None = None, + execution_success: bool | None = None, + failure_count: int = 0, + safety_incident_count: int = 0, + objective_delta: float | None = None, + proxy_gap_delta: float | None = None, + validation_success: bool | None = None, + recovery_attempted: bool = False, + recovery_success: bool | None = None, + context_request_fulfilled: bool | None = None, + human_override: bool | None = None, + metadata: Mapping[str, Any] | None = None, + campaign_metadata: Mapping[str, Any] | None = None, + policy_snapshot: Mapping[str, Any] | None = None, + observations: Sequence[Any] | None = None, + failures: Sequence[Any] | None = None, + recovery_events: Sequence[Any] | None = None, +) -> RuntimeDecisionAccountingResult: + """Close Trace -> Outcome -> Reward, persist it, then project to Markdown.""" + from app.services.decision_trajectory import persist_campaign_trajectory + + trace = _trace_with_observed_action(trace, observed_action) + outcome = CampaignDecisionOutcomeBuilder().build( + trace=trace, + observed_action=observed_action, + observed_backend=observed_backend, + candidate_count=candidate_count, + execution_success=execution_success, + failure_count=failure_count, + safety_incident_count=safety_incident_count, + objective_delta=objective_delta, + proxy_gap_delta=proxy_gap_delta, + validation_success=validation_success, + recovery_attempted=recovery_attempted, + recovery_success=recovery_success, + context_request_fulfilled=context_request_fulfilled, + human_override=human_override, + metadata=dict(metadata or {}), + ) + accounting = CampaignDecisionAccountingBuilder().build( + trace=trace, + outcome=outcome, + metadata=dict(metadata or {}), + ) + trajectory_id = persist_campaign_trajectory(accounting) + ledger_result = None + if _ledger_enabled(): + ledger_result = get_scientific_ledger().record_completed( + accounting, + campaign_metadata=campaign_metadata, + policy_snapshot=policy_snapshot, + observations=observations, + failures=failures, + recovery_events=recovery_events, + ) + return RuntimeDecisionAccountingResult( + accounting=accounting, + trajectory_id=trajectory_id, + ledger_result=ledger_result, + ) + + +def should_capture_decision_trace() -> bool: + """A trace is needed for shadow logging, ledger projection, or live authority.""" + from app.core.config import get_settings + + settings = get_settings() + return bool( + getattr(settings, "contextual_decision_shadow_enabled", False) + or getattr(settings, "scientific_ledger_enabled", False) + or getattr(settings, "campaign_decision_authority_enabled", False) + ) + + +def _ledger_enabled() -> bool: + from app.core.config import get_settings + + return bool(getattr(get_settings(), "scientific_ledger_enabled", False)) + + +def _trace_with_observed_action( + trace: CampaignDecisionTrace, + observed_action: str | None, +) -> CampaignDecisionTrace: + """Return a trace whose runtime comparison matches the consumed action.""" + if observed_action is None or trace.actual_action == observed_action: + return trace + shadow_action = trace.shadow_action.value + would_change_route = observed_action != shadow_action + comparison = { + **dict(trace.comparison), + "actual_action": observed_action, + "shadow_action": shadow_action, + "would_change_route": would_change_route, + } + return trace.model_copy( + deep=True, + update={ + "actual_action": observed_action, + "would_change_route": would_change_route, + "comparison": comparison, + }, + ) + + +__all__ = [ + "RuntimeDecisionAccountingResult", + "finalize_scientific_decision", + "record_pending_scientific_decision", + "should_capture_decision_trace", +] diff --git a/app/services/strategy_diagnostics.py b/app/services/strategy_diagnostics.py index 5d665ee..07485da 100644 --- a/app/services/strategy_diagnostics.py +++ b/app/services/strategy_diagnostics.py @@ -114,6 +114,26 @@ def compute_diagnostics( # 11. Batch param spread batch_param_spread = _compute_batch_spread(snapshot) + # === Failure-margin signals (E4) === + # Summarize the recent failure margin distribution so the selector can + # tell whether failures are *close* to feasibility (margin near 0) or + # far away. Close-miss failures mean the current search direction is + # nearly right and exploration should be intensified nearby; far failures + # mean the region is hard and exploration should widen. The objective + # values already encode margin distance (see continuous_failure_penalty: + # a failure at margin -0.01 scores ~-1.01, at margin -1.0 scores -2.0), + # so the *failed* objective values are used directly as the proxy. + failure_margin_mean = None + failure_margin_min = None + if snapshot.all_kpis: + failed_kpis = [ + float(kpi) for kpi in snapshot.all_kpis + if kpi is not None and float(kpi) < 0.0 + ] + if failed_kpis: + failure_margin_mean = float(sum(failed_kpis) / len(failed_kpis)) + failure_margin_min = float(min(failed_kpis)) + # === Drift detection (v4) === drift_score = _compute_drift_score(snapshot, config) @@ -132,6 +152,8 @@ def compute_diagnostics( batch_param_spread=batch_param_spread, calibration_factor=calibration_factor, drift_score=drift_score, + failure_margin_mean=failure_margin_mean, + failure_margin_min=failure_margin_min, ) diff --git a/app/services/strategy_models.py b/app/services/strategy_models.py index 806e0a8..336eae5 100644 --- a/app/services/strategy_models.py +++ b/app/services/strategy_models.py @@ -913,6 +913,10 @@ class DiagnosticSignals: calibration_factor: float | None = None # LOO calibration factor for model_uncertainty drift_score: float | None = None # distribution shift between recent and historical windows + # --- Failure-margin (E4) --- + failure_margin_mean: float | None = None # mean failed objective (margin proxy); None if no failures + failure_margin_min: float | None = None # worst failed objective (furthest from feasibility) + # --------------------------------------------------------------------------- # Action candidates diff --git a/benchmarks/methods/__main__.py b/benchmarks/methods/__main__.py new file mode 100644 index 0000000..9836b01 --- /dev/null +++ b/benchmarks/methods/__main__.py @@ -0,0 +1,262 @@ +"""CLI for the analytic BO/method benchmark suite. + +Without a subcommand this runs the classic study (unchanged behavior). +``pathology-study`` runs the evaluation-layer harness from a study YAML:: + + python -m benchmarks.methods pathology-study --study-config study.yaml +""" +from __future__ import annotations + +import argparse +import json +import sys +from dataclasses import asdict, replace +from pathlib import Path +from typing import Any + +import app.optimization # noqa: F401 - registers optional HELIOS backends +import benchmarks.methods.doe_backend # noqa: F401 - registers DoE baselines +import benchmarks.methods.helios_full # noqa: F401 - registers helios_full +from app.services.optimization_backends import list_backends +from benchmarks.methods.problems import get_problem, get_problems +from benchmarks.methods.recommend import recommend +from benchmarks.methods.report import ( + family_summary, + family_summary_to_csv, + family_summary_to_markdown, + recommendations_to_csv, + recommendations_to_markdown, + scoreboard_to_csv, + scoreboard_to_markdown, +) +from benchmarks.methods.runner import RunTrace, run_study +from benchmarks.methods.scoreboard import MethodScore, build_scoreboard + +DEFAULT_BACKENDS = ( + "helios_full", + "gp_backend", + "built_in", + "scipy_de", + "lhs", + "random_sampling", + "full_factorial", + "fractional_factorial", +) + + +def _parse_csv(value: str) -> list[str]: + return [part.strip() for part in value.split(",") if part.strip()] + + +def _parse_seeds(value: str) -> list[int]: + if ".." in value: + start_s, end_s = value.split("..", 1) + start, end = int(start_s), int(end_s) + return list(range(start, end + 1)) + return [int(part) for part in _parse_csv(value)] + + +def _available_default_backends() -> list[str]: + available = list_backends() + return [b for b in DEFAULT_BACKENDS if b == "helios_full" or available.get(b, False)] + + +def _trace_to_dict(trace: RunTrace) -> dict[str, Any]: + return asdict(trace) + + +def _score_to_dict(score: MethodScore) -> dict[str, Any]: + data = asdict(score) + data["tags"] = asdict(score.tags) + return data + + +def _dominance_summary(scores: list[MethodScore], reference: str) -> dict[str, Any]: + by_problem: dict[str, dict[str, MethodScore]] = {} + for score in scores: + by_problem.setdefault(score.problem_id, {})[score.backend] = score + + rows: list[dict[str, Any]] = [] + for problem_id, methods in sorted(by_problem.items()): + ref = methods.get(reference) + if ref is None: + continue + for backend, score in sorted(methods.items()): + if backend == reference: + continue + regret_delta = score.mean_regret - ref.mean_regret + auc_delta = score.mean_auc - ref.mean_auc + rows.append( + { + "problem_id": problem_id, + "comparison": f"{reference}_vs_{backend}", + "reference_better_regret": regret_delta > 0, + "reference_better_auc": auc_delta > 0, + "mean_regret_delta_other_minus_reference": regret_delta, + "mean_auc_delta_other_minus_reference": auc_delta, + } + ) + + baselines = sorted({r["comparison"].removeprefix(f"{reference}_vs_") for r in rows}) + aggregate: dict[str, Any] = {} + for baseline in baselines: + subset = [r for r in rows if r["comparison"] == f"{reference}_vs_{baseline}"] + aggregate[baseline] = { + "n_problems": len(subset), + "regret_wins": sum(1 for r in subset if r["reference_better_regret"]), + "auc_wins": sum(1 for r in subset if r["reference_better_auc"]), + "mean_regret_delta": ( + sum(float(r["mean_regret_delta_other_minus_reference"]) for r in subset) + / len(subset) + if subset + else 0.0 + ), + "mean_auc_delta": ( + sum(float(r["mean_auc_delta_other_minus_reference"]) for r in subset) + / len(subset) + if subset + else 0.0 + ), + } + return {"reference": reference, "by_baseline": aggregate, "per_problem": rows} + + +def _write_outputs( + output_dir: Path, + traces: list[RunTrace], + scores: list[MethodScore], + reference: str, +) -> None: + output_dir.mkdir(parents=True, exist_ok=True) + recs = recommend(scores) + summary = _dominance_summary(scores, reference) + available = list_backends() + + (output_dir / "traces.json").write_text( + json.dumps([_trace_to_dict(t) for t in traces], indent=2, sort_keys=True), + encoding="utf-8", + ) + (output_dir / "scoreboard.json").write_text( + json.dumps([_score_to_dict(s) for s in scores], indent=2, sort_keys=True), + encoding="utf-8", + ) + (output_dir / "scoreboard.csv").write_text(scoreboard_to_csv(scores), encoding="utf-8") + (output_dir / "scoreboard.md").write_text(scoreboard_to_markdown(scores), encoding="utf-8") + (output_dir / "family_summary.csv").write_text(family_summary_to_csv(scores), encoding="utf-8") + (output_dir / "family_summary.md").write_text(family_summary_to_markdown(scores), encoding="utf-8") + (output_dir / "family_summary.json").write_text( + json.dumps(family_summary(scores), indent=2, sort_keys=True), + encoding="utf-8", + ) + (output_dir / "recommendations.csv").write_text(recommendations_to_csv(recs), encoding="utf-8") + (output_dir / "recommendations.md").write_text(recommendations_to_markdown(recs), encoding="utf-8") + (output_dir / "dominance_summary.json").write_text( + json.dumps(summary, indent=2, sort_keys=True), + encoding="utf-8", + ) + (output_dir / "available_backends.json").write_text( + json.dumps(available, indent=2, sort_keys=True), + encoding="utf-8", + ) + + +def _pathology_study_main(argv: list[str]) -> None: + """Thin CLI over the study YAML: config is the interface, not flags.""" + from benchmarks.methods.ablation import ( + load_metrics_config, + load_study_config, + run_matrix, + write_study_artifacts, + ) + + parser = argparse.ArgumentParser( + prog="python -m benchmarks.methods pathology-study", + description="Run a pathology/ablation study from a study YAML", + ) + parser.add_argument("--study-config", required=True, help="Study YAML path") + parser.add_argument("--seeds", default=None, help="Override: seeds or A..B range") + parser.add_argument("--budget", type=int, default=None, help="Override: evals per cell") + parser.add_argument( + "--output-dir", + default="benchmark_results/pathology_study", + help="Study output root (a / dir is created inside)", + ) + parser.add_argument( + "--force", + action="store_true", + help="Overwrite an existing study directory (never silent)", + ) + args = parser.parse_args(argv) + + config = load_study_config(args.study_config) + matrix = config.matrix + if args.seeds is not None: + matrix = replace(matrix, seeds=tuple(_parse_seeds(args.seeds))) + budget = args.budget if args.budget is not None else config.budget + + metrics_config = load_metrics_config(config.metrics_config_path) + results = run_matrix(matrix, budget, batch=config.batch, n_init=config.n_init) + study_dir = write_study_artifacts( + args.output_dir, + matrix, + results, + metrics_config, + reference=config.reference, + budget=budget, + batch=config.batch, + n_init=config.n_init, + description=config.description, + problem_groups=config.problem_groups, + comparison_families=config.comparison_families, + force=args.force, + ) + failed = sum(1 for r in results if r.trace.error is not None) + print(f"cells={len(results)} failed={failed} budget={budget}") + print(f"study={study_dir}") + + +def main() -> None: + argv = sys.argv[1:] + if argv and argv[0] == "pathology-study": + _pathology_study_main(argv[1:]) + return + + parser = argparse.ArgumentParser(description="Run HELIOS analytic BO benchmarks") + parser.add_argument("--problems", default="all", help="Comma-separated problem ids, or all") + parser.add_argument( + "--backends", + default="default", + help="Comma-separated backend names, or default", + ) + parser.add_argument("--seeds", default="0..4", help="Comma-separated seeds or inclusive A..B range") + parser.add_argument("--budget", type=int, default=25, help="Evaluations per problem/backend/seed") + parser.add_argument("--batch", type=int, default=1, help="Candidates per optimizer round") + parser.add_argument("--n-init", type=int, default=3, help="Initial random design size") + parser.add_argument("--tol", type=float, default=1e-2, help="Target regret tolerance") + parser.add_argument("--reference", default="helios_full", help="Reference backend for dominance summary") + parser.add_argument("--output-dir", default="benchmark_results/bo_methods", help="Output artifact directory") + args = parser.parse_args() + + problems = get_problems() if args.problems == "all" else [get_problem(p) for p in _parse_csv(args.problems)] + backends = _available_default_backends() if args.backends == "default" else _parse_csv(args.backends) + seeds = _parse_seeds(args.seeds) + + traces = run_study( + problems, + backends, + seeds, + args.budget, + batch=args.batch, + n_init=args.n_init, + ) + scores = build_scoreboard(traces, tol=args.tol) + out = Path(args.output_dir) + _write_outputs(out, traces, scores, args.reference) + + print(f"problems={len(problems)} backends={len(backends)} seeds={len(seeds)} budget={args.budget}") + print(f"outputs={out}") + print(json.dumps(_dominance_summary(scores, args.reference)["by_baseline"], indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/methods/ablation.py b/benchmarks/methods/ablation.py new file mode 100644 index 0000000..7f30154 --- /dev/null +++ b/benchmarks/methods/ablation.py @@ -0,0 +1,1161 @@ +"""Ablation harness: matrix runner, event-grounded metrics, study artifacts. + +Runs ``problems x pathology bundles x backend configs x seeds`` through the +**unmodified** :func:`benchmarks.methods.runner.run_cell`, grounds every +adaptation metric in the pathology event ledger, and writes a reproducible +study directory (``matrix.json`` is the single source of truth). + +Two report surfaces keep the narrative honest: + +- ``tables/main_benchmark.csv`` -- all methods, standard raw-value metrics + (final regret, regret AUC, evals-to-target) under each pathology. +- ``tables/mechanism_analysis.csv`` -- HELIOS variants only: decision quality, + recovery efficiency, adaptation lag, constraint adaptation latency. + +Metric thresholds and the event-type -> response-class mapping come from a +versioned YAML config (``configs/pathology_metrics.yaml``); nothing is +hardcoded. Design: docs/plans/2026-07-22-pathology-benchmark-evaluation-layer-design.md. +""" +from __future__ import annotations + +import csv +import dataclasses +import hashlib +import io +import json +import math +import platform +import random +import statistics +import subprocess +from dataclasses import dataclass, field +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import yaml + +import benchmarks.methods.helios_full # noqa: F401 - registers helios_full + variants +from app.services.candidate_gen import ParameterSpace +from benchmarks.methods.helios_full import BACKEND_VARIANTS +from benchmarks.methods.metrics import convergence_auc, evals_to_target, simple_regret +from benchmarks.methods.pathologies import ( + PATHOLOGY_SCHEMA_VERSION, + PathologyEvent, + PathologySpec, + apply_pathology, + in_failure_region, + spec_from_dict, + spec_to_dict, +) +from benchmarks.methods.problems import OptProblem, get_problem, get_problems +from benchmarks.methods.runner import DEFAULT_INIT, RunTrace, run_cell +from benchmarks.methods.scoreboard import DEFAULT_TOL + +DEFAULT_METRICS_CONFIG_PATH = Path(__file__).parent / "configs" / "pathology_metrics.yaml" + +# Event types measured by adaptation lag (onset-style, not per-eval failures). +DRIFT_EVENT_TYPES = ("noise_drift", "proxy_gap_shift", "objective_shift", "censoring") +FAILURE_EVENT_TYPE = "spatial_failure" + + +# --------------------------------------------------------------------------- +# Metrics config (versioned YAML; part of the study identity) +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class MetricsConfig: + schema_version: int + thresholds: dict[str, float] + response_class_mapping: dict[str, list[str]] + content_hash: str + raw: dict[str, Any] + + +def load_metrics_config(path: str | Path = DEFAULT_METRICS_CONFIG_PATH) -> MetricsConfig: + text = Path(path).read_text(encoding="utf-8") + data = yaml.safe_load(text) + return MetricsConfig( + schema_version=int(data["schema_version"]), + thresholds={k: float(v) for k, v in dict(data["thresholds"]).items()}, + response_class_mapping={ + str(k): [str(c) for c in v] + for k, v in dict(data["response_class_mapping"]).items() + }, + content_hash=hashlib.sha256(text.encode("utf-8")).hexdigest(), + raw=data, + ) + + +# --------------------------------------------------------------------------- +# Event-grounded metrics (pure functions; all thresholds injected) +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class RecoveryResult: + mean_ratio: float | None + per_event: list[float] + skipped: int + + +def recovery_efficiency( + best_so_far: list[float], + events: list[PathologyEvent], + optimum: float, + *, + window: int, + epsilon: float, +) -> RecoveryResult: + """Post/pre regret-improvement-rate ratio around each failure event. + + Rates use best-so-far **true** regret over ``window`` evals on each side; + events without a full window on both sides are skipped (and counted). + The per-event ratios aggregate as a geometric mean. + """ + regret = [float(b) - float(optimum) for b in best_so_far] + window = int(window) + per_event: list[float] = [] + skipped = 0 + for event in events: + if event.event_type != FAILURE_EVENT_TYPE: + continue + i = event.eval_index - 1 + if i - window < 0 or i + window >= len(regret): + skipped += 1 + continue + pre_rate = (regret[i - window] - regret[i]) / window + post_rate = (regret[i] - regret[i + window]) / window + per_event.append((post_rate + epsilon) / (pre_rate + epsilon)) + mean = ( + math.exp(statistics.fmean(math.log(r) for r in per_event)) + if per_event + else None + ) + return RecoveryResult(mean_ratio=mean, per_event=per_event, skipped=skipped) + + +@dataclass(frozen=True) +class LagResult: + mean_lag: float | None + per_event: list[int | None] + skipped: int + + +def adaptation_lag( + raw_values: list[float], + events: list[PathologyEvent], + optimum: float, + *, + window: int, + delta: float, + sustain: int, +) -> LagResult: + """Evals from a drift/shift onset until performance recovers (change-point). + + ``regret_now(t)`` is the windowed min of post-onset raw values (the window + never crosses the event boundary, so pre-event evals cannot fake a + recovery). Recovered when ``regret_now <= regret_before + delta * + max(|regret_before|, 1)`` holds for ``sustain`` consecutive evals; the lag + is counted to the first eval of that sustained run. ``None`` = never + recovered within the trace. + """ + raws = [float(v) for v in raw_values] + window, sustain = int(window), int(sustain) + per_event: list[int | None] = [] + skipped = 0 + for event in events: + if event.event_type not in DRIFT_EVENT_TYPES: + continue + onset = event.eval_index - 1 + pre = raws[max(0, onset - window):onset] + if not pre: + skipped += 1 + continue + regret_before = min(pre) - float(optimum) + threshold = regret_before + float(delta) * max(abs(regret_before), 1.0) + lag: int | None = None + run = 0 + for t in range(onset, len(raws)): + lo = max(onset, t - window + 1) + regret_now = min(raws[lo : t + 1]) - float(optimum) + if regret_now <= threshold: + run += 1 + if run >= sustain: + lag = (t - sustain + 1) - onset + break + else: + run = 0 + per_event.append(lag) + recovered = [lag for lag in per_event if lag is not None] + mean = statistics.fmean(recovered) if recovered else None + return LagResult(mean_lag=mean, per_event=per_event, skipped=skipped) + + +@dataclass(frozen=True) +class LatencyResult: + latency: int | None + pre_rate: float | None + + +def constraint_adaptation_latency( + params_list: list[dict[str, Any]], + events: list[PathologyEvent], + space: ParameterSpace, + *, + window: int, + ratio_threshold: float, + sustain: int, +) -> LatencyResult: + """Evals until proposals leave the ledgered failure region. + + Baseline is the pre-event in-region proposal rate; adapted when the + rolling in-region rate stays below ``ratio_threshold * pre_rate`` for + ``sustain`` consecutive evals. ``pre_rate == 0`` means there was nothing + to adapt away from -> latency is undefined (``None``). + """ + event = next( + (e for e in events if e.event_type == FAILURE_EVENT_TYPE and e.region), + None, + ) + if event is None: + return LatencyResult(latency=None, pre_rate=None) + window, sustain = int(window), int(sustain) + onset = event.eval_index - 1 + inside = [ + 1.0 if in_failure_region(p, space, event.region) else 0.0 + for p in params_list + ] + pre = inside[:onset] + pre_rate = statistics.fmean(pre) if pre else None + if not pre or pre_rate == 0.0: + return LatencyResult(latency=None, pre_rate=pre_rate) + threshold = float(ratio_threshold) * pre_rate + run = 0 + for t in range(onset, len(inside)): + rate = statistics.fmean(inside[max(0, t - window + 1) : t + 1]) + if rate < threshold: + run += 1 + if run >= sustain: + return LatencyResult(latency=(t - sustain + 1) - onset, pre_rate=pre_rate) + else: + run = 0 + return LatencyResult(latency=None, pre_rate=pre_rate) + + +def dynamic_regret_auc(raw_values: list[float], optimum: float) -> float: + """Trapezoidal AUC of *per-evaluation* true regret. + + Unlike best-so-far AUC this ruler stays valid across ObjectiveShift + events: each raw value is the current landscape's value at the evaluated + point, so no stale pre-shift incumbent can carry over. + """ + return convergence_auc([float(v) for v in raw_values], optimum) + + +def epoch_metrics( + raw_values: list[float], + events: list[PathologyEvent], + optimum: float, +) -> list[dict[str, Any]]: + """Epoch-local simple regret / AUC, with epochs split at objective shifts. + + Each ``objective_shift`` event starts a new epoch at its (1-based) onset + eval; incumbents never cross an epoch boundary, so a pre-shift best can + never mask post-shift performance. Without shift events the whole trace + is one epoch (equivalent to the standard ruler). + """ + raws = [float(v) for v in raw_values] + if not raws: + return [] + onsets = sorted( + { + e.eval_index - 1 + for e in events + if e.event_type == "objective_shift" and 0 < e.eval_index - 1 < len(raws) + } + ) + boundaries = [0, *onsets, len(raws)] + epochs: list[dict[str, Any]] = [] + for start, end in zip(boundaries[:-1], boundaries[1:], strict=True): + segment = raws[start:end] + epochs.append( + { + "start_eval": start + 1, + "end_eval": end, + "simple_regret": min(segment) - float(optimum), + "regret_auc": convergence_auc(segment, optimum), + } + ) + return epochs + + +@dataclass(frozen=True) +class QualityResult: + hit_rate: float | None + per_event: list[bool] + + +def _round_token(entry: str) -> str: + """Terminal backend token of a backend_history entry.""" + core = entry.removesuffix(":early_stage_filtered") + return core.split(":")[-1] + + +def _round_classes(history: list[str], index: int) -> set[str]: + """Observable response classes exhibited by round ``index`` (>= 1).""" + entry = history[index] + classes: set[str] = set() + if "early_stage_filtered" in entry: + classes.add("constraint_filter") + phase = entry.split(":")[0] + token = _round_token(entry) + if "explor" in phase or "lhs" in token or "random" in token: + classes.add("exploration_action") + if index >= 2 and _round_token(history[index - 1]) != token: + classes.add("backend_switch") + return classes + + +def decision_quality( + backend_history: list[str], + events: list[PathologyEvent], + response_class_mapping: dict[str, list[str]], + *, + window_evals: int, + n_init: int, + batch: int, +) -> QualityResult: + """Did the agent exhibit a relevant response class after each event? + + Response classes are *interpretable reaction categories, not optimal + policies* (see configs/pathology_metrics.yaml). Only meaningful for + backends that expose decision provenance via ``backend_history`` -- + the harness computes it for HELIOS variants only. + """ + window_evals, n_init, batch = int(window_evals), int(n_init), max(1, int(batch)) + per_event: list[bool] = [] + for event in events: + relevant = set(response_class_mapping.get(event.event_type, [])) + if not relevant: + continue + hit = False + for index in range(1, len(backend_history)): + first_eval = n_init + (index - 1) * batch + 1 + last_eval = n_init + index * batch + if last_eval < event.eval_index: + continue + if first_eval > event.eval_index + window_evals: + break + if relevant & _round_classes(backend_history, index): + hit = True + break + per_event.append(hit) + hit_rate = statistics.fmean(per_event) if per_event else None + return QualityResult(hit_rate=hit_rate, per_event=per_event) + + +# --------------------------------------------------------------------------- +# Paired bootstrap + Holm correction +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class BootstrapResult: + mean: float + ci_low: float + ci_high: float + p_value: float + + +def paired_bootstrap( + diffs: list[float], + *, + n_boot: int = 10_000, + seed: int = 0, + ci: float = 0.95, +) -> BootstrapResult: + """Bootstrap CI + sign p-value for seed-paired metric differences.""" + values = [float(d) for d in diffs] + rng = random.Random(seed) + n = len(values) + means = sorted( + statistics.fmean(rng.choices(values, k=n)) for _ in range(int(n_boot)) + ) + lo_idx = int(((1.0 - ci) / 2.0) * len(means)) + hi_idx = min(len(means) - 1, int(((1.0 + ci) / 2.0) * len(means))) + non_positive = sum(1 for m in means if m <= 0.0) / len(means) + non_negative = sum(1 for m in means if m >= 0.0) / len(means) + return BootstrapResult( + mean=statistics.fmean(values), + ci_low=means[lo_idx], + ci_high=means[hi_idx], + p_value=min(1.0, 2.0 * min(non_positive, non_negative)), + ) + + +def holm_adjust(p_values: list[float]) -> list[float]: + """Holm step-down adjustment, preserving input order.""" + m = len(p_values) + order = sorted(range(m), key=lambda i: p_values[i]) + adjusted = [0.0] * m + running = 0.0 + for rank, idx in enumerate(order): + running = max(running, (m - rank) * p_values[idx]) + adjusted[idx] = min(1.0, running) + return adjusted + + +# --------------------------------------------------------------------------- +# Experiment matrix +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class PathologyBundle: + """A named, fully parameterized pathology composition ('clean' = no specs).""" + + id: str + specs: tuple[PathologySpec, ...] = () + + def to_dict(self) -> dict[str, Any]: + return {"id": self.id, "specs": [spec_to_dict(s) for s in self.specs]} + + +@dataclass(frozen=True) +class ExperimentCell: + cell_id: str + problem_id: str + bundle: PathologyBundle + config: str + seed: int + + +@dataclass(frozen=True) +class ExperimentMatrix: + """Pure data: the full study cross-product. Execution stays in runner.py.""" + + problem_ids: tuple[str, ...] + bundles: tuple[PathologyBundle, ...] + config_names: tuple[str, ...] + seeds: tuple[int, ...] + + def expand(self) -> list[ExperimentCell]: + cells: list[ExperimentCell] = [] + for problem_id in self.problem_ids: + for bundle in self.bundles: + for config in self.config_names: + for seed in self.seeds: + cells.append( + ExperimentCell( + cell_id=f"cell_{len(cells) + 1:04d}", + problem_id=problem_id, + bundle=bundle, + config=config, + seed=seed, + ) + ) + return cells + + def to_dict(self) -> dict[str, Any]: + return { + "problem_ids": list(self.problem_ids), + "pathology_bundles": [b.to_dict() for b in self.bundles], + "config_names": list(self.config_names), + "seed_list": list(self.seeds), + } + + +def _pathology_seed(bundle_id: str, cell_seed: int) -> int: + digest = hashlib.sha256(f"{bundle_id}:{cell_seed}".encode()).hexdigest() + return int(digest[:8], 16) + + +@dataclass(frozen=True) +class CellResult: + cell: ExperimentCell + trace: RunTrace + events: list[PathologyEvent] + + +def run_matrix( + matrix: ExperimentMatrix, + budget: int, + *, + batch: int = 1, + n_init: int = DEFAULT_INIT, +) -> list[CellResult]: + """Expand the matrix and run every cell through the unmodified runner.""" + results: list[CellResult] = [] + for cell in matrix.expand(): + base = get_problem(cell.problem_id) + problem: OptProblem = base + events: list[PathologyEvent] = [] + if cell.bundle.specs: + problem, state = apply_pathology( + base, + cell.bundle.specs, + seed=_pathology_seed(cell.bundle.id, cell.seed), + bundle_id=cell.bundle.id, + ) + else: + state = None + trace = run_cell(problem, cell.config, cell.seed, budget, batch=batch, n_init=n_init) + if state is not None: + events = list(state.events) + results.append(CellResult(cell=cell, trace=trace, events=events)) + return results + + +# --------------------------------------------------------------------------- +# Study identity + config loading +# --------------------------------------------------------------------------- + + +def _git(*args: str) -> str | None: + try: + out = subprocess.run( + ["git", *args], + capture_output=True, + text=True, + timeout=10, + check=False, + cwd=Path(__file__).resolve().parent, + ) + return out.stdout if out.returncode == 0 else None + except OSError: + return None + + +def _git_commit() -> str: + commit = (_git("rev-parse", "HEAD") or "").strip() + return commit or "unknown" + + +def _environment_provenance() -> dict[str, Any]: + """Reproducibility facts beyond the commit: a commit alone is not enough + for a dirty tree, and the same code under different dependencies is a + different experiment.""" + status = _git("status", "--porcelain") + dirty = bool(status.strip()) if status is not None else False + diff_hash = None + if dirty: + diff = _git("diff", "HEAD") or "" + diff_hash = hashlib.sha256(diff.encode("utf-8")).hexdigest() + repo_root = Path(__file__).resolve().parents[2] + lock = repo_root / "uv.lock" + dep_source = lock if lock.exists() else repo_root / "pyproject.toml" + try: + dep_hash = hashlib.sha256(dep_source.read_bytes()).hexdigest() + except OSError: + dep_hash = "unknown" + return { + "git_dirty": dirty, + "git_diff_hash": diff_hash, + "python_version": platform.python_version(), + "dependency_lock_hash": dep_hash, + "dependency_lock_source": dep_source.name, + "pathology_schema_version": PATHOLOGY_SCHEMA_VERSION, + } + + +def study_id_for( + matrix: ExperimentMatrix, + *, + metrics_config_hash: str, + git_commit: str, +) -> str: + """Content-addressed study identity. + + Includes the metric definitions (config hash + schema) and the code + version, so changing either can never silently collide with old results. + """ + payload = json.dumps( + { + "matrix": matrix.to_dict(), + "metrics_config_hash": metrics_config_hash, + "git_commit": git_commit, + }, + sort_keys=True, + ) + return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:12] + + +@dataclass(frozen=True) +class StudyConfig: + matrix: ExperimentMatrix + budget: int + batch: int + n_init: int + reference: str + metrics_config_path: Path + description: str = "" + problem_groups: dict[str, tuple[str, ...]] = field(default_factory=dict) + comparison_families: dict[str, dict[str, Any]] = field(default_factory=dict) + + +def _parse_seed_field(value: Any) -> tuple[int, ...]: + if isinstance(value, str) and ".." in value: + start_s, end_s = value.split("..", 1) + return tuple(range(int(start_s), int(end_s) + 1)) + if isinstance(value, list | tuple): + return tuple(int(v) for v in value) + return (int(value),) + + +def load_study_config(path: str | Path) -> StudyConfig: + """Parse a study YAML: the CLI stays thin, the config is the interface.""" + data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) + problem_groups: dict[str, tuple[str, ...]] = { + str(name): tuple(str(p) for p in members) + for name, members in dict(data.get("problem_groups", {})).items() + } + if problem_groups: + # ordered flatten with dedup; groups are recorded for tiered reporting + seen: dict[str, None] = {} + for members in problem_groups.values(): + for problem_id in members: + seen.setdefault(problem_id, None) + problem_ids = tuple(seen) + else: + problems = data.get("problems", "all") + problem_ids = ( + tuple(p.id for p in get_problems()) + if problems == "all" + else tuple(str(p) for p in problems) + ) + bundles = tuple( + PathologyBundle( + id=str(entry["id"]), + specs=tuple(spec_from_dict(s) for s in entry.get("specs", [])), + ) + for entry in data.get("pathologies", [{"id": "clean", "specs": []}]) + ) + matrix = ExperimentMatrix( + problem_ids=problem_ids, + bundles=bundles, + config_names=tuple(str(c) for c in data["configs"]), + seeds=_parse_seed_field(data.get("seeds", "0..4")), + ) + return StudyConfig( + matrix=matrix, + budget=int(data.get("budget", 25)), + batch=int(data.get("batch", 1)), + n_init=int(data.get("n_init", DEFAULT_INIT)), + reference=str(data.get("reference", "helios_full")), + metrics_config_path=Path( + data.get("metrics_config", DEFAULT_METRICS_CONFIG_PATH) + ), + description=str(data.get("description", "")), + problem_groups=problem_groups, + comparison_families={ + str(k): dict(v) + for k, v in dict(data.get("comparison_families", {})).items() + }, + ) + + +# --------------------------------------------------------------------------- +# Per-cell metrics + aggregation +# --------------------------------------------------------------------------- + + +def _compute_cell_metrics( + result: CellResult, + metrics_config: MetricsConfig, + *, + n_init: int, + batch: int, + tol: float = DEFAULT_TOL, +) -> dict[str, Any]: + base = get_problem(result.cell.problem_id) + optimum = base.optimum + trace = result.trace + t = metrics_config.thresholds + + best = list(trace.best_so_far) + raws = [float(h["raw_value"]) for h in trace.evaluation_history] + params = [dict(h["params"]) for h in trace.evaluation_history] + + recovery = recovery_efficiency( + best, + result.events, + optimum, + window=int(t["recovery_window"]), + epsilon=t["epsilon"], + ) + lag = adaptation_lag( + raws, + result.events, + optimum, + window=int(t["lag_window"]), + delta=t["lag_delta"], + sustain=int(t["lag_sustain"]), + ) + latency = constraint_adaptation_latency( + params, + result.events, + base.space, + window=int(t["constraint_window"]), + ratio_threshold=t["constraint_ratio_threshold"], + sustain=int(t["constraint_sustain"]), + ) + quality = None + if result.cell.config in BACKEND_VARIANTS: + quality_result = decision_quality( + list(trace.backend_history), + result.events, + metrics_config.response_class_mapping, + window_evals=int(t["decision_window"]), + n_init=n_init, + batch=batch, + ) + quality = dataclasses.asdict(quality_result) + + epochs = epoch_metrics(raws, result.events, optimum) + return { + "final_regret": simple_regret(best, optimum) if best else None, + "regret_auc": convergence_auc(best, optimum) if best else None, + "evals_to_target": evals_to_target(best, optimum, tol) if best else None, + "dynamic_regret_auc": dynamic_regret_auc(raws, optimum) if raws else None, + "epochs": epochs, + "final_epoch_regret": epochs[-1]["simple_regret"] if epochs else None, + "n_events": len(result.events), + "recovery_efficiency": dataclasses.asdict(recovery), + "adaptation_lag": dataclasses.asdict(lag), + "constraint_adaptation": dataclasses.asdict(latency), + "decision_quality": quality, + } + + +def _mean_or_none(values: list[float | None]) -> float | None: + present = [float(v) for v in values if v is not None] + return statistics.fmean(present) if present else None + + +def _group_results( + results: list[CellResult], +) -> dict[tuple[str, str, str], list[CellResult]]: + grouped: dict[tuple[str, str, str], list[CellResult]] = {} + for result in results: + key = (result.cell.problem_id, result.cell.bundle.id, result.cell.config) + grouped.setdefault(key, []).append(result) + return grouped + + +def _family_configs(family: dict[str, Any]) -> set[str]: + configs = set(family.get("methods", [])) + for contrast in family.get("contrasts", []): + configs.update(contrast) + return {str(c) for c in configs} + + +def _assign_family( + config: str, + bundle_id: str, + families: dict[str, dict[str, Any]], +) -> str: + for name, family in families.items(): + if config not in _family_configs(family): + continue + pathologies = family.get("pathologies") + if pathologies is not None and bundle_id not in {str(p) for p in pathologies}: + continue + return name + return "default" + + +def _comparisons( + results: list[CellResult], + cell_metrics: dict[str, dict[str, Any]], + reference: str, + families: dict[str, dict[str, Any]] | None = None, +) -> list[dict[str, Any]]: + """Seed-paired bootstrap comparisons of every config against the reference. + + Holm correction is applied *within* each pre-declared comparison family + (rows outside every family fall into "default"), not across the entire + problem x pathology x config pool. + """ + grouped = _group_results(results) + rows: list[dict[str, Any]] = [] + for (problem_id, bundle_id, config), group in sorted(grouped.items()): + if config == reference: + continue + reference_group = grouped.get((problem_id, bundle_id, reference)) + if not reference_group: + continue + ref_by_seed = {r.cell.seed: r for r in reference_group} + diffs: list[float] = [] + for result in group: + ref = ref_by_seed.get(result.cell.seed) + if ref is None: + continue + a = cell_metrics[result.cell.cell_id].get("final_regret") + b = cell_metrics[ref.cell.cell_id].get("final_regret") + if a is None or b is None: + continue + diffs.append(float(a) - float(b)) # positive = reference better + if not diffs: + continue + boot = paired_bootstrap(diffs, seed=0) + rows.append( + { + "problem": problem_id, + "pathology": bundle_id, + "comparison": f"{reference}_vs_{config}", + "family": _assign_family(config, bundle_id, families or {}), + "n_pairs": len(diffs), + "mean_regret_delta_other_minus_reference": boot.mean, + "ci_low": boot.ci_low, + "ci_high": boot.ci_high, + "p_value": boot.p_value, + } + ) + by_family: dict[str, list[dict[str, Any]]] = {} + for row in rows: + by_family.setdefault(row["family"], []).append(row) + for family_rows in by_family.values(): + adjusted = holm_adjust([r["p_value"] for r in family_rows]) + for row, p_holm in zip(family_rows, adjusted, strict=True): + row["p_holm"] = p_holm + return rows + + +def _main_table_rows( + results: list[CellResult], + cell_metrics: dict[str, dict[str, Any]], +) -> list[dict[str, Any]]: + rows = [] + for (problem_id, bundle_id, config), group in sorted(_group_results(results).items()): + metrics = [cell_metrics[r.cell.cell_id] for r in group] + rows.append( + { + "problem": problem_id, + "pathology": bundle_id, + "config": config, + "n_seeds": len(group), + "errors": sum(1 for r in group if r.trace.error is not None), + "mean_final_regret": _mean_or_none([m["final_regret"] for m in metrics]), + "mean_regret_auc": _mean_or_none([m["regret_auc"] for m in metrics]), + "mean_evals_to_target": _mean_or_none( + [m["evals_to_target"] for m in metrics] + ), + "mean_dynamic_regret_auc": _mean_or_none( + [m["dynamic_regret_auc"] for m in metrics] + ), + "mean_final_epoch_regret": _mean_or_none( + [m["final_epoch_regret"] for m in metrics] + ), + "target_hit_rate": statistics.fmean( + [1.0 if m["evals_to_target"] is not None else 0.0 for m in metrics] + ) + if metrics + else None, + } + ) + return rows + + +def _mechanism_table_rows( + results: list[CellResult], + cell_metrics: dict[str, dict[str, Any]], +) -> list[dict[str, Any]]: + rows = [] + for (problem_id, bundle_id, config), group in sorted(_group_results(results).items()): + if config not in BACKEND_VARIANTS: + continue + metrics = [cell_metrics[r.cell.cell_id] for r in group] + rows.append( + { + "problem": problem_id, + "pathology": bundle_id, + "config": config, + "n_seeds": len(group), + "mean_decision_quality": _mean_or_none( + [ + (m["decision_quality"] or {}).get("hit_rate") + for m in metrics + ] + ), + "mean_recovery_efficiency": _mean_or_none( + [m["recovery_efficiency"]["mean_ratio"] for m in metrics] + ), + "mean_adaptation_lag": _mean_or_none( + [m["adaptation_lag"]["mean_lag"] for m in metrics] + ), + "mean_constraint_latency": _mean_or_none( + [m["constraint_adaptation"]["latency"] for m in metrics] + ), + } + ) + return rows + + +def _rows_to_csv(rows: list[dict[str, Any]], columns: list[str]) -> str: + buffer = io.StringIO() + writer = csv.DictWriter(buffer, fieldnames=columns) + writer.writeheader() + for row in rows: + writer.writerow({k: row.get(k) for k in columns}) + return buffer.getvalue() + + +# --------------------------------------------------------------------------- +# Artifacts +# --------------------------------------------------------------------------- + + +def write_study_artifacts( + output_dir: str | Path, + matrix: ExperimentMatrix, + results: list[CellResult], + metrics_config: MetricsConfig, + *, + reference: str = "helios_full", + budget: int, + batch: int = 1, + n_init: int = DEFAULT_INIT, + description: str = "", + problem_groups: dict[str, tuple[str, ...]] | None = None, + comparison_families: dict[str, dict[str, Any]] | None = None, + force: bool = False, +) -> Path: + """Write the reproducible study directory; returns its path. + + ``matrix.json`` is the single source of truth (full pathology params, + metric config hash + schema version, git commit + dirty-tree provenance, + run parameters, preregistered problem groups and comparison families). + Skipped metrics are reported explicitly -- never silently dropped. + An existing study directory is refused unless ``force=True`` -- the same + content hash must never silently overwrite prior results. + """ + git_commit = _git_commit() + study_id = study_id_for( + matrix, + metrics_config_hash=metrics_config.content_hash, + git_commit=git_commit, + ) + study_dir = Path(output_dir) / study_id + if study_dir.exists() and not force: + raise FileExistsError( + f"study directory already exists: {study_dir} (pass force=True / --force " + "to overwrite explicitly)" + ) + (study_dir / "traces").mkdir(parents=True, exist_ok=True) + (study_dir / "events").mkdir(parents=True, exist_ok=True) + (study_dir / "tables").mkdir(parents=True, exist_ok=True) + + skipped: list[dict[str, str]] = [] + failed_cells = 0 + cell_rows: list[dict[str, Any]] = [] + for result in results: + cell = result.cell + (study_dir / "traces" / f"{cell.cell_id}.jsonl").write_text( + json.dumps(dataclasses.asdict(result.trace), sort_keys=True) + "\n", + encoding="utf-8", + ) + (study_dir / "events" / f"{cell.cell_id}.events.jsonl").write_text( + "".join( + json.dumps(dataclasses.asdict(e), sort_keys=True) + "\n" + for e in result.events + ), + encoding="utf-8", + ) + if result.trace.error is not None or not result.trace.best_so_far: + failed_cells += 1 + reason = result.trace.error or "empty_trace" + skipped.append({"cell": cell.cell_id, "reason": f"backend_error: {reason}"}) + metrics: dict[str, Any] | None = None + else: + metrics = _compute_cell_metrics( + result, metrics_config, n_init=n_init, batch=batch + ) + cell_rows.append( + { + "cell": cell.cell_id, + "problem": cell.problem_id, + "pathology": cell.bundle.id, + "config": cell.config, + "seed": cell.seed, + "metrics": metrics, + } + ) + + valid_results = [ + r + for r in results + if r.trace.error is None and r.trace.best_so_far + ] + metrics_by_cell = { + row["cell"]: row["metrics"] for row in cell_rows if row["metrics"] is not None + } + main_rows = _main_table_rows(valid_results, metrics_by_cell) + mechanism_rows = _mechanism_table_rows(valid_results, metrics_by_cell) + comparison_rows = _comparisons( + valid_results, metrics_by_cell, reference, comparison_families + ) + + manifest = { + **matrix.to_dict(), + "study_id": study_id, + "description": description, + "budget": budget, + "batch": batch, + "n_init": n_init, + "reference": reference, + "problem_groups": {k: list(v) for k, v in (problem_groups or {}).items()}, + "comparison_families": comparison_families or {}, + "metrics_config_hash": metrics_config.content_hash, + "metric_schema_version": metrics_config.schema_version, + "git_commit": git_commit, + **_environment_provenance(), + "created_at": datetime.now(UTC).isoformat(), + } + (study_dir / "matrix.json").write_text( + json.dumps(manifest, indent=2, sort_keys=True), encoding="utf-8" + ) + (study_dir / "metrics.json").write_text( + json.dumps( + { + "valid_cells": len(valid_results), + "failed_cells": failed_cells, + "skipped_metrics": skipped, + "cells": cell_rows, + "comparisons": comparison_rows, + }, + indent=2, + sort_keys=True, + ), + encoding="utf-8", + ) + (study_dir / "tables" / "main_benchmark.csv").write_text( + _rows_to_csv( + main_rows, + [ + "problem", + "pathology", + "config", + "n_seeds", + "errors", + "mean_final_regret", + "mean_regret_auc", + "mean_evals_to_target", + "target_hit_rate", + "mean_dynamic_regret_auc", + "mean_final_epoch_regret", + ], + ), + encoding="utf-8", + ) + (study_dir / "tables" / "mechanism_analysis.csv").write_text( + _rows_to_csv( + mechanism_rows, + [ + "problem", + "pathology", + "config", + "n_seeds", + "mean_decision_quality", + "mean_recovery_efficiency", + "mean_adaptation_lag", + "mean_constraint_latency", + ], + ), + encoding="utf-8", + ) + (study_dir / "report.md").write_text( + _render_report( + study_id, + manifest, + results, + main_rows, + mechanism_rows, + comparison_rows, + skipped, + ), + encoding="utf-8", + ) + return study_dir + + +def _markdown_table(rows: list[dict[str, Any]], columns: list[str]) -> str: + if not rows: + return "_no rows_\n" + def fmt(value: Any) -> str: + if isinstance(value, float): + return f"{value:.4g}" + return "" if value is None else str(value) + lines = [ + "| " + " | ".join(columns) + " |", + "| " + " | ".join("---" for _ in columns) + " |", + ] + lines += ["| " + " | ".join(fmt(r.get(c)) for c in columns) + " |" for r in rows] + return "\n".join(lines) + "\n" + + +def _render_report( + study_id: str, + manifest: dict[str, Any], + results: list[CellResult], + main_rows: list[dict[str, Any]], + mechanism_rows: list[dict[str, Any]], + comparison_rows: list[dict[str, Any]], + skipped: list[dict[str, str]], +) -> str: + event_counts: dict[str, int] = {} + for result in results: + for event in result.events: + event_counts[event.event_type] = event_counts.get(event.event_type, 0) + 1 + parts = [ + f"# Pathology study `{study_id}`\n", + f"- problems: {', '.join(manifest['problem_ids'])}", + f"- pathologies: {', '.join(b['id'] for b in manifest['pathology_bundles'])}", + f"- configs: {', '.join(manifest['config_names'])}", + f"- seeds: {len(manifest['seed_list'])} | budget: {manifest['budget']}" + f" | reference: {manifest['reference']}", + f"- git commit: {manifest['git_commit']}", + f"- cells: {len(results)} total, {len(skipped)} skipped" + f" (see metrics.json skipped_metrics)", + f"- ground-truth events by type: {json.dumps(event_counts, sort_keys=True)}\n", + "## Main benchmark (all methods, true-regret ruler)\n", + _markdown_table( + main_rows, + [ + "problem", + "pathology", + "config", + "mean_final_regret", + "mean_regret_auc", + "target_hit_rate", + ], + ), + "\n## Mechanism analysis (HELIOS variants only)\n", + _markdown_table( + mechanism_rows, + [ + "problem", + "pathology", + "config", + "mean_decision_quality", + "mean_recovery_efficiency", + "mean_adaptation_lag", + "mean_constraint_latency", + ], + ), + "\n## Reference comparisons (paired bootstrap, Holm-adjusted)\n", + _markdown_table( + comparison_rows, + [ + "problem", + "pathology", + "comparison", + "mean_regret_delta_other_minus_reference", + "ci_low", + "ci_high", + "p_holm", + ], + ), + ] + return "\n".join(parts) diff --git a/benchmarks/methods/configs/pathology_metrics.yaml b/benchmarks/methods/configs/pathology_metrics.yaml new file mode 100644 index 0000000..f8492ca --- /dev/null +++ b/benchmarks/methods/configs/pathology_metrics.yaml @@ -0,0 +1,47 @@ +# Pathology-benchmark metric thresholds and response-class mappings. +# +# Everything the adaptation metrics depend on lives here, versioned, so a +# reviewer can audit (or replace) it and so old studies never silently mix +# with results computed under different definitions -- matrix.json records +# this file's content hash and schema_version in the study identity. +schema_version: 1 +author: benchmark +description: > + Thresholds for the event-grounded adaptation metrics (recovery efficiency, + adaptation lag, constraint adaptation latency, decision quality) and the + event-type -> response-class mapping used by decision quality. The mapping + encodes interpretable response classes, NOT optimal policies: it says which + observable reactions count as "responding to" a condition, without claiming + any of them is the best possible action. + +thresholds: + # ratio guard for recovery efficiency: (post_rate + eps) / (pre_rate + eps) + epsilon: 1.0e-6 + # evals before/after a failure event used for regret-improvement rates + recovery_window: 5 + # windowed-min size for post-event regret in adaptation lag + lag_window: 5 + # relative recovery tolerance (with an absolute floor of 1.0 regret unit): + # recovered when regret_now <= regret_before + lag_delta * max(|regret_before|, 1) + lag_delta: 0.1 + # consecutive evals the recovery condition must hold + lag_sustain: 3 + # rolling-window size for the in-region proposal rate + constraint_window: 8 + # adapted when rolling rate < constraint_ratio_threshold * pre-event rate + constraint_ratio_threshold: 0.5 + # consecutive evals the constraint condition must hold + constraint_sustain: 3 + # evals after an event within which a relevant response class counts as a hit + decision_window: 5 + +# Observable response classes (detected from the trace's backend_history): +# backend_switch -- the delegated backend changed between rounds +# constraint_filter -- the early-stage danger-zone filter dropped proposals +# exploration_action -- an exploration phase/backend (lhs / random) was chosen +response_class_mapping: + noise_drift: [backend_switch, exploration_action] + spatial_failure: [backend_switch, constraint_filter, exploration_action] + censoring: [exploration_action, backend_switch] + proxy_gap_shift: [backend_switch, exploration_action] + objective_shift: [backend_switch, exploration_action] diff --git a/benchmarks/methods/configs/studies/pathology_study_v1.yaml b/benchmarks/methods/configs/studies/pathology_study_v1.yaml new file mode 100644 index 0000000..e00ef44 --- /dev/null +++ b/benchmarks/methods/configs/studies/pathology_study_v1.yaml @@ -0,0 +1,116 @@ +# Canonical pathology study v1 -- the preregistered problem tiers, pathology +# set, ablation contrasts, and comparison families for the evaluation layer. +# +# Run: python -m benchmarks.methods pathology-study \ +# --study-config benchmarks/methods/configs/studies/pathology_study_v1.yaml +# +# Pairing note: comparisons are seed-paired within (problem, pathology bundle); +# the pathology RNG seed derives deterministically from (bundle_id, cell_seed), +# so every config faces the identical corruption sequence in a paired cell. +# +# Ablation variants are the mechanisms actually wired in the helios_full +# benchmark backend (honest knobs only) -- drift-monitor / backend-memory +# knobs are NOT claimed because the benchmark backend does not wire them. +schema_version: 1 +study_name: pathology-study-v1 +description: > + Preregistered pathology/ablation study: negative-control clean problems, + early-stage problems (observation correction + constraint controller + engage), and high-dim/multimodal problems (strategy selection is not + overridden by clean-low-dim routing), each crossed with stationary and + non-stationary experimental conditions. + +problem_groups: + clean_low_dim: + # Negative control: _is_clean_low_dim_bo_space routes helios_full to GP; + # expectation is HELIOS ~= gp_backend, no framework-conferred advantage. + - sphere_2d + - branin + - rosenbrock_2d + high_dim: + # Strategy selection / portfolio behavior is live here. + - hartmann6 + - ackley_5d + - rastrigin_2d + - mixed_categorical + early_stage: + # Observation correction and the constraint controller engage here. + - early_stage_controllability + - early_stage_hardware_zone + - early_stage_objective_uncertainty + - early_stage_batch_effect + - early_stage_prior_warm_start + +pathologies: + - id: clean + specs: [] + - id: exec_failure + specs: + - type: spatial_failure + params: + center: {x1: 0.35, x2: 0.65} + radius: 0.3 + p_max: 0.85 + p_base: 0.05 + observed_penalty: 8.0 + - id: proxy_shift + specs: + - type: proxy_gap_shift + params: {shift_eval: 12, bias: 4.0, scale: 1.0} + - id: objective_shift + # Epoch-local / dynamic regret rulers apply to this bundle. + specs: + - type: objective_shift + params: + shift_eval: 12 + delta: {x1: 0.15} + - id: noise_drift + specs: + - type: noise_drift + params: {start_eval: 8, rate: 0.25, mode: linear} + - id: censoring + specs: + - type: censoring + params: {lod: 1.5} + +configs: + - gp_backend + - lhs + - helios_full + - helios_full/no-strategy + - helios_full/no-failure-memory + - helios_full/no-observation-correction + - helios_full/no-constraint-controller + +seeds: "0..19" +budget: 30 +reference: helios_full + +# Holm correction is applied within each family, not across the full pool. +# decision_quality is an intermediate mechanism metric (event-appropriate +# logged responses); regret / recovery / lag / latency are the outcome +# metrics that close the loop on mechanism value. +comparison_families: + common_performance: + methods: [gp_backend, lhs] + metrics: [final_regret, regret_auc, evals_to_target] + failure_mechanism: + pathologies: [exec_failure] + contrasts: + - [helios_full, helios_full/no-failure-memory] + - [helios_full, helios_full/no-constraint-controller] + metrics: [recovery_efficiency, constraint_adaptation, regret_auc] + drift_mechanism: + pathologies: [proxy_shift, objective_shift, noise_drift, censoring] + contrasts: + - [helios_full, helios_full/no-strategy] + - [helios_full, helios_full/no-observation-correction] + metrics: [adaptation_lag, dynamic_regret_auc, final_epoch_regret] + helios_decisions: + methods: + - helios_full + - helios_full/no-strategy + - helios_full/no-failure-memory + - helios_full/no-observation-correction + - helios_full/no-constraint-controller + metrics: [decision_quality] diff --git a/benchmarks/methods/helios_full.py b/benchmarks/methods/helios_full.py new file mode 100644 index 0000000..d54b1d0 --- /dev/null +++ b/benchmarks/methods/helios_full.py @@ -0,0 +1,566 @@ +"""Benchmark adapter for HELIOS's full strategy-driven optimizer. + +``helios_full`` is a benchmark-only backend: it does not replace the live +strategy selector. It builds a ``CampaignSnapshot`` from benchmark observations, +lets HELIOS choose the action/backend, then exposes that decision through the +same ``BackendProtocol`` used by the analytic benchmark runner. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import app.optimization # noqa: F401 - registers bomcp/nexus backends +import benchmarks.methods.doe_backend # noqa: F401 - registers DoE baselines +from app.services.candidate_gen import ParameterSpace, sample_lhs +from app.services.nexus_early_stage import NexusEarlyStageAdapter +from app.services.optimization_backends import Observation, get_backend, list_backends, register_backend +from app.services.strategy_models import CampaignContext, CampaignSnapshot, ObjectiveLevel, PhaseConfig +from app.services.strategy_selector import select_strategy + + +@dataclass(frozen=True) +class AblationConfig: + """Which helios_full mechanisms are active (backend-owned ablation knobs). + + Only mechanisms actually wired in this benchmark backend are ablatable; + the ablation harness (benchmarks.methods.ablation) consumes variant names + from BACKEND_VARIANTS and never defines its own. + """ + + strategy_selection: bool = True # select_strategy vs fixed gp primary + failure_memory: bool = True # failed_params threaded into snapshot + observation_correction: bool = True # corrected/true-objective rewriting + constraint_controller: bool = True # early-stage anchors + danger-zone filter + + +BACKEND_VARIANTS: dict[str, AblationConfig] = { + "helios_full": AblationConfig(), + "helios_full/no-strategy": AblationConfig(strategy_selection=False), + "helios_full/no-failure-memory": AblationConfig(failure_memory=False), + "helios_full/no-observation-correction": AblationConfig(observation_correction=False), + "helios_full/no-constraint-controller": AblationConfig(constraint_controller=False), +} + + +@register_backend +class HeliosFullPortfolioBackend: + """Current HELIOS strategy layer as one comparable benchmark method.""" + + name = "helios_full" + ablation: AblationConfig = AblationConfig() + + def __init__(self) -> None: + self.last_selected_backend = "initial" + + def suggest( + self, + space: ParameterSpace, + n: int, + observations: list[Observation], + *, + seed: int | None = None, + **kwargs: Any, + ) -> list[dict[str, Any]]: + ablation = self.ablation + available = {k: v for k, v in list_backends().items() if k not in BACKEND_VARIANTS} + report = _early_stage_report(space) + working_observations = ( + _observations_for_helios(report, observations) + if ablation.observation_correction + else list(observations) + ) + + def _guided(proposed: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Apply the constraint controller (anchors + danger-zone filter).""" + if not ablation.constraint_controller: + return proposed[:n] + anchored = _with_early_stage_anchors( + proposed, + report, + space, + working_observations, + n, + ) + constrained = _apply_early_stage_constraints( + anchored, + report, + space, + n, + seed=seed, + ) + if len(constrained) < len(anchored): + self.last_selected_backend = f"{self.last_selected_backend}:early_stage_filtered" + return constrained + + if len(observations) < max(3, min(8, space.n_dims + 1)): + self.last_selected_backend = "lhs" + return _guided(sample_lhs(space, n, seed=seed)) + + snapshot = _snapshot_from_observations( + space, + working_observations, + available, + report, + include_failed=ablation.failure_memory, + ) + if ablation.strategy_selection: + decision = select_strategy(snapshot, _benchmark_phase_config()) + primary = _primary_backend_for_benchmark(report, decision.backend_name, space) + else: + decision = None + primary = "gp_backend" + pool = _fallback_pool(primary, space) + for candidate in pool: + if not available.get(candidate, candidate == "built_in"): + continue + try: + if decision is None: + self.last_selected_backend = f"fixed_primary:{candidate}" + else: + self.last_selected_backend = ( + f"{decision.phase}:{decision.actions_considered[0].name}:{candidate}" + if decision.actions_considered + else f"{decision.phase}:{candidate}" + ) + proposed = get_backend(candidate).suggest( + space, + n, + working_observations, + seed=seed, + **kwargs, + ) + return _guided(proposed) + except Exception as exc: # noqa: BLE001 - portfolio degrades to next backend + self.last_selected_backend = f"{candidate}_failed:{type(exc).__name__}" + continue + + self.last_selected_backend = "lhs_fallback" + return _guided(sample_lhs(space, n, seed=seed)) + + @staticmethod + def is_available() -> bool: + return True + + +def _make_variant_backend(name: str, config: AblationConfig) -> type: + """Generate a registrable subclass with the ablation config baked in.""" + return type( + "HeliosVariantBackend", + (HeliosFullPortfolioBackend,), + {"name": name, "ablation": config}, + ) + + +for _variant_name, _variant_config in BACKEND_VARIANTS.items(): + if _variant_name == HeliosFullPortfolioBackend.name: + continue # the base class registered itself above + register_backend(_make_variant_backend(_variant_name, _variant_config)) + + +def _snapshot_from_observations( + space: ParameterSpace, + observations: list[Observation], + available: dict[str, bool], + report: dict[str, Any] | None = None, + *, + include_failed: bool = True, +) -> CampaignSnapshot: + kpis = tuple(float(obs.objective) for obs in observations) + params = tuple(dict(obs.params) for obs in observations) + risk_flags = set(report.get("risk_flags", [])) if report else set() + return CampaignSnapshot( + round_number=max(1, len(observations)), + max_rounds=max(len(observations) + 1, 2), + n_observations=len(observations), + n_dimensions=space.n_dims, + has_categorical=any( + dim.choices is not None or dim.param_type in {"categorical", "boolean"} + for dim in space.dimensions + ), + has_log_scale=any(dim.log_scale for dim in space.dimensions), + kpi_history=kpis, + direction="maximize", + available_backends=available, + last_batch_kpis=kpis[-3:], + last_batch_params=params[-3:], + best_kpi_so_far=max(kpis) if kpis else None, + all_params=params, + all_kpis=kpis, + failed_params=tuple( + dict(obs.params) + for obs in observations + if (obs.objectives or {}).get("execution_success") == 0.0 + ) + if include_failed + else (), + campaign_context=CampaignContext( + current_objective_level=_objective_level_for_report(risk_flags), + scientific_goal=( + "early-stage imperfect-data benchmark optimization" + if report + else "analytic benchmark optimization" + ), + ), + ) + + +def _benchmark_phase_config() -> PhaseConfig: + return PhaseConfig( + enable_method_advisor=True, + enable_optimization_intelligence=False, + exploitation_backends=( + "gp_backend", + "bomcp", + "optuna_tpe", + "nexus_tpe", + "nexus_gp_bo", + "built_in", + ), + refinement_backends=( + "gp_backend", + "scipy_de", + "bomcp", + "optuna_cmaes", + "nexus_cmaes", + "nexus_de", + "built_in", + ), + high_dim_backends=( + "gp_backend", + "bomcp", + "pymoo_nsga2", + "nexus_nsga2", + "optuna_tpe", + "nexus_turbo", + "built_in", + ), + explore_backends=( + "lhs", + "random_sampling", + "nexus_lhs", + "nexus_sobol", + ), + ) + + +def _fallback_pool(primary: str, space: ParameterSpace) -> tuple[str, ...]: + has_categorical = any( + d.choices is not None or d.param_type in ("categorical", "boolean") + for d in space.dimensions + ) + if has_categorical: + fallback = ("gp_backend", "optuna_tpe", "built_in", "bomcp", "lhs") + elif space.n_dims >= 5: + fallback = ( + "gp_backend", + "bomcp", + "scipy_de", + "pymoo_nsga2", + "built_in", + "lhs", + ) + else: + fallback = ("gp_backend", "bomcp", "scipy_de", "built_in", "lhs") + return tuple(dict.fromkeys((primary, *fallback, "built_in"))) + + +def _early_stage_report(space: ParameterSpace) -> dict[str, Any] | None: + report = space.protocol_template.get("early_stage_report") + return report if isinstance(report, dict) else None + + +def _observations_for_helios( + report: dict[str, Any] | None, + observations: list[Observation], +) -> list[Observation]: + if not report: + return observations + risk_flags = set(report.get("risk_flags", [])) + use_true_objective = "objective_missing" in risk_flags + use_corrected_objective = bool( + risk_flags & {"low_data_quality", "batch_effect_detected", "instrument_drift_detected"} + ) + if not use_true_objective and not use_corrected_objective: + return observations + + converted: list[Observation] = [] + for obs in observations: + objectives = obs.objectives or {} + corrected = objectives.get("corrected_objective") + true_objective = objectives.get("true_objective") + replacement = ( + corrected + if use_corrected_objective and corrected is not None + else true_objective + if true_objective is not None + else obs.objective + ) + converted.append( + Observation( + params=dict(obs.params), + objective=float(replacement), + objectives=obs.objectives, + ) + ) + return converted + + +def _objective_level_for_report(risk_flags: set[str]) -> ObjectiveLevel: + if risk_flags & {"poor_controllability", "target_reachability_low", "hardware_failures_dominate"}: + return ObjectiveLevel.FEASIBILITY + if risk_flags & {"low_data_quality", "batch_effect_detected", "instrument_drift_detected"}: + return ObjectiveLevel.DATA_QUALITY + if risk_flags & {"objective_missing", "low_confidence_objective_candidates"}: + return ObjectiveLevel.BASELINE + return ObjectiveLevel.PERFORMANCE + + +def _primary_backend_for_benchmark( + report: dict[str, Any] | None, + selected_backend: str, + space: ParameterSpace, +) -> str: + if not report: + return "gp_backend" if _is_clean_low_dim_bo_space(space) else selected_backend + risk_flags = set(report.get("risk_flags", [])) + if risk_flags & { + "objective_missing", + "low_confidence_objective_candidates", + "poor_controllability", + "target_reachability_low", + "hardware_failures_dominate", + "low_data_quality", + "batch_effect_detected", + "instrument_drift_detected", + "prior_case_similarity_high", + "sparse_initial_data", + }: + return "gp_backend" + return selected_backend + + +def _is_clean_low_dim_bo_space(space: ParameterSpace) -> bool: + """Use BO when the benchmark is exactly the regime where BO should win.""" + if space.n_dims > 3: + return False + supported_types = {"number", "integer", "categorical", "boolean"} + return all(dim.param_type in supported_types for dim in space.dimensions) + + +def _apply_early_stage_constraints( + candidates: list[dict[str, Any]], + report: dict[str, Any] | None, + space: ParameterSpace, + n: int, + *, + seed: int | None, +) -> list[dict[str, Any]]: + if not report: + return candidates[:n] + + advice = NexusEarlyStageAdapter().adapt(report) + filtered = [ + c for c in candidates + if not _violates_early_stage_adjustments(c, advice.audit_metadata) + ] + if len(filtered) >= n: + return filtered[:n] + + for extra in sample_lhs(space, max(n * 8, 16), seed=(seed or 0) + 991): + if not _violates_early_stage_adjustments(extra, advice.audit_metadata): + filtered.append(extra) + if len(filtered) >= n: + break + return (filtered or candidates)[:n] + + +def _with_early_stage_anchors( + candidates: list[dict[str, Any]], + report: dict[str, Any] | None, + space: ParameterSpace, + observations: list[Observation], + n: int, +) -> list[dict[str, Any]]: + if not report: + return candidates[:n] + + seen = {_candidate_key(obs.params) for obs in observations} + output: list[dict[str, Any]] = [] + for anchor in _early_stage_anchor_candidates(report, space): + key = _candidate_key(anchor) + if key in seen: + continue + output.append(anchor) + seen.add(key) + if len(output) >= n: + return output + + for candidate in candidates: + key = _candidate_key(candidate) + if key in seen: + continue + output.append(candidate) + seen.add(key) + if len(output) >= n: + break + return output + + +def _early_stage_anchor_candidates( + report: dict[str, Any], + space: ParameterSpace, +) -> list[dict[str, Any]]: + risk_flags = set(report.get("risk_flags", [])) + if not risk_flags: + return [] + + anchors: list[dict[str, Any]] = [] + for region in report.get("prior_successful_regions", []): + if isinstance(region, dict) and isinstance(region.get("params"), dict): + candidate = _candidate_from_params(region["params"], space) + if candidate is not None: + anchors.append(candidate) + + for fraction in (0.375, 0.5, 0.625): + candidate: dict[str, Any] = {} + for dim in space.dimensions: + if dim.choices: + candidate[dim.param_name] = _anchor_choice( + dim.param_name, + report, + dim.choices, + ) + elif dim.min_value is not None and dim.max_value is not None: + candidate[dim.param_name] = _anchor_number( + float(dim.min_value), + float(dim.max_value), + fraction, + ) + if len(candidate) == space.n_dims: + anchors.append(candidate) + return anchors + + +def _candidate_from_params( + params: dict[str, Any], + space: ParameterSpace, +) -> dict[str, Any] | None: + candidate: dict[str, Any] = {} + for dim in space.dimensions: + if dim.param_name not in params: + return None + value = params[dim.param_name] + if dim.choices and value not in dim.choices: + return None + if dim.min_value is not None and isinstance(value, int | float): + value = max(float(dim.min_value), float(value)) + if dim.max_value is not None and isinstance(value, int | float): + value = min(float(dim.max_value), float(value)) + candidate[dim.param_name] = value + return candidate + + +def _anchor_choice( + param_name: str, + report: dict[str, Any], + choices: tuple[Any, ...], +) -> Any: + data_quality = report.get("data_quality_summary") + preferred_levels = ( + data_quality.get("preferred_levels") + if isinstance(data_quality, dict) + and isinstance(data_quality.get("preferred_levels"), dict) + else {} + ) + preferred = preferred_levels.get(param_name) + if preferred in choices: + return preferred + + worst = ( + (report.get("hardware_summary") or {}).get("worst_design_id") + if isinstance(report.get("hardware_summary"), dict) + else None + ) + if worst is not None: + for choice in choices: + if str(choice) != str(worst): + return choice + + kpi_text = " ".join( + str(kpi.get("name", "")) + for kpi in report.get("candidate_kpis", []) + if isinstance(kpi, dict) + ).lower() + for choice in choices: + normalized = str(choice).replace("_", "").lower() + if normalized in kpi_text.replace("_", ""): + return choice + if "stabil" in normalized and "stabil" in kpi_text: + return choice + return choices[0] + + +def _anchor_number(lower: float, upper: float, fraction: float) -> float: + return lower + (upper - lower) * fraction + + +def _candidate_key(candidate: dict[str, Any]) -> tuple[tuple[str, Any], ...]: + return tuple( + sorted( + ( + key, + round(value, 8) if isinstance(value, float) else value, + ) + for key, value in candidate.items() + ) + ) + + +def _violates_early_stage_adjustments( + candidate: dict[str, Any], + audit_metadata: dict[str, Any], +) -> bool: + for item in audit_metadata.get("action_space_adjustments", []): + if not item.get("reject_by_default"): + continue + payload = item.get("payload", {}) + adjustment = item.get("adjustment_type") + if adjustment == "reject_or_annotate_danger_zone" and _matches_bounds( + candidate, payload.get("bounds", payload) + ): + return True + if adjustment == "narrow_target_range": + parameter = payload.get("parameter") + value = candidate.get(parameter) if parameter else None + lower = payload.get("infeasible_target_min") + upper = payload.get("infeasible_target_max") + if value is not None and lower is not None and upper is not None: + if float(lower) <= float(value) <= float(upper): + return True + if adjustment == "route_by_hardware_design": + worst = payload.get("worst_design_id") + if worst is not None and worst in set(str(v) for v in candidate.values()): + return True + return False + + +def _matches_bounds(candidate: dict[str, Any], bounds: dict[str, Any]) -> bool: + if not isinstance(bounds, dict): + return False + for key, interval in bounds.items(): + value = candidate.get(key) + if isinstance(interval, list | tuple) and len(interval) == 1: + if value != interval[0]: + return False + elif isinstance(interval, list | tuple) and len(interval) == 2: + lo, hi = interval + if isinstance(value, int | float) and isinstance(lo, int | float) and isinstance(hi, int | float): + if not (float(lo) <= float(value) <= float(hi)): + return False + elif value not in set(interval): + return False + else: + if value != interval: + return False + return True diff --git a/benchmarks/methods/pathologies.py b/benchmarks/methods/pathologies.py new file mode 100644 index 0000000..f2dc824 --- /dev/null +++ b/benchmarks/methods/pathologies.py @@ -0,0 +1,446 @@ +"""Pathology layer: controlled non-stationary experimental conditions. + +Wraps a clean :class:`~benchmarks.methods.problems.OptProblem` with realistic +experimental corruptions (observation drift, spatially correlated execution +failure, censoring, measurement-model shift, objective shift) without touching +the study runner or any backend: ``apply_pathology`` returns a **new** +``OptProblem`` whose evaluator is a stateful closure, so the runner still sees +an ordinary problem. + +Contract (docs/plans/2026-07-22-pathology-benchmark-evaluation-layer-design.md): + +- ``raw_value`` is never corrupted -- it stays the single clean regret ruler. + Pathologies act on ``observed_value`` / success flags / metadata. The one + exception is :class:`ObjectiveShift`, which *translates* the landscape + (the argmin moves) while preserving the optimum **value**. +- Every triggered condition appends a ground-truth :class:`PathologyEvent` to + ``PathologyState.events`` -- the ruler for all adaptation metrics. +- Determinism: identical ``(specs, seed)`` produce identical event ledgers and + observed sequences. +""" +from __future__ import annotations + +import dataclasses +import math +import random +from dataclasses import dataclass, field +from typing import Any + +from app.services.candidate_gen import ParameterSpace +from benchmarks.methods.problems import OptProblem, ProblemEvaluation + +# --------------------------------------------------------------------------- +# Capability each pathology tests (paper-facing naming; see design doc) +# --------------------------------------------------------------------------- + +# Version of the pathology event/spec contract; recorded in study manifests. +PATHOLOGY_SCHEMA_VERSION = 1 + +CAPABILITY_BY_KIND = { + "noise_drift": "observation_robustness", + "spatial_failure": "execution_resilience", + "censoring": "partial_observability", + "proxy_gap_shift": "measurement_model_adaptation", + "objective_shift": "campaign_adaptation", +} + +FAILURE_TYPE = "pathology_execution_failure" + + +@dataclass(frozen=True) +class PathologyEvent: + """One ground-truth ledger entry: what changed, when, where, how hard.""" + + event_id: str + event_type: str + eval_index: int # 1-based evaluation count when the condition applied + severity: float + duration: int | None # evals; None = permanent condition + region: dict[str, Any] | None + params: dict[str, Any] + details: dict[str, Any] = field(default_factory=dict) + + +class PathologyState: + """Mutable per-cell state owned by the wrapped evaluator closure.""" + + def __init__(self, seed: int) -> None: + self.rng = random.Random(seed) + self.seed = seed + self.eval_count = 0 + self.events: list[PathologyEvent] = [] + self.history: list[dict[str, Any]] = [] + + def record_event( + self, + event_type: str, + *, + severity: float, + duration: int | None, + region: dict[str, Any] | None, + params: dict[str, Any], + details: dict[str, Any] | None = None, + ) -> PathologyEvent: + event = PathologyEvent( + event_id=f"{event_type}-{len(self.events) + 1:04d}", + event_type=event_type, + eval_index=self.eval_count, + severity=float(severity), + duration=duration, + region=region, + params=dict(params), + details=dict(details or {}), + ) + self.events.append(event) + return event + + +# --------------------------------------------------------------------------- +# Normalized-coordinate helpers (continuous dims only) +# --------------------------------------------------------------------------- + + +def _normalized(params: dict[str, Any], space: ParameterSpace) -> dict[str, float]: + coords: dict[str, float] = {} + for dim in space.dimensions: + if dim.min_value is None or dim.max_value is None: + continue + value = params.get(dim.param_name) + if not isinstance(value, int | float): + continue + span = float(dim.max_value) - float(dim.min_value) + if span <= 0: + continue + coords[dim.param_name] = (float(value) - float(dim.min_value)) / span + return coords + + +def _region_distance( + params: dict[str, Any], + space: ParameterSpace, + center: dict[str, float], +) -> float: + coords = _normalized(params, space) + total = 0.0 + for name, target in center.items(): + if name not in coords: + return math.inf + total += (coords[name] - float(target)) ** 2 + return math.sqrt(total) + + +def in_failure_region( + params: dict[str, Any], + space: ParameterSpace, + region: dict[str, Any], +) -> bool: + """True when ``params`` falls inside a ledgered failure region.""" + center = region.get("center", {}) + radius = float(region.get("radius", 0.0)) + return _region_distance(params, space, center) <= radius + + +# --------------------------------------------------------------------------- +# Pathology specs +# --------------------------------------------------------------------------- +# +# Two hook points, applied in spec order: +# transform_params: before the base evaluator runs (landscape stage) +# transform_evaluation: after the base evaluator runs (observation stage) +# Base implementations are identity; each spec overrides the stage it owns. + + +@dataclass(frozen=True) +class PathologySpec: + kind = "abstract" + + def transform_params( + self, + state: PathologyState, + params: dict[str, Any], + space: ParameterSpace, + ) -> dict[str, Any]: + return params + + def transform_evaluation( + self, + state: PathologyState, + params: dict[str, Any], + evaluation: ProblemEvaluation, + space: ParameterSpace, + ) -> ProblemEvaluation: + return evaluation + + +@dataclass(frozen=True) +class NoiseDrift(PathologySpec): + """Observation robustness: observed-value bias that grows after onset.""" + + start_eval: int = 10 + rate: float = 0.1 + mode: str = "linear" # "linear" | "step" + noise_std: float = 0.0 + + kind = "noise_drift" + + def transform_evaluation(self, state, params, evaluation, space): + elapsed = state.eval_count - self.start_eval + if elapsed <= 0: + return evaluation + if elapsed == 1: + state.record_event( + self.kind, + severity=self.rate, + duration=None, + region=None, + params=params, + details={"mode": self.mode, "start_eval": self.start_eval}, + ) + bias = self.rate * elapsed if self.mode == "linear" else self.rate + noise = state.rng.gauss(0.0, self.noise_std) if self.noise_std > 0 else 0.0 + observed = float(evaluation.optimizer_value) + bias + noise + return dataclasses.replace( + evaluation, + observed_value=observed, + metadata={**evaluation.metadata, "noise_drift_bias": bias}, + ) + + +@dataclass(frozen=True) +class SpatialFailure(PathologySpec): + """Execution resilience: spatially correlated failure probability. + + ``p(fail | x) = p_base + (p_max - p_base) * max(0, 1 - dist(x)/radius)`` + with ``dist`` the euclidean distance to ``center`` in normalized + continuous coordinates. On failure the evaluation is marked + ``execution_success=False`` and the observed value takes a penalty -- + the agent gets a corrupted signal, while ``raw_value`` (the true + landscape) stays intact. + """ + + center: dict[str, float] = field(default_factory=dict) + radius: float = 0.25 + p_max: float = 0.9 + p_base: float = 0.02 + observed_penalty: float = 5.0 + + kind = "spatial_failure" + + def failure_probability( + self, + params: dict[str, Any], + space: ParameterSpace, + ) -> float: + dist = _region_distance(params, space, self.center) + if not math.isfinite(dist): + return self.p_base + proximity = max(0.0, 1.0 - dist / self.radius) if self.radius > 0 else 0.0 + return self.p_base + (self.p_max - self.p_base) * proximity + + @property + def region(self) -> dict[str, Any]: + return {"center": dict(self.center), "radius": self.radius} + + def transform_evaluation(self, state, params, evaluation, space): + p = self.failure_probability(params, space) + if state.rng.random() >= p: + return evaluation + state.record_event( + self.kind, + severity=p, + duration=1, + region=self.region, + params=params, + details={"failure_probability": p}, + ) + observed = float(evaluation.optimizer_value) + self.observed_penalty + return dataclasses.replace( + evaluation, + observed_value=observed, + execution_success=False, + qc_passed=False, + failure_type=FAILURE_TYPE, + metadata={**evaluation.metadata, "failure_probability": p}, + ) + + +@dataclass(frozen=True) +class Censoring(PathologySpec): + """Partial observability: right-censoring in minimization space. + + Observed values worse than ``lod`` are reported *at* ``lod`` -- the + optimizer cannot distinguish anything beyond the detection limit. + """ + + lod: float = 1.0 + + kind = "censoring" + + def transform_evaluation(self, state, params, evaluation, space): + observed = float(evaluation.optimizer_value) + if observed <= self.lod: + return evaluation + if not any(e.event_type == self.kind for e in state.events): + state.record_event( + self.kind, + severity=observed - self.lod, + duration=None, + region=None, + params=params, + details={"lod": self.lod}, + ) + return dataclasses.replace( + evaluation, + observed_value=self.lod, + metadata={**evaluation.metadata, "censored": True}, + ) + + +@dataclass(frozen=True) +class ProxyGapShift(PathologySpec): + """Measurement-model adaptation: the observed mapping changes mid-run. + + Before ``shift_eval`` the proxy is faithful; afterwards + ``observed = scale * observed + bias`` (a calibration change). + """ + + shift_eval: int = 15 + bias: float = 1.0 + scale: float = 1.0 + + kind = "proxy_gap_shift" + + def transform_evaluation(self, state, params, evaluation, space): + if state.eval_count <= self.shift_eval: + return evaluation + if state.eval_count == self.shift_eval + 1: + state.record_event( + self.kind, + severity=abs(self.bias) + abs(self.scale - 1.0), + duration=None, + region=None, + params=params, + details={"bias": self.bias, "scale": self.scale}, + ) + observed = self.scale * float(evaluation.optimizer_value) + self.bias + return dataclasses.replace( + evaluation, + observed_value=observed, + metadata={**evaluation.metadata, "proxy_gap_shifted": True}, + ) + + +@dataclass(frozen=True) +class ObjectiveShift(PathologySpec): + """Campaign adaptation: the landscape translates mid-run. + + After ``shift_eval`` the objective is evaluated at ``x - delta * range``, + i.e. the argmin moves by ``+delta`` (normalized units) while the optimum + *value* is preserved, so every regret ruler stays valid. + """ + + shift_eval: int = 15 + delta: dict[str, float] = field(default_factory=dict) + + kind = "objective_shift" + + def transform_params(self, state, params, space): + if state.eval_count <= self.shift_eval: + return params + if state.eval_count == self.shift_eval + 1: + state.record_event( + self.kind, + severity=math.sqrt(sum(v * v for v in self.delta.values())), + duration=None, + region={"delta": dict(self.delta)}, + params=params, + details={"shift_eval": self.shift_eval}, + ) + shifted = dict(params) + for dim in space.dimensions: + frac = self.delta.get(dim.param_name) + if frac is None or dim.min_value is None or dim.max_value is None: + continue + value = shifted.get(dim.param_name) + if not isinstance(value, int | float): + continue + span = float(dim.max_value) - float(dim.min_value) + shifted[dim.param_name] = float(value) - float(frac) * span + return shifted + + +SPEC_TYPES: dict[str, type[PathologySpec]] = { + spec.kind: spec + for spec in (NoiseDrift, SpatialFailure, Censoring, ProxyGapShift, ObjectiveShift) +} +# Also accept class names in study configs ("NoiseDrift" == "noise_drift"). +SPEC_TYPES.update({cls.__name__: cls for cls in tuple(SPEC_TYPES.values())}) + + +def spec_from_dict(data: dict[str, Any]) -> PathologySpec: + """Build a spec from a study-config entry: {"type": ..., "params": {...}}.""" + type_name = str(data.get("type", "")) + cls = SPEC_TYPES.get(type_name) + if cls is None: + raise ValueError( + f"Unknown pathology type '{type_name}'. Known: {sorted(set(SPEC_TYPES))}" + ) + return cls(**dict(data.get("params", {}))) + + +def spec_to_dict(spec: PathologySpec) -> dict[str, Any]: + """Full-parameter serialization (matrix.json must never store bare names).""" + return {"type": spec.kind, "params": dataclasses.asdict(spec)} + + +# --------------------------------------------------------------------------- +# Wrapper +# --------------------------------------------------------------------------- + + +def bundle_id_for(specs: list[PathologySpec] | tuple[PathologySpec, ...]) -> str: + return "+".join(spec.kind for spec in specs) if specs else "clean" + + +def apply_pathology( + problem: OptProblem, + specs: list[PathologySpec] | tuple[PathologySpec, ...], + seed: int, + *, + bundle_id: str | None = None, +) -> tuple[OptProblem, PathologyState]: + """Wrap ``problem`` with pathology ``specs``; runner and backends unchanged. + + Returns a new ``OptProblem`` (id ``"@"``) plus the mutable + :class:`PathologyState` whose ``events`` ledger grounds all adaptation + metrics. The base problem is untouched. + """ + specs = tuple(specs) + state = PathologyState(seed) + label = bundle_id or bundle_id_for(specs) + base_evaluate = problem.evaluate + space = problem.space + + def evaluator(params: dict[str, Any]) -> ProblemEvaluation: + state.eval_count += 1 + effective = dict(params) + for spec in specs: + effective = spec.transform_params(state, effective, space) + evaluation = base_evaluate(effective) + if evaluation.observed_value is None: + evaluation = dataclasses.replace( + evaluation, observed_value=float(evaluation.raw_value) + ) + for spec in specs: + evaluation = spec.transform_evaluation(state, params, evaluation, space) + state.history.append( + {"params": dict(params), "raw_value": float(evaluation.raw_value)} + ) + return evaluation + + wrapped = dataclasses.replace( + problem, + id=f"{problem.id}@{label}", + evaluator=evaluator, + ) + return wrapped, state diff --git a/benchmarks/methods/problems.py b/benchmarks/methods/problems.py index b875c36..87dff2a 100644 --- a/benchmarks/methods/problems.py +++ b/benchmarks/methods/problems.py @@ -17,11 +17,11 @@ import math from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, field +from typing import Any from app.services.candidate_gen import ParameterSpace, SearchDimension - # --------------------------------------------------------------------------- # Types # --------------------------------------------------------------------------- @@ -50,6 +50,43 @@ class OptProblem: optimum: float # f(optimum_x), the global minimum optimum_x: dict | None # argmin (None if not in closed form) tags: ProblemTags + evaluator: Callable[[dict], ProblemEvaluation] | None = None + + def evaluate(self, params: dict) -> ProblemEvaluation: + """Return the benchmark evaluation seen by scoring and optimizers.""" + if self.evaluator is not None: + return self.evaluator(params) + value = float(self.objective(params)) + return ProblemEvaluation(raw_value=value, observed_value=value) + + +@dataclass(frozen=True) +class ProblemEvaluation: + """One evaluation with true score plus imperfect early-stage observation.""" + + raw_value: float + observed_value: float | None = None + execution_success: bool = True + qc_passed: bool = True + failure_type: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + objective_values: dict[str, float] = field(default_factory=dict) + + @property + def optimizer_value(self) -> float: + """Minimization value exposed to ordinary optimizers.""" + return self.raw_value if self.observed_value is None else self.observed_value + + def observation_objectives(self) -> dict[str, float]: + """JSON-safe numeric metrics attached to an optimizer observation.""" + values = { + "true_objective": -float(self.raw_value), + "observed_objective": -float(self.optimizer_value), + "execution_success": 1.0 if self.execution_success else 0.0, + "qc_passed": 1.0 if self.qc_passed else 0.0, + } + values.update({str(k): float(v) for k, v in self.objective_values.items()}) + return values # --------------------------------------------------------------------------- @@ -160,6 +197,175 @@ def _mixed_categorical(p: dict) -> float: return (x - 0.5) ** 2 + offset +def _early_stage_controllability(p: dict) -> float: + target = float(p["target_temp"]) + flow = float(p["flow_rate"]) + actual = _actual_temperature(target, flow) + control_error = abs(actual - target) + failure = control_error > 8.0 + return ((actual - 72.0) / 12.0) ** 2 + ((flow - 4.0) / 3.0) ** 2 + (4.0 if failure else 0.0) + + +def _early_stage_controllability_eval(p: dict) -> ProblemEvaluation: + target = float(p["target_temp"]) + flow = float(p["flow_rate"]) + actual = _actual_temperature(target, flow) + control_error = abs(actual - target) + failure = control_error > 8.0 + raw = _early_stage_controllability(p) + return ProblemEvaluation( + raw_value=raw, + observed_value=raw, + execution_success=not failure, + qc_passed=control_error <= 6.0, + failure_type="controllability" if failure else None, + metadata={ + "target_temp": target, + "actual_temp": actual, + "control_error": control_error, + }, + objective_values={ + "actual_temp": actual, + "control_error": control_error, + }, + ) + + +def _actual_temperature(target: float, flow: float) -> float: + undershoot = max(0.0, target - 78.0) * 0.75 + max(0.0, flow - 6.0) * 1.8 + return target - undershoot + + +def _hardware_zone(p: dict) -> float: + temp = float(p["temp"]) + pressure = float(p["pressure"]) + design = str(p["reactor_design"]) + failure = design == "thin_wall" and temp >= 78.0 and pressure >= 4.0 + design_offset = 0.0 if design == "thick_wall" else 0.25 + return ((temp - 70.0) / 12.0) ** 2 + ((pressure - 3.0) / 2.0) ** 2 + design_offset + (5.0 if failure else 0.0) + + +def _hardware_zone_eval(p: dict) -> ProblemEvaluation: + raw = _hardware_zone(p) + temp = float(p["temp"]) + pressure = float(p["pressure"]) + design = str(p["reactor_design"]) + failure = design == "thin_wall" and temp >= 78.0 and pressure >= 4.0 + return ProblemEvaluation( + raw_value=raw, + observed_value=raw, + execution_success=not failure, + qc_passed=not failure, + failure_type="hardware" if failure else None, + metadata={ + "reactor_design": design, + "temp": temp, + "pressure": pressure, + }, + ) + + +def _objective_uncertain_true(p: dict) -> float: + temp = float(p["temp"]) + dwell = float(p["dwell_h"]) + additive = str(p["additive"]) + additive_penalty = 0.0 if additive == "stabilizer" else 0.8 + return ((temp - 62.0) / 10.0) ** 2 + ((dwell - 5.0) / 3.0) ** 2 + additive_penalty + + +def _objective_uncertain_proxy(p: dict) -> float: + temp = float(p["temp"]) + dwell = float(p["dwell_h"]) + additive = str(p["additive"]) + additive_bonus = -0.35 if additive == "fast_yield" else 0.0 + return ((temp - 86.0) / 12.0) ** 2 + ((dwell - 1.2) / 2.5) ** 2 + additive_bonus + + +def _objective_uncertain_eval(p: dict) -> ProblemEvaluation: + true_value = _objective_uncertain_true(p) + proxy_value = _objective_uncertain_proxy(p) + return ProblemEvaluation( + raw_value=true_value, + observed_value=proxy_value, + execution_success=True, + qc_passed=True, + metadata={"proxy_objective": "fast_yield", "true_objective": "stability"}, + objective_values={ + "candidate_kpi_stability": -true_value, + "candidate_kpi_fast_yield": -proxy_value, + }, + ) + + +def _batch_effect_true(p: dict) -> float: + temp = float(p["temp"]) + hold = float(p["hold_h"]) + ligand = str(p["ligand"]) + protocol = str(p["screen_protocol"]) + ligand_penalty = 0.0 if ligand == "L2" else 0.7 + protocol_penalty = 0.0 if protocol == "replicate_qc" else 0.25 + return ((temp - 64.0) / 9.0) ** 2 + ((hold - 3.5) / 1.8) ** 2 + ligand_penalty + protocol_penalty + + +def _batch_effect_observed(p: dict) -> float: + temp = float(p["temp"]) + hold = float(p["hold_h"]) + ligand = str(p["ligand"]) + protocol = str(p["screen_protocol"]) + fast_bias = -1.1 if protocol == "fast_screen" else 0.0 + ligand_bias = -0.25 if ligand == "L1" else 0.0 + return ((temp - 82.0) / 12.0) ** 2 + ((hold - 1.2) / 1.4) ** 2 + fast_bias + ligand_bias + + +def _batch_effect_eval(p: dict) -> ProblemEvaluation: + true_value = _batch_effect_true(p) + observed_value = _batch_effect_observed(p) + protocol = str(p["screen_protocol"]) + return ProblemEvaluation( + raw_value=true_value, + observed_value=observed_value, + execution_success=True, + qc_passed=protocol == "replicate_qc", + failure_type=None if protocol == "replicate_qc" else "batch_effect", + metadata={ + "screen_protocol": protocol, + "observed_bias": observed_value - true_value, + }, + objective_values={ + "corrected_objective": -true_value, + "batch_biased_objective": -observed_value, + }, + ) + + +def _prior_warm_start(p: dict) -> float: + temp = float(p["temp"]) + residence = float(p["residence_min"]) + catalyst = str(p["catalyst"]) + solvent = str(p["solvent"]) + catalyst_penalty = {"cat_c": 0.0, "cat_b": 0.35, "cat_d": 0.8}.get(catalyst, 1.2) + solvent_penalty = 0.0 if solvent == "polar" else 0.45 + return ((temp - 68.0) / 8.0) ** 2 + ((residence - 7.0) / 3.0) ** 2 + catalyst_penalty + solvent_penalty + + +def _early_stage_report( + *, + mode: str, + risk_flags: list[str], + recommendations: list[dict[str, Any]], + **extra: Any, +) -> dict[str, Any]: + report = { + "contract_version": "early_stage_system_characterization.v1", + "recommended_campaign_mode": mode, + "confidence": 0.9, + "risk_flags": risk_flags, + "diagnostic_recommendations": recommendations, + } + report.update(extra) + return report + + # --------------------------------------------------------------------------- # Registry # --------------------------------------------------------------------------- @@ -337,6 +543,340 @@ def _build_registry() -> dict[str, OptProblem]: ) ) + controllability_space = ParameterSpace( + dimensions=( + SearchDimension( + param_name="target_temp", + param_type="number", + min_value=58.0, + max_value=96.0, + ), + SearchDimension( + param_name="flow_rate", + param_type="number", + min_value=1.0, + max_value=9.0, + ), + ), + protocol_template={ + "early_stage_report": _early_stage_report( + mode="controllability_mapping", + risk_flags=["poor_controllability", "target_reachability_low"], + recommendations=[ + {"action_type": "run_controllability_mapping", "priority": "high"}, + {"action_type": "validate_target_reachability", "priority": "high"}, + ], + target_feasibility_summary=[ + { + "parameter": "target_temp", + "infeasible_target_min": 84.0, + "infeasible_target_max": 96.0, + } + ], + insights=["Actual temperature undershoots high target settings."], + ) + }, + ) + problems.append( + OptProblem( + id="early_stage_controllability", + name="Early-stage target-vs-actual controllability", + space=controllability_space, + objective=_early_stage_controllability, + optimum=0.0, + optimum_x={"target_temp": 72.0, "flow_rate": 4.0}, + tags=ProblemTags( + n_dims=2, + multimodal=False, + noise_std=0.0, + separable=False, + has_categorical=False, + surface_class="early_stage_controllability", + ), + evaluator=_early_stage_controllability_eval, + ) + ) + + hardware_space = ParameterSpace( + dimensions=( + SearchDimension( + param_name="reactor_design", + param_type="categorical", + choices=("thin_wall", "thick_wall"), + ), + SearchDimension( + param_name="temp", + param_type="number", + min_value=55.0, + max_value=92.0, + ), + SearchDimension( + param_name="pressure", + param_type="number", + min_value=1.0, + max_value=6.0, + ), + ), + protocol_template={ + "early_stage_report": _early_stage_report( + mode="hardware_feasibility_discovery", + risk_flags=["hardware_failures_dominate", "hardware_design_changed"], + recommendations=[ + {"action_type": "map_hardware_feasibility", "priority": "high"}, + {"action_type": "shrink_or_annotate_action_space", "priority": "high"}, + ], + feasibility_summary={ + "danger_zones": [ + { + "bounds": { + "reactor_design": ["thin_wall"], + "temp": [78.0, 92.0], + "pressure": [4.0, 6.0], + }, + "reason": "thin_wall timeout and leak region", + } + ] + }, + hardware_summary={"worst_design_id": "thin_wall"}, + insights=["Thin-wall hardware fails under high temp and pressure."], + ) + }, + ) + problems.append( + OptProblem( + id="early_stage_hardware_zone", + name="Early-stage hardware infeasible zone", + space=hardware_space, + objective=_hardware_zone, + optimum=0.0, + optimum_x={"reactor_design": "thick_wall", "temp": 70.0, "pressure": 3.0}, + tags=ProblemTags( + n_dims=3, + multimodal=True, + noise_std=0.0, + separable=False, + has_categorical=True, + surface_class="early_stage_hardware", + ), + evaluator=_hardware_zone_eval, + ) + ) + + objective_space = ParameterSpace( + dimensions=( + SearchDimension( + param_name="additive", + param_type="categorical", + choices=("fast_yield", "stabilizer"), + ), + SearchDimension( + param_name="temp", + param_type="number", + min_value=45.0, + max_value=95.0, + ), + SearchDimension( + param_name="dwell_h", + param_type="number", + min_value=0.5, + max_value=8.0, + ), + ), + protocol_template={ + "early_stage_report": _early_stage_report( + mode="objective_discovery", + risk_flags=["objective_missing", "low_confidence_objective_candidates"], + recommendations=[ + {"action_type": "run_objective_discovery", "priority": "high"} + ], + candidate_kpis=[ + { + "name": "candidate_kpi_stability", + "direction": "maximize", + "confidence": 0.86, + }, + { + "name": "candidate_kpi_fast_yield", + "direction": "maximize", + "confidence": 0.42, + }, + ], + insights=["Fast yield is a misleading proxy for stability."], + ) + }, + ) + problems.append( + OptProblem( + id="early_stage_objective_uncertainty", + name="Early-stage objective uncertainty", + space=objective_space, + objective=_objective_uncertain_true, + optimum=0.0, + optimum_x={"additive": "stabilizer", "temp": 62.0, "dwell_h": 5.0}, + tags=ProblemTags( + n_dims=3, + multimodal=True, + noise_std=0.0, + separable=False, + has_categorical=True, + surface_class="early_stage_objective_uncertainty", + ), + evaluator=_objective_uncertain_eval, + ) + ) + + batch_effect_space = ParameterSpace( + dimensions=( + SearchDimension( + param_name="screen_protocol", + param_type="categorical", + choices=("fast_screen", "replicate_qc"), + ), + SearchDimension( + param_name="ligand", + param_type="categorical", + choices=("L1", "L2", "L3"), + ), + SearchDimension( + param_name="temp", + param_type="number", + min_value=45.0, + max_value=90.0, + ), + SearchDimension( + param_name="hold_h", + param_type="number", + min_value=0.5, + max_value=6.0, + ), + ), + protocol_template={ + "early_stage_report": _early_stage_report( + mode="data_quality_diagnostic", + risk_flags=["low_data_quality", "batch_effect_detected"], + recommendations=[ + {"action_type": "run_data_quality_diagnostic", "priority": "high"}, + {"action_type": "replicate_suspicious_hits", "priority": "high"}, + ], + data_quality_summary={ + "preferred_levels": { + "screen_protocol": "replicate_qc", + "ligand": "L2", + }, + "biased_protocol": "fast_screen", + }, + insights=["Fast-screen observations are batch-biased and overstate yield."], + ) + }, + ) + problems.append( + OptProblem( + id="early_stage_batch_effect", + name="Early-stage batch effect and data-quality drift", + space=batch_effect_space, + objective=_batch_effect_true, + optimum=0.0, + optimum_x={ + "screen_protocol": "replicate_qc", + "ligand": "L2", + "temp": 64.0, + "hold_h": 3.5, + }, + tags=ProblemTags( + n_dims=4, + multimodal=True, + noise_std=0.0, + separable=False, + has_categorical=True, + surface_class="early_stage_batch_effect", + ), + evaluator=_batch_effect_eval, + ) + ) + + prior_space = ParameterSpace( + dimensions=( + SearchDimension( + param_name="catalyst", + param_type="categorical", + choices=("cat_a", "cat_b", "cat_c", "cat_d"), + ), + SearchDimension( + param_name="solvent", + param_type="categorical", + choices=("polar", "apolar"), + ), + SearchDimension( + param_name="temp", + param_type="number", + min_value=35.0, + max_value=95.0, + ), + SearchDimension( + param_name="residence_min", + param_type="number", + min_value=1.0, + max_value=20.0, + ), + ), + protocol_template={ + "early_stage_report": _early_stage_report( + mode="optimization_ready", + risk_flags=["prior_case_similarity_high", "sparse_initial_data"], + recommendations=[ + {"action_type": "seed_from_prior_cases", "priority": "high"}, + {"action_type": "proceed_with_guarded_optimization", "priority": "medium"}, + ], + prior_successful_regions=[ + { + "params": { + "catalyst": "cat_c", + "solvent": "polar", + "temp": 68.0, + "residence_min": 7.0, + }, + "source": "similar-literature-case", + "confidence": 0.88, + }, + { + "params": { + "catalyst": "cat_b", + "solvent": "polar", + "temp": 66.0, + "residence_min": 8.5, + }, + "source": "neighboring-substrate-case", + "confidence": 0.72, + }, + ], + insights=["Similar prior cases suggest a narrow productive region."], + ) + }, + ) + problems.append( + OptProblem( + id="early_stage_prior_warm_start", + name="Early-stage prior-case warm start", + space=prior_space, + objective=_prior_warm_start, + optimum=0.0, + optimum_x={ + "catalyst": "cat_c", + "solvent": "polar", + "temp": 68.0, + "residence_min": 7.0, + }, + tags=ProblemTags( + n_dims=4, + multimodal=True, + noise_std=0.0, + separable=False, + has_categorical=True, + surface_class="early_stage_prior_warm_start", + ), + ) + ) + return {p.id: p for p in problems} diff --git a/benchmarks/methods/recommend.py b/benchmarks/methods/recommend.py index 27a9919..c782067 100644 --- a/benchmarks/methods/recommend.py +++ b/benchmarks/methods/recommend.py @@ -122,7 +122,7 @@ def recommend( for key, bucket_scores in sorted(by_bucket.items()): agg = _aggregate_by_backend(bucket_scores) - def _finite(metric: str) -> list[float]: + def _finite(metric: str, agg=agg) -> list[float]: return [m[metric] for m in agg.values() if m[metric] != float("inf")] # Per-bucket normalization ranges (ignore inf for the range). diff --git a/benchmarks/methods/report.py b/benchmarks/methods/report.py index 7e5f697..7119325 100644 --- a/benchmarks/methods/report.py +++ b/benchmarks/methods/report.py @@ -7,6 +7,8 @@ import csv import io +from collections import defaultdict +from typing import Any from benchmarks.methods.recommend import Recommendation from benchmarks.methods.scoreboard import MethodScore @@ -30,19 +32,8 @@ def scoreboard_to_markdown(scores: list[MethodScore]) -> str: rows = [header, sep] for s in scores: rows.append( - "| {pid} | {be} | {ns} | {reg} | {auc} | {ett} | {hit} | " - "{rob} | {cost} | {err} |".format( - pid=s.problem_id, - be=s.backend, - ns=s.n_seeds, - reg=_fmt(s.mean_regret), - auc=_fmt(s.mean_auc), - ett=_fmt(s.mean_evals_to_target, 1), - hit=_fmt(s.target_hit_rate, 2), - rob=_fmt(s.regret_robustness), - cost=_fmt(s.mean_cost_s, 5), - err=s.errors, - ) + f"| {s.problem_id} | {s.backend} | {s.n_seeds} | {_fmt(s.mean_regret)} | {_fmt(s.mean_auc)} | {_fmt(s.mean_evals_to_target, 1)} | {_fmt(s.target_hit_rate, 2)} | " + f"{_fmt(s.regret_robustness)} | {_fmt(s.mean_cost_s, 5)} | {s.errors} |" ) return "\n".join(rows) @@ -83,6 +74,95 @@ def scoreboard_to_csv(scores: list[MethodScore]) -> str: return buf.getvalue() +def benchmark_family(score: MethodScore) -> str: + """Classify a benchmark problem into the family that should drive conclusions.""" + tags = score.tags + if tags.surface_class.startswith("early_stage"): + return "early_stage_imperfect_data" + if tags.has_categorical: + return "mixed_categorical" + if tags.n_dims <= 3 and tags.noise_std <= 0.0: + return "clean_low_dim_bo" + if tags.n_dims > 3 and tags.noise_std <= 0.0: + return "clean_high_dim" + return "analytic_other" + + +def family_summary(scores: list[MethodScore]) -> list[dict[str, Any]]: + """Average method performance within each benchmark family.""" + grouped: dict[tuple[str, str], list[MethodScore]] = defaultdict(list) + for score in scores: + grouped[(benchmark_family(score), score.backend)].append(score) + + rows: list[dict[str, Any]] = [] + for (family, backend), items in sorted(grouped.items()): + n = len(items) + rows.append( + { + "family": family, + "backend": backend, + "n_problems": n, + "mean_regret": sum(s.mean_regret for s in items) / n, + "mean_auc": sum(s.mean_auc for s in items) / n, + "target_hit_rate": sum(s.target_hit_rate for s in items) / n, + "mean_cost_s": sum(s.mean_cost_s for s in items) / n, + "errors": sum(s.errors for s in items), + } + ) + rows.sort(key=lambda row: (str(row["family"]), float(row["mean_regret"]))) + return rows + + +def family_summary_to_markdown(scores: list[MethodScore]) -> str: + """Render family-level method performance as a markdown table.""" + header = ( + "| family | method | problems | mean_regret | mean_auc | " + "hit_rate | cost_s | errors |" + ) + sep = "|" + "|".join(["---"] * 8) + "|" + rows = [header, sep] + for row in family_summary(scores): + rows.append( + f"| {row['family']} | {row['backend']} | {row['n_problems']} | " + f"{_fmt(float(row['mean_regret']))} | {_fmt(float(row['mean_auc']))} | " + f"{_fmt(float(row['target_hit_rate']), 2)} | " + f"{_fmt(float(row['mean_cost_s']), 5)} | {row['errors']} |" + ) + return "\n".join(rows) + + +def family_summary_to_csv(scores: list[MethodScore]) -> str: + """Render family-level method performance as CSV.""" + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow( + [ + "family", + "backend", + "n_problems", + "mean_regret", + "mean_auc", + "target_hit_rate", + "mean_cost_s", + "errors", + ] + ) + for row in family_summary(scores): + writer.writerow( + [ + row["family"], + row["backend"], + row["n_problems"], + row["mean_regret"], + row["mean_auc"], + row["target_hit_rate"], + row["mean_cost_s"], + row["errors"], + ] + ) + return buf.getvalue() + + def recommendations_to_markdown(recs: list[Recommendation]) -> str: """Render the decision table (tag bucket -> ranked methods) as markdown.""" lines = ["## Method recommendations by problem structure", ""] diff --git a/benchmarks/methods/runner.py b/benchmarks/methods/runner.py index 0fa71b2..dd1f7f3 100644 --- a/benchmarks/methods/runner.py +++ b/benchmarks/methods/runner.py @@ -14,14 +14,12 @@ from __future__ import annotations import logging -import random import time from dataclasses import dataclass, field from typing import Any from app.services.candidate_gen import ParameterSpace, sample_random from app.services.optimization_backends import Observation, get_backend - from benchmarks.methods.problems import OptProblem logger = logging.getLogger(__name__) @@ -43,13 +41,15 @@ class RunTrace: seed: int best_so_far: list[float] = field(default_factory=list) evals: list[int] = field(default_factory=list) + backend_history: list[str] = field(default_factory=list) + evaluation_history: list[dict[str, Any]] = field(default_factory=list) wall_s: float = 0.0 error: str | None = None -def _evaluate(problem: OptProblem, params: dict[str, Any]) -> float: +def _evaluate(problem: OptProblem, params: dict[str, Any]): """Evaluate the raw (minimization) objective at ``params``.""" - return float(problem.objective(params)) + return problem.evaluate(params) def run_cell( @@ -66,23 +66,46 @@ def run_cell( best_so_far: list[float] = [] evals: list[int] = [] observations: list[Observation] = [] + backend_history: list[str] = [] + evaluation_history: list[dict[str, Any]] = [] best: float | None = None t0 = time.perf_counter() def _record(params: dict[str, Any]) -> None: nonlocal best - f = _evaluate(problem, params) + evaluation = _evaluate(problem, params) + f = float(evaluation.raw_value) best = f if best is None else min(best, f) best_so_far.append(best) evals.append(len(best_so_far)) - # Backends maximize -> store the negated value. - observations.append(Observation(params=dict(params), objective=-f)) + # Backends maximize. Early-stage problems may expose an imperfect + # observed/proxy value while true regret still uses raw_value. + observations.append( + Observation( + params=dict(params), + objective=-float(evaluation.optimizer_value), + objectives=evaluation.observation_objectives(), + ) + ) + evaluation_history.append( + { + "params": dict(params), + "raw_value": f, + "observed_value": float(evaluation.optimizer_value), + "execution_success": evaluation.execution_success, + "qc_passed": evaluation.qc_passed, + "failure_type": evaluation.failure_type, + "metadata": dict(evaluation.metadata), + } + ) try: # 1. Initial random design. n_first = min(n_init, budget) for params in sample_random(space, n_first, seed=seed): _record(params) + if n_first: + backend_history.append("initial_random") # 2. Optimizer loop. backend = get_backend(backend_name) @@ -93,6 +116,9 @@ def _record(params: dict[str, Any]) -> None: candidates = backend.suggest( space, take, observations, seed=round_seed ) + backend_history.append( + str(getattr(backend, "last_selected_backend", backend_name)) + ) if not candidates: logger.warning( "backend %s returned no candidates; stopping cell early", @@ -114,6 +140,8 @@ def _record(params: dict[str, Any]) -> None: seed=seed, best_so_far=best_so_far, evals=evals, + backend_history=backend_history, + evaluation_history=evaluation_history, wall_s=time.perf_counter() - t0, error=f"{type(exc).__name__}: {exc}", ) @@ -124,6 +152,8 @@ def _record(params: dict[str, Any]) -> None: seed=seed, best_so_far=best_so_far, evals=evals, + backend_history=backend_history, + evaluation_history=evaluation_history, wall_s=time.perf_counter() - t0, ) diff --git a/docs/development_progress.md b/docs/development_progress.md index 77a03c8..e2c05dc 100644 --- a/docs/development_progress.md +++ b/docs/development_progress.md @@ -23,17 +23,37 @@ Legend: **not started** · **partial** (some infra exists, not wired/proven). and future scale/fidelity decisions. The shipped core includes the (`CampaignIntent` + `OptimizationMode`) taxonomy, phase posterior, evidence-based scoring, safety gates, Nexus optimization-intelligence - evidence, backend recommendations, and replay/validation accounting. See - README -> Architecture. + evidence, backend recommendations, replay/validation accounting, and a + default-off live authority gate (`CAMPAIGN_DECISION_AUTHORITY_ENABLED`) that + can defer candidate generation for validation/recovery/context/objective/ + constraint actions while persisting the requested state update. See README -> + Architecture. - **Nexus/local candidate arbitration** — provider facade, Nexus backend adapters, multi-source candidate-pool builder, hard-gated decision policy, scored arbitration portfolio, provenance logging, and the `ENABLE_CANDIDATE_ARBITRATION` loop seam. Nexus remains advisory/backend input; HELIOS retains campaign decision authority. +- **Experimental-node active learning** — Nexus advisory route-evidence client, + HELIOS-owned route scoring and capability/safety/budget/approval gates, + default-off live authority, per-node parameter/protocol execution mapping, + route-labelled observations, campaign-context checkpoints, replayable + Scientific Decision Ledger metadata, and a live cross-repository contract + test against Nexus `/api/experimental-routes/analyze`. - **Context / memory / logging** — campaign context, objective stack + proxy gap, typed failure taxonomy (`failure_signatures`), backend performance memory + `ContextualStrategyBandit`, candidate-pool memory (recall), cross-campaign failure-zone memory, decision trace / evidence / outcome / reward / replay. +- **Scientific Decision Ledger** — live Pending -> Outcome -> Reward Decision + Cards; deterministic/redacted Markdown projections for objective, + observations, strategy, evidence, failure, recovery, and summary; policy and + Nexus version snapshots; exact-text scientific-memory retrieval; typed RLVR + JSONL export; and optional per-campaign local Git history that never pushes. + See [scientific_decision_ledger.md](scientific_decision_ledger.md). +- **Scientific evidence loop (shadow)** — typed falsifiable claims, independent + evidence blocks, posterior-odds updates from auditable likelihood ratios, + preregistration/design-quality requirements, explicit promotion gates, robust + information-gain experiment ranking, ObjectiveState binding, and reviewable + ledger artifacts. See [scientific_evidence_loop.md](scientific_evidence_loop.md). - **Loop / goal harness primitives (pure service layer)** — `loop_engineering` records observe-decide-act-evaluate iterations, reward, and replay summaries; `goal_harness` adds persistent goal state, normalized @@ -41,8 +61,11 @@ Legend: **not started** · **partial** (some infra exists, not wired/proven). human blockers, and bad-path kill records. These layers are side-effect-free: they do not call PUDA, write DB state, execute tools, or promote policies. -Everything shipped is read-only / fail-open / shadow or approval-gated and does -not change live candidate selection by default. +By default, shipped campaign-decision features are read-only / fail-open / +shadow or approval-gated and do not change live candidate selection. The +explicit live authority flag promotes selected campaign decisions into bounded +pre-candidate routing, but still does not execute hardware or auto-apply +objective/space changes. --- @@ -50,13 +73,13 @@ not change live candidate selection by default. | # | Item | Origin | Status | Notes | |---|------|--------|--------|-------| -| B1 | **HypothesisState** — active/supported/contradicted hypotheses, discriminating experiments | v3 §3 | not started | VoI `expected_hypothesis_resolution` stays 0 until this exists | +| B1 | **HypothesisState** — active/supported/contradicted hypotheses, discriminating experiments | v3 §3 | partial | Typed claims/evidence and robust discrimination planning shipped; next-round campaign context and VoI threading remain | | B2 | **Instrument / runtime belief state + PUDA telemetry** — calibration confidence, drift, telemetry anomalies | v3 §6 | not started | Would let a bad reading be attributed to the instrument, not the sample | | B3 | **OperationalAbstractionLearner** (Phase 6) — promote repeated successful action sequences to reusable ops (proposal-only) | v3 §8 | not started | Explicitly deferred until several real shadow logs are reviewed | | B4 | **Campaign-level memory beyond candidate/failure** — objective patterns, strategy-success-by-phase, hypothesis-resolution patterns, useful context queries, per-instrument reliability | v3 §9 | not started | Higher tier than failure-zone memory | | B5 | **StrategyClass scientific-action dimension** on the selector (PARAMETER_OPTIMIZATION / HYPOTHESIS_DISCRIMINATION / CALIBRATION / …) | v3 §10 | partial | `OptimizationMode`/`CampaignIntent` exist but not this explicit class | -| B6 | **Objective staging / fidelity escalation + ObjectiveManager** — proxy → mechanism → functional → deployment ladder, staged scoring, objective versioning wired into selection | data_layer §4; enh L7 | partial | `ObjectiveStack`/`ObjectiveState`/proxy_gap exist; staging/escalation and `objective_transitions` consumption not wired | -| B7 | **Parameter-space / synthesis-route revision as first-class** — `SpaceRevision`, `ParameterSpacePolicy`, route switching | enh L14; v3-adjacent | partial | `revise_space` intent + `space_revision` records exist but not consumed | +| B6 | **Objective staging / fidelity escalation + ObjectiveManager** — proxy → mechanism → functional → deployment ladder, staged scoring, objective versioning wired into selection | data_layer §4; enh L7 | partial | `ObjectiveStack`/`ObjectiveState`/proxy_gap exist; authority gate can persist objective-transition requests, but staged ObjectiveManager execution is not wired | +| B7 | **Parameter-space / synthesis-route revision as first-class** — `SpaceRevision`, `ParameterSpacePolicy`, route switching | enh L14; v3-adjacent | partial | `revise_space` intent + `space_revision` records exist and authority gate can persist constraint/space requests; no route-switch/space-policy executor yet | --- @@ -139,7 +162,7 @@ guardrailed and incomplete by design. | G2 | **Unified perception layer** — normalize PUDA responses, API feedback, logs, artifacts, images, QC signals, and human notes into one observation stream | partial | `ObservationEnvelope` exists; no live adapters yet for PUDA telemetry, vision, SSE logs, or analyzer outputs | | G3 | **Agent-facing tool registry** — typed tools with capability, schema, risk, timeout, permissions, rollback, and output observation contract | partial | `ToolDescriptor` exists in the harness; it is not connected to primitives registry, PUDA backend, MCP tools, or hardware adapters | | G4 | **Action executor bridge** — route proposed `ToolAction` through approval, safety, PUDA/run creation, execution, and returned observations | not started | Must stay separate from the pure harness; live hardware requires human/governance gates | -| G5 | **Campaign-level multi-step correction** — kill bad optimization paths, revise strategy, request calibration, switch backend, or narrow/expand parameter space | partial | Harness can record bad-path kills; no campaign-loop consumer yet excludes killed paths from future candidate/proposal generation | +| G5 | **Campaign-level multi-step correction** — kill bad optimization paths, revise strategy, request calibration, switch backend, or narrow/expand parameter space | partial | Authority gate can defer a round and persist validation/recovery/context/objective/constraint requests; no campaign-loop consumer yet excludes killed paths from future candidate/proposal generation | | G6 | **Agent notebook / reflection memory** — structured notes for failed paths, disproven hypotheses, unreliable tools, human overrides, and future constraints | partial | `ReflectionNote` exists; notes are not persisted into semantic/procedural memory or forced into next-round context | | G7 | **Live self-optimization promotion** — learned policy moves from replay/shadow/canary into bounded live influence after evidence thresholds | not started | Existing learned-policy path remains conservative; no automatic live promotion without explicit approval workflow | | G8 | **Long-running daemon / scheduler integration** — wake on external events, resume after restart, wait for PUDA or human, and continue the goal | not started | Existing durable run/campaign pieces exist, but no single autonomous goal daemon owns the full lifecycle | diff --git a/docs/math/objective_evolution_and_proxy_gap_math.md b/docs/math/objective_evolution_and_proxy_gap_math.md index 1842805..9b62f1b 100644 --- a/docs/math/objective_evolution_and_proxy_gap_math.md +++ b/docs/math/objective_evolution_and_proxy_gap_math.md @@ -446,6 +446,25 @@ The main missing pieces are: - Consumption of `objective_transitions_json` by the next-round context builder. - An approval path that can apply an objective transition after validation. +### Evidence-backed confidence path + +The additive confidence update above remains the backward-compatible +`heuristic_outcome_delta` routing path. HELIOS now also supports a distinct +`scientific_evidence_posterior` path in `scientific_evidence.py` and +`objective_state.py`: + +```text +logit confidence_{t+1} + = logit prior_confidence + sum_i log Bayes factor_i. +``` + +Only independent evidence blocks with an auditable analysis method enter this +sum. Descriptive evidence is recorded without changing confidence, duplicate +independence keys are rejected, and operational execution success cannot alter +an objective once it is bound to the evidence-posterior path. Promotion remains +shadow-only and requires predeclared evidence/design gates plus explicit human +approval; it never applies an objective transition automatically. + ## 13. Recommended implementation path The next implementation should keep the current safety boundary: diff --git a/docs/scientific_decision_ledger.md b/docs/scientific_decision_ledger.md new file mode 100644 index 0000000..10b018c --- /dev/null +++ b/docs/scientific_decision_ledger.md @@ -0,0 +1,180 @@ +# Scientific Decision Ledger + +## Purpose + +The Scientific Decision Ledger turns HELIOS campaign reasoning into durable, +human-readable scientific artifacts. It is more than a log: every Decision +Card connects the question, evidence, alternatives, chosen action, expected +gain, observed outcome, deterministic reward, failure attribution, recovery, +and the versions of code, policy, rubric, and Nexus contract that produced it. + +The design supports five uses from one trace: + +1. Git and pull-request review of scientific work. +2. Exact-text Scientific Memory retrieval without an embedding dependency. +3. Reproducibility and audit of strategy changes. +4. Paper-ready decision trajectories and policy/Nexus evolution. +5. Typed decision-trajectory export for supervised learning, RLVR, replay, and + later Runtime Agent evaluation. + +## Authority and Data Model + +The ledger is a projection, not the campaign transaction engine: + +| Layer | Authority | Failure behavior | +|---|---|---| +| Typed decision DTOs and SQLite `decision_trajectories` | Runtime and machine-readable source of truth | Persistence failure is reported by the accounting call | +| Markdown artifacts | Human-readable, deterministic scientific projection | Orchestrator hooks are fail-open and do not change routing | +| Campaign-local Git | Optional version history for Markdown only | Disabled by default; no repository means no commit unless auto-init is enabled | + +Markdown is never parsed to calculate reward or construct RLVR rows. The +machine export is rebuilt from the typed trajectory table, so a manual Markdown +edit cannot silently become training truth. + +## Live Lifecycle + +```mermaid +flowchart LR + context["Round context and evidence"] --> trace["DecisionTrace"] + trace --> pending["Decision Card: Pending"] + trace --> execution["Candidate generation and execution"] + execution --> analysis["Observations, failures, recovery, stop analysis"] + analysis --> outcome["Typed Outcome"] + outcome --> verifier["Deterministic verifiers and Reward"] + verifier --> sqlite["decision_trajectories"] + verifier --> complete["Decision Card: Completed"] + complete --> memory["Search and PR review"] + sqlite --> rlvr["Deterministic RLVR JSONL"] + complete --> git["Optional campaign-local Git"] +``` + +The orchestrator captures a decision trace whenever either the legacy +contextual shadow log or the Scientific Ledger is enabled. This means ledger +capture does not depend on enabling verbose shadow logging. Terminal authority +decisions, deferred validation/recovery/context decisions, design failures, and +ordinary completed rounds all close their Decision Card. + +## Artifact Layout + +Each campaign uses a traversal-safe, collision-resistant directory component: + +```text +/campaigns// + campaign.md + index.md + summary.md + trajectory.md + policy.md + policy_versions/.md + nexus.md + training_dataset.md + rounds// + objective.md + observations.md + decision_.md + strategy.md + evidence.md + failure.md + recovery.md + summary.md +``` + +Every artifact is Markdown with YAML front matter. A Decision Card uses schema +`helios.decision-card/v1`; the renderer version and a SHA-256 of the redacted +source bundle are recorded for deterministic comparison. Campaign summaries +and the Mermaid trajectory are regenerated after every lifecycle transition. + +`policy.md` shows the current policy state. The first observed projection of +each policy version is retained under `policy_versions/`, making policy +evolution reviewable as ordinary Git history. `nexus.md` independently records +Nexus diagnostics and its contract/schema version, preserving the boundary: +Nexus supplies diagnosis and characterization evidence; HELIOS owns the next +campaign action. + +## Decision Card Contract + +A complete card contains: + +- `Question`: the campaign-level decision being answered. +- `Context`: objective, constraints, failures, safety, memory, Nexus, + validation, human observation, and literature inputs supplied to the policy. +- `Evidence`: structured evidence source, type, summary, and weight. +- `Candidate Actions`: ranked alternatives with improvement, information gain, + risk, utility, and reason when the policy provides them. +- `Chosen`: action and backend selected by HELIOS. +- `Decision Rationale`: the policy explanation and fallback. +- `Confidence and Expected Gain`: decision confidence and selected utility + components. +- `Outcome`: observed execution, candidate count, objective/proxy-gap delta, + validation, recovery, context, and human-override state. +- `Reward and Verification`: total/process/outcome reward, regret, rubric + version, and the complete verifier table. +- `Failure and Recovery`: linked counts plus dedicated detailed artifacts. +- `Reproducibility`: code commit/dirty state, policy ID/version, Nexus contract, + reward rubric, and renderer version. + +## Scientific Memory API + +The endpoints are read-only: + +```http +GET /api/v1/memory/scientific/search?q=pipette%20offset&campaign_id=campaign-32 +GET /api/v1/memory/scientific/campaign-32/artifact?path=rounds/003/failure.md +GET /api/v1/memory/scientific/campaign-32/rlvr +``` + +Search is deterministic, case-insensitive exact-text matching across Markdown. +Results include the campaign, campaign-local artifact path, title, line number, +and snippet. This makes a phrase such as `pipette offset` immediately +retrievable without a vector database. The artifact endpoint accepts only a +campaign-local `.md` path and rejects absolute paths, `.git`, and traversal. + +RLVR rows use schema `helios.rlvr/v1` and include context, candidates, chosen +action/backend, rationale, confidence, outcome, reward, verifier report, +rubric/trajectory versions, and creation time. Output ordering and JSON key +ordering are deterministic. + +## Git Semantics + +Set `SCIENTIFIC_LEDGER_GIT_ENABLED=true` to record local history. With +`SCIENTIFIC_LEDGER_GIT_AUTO_INIT=true`, HELIOS initializes one repository inside +each campaign directory. The implementation: + +- never initializes or commits to the HELIOS source repository; +- never switches branches and therefore avoids cross-campaign branch races; +- stages only the exact changed `.md` paths for the lifecycle transition; +- rejects paths outside the campaign and non-Markdown paths; +- uses a sanitized local author and single-line commit message; +- never configures a remote and never pushes. + +The Pending and Completed transitions normally become separate commits, so Git +diff directly shows how evidence, confidence, outcome, reward, or recovery +changed. + +## Safety and Consistency + +- Sensitive keys and recognizable bearer tokens, OpenAI-style keys, and JWTs + are recursively redacted before hashing or rendering. +- Campaign and artifact paths are normalized, collision-resistant, and checked + after filesystem resolution to prevent traversal and symlink escape. +- Writes use a per-campaign thread/process lock, a same-directory temporary + file, `fsync`, and atomic replacement. +- Repeating an identical write is idempotent and produces no new Git commit. +- Ledger and Git hooks are best-effort in the orchestrator so reporting cannot + redirect or stop a scientific campaign. +- Git is local-only. Publication or remote synchronization remains an explicit + operator action. + +## Configuration + +| Variable | Default | Meaning | +|---|---|---| +| `SCIENTIFIC_LEDGER_ENABLED` | `true` | Capture live typed accounting and Markdown artifacts | +| `SCIENTIFIC_LEDGER_ROOT` | `/scientific_ledger` | Artifact root | +| `SCIENTIFIC_LEDGER_GIT_ENABLED` | `false` | Enable campaign-local commits | +| `SCIENTIFIC_LEDGER_GIT_AUTO_INIT` | `true` | Initialize a missing campaign repository when Git is enabled | +| `SCIENTIFIC_LEDGER_GIT_AUTHOR_NAME` | `HELIOS Scientific Ledger` | Commit author name | +| `SCIENTIFIC_LEDGER_GIT_AUTHOR_EMAIL` | `helios-ledger@localhost` | Commit author email | + +When enabled, the ledger root is included in startup writable-directory +validation. It is ignored by the HELIOS source repository. diff --git a/docs/scientific_evidence_loop.md b/docs/scientific_evidence_loop.md new file mode 100644 index 0000000..d814837 --- /dev/null +++ b/docs/scientific_evidence_loop.md @@ -0,0 +1,162 @@ +# Scientific Evidence Loop + +## Purpose + +HELIOS separates three quantities that are easy to conflate: + +1. operational success — an experiment executed without hardware or workflow failure; +2. optimization progress — a configured KPI improved; +3. scientific evidence — an observation discriminated a falsifiable claim from a declared alternative. + +Only the third quantity may update a scientific claim posterior. The implementation is pure, deterministic, shadow-only, and incapable of mutating a live objective, constraint, search space, or hardware route. + +## Core modules + +| Module | Responsibility | +|---|---| +| `app/services/scientific_evidence.py` | Typed claims, evidence, posterior-odds updates, evidence requirements, and promotion gates | +| `app/services/hypothesis_experiment_planner.py` | Prior-sensitive expected-information-gain scoring of experiments that distinguish competing hypotheses | +| `app/services/objective_state.py` | Binds an evidence posterior to objective state without allowing later operational outcomes to corrupt it | +| `app/services/scientific_ledger.py` | Writes reviewable claim assessments and experiment plans under the campaign evidence tree | + +## Claim posterior + +For claim `H`, prior probability `p(H)`, and independent evidence blocks `D_i`, HELIOS accepts an externally audited log Bayes factor for each block: + +```text +log_BF_i = log p(D_i | H) - log p(D_i | not H) +``` + +The update is: + +```text +logit p(H | D) = logit p(H) + sum_i log_BF_i +``` + +HELIOS does not infer a Bayes factor from generic success, objective delta, an LLM narrative, or an arbitrary evidence grade. An evidence item without a log Bayes factor is stored as descriptive evidence and does not move the posterior. + +Every item has an `independence_key`. Duplicate keys are rejected so repeated summaries of the same plate, batch, dataset, or analysis cannot be counted as independent support. Raw dependent observations should first be combined by the declared statistical model and submitted as one evidence item. + +The claim also records a prior version and rationale. Once an `ObjectiveState` is bound to a claim posterior, changing that prior is rejected; a scientifically justified revision must create a new versioned claim rather than silently rewriting prior odds after observing results. + +## Evidence status + +A posterior threshold alone is insufficient. `EvidenceAssessmentPolicy` can require: + +- a minimum number of scored evidence blocks; +- prospective evidence; +- distinct experimental blocks; +- at least one interventional or independent-replication result; +- no triggered predeclared falsifier; +- no unresolved block on the claim, such as unvalidated placeholder metadata. + +The resulting status is `proposed`, `inconclusive`, `supported`, `refuted`, or `blocked`. + +## Promotion gate + +`PromotionCriteria` is stricter than claim assessment. It can require additional preregistration, independent blocks, interventional evidence, safety history, predictive calibration checks, and human approval. + +```text +evidence_criteria_satisfied = true +human_approved = true +promotion_allowed = true +auto_applied = false +``` + +`auto_applied` is structurally forbidden. Promotion means that an authorized operator may review an objective or policy change; it is not the change itself. + +## Hypothesis-discrimination planning + +Each candidate experiment declares a categorical outcome likelihood under every competing hypothesis. For prior scenario `s`, the expected information gain is: + +```text +EIG_s(experiment) = H(H | prior_s) - E_y[H(H | y, experiment, prior_s)] +``` + +Because EIG can be sensitive to a subjective prior, HELIOS computes it under multiple predeclared prior scenarios and uses: + +```text +robust_EIG = min_s EIG_s +ranking_utility = robust_EIG / experiment_cost +``` + +Experiments without source-backed safety approval remain visible but are excluded from the actionable ranking. Every plan requires operator approval and remains shadow-only. + +## Minimal example + +```python +import math + +from app.services.scientific_evidence import ( + EvidenceAssessmentPolicy, + EvidenceDesign, + EvidenceItem, + EvidenceSet, + ScientificClaim, + assess_claim_evidence, +) + +claim = ScientificClaim( + claim_id="scalarization-cliff", + statement="Hard scalarization destroys useful feasibility signal.", + scope="multi-drug solubilization campaign", + prior_probability=0.5, + prior_rationale="Balanced prior registered before prospective validation.", + falsifying_observations=[ + "No held-out calibration gain from an explicitly constrained model." + ], +) + +evidence = EvidenceSet( + claim_id=claim.claim_id, + items=[ + EvidenceItem( + evidence_id="plate-a", + claim_id=claim.claim_id, + independence_key="plate-a", + design=EvidenceDesign.PROSPECTIVE_INTERVENTIONAL, + source="preregistered held-out comparison", + log_bayes_factor=math.log(9), + analysis_method="predictive likelihood ratio", + dataset_hash="sha256:...", + registered_before_observation=True, + block_ids=["plate-a"], + ) + ], +) + +assessment = assess_claim_evidence( + claim, + evidence, + policy=EvidenceAssessmentPolicy( + min_prospective_evidence=1, + min_independent_blocks=1, + require_interventional_evidence=True, + ), +) +``` + +## Objective-state boundary + +`apply_evidence_to_objective_state()` replaces `objective_confidence` with the audited posterior and records `objective_confidence_method=scientific_evidence_posterior`. Once bound, ordinary execution success, failure counts, or objective deltas continue to update operational state but cannot numerically alter the scientific posterior. + +The legacy `heuristic_outcome_delta` path remains available for backward compatibility. It is a routing heuristic, not a probability statement. + +## Ledger artifacts + +```text +data/scientific_ledger/campaigns//evidence/ + index.md + claims/.md + plans/.md +``` + +Claim artifacts record falsifiers, every evidence block, log Bayes factors, design quality, warnings, unmet requirements, and promotion state. Plan artifacts record robust and mean information gain, cost-normalized ranking, and safety exclusions. + +## What this still does not prove + +- A supplied likelihood model may be scientifically wrong; its method and dataset hash therefore remain part of the evidence record. +- Posterior probability is conditional on declared hypotheses, priors, likelihoods, and independence assumptions. +- An experiment plan is only as meaningful as its hypothesis-specific outcome predictions. +- Retrospective evidence cannot substitute for prospective independent validation. +- No LLM output is scientific evidence by itself. diff --git a/tests/fixtures/scientific_evidence.py b/tests/fixtures/scientific_evidence.py new file mode 100644 index 0000000..51c8463 --- /dev/null +++ b/tests/fixtures/scientific_evidence.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + + +def scientific_evidence_bundle_payload( + *, + claims: list[dict[str, Any]] | None = None, + conflicts: list[dict[str, Any]] | None = None, + expires_at: str | None = "2030-01-01T00:00:00Z", +) -> dict[str, Any]: + default_claims = [ + { + "claim_id": "claim-1", + "claim_type": "process_outcome", + "statement": "Lower flow rate improves film uniformity in the studied range.", + "namespace": "published_evidence", + "polarity": "positive", + "centrality": "core_contribution", + "confidence": 0.8, + "applicability": { + "status": "applicable", + "material_families": ["perovskite"], + "methods": ["spin_coating"], + "conditions": {"temperature_c": 25}, + }, + "source_refs": [ + { + "ref_id": "source-1", + "source_type": "paper", + "source_id": "doi:10.1000/example", + "paper_id": "doi:10.1000/example", + "chunk_ids": ["chunk-10", "chunk-11"], + "title": "Example source", + "doi": "10.1000/example", + "pages": ["4-5"], + } + ], + "tags": ["flow_rate", "uniformity"], + } + ] + effective_claims = default_claims if claims is None else claims + evidence_paths = ( + [ + { + "path_id": "path-1", + "claim_id": "claim-1", + "relation_types": ["supports"], + "source_ref_ids": ["source-1"], + "summary": "The attributed source chunks directly support the claim.", + "confidence": 0.78, + } + ] + if effective_claims + and effective_claims[0].get("claim_id") == "claim-1" + and effective_claims[0].get("source_refs") + else [] + ) + return { + "contract_version": "scientific_evidence_bundle.v1", + "authority": "advisory_only", + "bundle_id": "pas-bundle-1", + "query_id": "pas-query-1", + "claims": effective_claims, + "evidence_paths": evidence_paths, + "conflicts": conflicts or [], + "corpus_version": "pas-corpus-2026-07-28", + "ontology_version": "pas-aet-v1", + "created_at": datetime(2026, 7, 28, tzinfo=UTC).isoformat(), + "expires_at": expires_at, + "metadata": {"retrieval_mode": "knowledge_graph"}, + } diff --git a/tests/fixtures/scientific_ledger.py b/tests/fixtures/scientific_ledger.py new file mode 100644 index 0000000..e2ec77f --- /dev/null +++ b/tests/fixtures/scientific_ledger.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from app.services.decision_layer import CampaignDecisionLayer +from app.services.decision_outcome import ( + CampaignDecisionAccounting, + CampaignDecisionAccountingBuilder, + CampaignDecisionOutcomeBuilder, +) +from app.services.decision_trace import CampaignDecisionTrace, CampaignDecisionTraceBuilder +from app.services.round_context import CampaignRoundContextBuilder + + +def decision_trace( + *, + campaign_id: str = "campaign-32", + round_index: int = 3, + trace_id: str = "cdt-ledger-003", +) -> CampaignDecisionTrace: + context = CampaignRoundContextBuilder().build( + campaign_id=campaign_id, + round_index=round_index, + objective_summary={ + "objective_kpi": "yield", + "direction": "maximize", + "target_value": 0.9, + }, + failure_summary={"failure_count": 0}, + nexus_diagnostics={ + "contract_version": "early_stage_system_characterization.v1", + "entropy_score": 0.21, + "failure_attribution_distribution": {"pipette_offset": 0.72}, + }, + human_observations=["Meniscus was stable."], + strategy_selection_result={ + "campaign_intent": "optimize", + "optimization_mode": "exploit", + "candidate_generation_backend": "bo_mcp", + "confidence": 0.83, + "strategy_trace": { + "policy_id": "campaign-meta-controller", + "policy_version": "v4", + "selected_backend": "bo_mcp", + "available_actions": [ + { + "name": "validation", + "backend_name": "built_in", + "expected_improvement": 0.2, + "expected_info_gain": 0.82, + "risk": 0.1, + "utility": 0.77, + "reason": "Resolve uncertainty before optimization", + }, + { + "name": "exploit", + "backend_name": "bo_mcp", + "expected_improvement": 0.72, + "expected_info_gain": 0.45, + "risk": 0.18, + "utility": 0.69, + "reason": "Stable objective and calibrated model", + }, + { + "name": "random", + "backend_name": "random", + "expected_improvement": 0.1, + "expected_info_gain": 0.3, + "risk": 0.2, + "utility": 0.12, + "reason": "Fallback exploration", + }, + ], + }, + "evidence": [ + { + "source": "dataset", + "kind": "sample_size", + "summary": "Dataset size = 18", + "weight": 0.8, + }, + { + "source": "optimizer", + "kind": "acquisition_confidence", + "summary": "Acquisition confidence = 0.83", + "weight": 0.83, + }, + ], + }, + metadata={"operator_note": "do not expose sk-test-secret-value"}, + ) + plan = CampaignDecisionLayer().decide(context) + return CampaignDecisionTraceBuilder().build( + context=context, + decision_plan=plan, + actual_stage="candidate_generation", + actual_action="propose_candidates", + trace_id=trace_id, + ) + + +def decision_accounting( + *, + campaign_id: str = "campaign-32", + round_index: int = 3, + trace_id: str = "cdt-ledger-003", +) -> CampaignDecisionAccounting: + trace = decision_trace( + campaign_id=campaign_id, + round_index=round_index, + trace_id=trace_id, + ) + outcome = CampaignDecisionOutcomeBuilder().build( + trace=trace, + observed_action="propose_candidates", + observed_backend="bo_mcp", + candidate_count=4, + execution_success=True, + failure_count=1, + objective_delta=0.18, + validation_success=True, + context_request_fulfilled=True, + metadata={"authorization": "Bearer super-secret-token"}, + ) + return CampaignDecisionAccountingBuilder().build(trace=trace, outcome=outcome) diff --git a/tests/integration/test_nexus_experimental_routes_joint.py b/tests/integration/test_nexus_experimental_routes_joint.py new file mode 100644 index 0000000..19885ba --- /dev/null +++ b/tests/integration/test_nexus_experimental_routes_joint.py @@ -0,0 +1,134 @@ +"""Live contract test across the sibling Nexus and HELIOS checkouts.""" +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import pytest + +from app.services.experimental_route_policy import select_experimental_route + + +def _nexus_repo() -> Path: + return Path(os.getenv("NEXUS_REPO_PATH", "/Users/sissifeng/Nexus")) + + +def test_nexus_report_is_consumed_by_helios_route_authority(tmp_path) -> None: + nexus_repo = _nexus_repo() + if not (nexus_repo / "optimization_copilot").is_dir(): + pytest.skip("Sibling Nexus checkout is unavailable") + pytest.importorskip("fastapi") + pytest.importorskip("httpx") + from fastapi.testclient import TestClient + + sys.path.insert(0, str(nexus_repo)) + try: + from optimization_copilot.api.app import create_app + + client = TestClient(create_app(workspace_dir=str(tmp_path / "nexus-workspace"))) + response = client.post( + "/api/experimental-routes/analyze", + json={ + "campaign_id": "helios-nexus-joint", + "graph": { + "graph_id": "joint-routes", + "active_node_id": "baseline", + "nodes": [ + { + "node_id": "baseline", + "label": "Baseline synthesis", + "parameter_space": [ + {"name": "voltage", "lower": 0.1, "upper": 1.0} + ], + "protocol_ref": {"use_campaign_default": True}, + "required_capabilities": ["potentiostat"], + "prior_weight": 0.1, + }, + { + "node_id": "alternate", + "label": "Alternate synthesis", + "parameter_space": [ + {"name": "temperature", "lower": 300, "upper": 700} + ], + "protocol_ref": { + "protocol_template": { + "steps": [ + {"primitive": "robot.dispense", "params": {}} + ] + } + }, + "required_capabilities": ["furnace"], + "prior_weight": 5.0, + }, + ], + "transitions": [ + { + "source_id": "baseline", + "target_id": "alternate", + "approval_required": False, + } + ], + }, + "available_capabilities": ["potentiostat", "furnace"], + "objectives": ["yield"], + "objective_directions": ["maximize"], + "observations": [ + { + "iteration": 1, + "parameters": {"voltage": 0.5}, + "kpi_values": {"yield": 0.1}, + "qc_passed": False, + "is_failure": True, + "failure_reason": "baseline failed", + "metadata": {"experimental_node_id": "baseline"}, + } + ], + }, + ) + finally: + sys.path.remove(str(nexus_repo)) + + assert response.status_code == 200, response.text + report = response.json()["report"] + assert report["authority"] == "advisory_only" + assert report["contract_version"] == "experimental_route_intelligence.v1" + + decision = select_experimental_route( + report=report, + execution_graph={ + **report["graph"], + "nodes": [ + { + **node, + "protocol_ref": ( + {"use_campaign_default": True} + if node["node_id"] == "baseline" + else { + "protocol_template": { + "steps": [ + {"primitive": "robot.dispense", "params": {}} + ] + } + } + ), + } + for node in report["graph"]["nodes"] + ], + }, + campaign_dimensions=[ + {"param_name": "voltage", "min_value": 0.1, "max_value": 1.0} + ], + campaign_protocol_template={"steps": []}, + campaign_protocol_pattern_id="", + direction="maximize", + authority_enabled=True, + available_capabilities=["potentiostat", "furnace"], + policy_snapshot={}, + ) + + assert decision.applied is True + assert decision.selected_node_id == "alternate" + assert decision.selected_option is not None + assert decision.selected_option.runtime is not None + assert decision.selected_option.runtime.dimensions[0]["param_name"] == "temperature" diff --git a/tests/test_campaign_decision_authority.py b/tests/test_campaign_decision_authority.py new file mode 100644 index 0000000..29dbd6d --- /dev/null +++ b/tests/test_campaign_decision_authority.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +from app.services.campaign_decision_authority import ( + evaluate_campaign_decision_authority, +) +from app.services.decision_models import ( + CampaignContextRequest, + CampaignDecisionAction, + CampaignDecisionPlan, + ConstraintPatch, + ObjectivePatch, +) + + +def _plan( + action: CampaignDecisionAction, + **kwargs, +) -> CampaignDecisionPlan: + return CampaignDecisionPlan( + action_type=action, + rationale=kwargs.pop("rationale", f"{action.value} rationale"), + **kwargs, + ) + + +def test_disabled_authority_never_consumes_shadow_plan(): + verdict = evaluate_campaign_decision_authority( + _plan(CampaignDecisionAction.STOP_CAMPAIGN), + enabled=False, + ) + + assert verdict.consumed is False + assert verdict.proceed_to_candidates is True + assert verdict.terminal is False + assert verdict.reason == "campaign decision authority disabled" + + +def test_propose_candidates_continues_when_authority_enabled(): + verdict = evaluate_campaign_decision_authority( + _plan( + CampaignDecisionAction.PROPOSE_CANDIDATES, + candidate_generation_backend="gp_backend", + ), + enabled=True, + ) + + assert verdict.consumed is False + assert verdict.proceed_to_candidates is True + assert verdict.terminal is False + + +def test_stop_campaign_becomes_terminal_authority_verdict(): + verdict = evaluate_campaign_decision_authority( + _plan(CampaignDecisionAction.STOP_CAMPAIGN), + enabled=True, + ) + + assert verdict.consumed is True + assert verdict.proceed_to_candidates is False + assert verdict.terminal is True + assert verdict.stop_reason == "campaign_decision_authority_stop" + assert verdict.round_status == "completed" + + +def test_objective_and_constraint_actions_emit_persistable_updates(): + verdict = evaluate_campaign_decision_authority( + _plan( + CampaignDecisionAction.REVISE_OBJECTIVE, + objective_patch=ObjectivePatch( + reason="proxy gap too high", + proposed_changes={"active_objective": "functional_kpi"}, + ), + constraint_patch=ConstraintPatch( + reason="tighten unsafe region", + proposed_changes={"temperature_c": {"max": 80}}, + ), + ), + enabled=True, + ) + + assert verdict.consumed is True + assert verdict.proceed_to_candidates is False + updates = {update.update_type: update.payload for update in verdict.state_updates} + assert updates["objective_transition"]["proposed_changes"] == { + "active_objective": "functional_kpi" + } + assert updates["objective_transition"]["auto_applied"] is False + assert updates["space_revision"]["revision_type"] == "constraint_update" + assert updates["space_revision"]["approval_required"] is True + + +def test_context_actions_synthesize_or_preserve_context_requests(): + literature = evaluate_campaign_decision_authority( + _plan(CampaignDecisionAction.QUERY_LITERATURE), + enabled=True, + ) + assert literature.state_updates[0].update_type == "context_request" + assert literature.state_updates[0].payload["request_type"] == "literature_context" + + human = evaluate_campaign_decision_authority( + _plan( + CampaignDecisionAction.REQUEST_HUMAN_OBSERVATION, + context_requests=[ + CampaignContextRequest( + request_type="failure_attribution", + reason="low confidence", + priority="high", + target="failure_summary", + ) + ], + ), + enabled=True, + ) + assert human.state_updates[0].payload["request_type"] == "failure_attribution" + assert human.state_updates[0].payload["target"] == "failure_summary" + + +def test_validation_and_recovery_emit_action_requests(): + validation = evaluate_campaign_decision_authority( + _plan(CampaignDecisionAction.RUN_VALIDATION), + enabled=True, + ) + recovery = evaluate_campaign_decision_authority( + _plan(CampaignDecisionAction.RECOVER_FAILURE), + enabled=True, + ) + + validation_types = [update.update_type for update in validation.state_updates] + recovery_types = [update.update_type for update in recovery.state_updates] + assert "validation_request" in validation_types + assert "recovery_request" in recovery_types + + +def test_campaign_decision_authority_config_defaults_off(monkeypatch): + from app.core.config import get_settings + + monkeypatch.delenv("CAMPAIGN_DECISION_AUTHORITY_ENABLED", raising=False) + get_settings.cache_clear() + assert get_settings().campaign_decision_authority_enabled is False + + monkeypatch.setenv("CAMPAIGN_DECISION_AUTHORITY_ENABLED", "true") + get_settings.cache_clear() + assert get_settings().campaign_decision_authority_enabled is True + + get_settings.cache_clear() + + +async def test_orchestrator_consumes_enabled_authority_before_candidate_generation( + monkeypatch, + tmp_path, +): + from app.agents.orchestrator import OrchestratorAgent, OrchestratorInput + from app.core.config import get_settings + from app.core.db import init_db + from app.services.campaign_events import replay_events + + monkeypatch.setenv("DATA_DIR", str(tmp_path / "data")) + monkeypatch.setenv("DB_PATH", str(tmp_path / "data" / "orchestrator.db")) + monkeypatch.setenv("OBJECT_STORE_DIR", str(tmp_path / "objects")) + monkeypatch.setenv("CAMPAIGN_DECISION_AUTHORITY_ENABLED", "true") + monkeypatch.setenv("SCIENTIFIC_LEDGER_ENABLED", "false") + get_settings.cache_clear() + init_db() + + campaign_id = "camp-authority-integration" + orchestrator = OrchestratorAgent() + result = await orchestrator.process( + OrchestratorInput( + contract_id="contract-authority", + objective_kpi="yield", + direction="maximize", + max_rounds=1, + batch_size=2, + strategy="lhs", + dry_run=True, + campaign_id=campaign_id, + policy_snapshot={"risk_level": "high"}, + dimensions=[ + { + "param_name": "temperature_c", + "param_type": "number", + "min_value": 20, + "max_value": 100, + } + ], + protocol_template={"steps": [{"primitive": "log", "params": {}}]}, + ) + ) + + assert result.status == "completed" + events = replay_events(campaign_id) + payloads = [event["payload"] for event in events] + authority = [ + payload + for payload in payloads + if payload.get("type") == "campaign_decision_authority" + ] + assert authority + assert authority[0]["consumed"] is True + assert authority[0]["action_type"] == "tighten_constraints" + assert any(payload.get("type") == "round_deferred" for payload in payloads) + assert not any( + payload.get("type") == "agent_result" and payload.get("agent") == "design" + for payload in payloads + ) + from app.services.decision_trajectory import load_trajectories + + rows = load_trajectories(campaign_id) + assert len(rows) == 1 + trajectory = rows[0]["trajectory"] + assert trajectory["trace"]["actual_action"] == "tighten_constraints" + assert trajectory["trace"]["comparison"]["would_change_route"] is False + assert trajectory["outcome"]["observed_action"] == "tighten_constraints" + + get_settings.cache_clear() diff --git a/tests/test_closed_loop_drift.py b/tests/test_closed_loop_drift.py new file mode 100644 index 0000000..994a99a --- /dev/null +++ b/tests/test_closed_loop_drift.py @@ -0,0 +1,575 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from app.services.closed_loop_drift import ( + DriftStatus, + assess_closed_loop_drift, + build_next_round_decision_memory, +) + + +def _trajectory( + index: int, + *, + backend: str, + reward: float, + expected_improvement: float = 0.8, + objective_delta: float = 0.8, + proxy_gap_delta: float | None = None, + human_override: bool | None = None, + metadata: dict[str, Any] | None = None, +) -> dict[str, Any]: + return { + "id": f"row-{index}", + "layer": "campaign", + "trace_id": f"trace-{index}", + "round_index": index, + "reward": reward, + "trajectory": { + "trace": { + "trace_id": f"trace-{index}", + "round_index": index, + "would_change_route": False, + "context": { + "objective_summary": { + "objective_kpi": "yield", + "direction": "maximize", + }, + "metadata": {"round_strategy": "adaptive"}, + }, + "decision_plan": { + "action_type": "propose_candidates", + "candidate_generation_backend": backend, + "rationale": f"selected {backend} because replay evidence supported it", + "context_requests": [], + "strategy_trace": { + "selected_mode": "exploit", + "available_actions": [ + { + "name": "exploit", + "expected_improvement": expected_improvement, + } + ], + }, + }, + }, + "outcome": { + "observed_action": "propose_candidates", + "observed_backend": backend, + "objective_delta": objective_delta, + "proxy_gap_delta": proxy_gap_delta, + "failure_count": 0, + "human_override": human_override, + "metadata": dict(metadata or {}), + }, + "reward": {"reward": reward}, + }, + } + + +def _signals(report): + return {signal.name: signal for signal in report.signals} + + +def test_empty_history_is_insufficient_and_never_requests_live_action(): + report = assess_closed_loop_drift( + campaign_id="empty", + round_index=1, + ) + + assert report.overall_status == DriftStatus.INSUFFICIENT + assert report.requires_validation is False + assert report.requires_objective_review is False + assert report.requires_context_review is False + assert report.recommended_actions == [] + + +def test_monitor_detects_four_primary_quantities_and_six_drift_modes(): + trajectories = [ + *[_trajectory(i, backend="historical_good", reward=0.8, objective_delta=0.8) for i in range(1, 4)], + *[_trajectory(i, backend="current_bad", reward=-0.8, objective_delta=0.0) for i in range(4, 7)], + ] + parameters = [ + {"x": 0.2}, + {"x": 0.4}, + {"x": 0.5}, + {"x": 0.6}, + {"x": 9.2}, + {"x": 9.4}, + {"x": 9.5}, + {"x": 9.8}, + ] + context = { + "proxy_gap_assessment": {"score": 0.8, "level": "high"}, + "instrument_state": {"calibration_confidence": 0.2}, + "closed_loop_observations": [ + *[{"telemetry": {"sensor_zero": value}} for value in (1.0, 1.1, 1.0, 1.1)], + *[{"telemetry": {"sensor_zero": value}} for value in (4.0, 4.1)], + ], + } + decision_memory = { + "record_count": 3, + "records": [{}, {}, {}], + "omissions": [{"trace_id": "trace-4", "missing": "human_override_reason"}], + } + candidates = [ + { + "status": "completed", + "kpi_value": 1.0, + "applicability_context": {}, + } + ] + + report = assess_closed_loop_drift( + campaign_id="drifting", + round_index=7, + parameters=parameters, + parameter_rounds=[1, 1, 2, 2, 3, 3, 4, 4], + dimensions=[ + { + "param_name": "x", + "param_type": "number", + "min_value": 0.0, + "max_value": 10.0, + } + ], + trajectories=trajectories, + campaign_context=context, + candidate_records=candidates, + decision_memory=decision_memory, + ) + signals = _signals(report) + + assert signals["observation_distribution"].status == DriftStatus.DRIFT + assert signals["prediction_outcome_residual"].status == DriftStatus.DRIFT + assert signals["objective_proxy_gap"].status == DriftStatus.DRIFT + assert signals["replay_policy_performance"].status == DriftStatus.DRIFT + assert signals["measurement_telemetry"].status == DriftStatus.DRIFT + assert signals["decision_context_completeness"].status == DriftStatus.DRIFT + assert signals["candidate_memory_applicability"].status == DriftStatus.DRIFT + assert report.requires_validation is True + assert report.requires_objective_review is True + assert report.requires_context_review is True + assert report.safe_for_memory_reuse is False + assert "run_validation_before_more_candidates" in report.recommended_actions + assert "block_unqualified_candidate_memory_reuse" in report.recommended_actions + assert report.model_dump(mode="json")["schema_version"] == "closed_loop_drift.v1" + + +def test_replay_signal_is_explicitly_non_causal(): + trajectories = [ + *[_trajectory(i, backend="old", reward=0.9) for i in range(1, 4)], + *[_trajectory(i, backend="new", reward=-0.5) for i in range(4, 7)], + ] + + report = assess_closed_loop_drift( + campaign_id="replay", + round_index=7, + trajectories=trajectories, + ) + replay = _signals(report)["replay_policy_performance"] + + assert replay.status == DriftStatus.DRIFT + assert replay.metadata == { + "current_policy": "new", + "historical_policy": "old", + } + assert any("not a causal counterfactual" in line for line in replay.evidence) + + +def test_replay_does_not_compare_a_single_current_policy_outcome(): + trajectories = [ + *[_trajectory(i, backend="old", reward=0.9) for i in range(1, 5)], + _trajectory(5, backend="intermediate", reward=0.1), + _trajectory(6, backend="new", reward=-0.9), + ] + + replay = _signals( + assess_closed_loop_drift( + campaign_id="replay-small-current", + round_index=7, + trajectories=trajectories, + ) + )["replay_policy_performance"] + + assert replay.status == DriftStatus.INSUFFICIENT + assert replay.recent_count == 1 + + +def test_runtime_prediction_residual_shift_takes_precedence_over_strategy_proxy(): + report = assess_closed_loop_drift( + campaign_id="runtime-residual", + round_index=5, + campaign_context={ + "closed_loop_observations": [ + {"predicted_value": 10.0, "outcome_value": 10.0}, + {"predicted_value": 10.2, "outcome_value": 10.0}, + {"predicted_value": 18.0, "outcome_value": 10.0}, + {"predicted_value": 19.0, "outcome_value": 10.0}, + ] + }, + ) + + residual = _signals(report)["prediction_outcome_residual"] + assert residual.status == DriftStatus.DRIFT + assert residual.trend is not None and residual.trend > 0.7 + assert residual.metadata["source"] == "runtime_prediction" + assert report.requires_validation is True + + +def test_sampling_region_shift_alone_is_observed_without_forcing_validation(): + report = assess_closed_loop_drift( + campaign_id="intentional-exploitation", + round_index=4, + parameters=[{"x": 0.0}, {"x": 0.1}, {"x": 9.8}, {"x": 10.0}], + parameter_rounds=[1, 1, 3, 3], + dimensions=[ + { + "param_name": "x", + "param_type": "number", + "min_value": 0.0, + "max_value": 10.0, + } + ], + ) + + assert _signals(report)["observation_distribution"].status == DriftStatus.DRIFT + assert report.requires_validation is False + + +def test_latest_runtime_calibration_overrides_stale_initial_belief(): + report = assess_closed_loop_drift( + campaign_id="measurement", + round_index=3, + campaign_context={ + "instrument_state": {"calibration_confidence": 0.95}, + "closed_loop_observations": [ + {"calibration_confidence": 0.8}, + {"calibration_confidence": 0.2}, + ], + }, + ) + + measurement = _signals(report)["measurement_telemetry"] + assert measurement.status == DriftStatus.DRIFT + assert measurement.current_value == 0.2 + + +def test_runtime_signal_contract_is_allowlisted_and_bounded(): + from app.agents.orchestrator import _extract_closed_loop_runtime_signals + + payload = _extract_closed_loop_runtime_signals( + { + "closed_loop_signals": { + "telemetry": { + **{f"sensor_{index}": index for index in range(50)}, + "raw_blob": "not persisted", + }, + "calibration": { + "calibration_id": "cal-2", + "calibration_confidence": 0.4, + "untrusted_blob": "not persisted", + }, + } + } + ) + + assert len(payload["telemetry"]) == 32 + assert "raw_blob" not in payload["telemetry"] + assert payload["instrument_state"]["calibration_id"] == "cal-2" + assert "untrusted_blob" not in payload["calibration"] + + +def test_only_explicit_human_rejections_are_recorded_as_overrides(): + from app.agents.orchestrator import _human_override_from_steps + + assert _human_override_from_steps([{"status": "rejected", "reason": "safety envelope violation"}]) == (None, None) + assert _human_override_from_steps( + [ + { + "status": "rejected", + "human_override": True, + "human_override_reason": "operator saw precipitation", + } + ] + ) == (True, "operator saw precipitation") + + +def test_current_proxy_gap_delta_uses_new_runtime_observations(): + from app.agents.orchestrator import _current_proxy_gap_delta + + delta = _current_proxy_gap_delta( + { + "closed_loop_observations": [ + {"proxy_value": 10.0, "scientific_value": 9.0}, + {"proxy_value": 10.0, "scientific_value": 2.0}, + ] + }, + { + "signals": [ + { + "name": "objective_proxy_gap", + "current_value": 0.1, + } + ] + }, + ) + + assert delta == pytest.approx(0.35) + + +def test_memory_report_rejects_success_from_a_different_calibration_context(): + report = assess_closed_loop_drift( + campaign_id="memory-context", + round_index=3, + campaign_context={ + "scientific_goal": "yield", + "objective_hierarchy": [{"metric": "yield", "direction": "maximize"}], + "instrument_state": {"calibration_id": "cal-new"}, + }, + candidate_records=[ + { + "status": "completed", + "kpi_value": 0.9, + "applicability_context": { + "objective_kpi": "yield", + "direction": "maximize", + "calibration_id": "cal-old", + }, + } + ], + ) + + memory = _signals(report)["candidate_memory_applicability"] + assert memory.status == DriftStatus.DRIFT + assert memory.metadata["mismatched_context_count"] == 1 + assert report.safe_for_memory_reuse is False + + +def test_decision_memory_carries_reasons_and_flags_missing_override_reason(): + complete = _trajectory( + 1, + backend="bo", + reward=0.5, + human_override=True, + metadata={ + "human_override_reason": "operator saw bubbles", + "failure_reasons": ["sensor timeout"], + }, + ) + missing = _trajectory( + 2, + backend="lhs", + reward=-0.2, + human_override=True, + ) + missing["trajectory"]["outcome"]["failure_count"] = 1 + + memory = build_next_round_decision_memory([complete, missing]) + + assert memory["record_count"] == 2 + assert memory["records"][0]["human_override_reason"] == "operator saw bubbles" + assert memory["records"][0]["failure_reasons"] == ["sensor timeout"] + assert memory["records"][0]["strategy_change_reason"] + assert memory["records"][0]["applicability_context"]["objective_kpi"] == "yield" + assert {item["missing"] for item in memory["omissions"]} == { + "human_override_reason", + "failure_reasons", + } + + +def test_decision_memory_does_not_recursively_copy_context_request_payloads(): + row = _trajectory(1, backend="bo", reward=0.2) + row["trajectory"]["trace"]["decision_plan"]["context_requests"] = [ + { + "request_type": "decision_context_completion", + "reason": "missing operator rationale", + "priority": "high", + "target": "decision_memory", + "payload": { + "decision_memory": {"records": [{"nested": "old memory"}]}, + "drift_summary": {"signals": ["large prior report"]}, + }, + } + ] + + memory = build_next_round_decision_memory([row]) + request = memory["records"][0]["context_requests"][0] + + assert request == { + "request_type": "decision_context_completion", + "reason": "missing operator rationale", + "priority": "high", + "target": "decision_memory", + } + assert "payload" not in request + + +def test_decision_memory_bounds_free_text_and_failure_lists(): + row = _trajectory( + 1, + backend="bo", + reward=-0.2, + human_override=True, + metadata={ + "human_override_reason": "h" * 900, + "failure_reasons": [f"failure-{index}-" + "x" * 700 for index in range(20)], + }, + ) + row["trajectory"]["outcome"]["failure_count"] = 20 + row["trajectory"]["trace"]["decision_plan"]["rationale"] = "r" * 2000 + + record = build_next_round_decision_memory([row])["records"][0] + + assert len(record["strategy_change_reason"]) == 1000 + assert len(record["human_override_reason"]) == 500 + assert len(record["failure_reasons"]) == 8 + assert all(len(reason) == 500 for reason in record["failure_reasons"]) + + +@pytest.mark.parametrize( + ("summary", "expected_action"), + [ + ( + {"requires_validation": True, "signals": [{"status": "drift", "score": 0.8}]}, + "run_validation", + ), + ( + {"requires_objective_review": True, "signals": [{"status": "drift", "score": 0.8}]}, + "revise_objective", + ), + ( + {"requires_context_review": True, "signals": [{"status": "drift", "score": 0.8}]}, + "request_human_observation", + ), + ], +) +def test_decision_layer_consumes_drift_only_as_bounded_review_action(summary, expected_action): + from app.services.decision_layer import CampaignDecisionLayer + from app.services.round_context import build_campaign_round_context + + context = build_campaign_round_context( + campaign_id="decision", + round_index=3, + drift_summary=summary, + decision_memory={"records": []}, + strategy_selection_result={"backend": "bo"}, + ) + + plan = CampaignDecisionLayer().decide(context) + + assert plan.action_type.value == expected_action + assert plan.shadow_only is True + if plan.objective_patch is not None: + assert plan.objective_patch.proposed_changes["auto_applied"] is False + + +def test_monitor_decision_layer_and_authority_form_a_bounded_validation_chain(): + from app.services.campaign_decision_authority import ( + evaluate_campaign_decision_authority, + ) + from app.services.decision_layer import CampaignDecisionLayer + from app.services.round_context import build_campaign_round_context + + report = assess_closed_loop_drift( + campaign_id="authority-chain", + round_index=3, + campaign_context={"instrument_state": {"calibration_confidence": 0.1}}, + ) + plan = CampaignDecisionLayer().decide( + build_campaign_round_context( + campaign_id="authority-chain", + round_index=3, + drift_summary=report.model_dump(mode="json"), + ) + ) + + disabled = evaluate_campaign_decision_authority(plan, enabled=False) + enabled = evaluate_campaign_decision_authority(plan, enabled=True) + + assert plan.action_type.value == "run_validation" + assert disabled.proceed_to_candidates is True + assert enabled.proceed_to_candidates is False + assert enabled.round_status == "deferred" + assert any(update.update_type == "validation_request" for update in enabled.state_updates) + + +async def test_orchestrator_threads_report_outcome_and_applicability_into_next_round( + monkeypatch, + tmp_path, +): + from app.agents.orchestrator import OrchestratorAgent, OrchestratorInput + from app.core.config import get_settings + from app.core.db import init_db + from app.services.campaign_events import replay_events + from app.services.campaign_state import load_all_candidates, load_campaign + from app.services.decision_trajectory import load_trajectories + + monkeypatch.setenv("DATA_DIR", str(tmp_path / "data")) + monkeypatch.setenv("DB_PATH", str(tmp_path / "data" / "orchestrator.db")) + monkeypatch.setenv("OBJECT_STORE_DIR", str(tmp_path / "objects")) + monkeypatch.setenv("SCIENTIFIC_LEDGER_ENABLED", "false") + monkeypatch.setenv("CONTEXTUAL_DECISION_SHADOW_ENABLED", "false") + monkeypatch.setenv("CAMPAIGN_DECISION_AUTHORITY_ENABLED", "false") + monkeypatch.setenv("CLOSED_LOOP_DRIFT_MONITOR_ENABLED", "true") + get_settings.cache_clear() + init_db() + + campaign_id = "camp-drift-integration" + result = await OrchestratorAgent().process( + OrchestratorInput( + contract_id="contract-drift", + objective_kpi="yield", + direction="maximize", + max_rounds=1, + batch_size=2, + strategy="lhs", + dry_run=True, + campaign_id=campaign_id, + dimensions=[ + { + "param_name": "temperature_c", + "param_type": "number", + "min_value": 20, + "max_value": 100, + } + ], + protocol_template={"steps": [{"primitive": "log", "params": {}}]}, + closed_loop_context={ + "instrument_state": { + "instrument_id": "reader-1", + "calibration_id": "cal-2026-07", + "calibration_confidence": 0.9, + }, + "proxy_gap_assessment": {"score": 0.2, "level": "low"}, + }, + ) + ) + + assert result.status == "completed" + payloads = [event["payload"] for event in replay_events(campaign_id)] + reports = [payload for payload in payloads if payload.get("type") == "closed_loop_drift_report"] + assert reports + assert reports[0]["round"] == 1 + assert reports[-1]["round"] == 2 + + trajectories = load_trajectories(campaign_id) + assert any(row["layer"] == "campaign" for row in trajectories) + campaign_trajectory = next(row["trajectory"] for row in trajectories if row["layer"] == "campaign") + assert "drift_summary" in campaign_trajectory["trace"]["context"] + + state = load_campaign(campaign_id) + assert state is not None + next_context = state["campaign_context"] + assert next_context["closed_loop_drift_report"]["round_index"] == 2 + assert next_context["decision_memory"]["record_count"] == 1 + + candidates = load_all_candidates(campaign_id) + assert candidates + assert all(row["applicability_context"]["objective_kpi"] == "yield" for row in candidates) + assert all(row["applicability_context"]["calibration_id"] == "cal-2026-07" for row in candidates) + + get_settings.cache_clear() diff --git a/tests/test_contextual_shadow_hook.py b/tests/test_contextual_shadow_hook.py index 94a14c4..23700b1 100644 --- a/tests/test_contextual_shadow_hook.py +++ b/tests/test_contextual_shadow_hook.py @@ -4,8 +4,15 @@ class _Settings: - def __init__(self, enabled: bool) -> None: + def __init__( + self, + enabled: bool, + ledger_enabled: bool = False, + authority_enabled: bool = False, + ) -> None: self.contextual_decision_shadow_enabled = enabled + self.scientific_ledger_enabled = ledger_enabled + self.campaign_decision_authority_enabled = authority_enabled def test_contextual_shadow_hook_disabled_does_not_call_services(monkeypatch): @@ -51,6 +58,83 @@ def test_contextual_shadow_hook_enabled_builds_and_logs_trace(monkeypatch, caplo assert "contextual_shadow_decision_trace" in caplog.text +def test_scientific_ledger_builds_trace_without_legacy_shadow_log(monkeypatch, caplog): + import app.agents.orchestrator as orch + + monkeypatch.setattr(orch, "get_settings", lambda: _Settings(False, True)) + + with caplog.at_level(logging.INFO): + trace = orch._maybe_record_contextual_shadow_decision( + campaign_id="campaign-ledger", + round_index=2, + strategy_selection_result={ + "campaign_intent": "optimize", + "optimization_mode": "explore", + "candidate_generation_backend": "random", + }, + ) + + assert trace is not None + assert trace.context.campaign_id == "campaign-ledger" + assert trace.context.round_index == 2 + assert "contextual_shadow_decision_trace" not in caplog.text + + +def test_live_authority_builds_trace_without_shadow_or_ledger(monkeypatch, caplog): + import app.agents.orchestrator as orch + + monkeypatch.setattr( + orch, "get_settings", lambda: _Settings(False, False, True) + ) + + with caplog.at_level(logging.INFO): + trace = orch._maybe_record_contextual_shadow_decision( + campaign_id="campaign-authority", + round_index=3, + strategy_selection_result={ + "campaign_intent": "optimize", + "optimization_mode": "exploit", + "candidate_generation_backend": "bo_mcp", + }, + ) + + assert trace is not None + assert trace.context.campaign_id == "campaign-authority" + assert trace.context.round_index == 3 + assert "contextual_shadow_decision_trace" not in caplog.text + + +def test_recovery_event_projection_deduplicates_episode_ids(): + import app.agents.orchestrator as orch + + episode = { + "episode_id": "recovery-1", + "phase": "exit", + "attempts": [{"action": "retry_original", "result": "success"}], + } + events = orch._recovery_events_from_steps( + [ + {"step_key": "aspirate", "recovery_episode": episode}, + {"step_key": "aspirate", "recovery_episode": episode}, + {"step_key": "measure"}, + ] + ) + + assert events == [episode] + assert events[0] is not episode + + +def test_planned_strategy_decision_keeps_first_round_explainable(): + import app.agents.orchestrator as orch + + decision = orch._planned_strategy_decision("lhs") + + assert decision["campaign_intent"] == "optimize" + assert decision["optimization_mode"] == "explore" + assert decision["candidate_generation_backend"] == "lhs" + assert decision["strategy_trace"]["selected_backend"] == "lhs" + + def test_contextual_shadow_hook_swallows_and_logs_exceptions(monkeypatch, caplog): import app.agents.orchestrator as orch diff --git a/tests/test_decision_markdown.py b/tests/test_decision_markdown.py new file mode 100644 index 0000000..9d1fa9c --- /dev/null +++ b/tests/test_decision_markdown.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +from app.services.decision_markdown import ( + LedgerProvenance, + redact_sensitive, + render_completed_decision, + render_nexus_snapshot, + render_pending_decision, + render_policy_snapshot, + render_trajectory_figure, +) +from tests.fixtures.scientific_ledger import decision_accounting, decision_trace + + +def _provenance() -> LedgerProvenance: + return LedgerProvenance( + code_commit="abc123", + policy_id="campaign-meta-controller", + policy_version="v4", + nexus_contract_version="early_stage_system_characterization.v1", + rubric_version="v0.1_static", + ) + + +def test_pending_bundle_is_deterministic_and_complete(): + trace = decision_trace() + first = render_pending_decision(trace, provenance=_provenance()) + second = render_pending_decision(trace, provenance=_provenance()) + + assert first == second + assert set(first.files) == { + "rounds/003/objective.md", + "rounds/003/observations.md", + "rounds/003/decision_003.md", + "rounds/003/strategy.md", + "rounds/003/evidence.md", + "rounds/003/failure.md", + "rounds/003/recovery.md", + "rounds/003/summary.md", + } + card = first.files["rounds/003/decision_003.md"] + assert "status: pending" in card + assert "## Candidate Actions" in card + assert "Information gain" in card + assert "validation" in card + assert "Pending" in card + assert "sk-test-secret-value" not in card + assert "[REDACTED]" in card + + +def test_completed_card_contains_outcome_reward_failure_and_recovery(): + accounting = decision_accounting() + bundle = render_completed_decision( + accounting, + provenance=_provenance(), + observations=[{"measurement": "yield", "value": 0.84}], + failures=[{"failure_type": "hardware", "root_cause": "pipette offset"}], + recovery_events=[{"fix": "increase z offset 0.5mm", "result": "pass"}], + ) + card = bundle.files["rounds/003/decision_003.md"] + assert "status: completed" in card + assert "Execution success | yes" in card + assert "Total reward:" in card + assert "Verifier | Passed | Score" in card + assert "Failure events: 1" in card + assert "Recovery events: 1" in card + assert "Bearer super-secret-token" not in "\n".join(bundle.files.values()) + assert "pipette offset" in bundle.files["rounds/003/failure.md"] + assert "increase z offset 0.5mm" in bundle.files["rounds/003/recovery.md"] + + +def test_redaction_recurses_through_nested_payloads_and_strings(): + value = { + "api_key": "secret", + "nested": { + "Authorization": "Bearer abcdefghijklmnop", + "note": "token sk-1234567890abcdefghijkl", + }, + } + redacted = redact_sensitive(value) + assert redacted["api_key"] == "[REDACTED]" + assert redacted["nested"]["Authorization"] == "[REDACTED]" + assert "sk-" not in redacted["nested"]["note"] + + +def test_policy_nexus_and_trajectory_are_markdown_artifacts(): + provenance = _provenance() + policy = render_policy_snapshot( + campaign_id="campaign-32", + policy={"rule": "BO if stable", "failure_recovery": True}, + provenance=provenance, + ) + nexus = render_nexus_snapshot( + campaign_id="campaign-32", + diagnostics={"entropy_score": 0.2, "failure_attribution": {"offset": 0.8}}, + provenance=provenance, + ) + trajectory = render_trajectory_figure( + campaign_id="campaign-32", + decision_rows=[ + {"round_index": 1, "action": "validation", "status": "completed", "reward": 0.4}, + {"round_index": 2, "action": "propose_candidates", "status": "completed", "reward": 0.6}, + ], + ) + assert "# Decision Policy" in policy and "BO if stable" in policy + assert "# Nexus Optimization Health" in nexus and "failure_attribution.offset" in nexus + assert "```mermaid" in trajectory and "d0 --> d1" in trajectory + + +def test_trajectory_uses_decision_card_front_matter_names(): + trajectory = render_trajectory_figure( + campaign_id="campaign-32", + decision_rows=[ + { + "round_index": 3, + "selected_action": "validate", + "selected_backend": "bo_mcp", + "status": "completed", + "reward": 0.8, + } + ], + ) + + assert "R3 · validate · completed · reward=0.8" in trajectory + + +def test_renderer_tolerates_scalar_campaign_objective_metadata(): + trace = decision_trace().model_copy(deep=True) + trace.context.objective_summary = {} + + bundle = render_pending_decision( + trace, + provenance=_provenance(), + campaign_metadata={"objective": "maximize yield"}, + ) + + assert "**value:** maximize yield" in bundle.files["rounds/003/objective.md"] diff --git a/tests/test_decision_outcome.py b/tests/test_decision_outcome.py index 7bc47e3..13e0792 100644 --- a/tests/test_decision_outcome.py +++ b/tests/test_decision_outcome.py @@ -83,6 +83,19 @@ def test_failure_count_penalty(): assert reward.failure_penalty == -0.3 +def test_recovery_is_verifiable_and_rewarded(): + outcome = build_campaign_decision_outcome( + trace=_trace(), + recovery_attempted=True, + recovery_success=True, + ) + reward = calculate_campaign_decision_reward(outcome) + assert reward.recovery_reward == 0.1 + recovery = next(item for item in reward.verifications if item.name == "recovery") + assert recovery.passed is True + assert recovery.score == 0.1 + + def test_proxy_gap_delta_semantics(): improved = calculate_campaign_decision_reward( build_campaign_decision_outcome(trace=_trace(), proxy_gap_delta=-0.5) diff --git a/tests/test_decision_replay.py b/tests/test_decision_replay.py index f0c89c5..06f147d 100644 --- a/tests/test_decision_replay.py +++ b/tests/test_decision_replay.py @@ -51,6 +51,8 @@ def _accounting( objective_delta: float | None = None, proxy_gap_delta: float | None = None, validation_success: bool | None = None, + recovery_attempted: bool = False, + recovery_success: bool | None = None, context_request_fulfilled: bool | None = None, human_override: bool | None = None, ): @@ -69,6 +71,8 @@ def _accounting( objective_delta=objective_delta, proxy_gap_delta=proxy_gap_delta, validation_success=validation_success, + recovery_attempted=recovery_attempted, + recovery_success=recovery_success, context_request_fulfilled=context_request_fulfilled, human_override=human_override, ) @@ -164,6 +168,8 @@ def test_component_averages(): objective_delta=0.5, proxy_gap_delta=-0.2, validation_success=True, + recovery_attempted=True, + recovery_success=True, context_request_fulfilled=True, ), _accounting( @@ -173,6 +179,8 @@ def test_component_averages(): objective_delta=-0.5, proxy_gap_delta=0.2, validation_success=False, + recovery_attempted=True, + recovery_success=False, context_request_fulfilled=False, ), ], @@ -184,7 +192,9 @@ def test_component_averages(): assert summary.average_objective_reward == 0.0 assert summary.average_proxy_gap_reward == 0.0 assert summary.average_validation_reward == 0.0 + assert summary.average_recovery_reward == 0.0 assert summary.average_context_reward == 0.05 + assert summary.recovery_success_rate == 0.5 def test_optional_rates_ignore_none(): diff --git a/tests/test_e2e_study.py b/tests/test_e2e_study.py index eff20de..d4e2f7d 100644 --- a/tests/test_e2e_study.py +++ b/tests/test_e2e_study.py @@ -1,6 +1,7 @@ from __future__ import annotations import math +from pathlib import Path def test_electrochem_surrogate_is_deterministic_and_structured(): @@ -61,6 +62,9 @@ async def test_e2e_study_runs_real_orchestrator_path(monkeypatch, tmp_path): monkeypatch.setenv("DATA_DIR", str(tmp_path / "data")) monkeypatch.setenv("DB_PATH", str(tmp_path / "data" / "orchestrator.db")) monkeypatch.setenv("OBJECT_STORE_DIR", str(tmp_path / "objects")) + monkeypatch.setenv("SCIENTIFIC_LEDGER_ROOT", str(tmp_path / "scientific-ledger")) + monkeypatch.setenv("SCIENTIFIC_LEDGER_ENABLED", "true") + monkeypatch.setenv("SCIENTIFIC_LEDGER_GIT_ENABLED", "false") get_settings.cache_clear() from benchmarks.e2e_study import StudySpec, run_study @@ -86,3 +90,18 @@ async def test_e2e_study_runs_real_orchestrator_path(monkeypatch, tmp_path): assert random.trace.score == 0.0 assert summary["helios_full"]["trace_completeness_rate"] == 1.0 assert summary["random"]["n"] == 1 + + # The real orchestrator path must close its live Pending Decision Card, + # persist typed accounting, and make the trajectory exportable for RLVR. + from app.services.scientific_ledger import get_scientific_ledger + + campaign_id = helios.metadata["campaign_id"] + ledger = get_scientific_ledger() + campaign_dir = Path(ledger.campaign_directory(campaign_id)) + card = campaign_dir / "rounds/001/decision_001.md" + assert card.is_file() + card_text = card.read_text(encoding="utf-8") + assert "status: completed" in card_text + assert "selected_backend: adaptive" in card_text + assert "## Reward and Verification" in card_text + assert len(ledger.export_rlvr_records(campaign_id)) == 1 diff --git a/tests/test_experimental_route_policy.py b/tests/test_experimental_route_policy.py new file mode 100644 index 0000000..d428860 --- /dev/null +++ b/tests/test_experimental_route_policy.py @@ -0,0 +1,437 @@ +from __future__ import annotations + +import json +from copy import deepcopy +from unittest.mock import patch + +from app.services.experimental_route_policy import ( + normalize_route_dimensions, + select_experimental_route, +) +from app.services.nexus_experimental_routes import ( + NexusExperimentalRouteClient, + NexusExperimentalRouteErrorType, + build_experimental_route_payload, +) + + +def _report(*, approval_required: bool = False, capability_status: str = "available"): + template = {"steps": [{"primitive": "log", "params": {}}]} + return { + "campaign_id": "camp-route", + "contract_version": "experimental_route_intelligence.v1", + "authority": "advisory_only", + "confidence": 0.7, + "capability_inventory_supplied": True, + "risk_flags": [], + "graph": { + "graph_id": "routes", + "active_node_id": "baseline", + "nodes": [ + { + "node_id": "baseline", + "label": "Baseline", + "parameter_space": [], + "protocol_ref": {"use_campaign_default": True}, + "expected_cost": 1.0, + "expected_duration_s": 10.0, + "safety_risk": 0.1, + }, + { + "node_id": "alternate", + "label": "Alternate synthesis", + "parameter_space": [ + {"name": "temperature", "lower": 300, "upper": 700} + ], + "protocol_ref": {"protocol_template": template}, + "required_capabilities": ["furnace"], + "expected_cost": 1.0, + "expected_duration_s": 10.0, + "safety_risk": 0.1, + }, + ], + "transitions": [ + { + "source_id": "baseline", + "target_id": "alternate", + "approval_required": approval_required, + } + ], + }, + "node_assessments": [ + { + "node_id": "baseline", + "status": "high_failure", + "failure_rate": 0.9, + "information_gap": 0.0, + "normalized_prior": 0.1, + "evidence_strength": 0.8, + "missing_capabilities": [], + "objective_summaries": [], + }, + { + "node_id": "alternate", + "status": "unobserved", + "failure_rate": 0.0, + "information_gap": 1.0, + "normalized_prior": 0.9, + "evidence_strength": 0.0, + "missing_capabilities": [], + "objective_summaries": [], + }, + ], + "available_transitions": [ + { + "source_id": "baseline", + "target_id": "alternate", + "transition_id": "baseline->alternate", + "switch_cost": 0.0, + "switch_duration_s": 0.0, + "approval_required": approval_required, + "target_capability_status": capability_status, + "target_missing_capabilities": [], + } + ], + } + + +def _select( + report, + *, + enabled: bool, + policy=None, + execution_graph=None, + available_capabilities=None, +): + return select_experimental_route( + report=report, + execution_graph=execution_graph or report["graph"], + campaign_dimensions=[ + {"param_name": "voltage", "min_value": 0.1, "max_value": 1.0} + ], + campaign_protocol_template={"steps": []}, + campaign_protocol_pattern_id="", + direction="maximize", + authority_enabled=enabled, + available_capabilities=( + ["furnace"] + if available_capabilities is None + else available_capabilities + ), + policy_snapshot=policy, + ) + + +def test_shadow_policy_records_preferred_route_without_applying_it() -> None: + decision = _select(_report(), enabled=False) + + assert decision.selected_node_id == "alternate" + assert decision.applied is False + assert decision.changed is False + assert "Shadow policy prefers alternate" in decision.reason + + +def test_live_policy_applies_executable_approved_route() -> None: + decision = _select(_report(), enabled=True) + + assert decision.applied is True + assert decision.changed is True + assert decision.selected_option is not None + assert decision.selected_option.runtime is not None + assert decision.selected_option.runtime.dimensions[0]["param_name"] == "temperature" + + +def test_operator_approval_and_capability_inventory_are_hard_gates() -> None: + pending = _select(_report(approval_required=True), enabled=True) + alternate = next(item for item in pending.options if item.node_id == "alternate") + assert pending.applied is False + assert "operator_approval_required" in alternate.rejection_reasons + + approved = _select( + _report(approval_required=True), + enabled=True, + policy={"approved_experimental_route_transitions": ["baseline->alternate"]}, + ) + assert approved.applied is True + + unknown = _select(_report(capability_status="unknown"), enabled=True) + alternate = next(item for item in unknown.options if item.node_id == "alternate") + assert unknown.applied is False + assert "target_capability_status_unknown" in alternate.rejection_reasons + + +def test_nexus_cannot_replace_helios_execution_mapping() -> None: + report = _report() + helios_graph = deepcopy(report["graph"]) + report["graph"]["nodes"][1]["protocol_ref"] = { + "protocol_template": {"steps": [{"primitive": "untrusted.execute"}]} + } + + decision = _select( + report, + enabled=True, + execution_graph=helios_graph, + ) + + assert decision.applied is True + assert decision.selected_option is not None + assert decision.selected_option.runtime is not None + assert decision.selected_option.runtime.protocol_template == { + "steps": [{"primitive": "log", "params": {}}] + } + + +def test_nexus_cannot_claim_a_locally_missing_capability_is_available() -> None: + decision = _select( + _report(capability_status="available"), + enabled=True, + available_capabilities=[], + ) + + alternate = next(item for item in decision.options if item.node_id == "alternate") + assert decision.applied is False + assert "missing_required_capabilities" in alternate.rejection_reasons + + +def test_non_finite_policy_threshold_cannot_bypass_safety_gate() -> None: + report = _report() + report["graph"]["nodes"][1]["safety_risk"] = 0.9 + + decision = _select( + report, + enabled=True, + policy={"experimental_route_max_safety_risk": float("nan")}, + ) + + alternate = next(item for item in decision.options if item.node_id == "alternate") + assert decision.applied is False + assert "safety_risk_above_policy" in alternate.rejection_reasons + + +def test_invalid_route_parameter_space_is_not_promoted_live() -> None: + report = _report() + report["graph"]["nodes"][1]["parameter_space"] = [ + {"name": "temperature", "lower": 700, "upper": 300} + ] + + decision = _select(report, enabled=True) + + alternate = next(item for item in decision.options if item.node_id == "alternate") + assert decision.applied is False + assert "route_has_no_executable_helios_mapping" in alternate.rejection_reasons + + +def test_policy_blocks_execution_when_current_and_alternate_routes_are_ineligible() -> None: + report = _report(approval_required=True) + report["graph"]["nodes"][0]["safety_risk"] = 0.9 + + decision = _select(report, enabled=True) + + assert decision.selected_node_id is None + assert decision.execution_allowed is False + assert decision.applied is False + + +def test_route_dimension_normalizer_accepts_nexus_bounds() -> None: + assert normalize_route_dimensions( + [{"name": "temperature", "lower": 20, "upper": 80}] + ) == [ + { + "param_name": "temperature", + "param_type": "number", + "min_value": 20, + "max_value": 80, + "log_scale": False, + } + ] + + +def test_experimental_route_gates_default_off(monkeypatch) -> None: + from app.core.config import get_settings + + monkeypatch.delenv("NEXUS_EXPERIMENTAL_ROUTES_ENABLED", raising=False) + monkeypatch.delenv("EXPERIMENTAL_ROUTE_AUTHORITY_ENABLED", raising=False) + get_settings.cache_clear() + settings = get_settings() + assert settings.nexus_experimental_routes_enabled is False + assert settings.experimental_route_authority_enabled is False + get_settings.cache_clear() + + +class _FakeHTTPResponse: + status = 200 + + def __init__(self, payload): + self.payload = payload + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self, _limit=-1): + return json.dumps(self.payload).encode() + + +def test_nexus_client_sends_api_key_and_rejects_non_advisory_authority() -> None: + report = _report() + with patch( + "app.services.nexus_experimental_routes.urlopen", + return_value=_FakeHTTPResponse({"report": report}), + ) as opener: + response = NexusExperimentalRouteClient( + base_url="http://nexus.test/api", api_key="secret" + ).analyze({"campaign_id": "camp-route"}) + + assert response.ok is True + assert opener.call_args.args[0].headers["X-api-key"] == "secret" + + report["authority"] = "route_selector" + with patch( + "app.services.nexus_experimental_routes.urlopen", + return_value=_FakeHTTPResponse({"report": report}), + ): + rejected = NexusExperimentalRouteClient( + base_url="http://nexus.test/api" + ).analyze({"campaign_id": "camp-route"}) + assert rejected.ok is False + assert rejected.error_type == NexusExperimentalRouteErrorType.INVALID_AUTHORITY + + +def test_payload_builder_strips_server_generated_transition_id() -> None: + payload = build_experimental_route_payload( + campaign_id="c", + graph={ + "nodes": [{"node_id": "a", "label": "A", "unexpected": True}], + "transitions": [ + { + "source_id": "a", + "target_id": "b", + "transition_id": "a->b", + } + ], + }, + observations=[], + objective="yield", + direction="maximize", + available_capabilities=None, + ) + + assert "unexpected" not in payload["graph"]["nodes"][0] + assert "transition_id" not in payload["graph"]["transitions"][0] + + +def test_payload_builder_caps_observation_history() -> None: + observations = [{"iteration": index} for index in range(10_005)] + payload = build_experimental_route_payload( + campaign_id="c", + graph={"nodes": [{"node_id": "a", "label": "A"}]}, + observations=observations, + objective="yield", + direction="maximize", + available_capabilities=None, + ) + + assert len(payload["observations"]) == 10_000 + assert payload["observations"][0]["iteration"] == 5 + + +async def test_orchestrator_applies_route_and_records_route_labelled_observation( + monkeypatch, tmp_path +) -> None: + from app.agents.orchestrator import OrchestratorAgent, OrchestratorInput + from app.core.config import get_settings + from app.core.db import init_db + from app.services.campaign_events import replay_events + from app.services.campaign_state import load_all_candidates, load_campaign + from app.services.nexus_experimental_routes import ( + NexusExperimentalRouteResponse, + ) + + monkeypatch.setenv("DATA_DIR", str(tmp_path / "data")) + monkeypatch.setenv("DB_PATH", str(tmp_path / "data" / "orchestrator.db")) + monkeypatch.setenv("OBJECT_STORE_DIR", str(tmp_path / "objects")) + monkeypatch.setenv("SCIENTIFIC_LEDGER_ENABLED", "true") + monkeypatch.setenv( + "SCIENTIFIC_LEDGER_ROOT", str(tmp_path / "scientific-ledger") + ) + monkeypatch.setenv("SCIENTIFIC_LEDGER_GIT_ENABLED", "false") + monkeypatch.setenv("NEXUS_EXPERIMENTAL_ROUTES_ENABLED", "true") + monkeypatch.setenv("EXPERIMENTAL_ROUTE_AUTHORITY_ENABLED", "true") + get_settings.cache_clear() + init_db() + + report = _report() + monkeypatch.setattr( + "app.services.nexus_experimental_routes.NexusExperimentalRouteClient.analyze", + lambda _self, _payload: NexusExperimentalRouteResponse( + ok=True, + endpoint="http://nexus.test/api/experimental-routes/analyze", + status_code=200, + campaign_id="camp-route-live", + report=report, + raw={"report": report}, + ), + ) + graph = dict(report["graph"]) + graph["transitions"] = [ + { + "source_id": "baseline", + "target_id": "alternate", + "approval_required": False, + } + ] + result = await OrchestratorAgent().process( + OrchestratorInput( + contract_id="contract-route-live", + objective_kpi="yield", + direction="maximize", + max_rounds=1, + batch_size=1, + strategy="lhs", + dry_run=True, + campaign_id="camp-route-live", + dimensions=[ + { + "param_name": "voltage", + "param_type": "number", + "min_value": 0.1, + "max_value": 1.0, + } + ], + protocol_template={"steps": [{"primitive": "log", "params": {}}]}, + experimental_route_graph=graph, + available_capabilities=["furnace"], + ) + ) + + assert result.status == "completed" + saved = load_campaign("camp-route-live") + assert saved is not None + context = saved["campaign_context"] + assert context["active_experimental_node_id"] == "alternate" + assert context["experimental_route_decisions"][0]["applied"] is True + assert context["experimental_route_observations"][0]["metadata"][ + "experimental_node_id" + ] == "alternate" + candidates = load_all_candidates("camp-route-live") + assert "temperature" in candidates[0]["params"] + events = [item["payload"] for item in replay_events("camp-route-live")] + assert any( + event.get("type") == "experimental_route_decision" + and event.get("selected_node_id") == "alternate" + and event.get("applied") is True + for event in events + ) + from app.services.decision_trajectory import load_trajectories + + trajectory = next( + row["trajectory"] + for row in load_trajectories("camp-route-live") + if row["layer"] == "campaign" + ) + route_trace = trajectory["trace"]["metadata"]["experimental_route_decision"] + assert route_trace["selected_node_id"] == "alternate" + assert route_trace["applied"] is True + get_settings.cache_clear() diff --git a/tests/test_hypothesis_experiment_planner.py b/tests/test_hypothesis_experiment_planner.py new file mode 100644 index 0000000..ca69f55 --- /dev/null +++ b/tests/test_hypothesis_experiment_planner.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import pytest + +from app.services.hypothesis_experiment_planner import ( + DiscriminationExperiment, + ExperimentPrediction, + HypothesisPriorScenario, + rank_discrimination_experiments, +) + + +def _experiment( + experiment_id: str, + h1_positive: float, + h2_positive: float, + *, + cost: float = 1.0, + safety_approved: bool = True, +): + return DiscriminationExperiment( + experiment_id=experiment_id, + description=f"Test {experiment_id}", + predictions=[ + ExperimentPrediction( + hypothesis_id="hard-scalar-cliff", + outcome_probabilities={"positive": h1_positive, "negative": 1 - h1_positive}, + ), + ExperimentPrediction( + hypothesis_id="assay-drift", + outcome_probabilities={"positive": h2_positive, "negative": 1 - h2_positive}, + ), + ], + cost=cost, + safety_approved=safety_approved, + ) + + +def _priors(): + return [ + HypothesisPriorScenario( + scenario_id="balanced", + probabilities={"hard-scalar-cliff": 0.5, "assay-drift": 0.5}, + ), + HypothesisPriorScenario( + scenario_id="drift-favored", + probabilities={"hard-scalar-cliff": 0.2, "assay-drift": 0.8}, + ), + ] + + +def test_discriminating_experiment_outranks_uninformative_experiment(): + plan = rank_discrimination_experiments( + [ + _experiment("anchor-replicate", 0.9, 0.1), + _experiment("ordinary-bo-point", 0.5, 0.5), + ], + _priors(), + plan_id="plate-42", + ) + + assert [score.experiment_id for score in plan.ranked_experiments] == [ + "anchor-replicate", + "ordinary-bo-point", + ] + assert plan.ranked_experiments[0].robust_expected_information_gain > 0 + assert plan.ranked_experiments[1].robust_expected_information_gain == pytest.approx(0.0) + assert plan.ranked_experiments[0].rank == 1 + assert plan.shadow_only is True + assert plan.operator_approval_required is True + + +def test_robust_score_uses_worst_prior_scenario_and_cost(): + plan = rank_discrimination_experiments( + [ + _experiment("high-information-high-cost", 0.95, 0.05, cost=10.0), + _experiment("moderate-information-low-cost", 0.8, 0.2, cost=1.0), + ], + _priors(), + plan_id="cost-aware", + ) + + top = plan.ranked_experiments[0] + assert top.experiment_id == "moderate-information-low-cost" + assert top.robust_expected_information_gain == pytest.approx( + min(top.expected_information_gain_by_scenario.values()) + ) + + +def test_unapproved_experiment_is_visible_but_excluded(): + plan = rank_discrimination_experiments( + [_experiment("unsafe", 0.99, 0.01, safety_approved=False)], + _priors(), + plan_id="safety-review", + ) + + assert plan.ranked_experiments == [] + assert plan.excluded_experiments[0].experiment_id == "unsafe" + assert plan.excluded_experiments[0].information_gain_per_cost == 0.0 + assert "safety approval" in plan.excluded_experiments[0].reasons[0] + + +def test_prior_scenarios_must_cover_same_hypotheses(): + incompatible = [ + _priors()[0], + HypothesisPriorScenario( + scenario_id="different", + probabilities={"hard-scalar-cliff": 0.5, "narrow-feasible-region": 0.5}, + ), + ] + + with pytest.raises(ValueError, match="same hypotheses"): + rank_discrimination_experiments( + [_experiment("anchor", 0.9, 0.1)], + incompatible, + plan_id="invalid", + ) + + +def test_probability_distributions_are_validated(): + with pytest.raises(ValueError, match="sum to 1"): + HypothesisPriorScenario( + scenario_id="invalid", + probabilities={"h1": 0.7, "h2": 0.7}, + ) diff --git a/tests/test_methods_ablation.py b/tests/test_methods_ablation.py new file mode 100644 index 0000000..5c38d31 --- /dev/null +++ b/tests/test_methods_ablation.py @@ -0,0 +1,684 @@ +"""Ablation harness: event-grounded metrics, matrix, artifacts, CLI.""" +from __future__ import annotations + +import dataclasses +import json + +import pytest + +from benchmarks.methods.ablation import ( + DEFAULT_METRICS_CONFIG_PATH, + ExperimentMatrix, + PathologyBundle, + adaptation_lag, + constraint_adaptation_latency, + decision_quality, + load_metrics_config, + load_study_config, + paired_bootstrap, + recovery_efficiency, + run_matrix, + study_id_for, + write_study_artifacts, +) +from benchmarks.methods.pathologies import ( + NoiseDrift, + PathologyEvent, + SpatialFailure, +) +from benchmarks.methods.problems import get_problem + +INSIDE = {"x1": 0.0, "x2": 0.0} # normalized (0.5, 0.5) +OUTSIDE = {"x1": -5.0, "x2": -5.0} # normalized (0.0, 0.0) +REGION = {"center": {"x1": 0.5, "x2": 0.5}, "radius": 0.2} + + +def _event(event_type: str, eval_index: int, region=None) -> PathologyEvent: + return PathologyEvent( + event_id=f"{event_type}-{eval_index:04d}", + event_type=event_type, + eval_index=eval_index, + severity=1.0, + duration=None, + region=region, + params={}, + ) + + +# --------------------------------------------------------------------------- +# Metrics on synthetic traces with known answers +# --------------------------------------------------------------------------- + + +def test_recovery_efficiency_flatline_after_failure_is_near_zero(): + best = [10.0, 8.0, 6.0, 4.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0] + result = recovery_efficiency( + best, [_event("spatial_failure", 6)], optimum=0.0, window=5, epsilon=1e-6 + ) + # pre rate = (10-2)/5 = 1.6, post rate = 0 -> ratio ~ eps/1.6 + assert result.skipped == 0 + assert len(result.per_event) == 1 + assert result.mean_ratio < 1e-5 + + +def test_recovery_efficiency_known_ratio(): + best = [10.0, 8.0, 6.0, 4.0, 2.0, 2.0, 1.6, 1.2, 0.8, 0.4, 0.0] + result = recovery_efficiency( + best, [_event("spatial_failure", 6)], optimum=0.0, window=5, epsilon=1e-6 + ) + # pre rate 1.6, post rate (2-0)/5 = 0.4 -> ratio 0.25 + assert result.mean_ratio == pytest.approx(0.25, rel=1e-3) + + +def test_recovery_efficiency_skips_events_too_close_to_boundary(): + best = [10.0, 9.0, 8.0, 7.0] + result = recovery_efficiency( + best, [_event("spatial_failure", 2)], optimum=0.0, window=5, epsilon=1e-6 + ) + assert result.per_event == [] + assert result.skipped == 1 + assert result.mean_ratio is None + + +def test_adaptation_lag_exact_recovery_point(): + raws = [1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 1.0, 1.0, 1.0, 1.0] + result = adaptation_lag( + raws, + [_event("noise_drift", 4)], + optimum=0.0, + window=3, + delta=0.1, + sustain=2, + ) + # regret_before = 1.0; threshold 1.1; post-onset windowed min recovers at + # 0-based t=6 and holds -> lag = 6 - 3 = 3 evals. + assert result.per_event == [3] + assert result.mean_lag == pytest.approx(3.0) + + +def test_adaptation_lag_never_recovered_is_none(): + raws = [1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0, 5.0] + result = adaptation_lag( + raws, + [_event("noise_drift", 4)], + optimum=0.0, + window=3, + delta=0.1, + sustain=2, + ) + assert result.per_event == [None] + assert result.mean_lag is None + + +def test_constraint_adaptation_latency_known_value(): + space = get_problem("sphere_2d").space + params = ( + [INSIDE, OUTSIDE, INSIDE, OUTSIDE] # pre-event rate 0.5 + + [INSIDE, INSIDE, INSIDE] # keeps proposing into the bad region + + [OUTSIDE] * 5 # adapts + ) + result = constraint_adaptation_latency( + params, + [_event("spatial_failure", 5, region=REGION)], + space, + window=2, + ratio_threshold=0.5, + sustain=2, + ) + # pre_rate 0.5, threshold 0.25; rolling rate drops below at 0-based t=8 + # sustained -> latency = 8 - 4 = 4. + assert result.pre_rate == pytest.approx(0.5) + assert result.latency == 4 + + +def test_constraint_adaptation_latency_without_pre_exposure_is_none(): + space = get_problem("sphere_2d").space + params = [OUTSIDE] * 4 + [INSIDE] + [OUTSIDE] * 5 + result = constraint_adaptation_latency( + params, + [_event("spatial_failure", 5, region=REGION)], + space, + window=2, + ratio_threshold=0.5, + sustain=2, + ) + assert result.pre_rate == pytest.approx(0.0) + assert result.latency is None + + +def test_decision_quality_detects_backend_switch(): + history = [ + "initial_random", + "exploit:sample:gp_backend", + "exploit:sample:gp_backend", + "explore:probe:lhs", + "explore:probe:lhs", + ] + result = decision_quality( + history, + [_event("spatial_failure", 5)], + {"spatial_failure": ["backend_switch"]}, + window_evals=3, + n_init=3, + batch=1, + ) + assert result.per_event == [True] + assert result.hit_rate == 1.0 + + +def test_decision_quality_miss_when_no_relevant_response(): + history = [ + "initial_random", + "exploit:sample:gp_backend", + "exploit:sample:gp_backend", + "exploit:sample:gp_backend", + "exploit:sample:gp_backend", + ] + result = decision_quality( + history, + [_event("spatial_failure", 5)], + {"spatial_failure": ["backend_switch", "constraint_filter"]}, + window_evals=3, + n_init=3, + batch=1, + ) + assert result.per_event == [False] + assert result.hit_rate == 0.0 + + +def test_decision_quality_detects_exploration_and_filter_classes(): + history = [ + "initial_random", + "exploit:sample:gp_backend", + "explore:probe:lhs", + "exploit:sample:gp_backend:early_stage_filtered", + ] + exploration = decision_quality( + history, + [_event("noise_drift", 4)], + {"noise_drift": ["exploration_action"]}, + window_evals=2, + n_init=3, + batch=1, + ) + filtered = decision_quality( + history, + [_event("noise_drift", 5)], + {"noise_drift": ["constraint_filter"]}, + window_evals=2, + n_init=3, + batch=1, + ) + assert exploration.per_event == [True] + assert filtered.per_event == [True] + + +def test_paired_bootstrap_constant_diff_excludes_zero(): + result = paired_bootstrap([2.0, 2.0, 2.0, 2.0], n_boot=200, seed=1) + assert result.mean == pytest.approx(2.0) + assert result.ci_low > 0.0 + + +def test_paired_bootstrap_balanced_diffs_straddle_zero(): + result = paired_bootstrap([1.0, -1.0, 1.0, -1.0], n_boot=500, seed=1) + assert result.ci_low < 0.0 < result.ci_high + assert result.p_value > 0.05 + + +# --------------------------------------------------------------------------- +# Matrix / config / identity +# --------------------------------------------------------------------------- + + +def _small_matrix() -> ExperimentMatrix: + return ExperimentMatrix( + problem_ids=("sphere_2d", "branin"), + bundles=( + PathologyBundle(id="clean", specs=()), + PathologyBundle( + id="failure", + specs=( + SpatialFailure( + center={"x1": 0.5, "x2": 0.5}, + radius=0.3, + p_max=0.8, + p_base=0.05, + observed_penalty=5.0, + ), + ), + ), + ), + config_names=("lhs", "random_sampling"), + seeds=(0, 1), + ) + + +def test_matrix_expands_full_cross_product_with_stable_cell_ids(): + cells = _small_matrix().expand() + assert len(cells) == 16 + assert cells[0].cell_id == "cell_0001" + assert cells[-1].cell_id == "cell_0016" + assert len({c.cell_id for c in cells}) == 16 + + +def test_study_id_deterministic_and_sensitive(): + matrix = _small_matrix() + a = study_id_for(matrix, metrics_config_hash="abc", git_commit="deadbeef") + b = study_id_for(matrix, metrics_config_hash="abc", git_commit="deadbeef") + c = study_id_for(matrix, metrics_config_hash="xyz", git_commit="deadbeef") + d = study_id_for(matrix, metrics_config_hash="abc", git_commit="cafef00d") + assert a == b + assert len(a) == 12 + assert len({a, c, d}) == 3 + + +def test_default_metrics_config_loads_and_covers_all_pathologies(): + config = load_metrics_config(DEFAULT_METRICS_CONFIG_PATH) + assert config.schema_version == 1 + for key in ( + "epsilon", + "recovery_window", + "lag_window", + "lag_delta", + "lag_sustain", + "constraint_window", + "constraint_ratio_threshold", + "constraint_sustain", + "decision_window", + ): + assert key in config.thresholds + for kind in ( + "noise_drift", + "spatial_failure", + "censoring", + "proxy_gap_shift", + "objective_shift", + ): + assert kind in config.response_class_mapping + assert config.content_hash + + +def test_load_study_config_parses_bundles_and_seeds(tmp_path): + study = tmp_path / "study.yaml" + study.write_text( + """ +schema_version: 1 +description: test study +problems: [sphere_2d] +pathologies: + - id: drift + specs: + - type: noise_drift + params: {start_eval: 2, rate: 0.5} + - id: clean + specs: [] +configs: [lhs] +seeds: "0..2" +budget: 6 +reference: lhs +""", + encoding="utf-8", + ) + config = load_study_config(study) + assert config.matrix.problem_ids == ("sphere_2d",) + assert [b.id for b in config.matrix.bundles] == ["drift", "clean"] + assert config.matrix.bundles[0].specs == (NoiseDrift(start_eval=2, rate=0.5),) + assert config.matrix.seeds == (0, 1, 2) + assert config.budget == 6 + assert config.reference == "lhs" + + +# --------------------------------------------------------------------------- +# Harness + artifacts (real runner, cheap backends) +# --------------------------------------------------------------------------- + + +def _tiny_matrix() -> ExperimentMatrix: + return ExperimentMatrix( + problem_ids=("sphere_2d",), + bundles=( + PathologyBundle(id="clean", specs=()), + PathologyBundle( + id="failure", + specs=( + SpatialFailure( + center={"x1": 0.5, "x2": 0.5}, + radius=0.4, + p_max=0.9, + p_base=0.1, + observed_penalty=5.0, + ), + ), + ), + ), + config_names=("lhs",), + seeds=(0, 1), + ) + + +def test_run_matrix_reuses_runner_and_is_deterministic(): + results_a = run_matrix(_tiny_matrix(), budget=6) + results_b = run_matrix(_tiny_matrix(), budget=6) + + assert len(results_a) == 4 + assert all(r.trace.error is None for r in results_a) + assert all(len(r.trace.best_so_far) == 6 for r in results_a) + events_a = [[dataclasses.asdict(e) for e in r.events] for r in results_a] + events_b = [[dataclasses.asdict(e) for e in r.events] for r in results_b] + assert events_a == events_b + # the failure bundle actually fired somewhere across its cells + assert any(e for r in results_a if r.cell.bundle.id == "failure" for e in r.events) + + +def test_write_study_artifacts_round_trip(tmp_path): + matrix = _tiny_matrix() + results = run_matrix(matrix, budget=6) + metrics_config = load_metrics_config(DEFAULT_METRICS_CONFIG_PATH) + + study_dir = write_study_artifacts( + tmp_path, + matrix, + results, + metrics_config, + reference="lhs", + budget=6, + ) + + manifest = json.loads((study_dir / "matrix.json").read_text()) + assert manifest["problem_ids"] == ["sphere_2d"] + # full pathology params, never bare names + failure_bundle = next( + b for b in manifest["pathology_bundles"] if b["id"] == "failure" + ) + assert failure_bundle["specs"][0]["params"]["radius"] == 0.4 + assert manifest["seed_list"] == [0, 1] + assert "git_commit" in manifest + assert "metrics_config_hash" in manifest + assert manifest["metric_schema_version"] == 1 + + metrics = json.loads((study_dir / "metrics.json").read_text()) + assert metrics["valid_cells"] == 4 + assert metrics["failed_cells"] == 0 + assert isinstance(metrics["skipped_metrics"], list) + assert len(metrics["cells"]) == 4 + + assert (study_dir / "traces" / "cell_0001.jsonl").exists() + assert (study_dir / "events" / "cell_0001.events.jsonl").exists() + assert (study_dir / "tables" / "main_benchmark.csv").exists() + assert (study_dir / "tables" / "mechanism_analysis.csv").exists() + assert (study_dir / "report.md").exists() + + +def test_ci_mini_matrix_end_to_end_with_helios(tmp_path): + matrix = ExperimentMatrix( + problem_ids=("sphere_2d", "branin"), + bundles=( + PathologyBundle(id="clean", specs=()), + PathologyBundle( + id="drift", + specs=(NoiseDrift(start_eval=3, rate=1.0),), + ), + ), + config_names=("helios_full", "lhs"), + seeds=(0, 1), + ) + results = run_matrix(matrix, budget=6) + assert len(results) == 16 + assert all(r.trace.error is None for r in results) + + metrics_config = load_metrics_config(DEFAULT_METRICS_CONFIG_PATH) + study_dir = write_study_artifacts( + tmp_path, matrix, results, metrics_config, reference="helios_full", budget=6 + ) + metrics = json.loads((study_dir / "metrics.json").read_text()) + assert metrics["valid_cells"] == 16 + # decision quality is computed for HELIOS variants only + for cell in metrics["cells"]: + if not cell["config"].startswith("helios_full"): + assert cell["metrics"].get("decision_quality") is None + + +# --------------------------------------------------------------------------- +# ObjectiveShift regret semantics: dynamic + epoch-local rulers +# --------------------------------------------------------------------------- + + +def test_dynamic_regret_auc_uses_per_eval_raw_values(): + from benchmarks.methods.ablation import dynamic_regret_auc + + # per-eval regret [2, 1, 0, 1] -> trapezoid 1.5 + 0.5 + 0.5 + assert dynamic_regret_auc([3.0, 2.0, 1.0, 2.0], optimum=1.0) == pytest.approx(2.5) + + +def test_epoch_metrics_split_at_objective_shift(): + from benchmarks.methods.ablation import epoch_metrics + + raws = [1.0, 0.5, 4.0, 3.0, 2.0] + epochs = epoch_metrics(raws, [_event("objective_shift", 3)], optimum=0.0) + + assert len(epochs) == 2 + assert epochs[0]["start_eval"] == 1 + assert epochs[0]["end_eval"] == 2 + assert epochs[0]["simple_regret"] == pytest.approx(0.5) + assert epochs[0]["regret_auc"] == pytest.approx(0.75) + assert epochs[1]["start_eval"] == 3 + assert epochs[1]["end_eval"] == 5 + assert epochs[1]["simple_regret"] == pytest.approx(2.0) + assert epochs[1]["regret_auc"] == pytest.approx(6.0) + + +def test_epoch_metrics_without_shift_is_single_epoch(): + from benchmarks.methods.ablation import epoch_metrics + + epochs = epoch_metrics([2.0, 1.0], [], optimum=0.0) + assert len(epochs) == 1 + assert epochs[0]["simple_regret"] == pytest.approx(1.0) + + +def test_cell_metrics_include_epoch_semantics_for_objective_shift(tmp_path): + from benchmarks.methods.pathologies import ObjectiveShift + + matrix = ExperimentMatrix( + problem_ids=("sphere_2d",), + bundles=( + PathologyBundle( + id="shift", + specs=(ObjectiveShift(shift_eval=3, delta={"x1": 0.1}),), + ), + ), + config_names=("lhs",), + seeds=(0,), + ) + results = run_matrix(matrix, budget=6) + study_dir = write_study_artifacts( + tmp_path, + matrix, + results, + load_metrics_config(DEFAULT_METRICS_CONFIG_PATH), + reference="lhs", + budget=6, + ) + cell = json.loads((study_dir / "metrics.json").read_text())["cells"][0] + metrics = cell["metrics"] + assert metrics["dynamic_regret_auc"] is not None + assert metrics["final_epoch_regret"] is not None + assert len(metrics["epochs"]) == 2 # shift at eval 4 splits the trace + + +# --------------------------------------------------------------------------- +# Study config: problem groups, comparison families, canonical v1 study +# --------------------------------------------------------------------------- + + +def test_load_study_config_supports_problem_groups_and_families(tmp_path): + study = tmp_path / "study.yaml" + study.write_text( + """ +schema_version: 1 +problem_groups: + clean_low_dim: [sphere_2d, branin] + early_stage: [early_stage_controllability] +pathologies: + - id: clean + specs: [] +configs: [helios_full, gp_backend, lhs] +seeds: "0..1" +budget: 6 +reference: helios_full +comparison_families: + common_performance: + methods: [gp_backend, lhs] + mechanism: + contrasts: + - [helios_full, gp_backend] +""", + encoding="utf-8", + ) + config = load_study_config(study) + assert config.matrix.problem_ids == ( + "sphere_2d", + "branin", + "early_stage_controllability", + ) + assert config.problem_groups == { + "clean_low_dim": ("sphere_2d", "branin"), + "early_stage": ("early_stage_controllability",), + } + assert "common_performance" in config.comparison_families + + +def test_comparisons_apply_holm_within_declared_families(tmp_path): + matrix = ExperimentMatrix( + problem_ids=("sphere_2d",), + bundles=(PathologyBundle(id="clean", specs=()),), + config_names=("lhs", "random_sampling", "full_factorial"), + seeds=(0, 1, 2), + ) + results = run_matrix(matrix, budget=6) + families = { + "family_a": {"methods": ["random_sampling"]}, + "family_b": {"methods": ["full_factorial"]}, + } + study_dir = write_study_artifacts( + tmp_path, + matrix, + results, + load_metrics_config(DEFAULT_METRICS_CONFIG_PATH), + reference="lhs", + budget=6, + comparison_families=families, + ) + comparisons = json.loads((study_dir / "metrics.json").read_text())["comparisons"] + by_family = {row["comparison"]: row["family"] for row in comparisons} + assert by_family["lhs_vs_random_sampling"] == "family_a" + assert by_family["lhs_vs_full_factorial"] == "family_b" + # one comparison per family -> Holm within family degenerates to identity + for row in comparisons: + assert row["p_holm"] == pytest.approx(row["p_value"]) + + +def test_canonical_v1_study_config_is_valid(): + from app.services.optimization_backends import list_backends + + config = load_study_config( + "benchmarks/methods/configs/studies/pathology_study_v1.yaml" + ) + for problem_id in config.matrix.problem_ids: + get_problem(problem_id) # raises on unknown ids + registered = list_backends() + for name in config.matrix.config_names: + assert name in registered + assert len(config.matrix.seeds) >= 20 + assert config.problem_groups # tiers declared + assert config.comparison_families # preregistered contrasts + + +# --------------------------------------------------------------------------- +# Provenance + overwrite protection +# --------------------------------------------------------------------------- + + +def test_manifest_records_environment_provenance(tmp_path): + matrix = ExperimentMatrix( + problem_ids=("sphere_2d",), + bundles=(PathologyBundle(id="clean", specs=()),), + config_names=("lhs",), + seeds=(0,), + ) + results = run_matrix(matrix, budget=5) + study_dir = write_study_artifacts( + tmp_path, + matrix, + results, + load_metrics_config(DEFAULT_METRICS_CONFIG_PATH), + reference="lhs", + budget=5, + ) + manifest = json.loads((study_dir / "matrix.json").read_text()) + assert isinstance(manifest["git_dirty"], bool) + assert manifest["git_dirty"] is False or manifest["git_diff_hash"] + assert manifest["python_version"] + assert manifest["dependency_lock_hash"] + assert manifest["pathology_schema_version"] == 1 + assert manifest["metric_schema_version"] == 1 + + +def test_write_study_artifacts_refuses_silent_overwrite(tmp_path): + matrix = ExperimentMatrix( + problem_ids=("sphere_2d",), + bundles=(PathologyBundle(id="clean", specs=()),), + config_names=("lhs",), + seeds=(0,), + ) + results = run_matrix(matrix, budget=5) + metrics_config = load_metrics_config(DEFAULT_METRICS_CONFIG_PATH) + + write_study_artifacts( + tmp_path, matrix, results, metrics_config, reference="lhs", budget=5 + ) + with pytest.raises(FileExistsError): + write_study_artifacts( + tmp_path, matrix, results, metrics_config, reference="lhs", budget=5 + ) + # explicit force overwrites + write_study_artifacts( + tmp_path, matrix, results, metrics_config, reference="lhs", budget=5, force=True + ) + + +def test_pathology_study_cli(tmp_path, monkeypatch): + study = tmp_path / "study.yaml" + study.write_text( + """ +schema_version: 1 +problems: [sphere_2d] +pathologies: + - id: clean + specs: [] +configs: [lhs] +seeds: "0..1" +budget: 5 +reference: lhs +""", + encoding="utf-8", + ) + out_dir = tmp_path / "out" + monkeypatch.setattr( + "sys.argv", + [ + "benchmarks.methods", + "pathology-study", + "--study-config", + str(study), + "--output-dir", + str(out_dir), + ], + ) + from benchmarks.methods.__main__ import main + + main() + + studies = list(out_dir.iterdir()) + assert len(studies) == 1 + assert (studies[0] / "matrix.json").exists() + assert (studies[0] / "report.md").exists() diff --git a/tests/test_methods_helios_full.py b/tests/test_methods_helios_full.py new file mode 100644 index 0000000..4185db6 --- /dev/null +++ b/tests/test_methods_helios_full.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import app.optimization # noqa: F401 +import benchmarks.methods.helios_full # noqa: F401 +from app.services.optimization_backends import Observation, get_backend, list_backends +from benchmarks.methods.helios_full import ( + _early_stage_anchor_candidates, + _early_stage_report, + _observations_for_helios, + _primary_backend_for_benchmark, +) +from benchmarks.methods.problems import get_problem +from benchmarks.methods.runner import run_cell + + +def test_helios_full_backend_registered(): + assert list_backends()["helios_full"] is True + assert get_backend("helios_full").name == "helios_full" + + +def test_helios_full_run_records_effective_backend_history(): + trace = run_cell(get_problem("sphere_2d"), "helios_full", seed=0, budget=6, n_init=2) + assert trace.error is None + assert len(trace.best_so_far) == 6 + assert trace.backend_history[0] == "initial_random" + assert len(trace.backend_history) >= 2 + + +def test_helios_full_filters_early_stage_target_infeasible_range(): + backend = get_backend("helios_full") + problem = get_problem("early_stage_controllability") + + candidates = backend.suggest(problem.space, 12, [], seed=2) + + assert len(candidates) == 12 + assert all( + not 84.0 <= float(candidate["target_temp"]) <= 96.0 + for candidate in candidates + ) + + +def test_helios_full_uses_true_objective_for_objective_uncertainty(): + problem = get_problem("early_stage_objective_uncertainty") + report = _early_stage_report(problem.space) + proxy_favored = problem.evaluate( + {"additive": "fast_yield", "temp": 86.0, "dwell_h": 1.2} + ) + observations = [ + Observation( + params={"additive": "fast_yield", "temp": 86.0, "dwell_h": 1.2}, + objective=-proxy_favored.optimizer_value, + objectives=proxy_favored.observation_objectives(), + ) + ] + + converted = _observations_for_helios(report, observations) + + assert converted[0].objective == proxy_favored.observation_objectives()[ + "true_objective" + ] + assert converted[0].objective < observations[0].objective + + +def test_helios_full_uses_corrected_objective_for_batch_effect(): + problem = get_problem("early_stage_batch_effect") + report = _early_stage_report(problem.space) + biased = problem.evaluate( + { + "screen_protocol": "fast_screen", + "ligand": "L1", + "temp": 82.0, + "hold_h": 1.2, + } + ) + observations = [ + Observation( + params={ + "screen_protocol": "fast_screen", + "ligand": "L1", + "temp": 82.0, + "hold_h": 1.2, + }, + objective=-biased.optimizer_value, + objectives=biased.observation_objectives(), + ) + ] + + converted = _observations_for_helios(report, observations) + + assert converted[0].objective == biased.observation_objectives()[ + "corrected_objective" + ] + assert converted[0].objective < observations[0].objective + + +def test_helios_full_uses_prior_successful_regions_as_anchors(): + problem = get_problem("early_stage_prior_warm_start") + report = _early_stage_report(problem.space) + + anchors = _early_stage_anchor_candidates(report, problem.space) + + assert anchors[0] == problem.optimum_x + + +def test_helios_full_routes_clean_low_dim_benchmark_to_gp(): + problem = get_problem("branin") + + assert _primary_backend_for_benchmark( + None, + "built_in", + problem.space, + ) == "gp_backend" + + +# --------------------------------------------------------------------------- +# Ablation variants (backend-owned; consumed by benchmarks.methods.ablation) +# --------------------------------------------------------------------------- + + +def _observations(n: int) -> list[Observation]: + return [ + Observation( + params={"x1": float(i) / n, "x2": 1.0 - float(i) / n}, + objective=-float(i), + objectives={"execution_success": 1.0}, + ) + for i in range(n) + ] + + +def test_backend_variants_all_registered(): + from benchmarks.methods.helios_full import BACKEND_VARIANTS + + backends = list_backends() + assert set(BACKEND_VARIANTS) == { + "helios_full", + "helios_full/no-strategy", + "helios_full/no-failure-memory", + "helios_full/no-observation-correction", + "helios_full/no-constraint-controller", + } + for name in BACKEND_VARIANTS: + assert backends[name] is True + assert get_backend(name).name == name + + +def test_variant_ablation_configs_wired(): + from benchmarks.methods.helios_full import BACKEND_VARIANTS, AblationConfig + + assert BACKEND_VARIANTS["helios_full"] == AblationConfig() + assert get_backend("helios_full").ablation == AblationConfig() + assert get_backend("helios_full/no-strategy").ablation.strategy_selection is False + assert get_backend("helios_full/no-failure-memory").ablation.failure_memory is False + assert ( + get_backend("helios_full/no-observation-correction").ablation.observation_correction + is False + ) + assert ( + get_backend("helios_full/no-constraint-controller").ablation.constraint_controller + is False + ) + + +def test_no_strategy_variant_uses_fixed_primary_backend(): + backend = get_backend("helios_full/no-strategy") + problem = get_problem("sphere_2d") + + candidates = backend.suggest(problem.space, 2, _observations(10), seed=3) + + assert len(candidates) == 2 + assert backend.last_selected_backend.startswith("fixed_primary:") + + +def test_variants_never_delegate_to_helios_variants(): + backend = get_backend("helios_full/no-failure-memory") + problem = get_problem("sphere_2d") + + backend.suggest(problem.space, 2, _observations(10), seed=3) + + assert "helios_full" not in backend.last_selected_backend + + +def test_no_failure_memory_drops_failed_params_from_snapshot(): + from benchmarks.methods.helios_full import _snapshot_from_observations + + problem = get_problem("sphere_2d") + failed = Observation( + params={"x1": 2.0, "x2": 2.0}, + objective=-9.0, + objectives={"execution_success": 0.0}, + ) + observations = [*_observations(3), failed] + + snap_with = _snapshot_from_observations(problem.space, observations, {}) + snap_without = _snapshot_from_observations( + problem.space, observations, {}, include_failed=False + ) + + assert snap_with.failed_params == ({"x1": 2.0, "x2": 2.0},) + assert snap_without.failed_params == () + + +def test_no_constraint_controller_returns_plain_lhs_on_cold_start(): + from app.services.candidate_gen import sample_lhs + + variant = get_backend("helios_full/no-constraint-controller") + problem = get_problem("early_stage_controllability") + + candidates = variant.suggest(problem.space, 6, [], seed=5) + + assert candidates == sample_lhs(problem.space, 6, seed=5) diff --git a/tests/test_methods_pathologies.py b/tests/test_methods_pathologies.py new file mode 100644 index 0000000..a0923e7 --- /dev/null +++ b/tests/test_methods_pathologies.py @@ -0,0 +1,210 @@ +"""Pathology layer: wrapper injection, ground-truth event ledger, determinism.""" +from __future__ import annotations + +import dataclasses + +import pytest + +from benchmarks.methods.pathologies import ( + Censoring, + NoiseDrift, + ObjectiveShift, + ProxyGapShift, + SpatialFailure, + apply_pathology, + in_failure_region, +) +from benchmarks.methods.problems import get_problem + +ORIGIN = {"x1": 0.0, "x2": 0.0} +CORNER = {"x1": -5.0, "x2": -5.0} + + +def _sphere(): + return get_problem("sphere_2d") + + +def test_wrapper_returns_new_problem_and_leaves_base_untouched(): + base = _sphere() + wrapped, state = apply_pathology(base, [NoiseDrift(start_eval=0, rate=1.0)], seed=7) + + assert wrapped is not base + assert wrapped.id == "sphere_2d@noise_drift" + assert base.evaluate(ORIGIN).observed_value == 0.0 # base evaluator untouched + assert state.eval_count == 0 + assert state.events == [] + + +def test_noise_drift_bias_grows_linearly_after_onset(): + wrapped, state = apply_pathology( + _sphere(), [NoiseDrift(start_eval=3, rate=0.5, mode="linear")], seed=0 + ) + + observed = [wrapped.evaluate(ORIGIN).observed_value for _ in range(6)] + + # Evals 1-3 untouched; bias = rate * (eval_index - start_eval) afterwards. + assert observed[:3] == [0.0, 0.0, 0.0] + assert observed[3] == pytest.approx(0.5) + assert observed[4] == pytest.approx(1.0) + assert observed[5] == pytest.approx(1.5) + # raw ruler never corrupted + assert all(e.raw_value == 0.0 for e in [wrapped.evaluate(ORIGIN)]) + # exactly one onset event, at the first affected eval + drift_events = [e for e in state.events if e.event_type == "noise_drift"] + assert len(drift_events) == 1 + assert drift_events[0].eval_index == 4 + assert drift_events[0].duration is None + + +def test_noise_drift_step_mode_applies_constant_bias(): + wrapped, _ = apply_pathology( + _sphere(), [NoiseDrift(start_eval=2, rate=2.0, mode="step")], seed=0 + ) + observed = [wrapped.evaluate(ORIGIN).observed_value for _ in range(4)] + assert observed == [0.0, 0.0, 2.0, 2.0] + + +def test_spatial_failure_fires_only_inside_region(): + spec = SpatialFailure( + center={"x1": 0.5, "x2": 0.5}, + radius=0.2, + p_max=1.0, + p_base=0.0, + observed_penalty=10.0, + ) + wrapped, state = apply_pathology(_sphere(), [spec], seed=1) + + at_center = wrapped.evaluate(ORIGIN) # origin = normalized (0.5, 0.5) + assert at_center.execution_success is False + assert at_center.failure_type == "pathology_execution_failure" + assert at_center.observed_value == pytest.approx(at_center.raw_value + 10.0) + assert at_center.raw_value == 0.0 # ruler untouched + + far = wrapped.evaluate(CORNER) + assert far.execution_success is True + assert far.failure_type is None + + failures = [e for e in state.events if e.event_type == "spatial_failure"] + assert len(failures) == 1 + assert failures[0].eval_index == 1 + assert failures[0].region == {"center": {"x1": 0.5, "x2": 0.5}, "radius": 0.2} + assert failures[0].params == ORIGIN + + +def test_spatial_failure_is_deterministic_per_seed(): + spec = SpatialFailure( + center={"x1": 0.5, "x2": 0.5}, radius=0.4, p_max=0.6, p_base=0.1 + ) + points = [ + {"x1": (i % 5) - 2.0, "x2": (i % 3) - 1.0} for i in range(30) + ] + + def run(seed): + wrapped, state = apply_pathology(_sphere(), [spec], seed=seed) + evals = [wrapped.evaluate(p) for p in points] + return ( + [e.execution_success for e in evals], + [e.observed_value for e in evals], + [dataclasses.asdict(ev) for ev in state.events], + ) + + assert run(42) == run(42) + successes_a, _, _ = run(42) + successes_b, _, _ = run(43) + assert successes_a != successes_b # seed actually matters + + +def test_censoring_clips_observed_and_flags_metadata(): + wrapped, state = apply_pathology(_sphere(), [Censoring(lod=1.0)], seed=0) + + censored = wrapped.evaluate({"x1": 2.0, "x2": 0.0}) # raw = 4.0 > lod + clear = wrapped.evaluate({"x1": 0.5, "x2": 0.0}) # raw = 0.25 <= lod + + assert censored.observed_value == 1.0 + assert censored.raw_value == 4.0 + assert censored.metadata["censored"] is True + assert clear.observed_value == 0.25 + assert "censored" not in clear.metadata + + events = [e for e in state.events if e.event_type == "censoring"] + assert len(events) == 1 # only the first censored eval is ledgered + assert events[0].eval_index == 1 + wrapped.evaluate({"x1": 3.0, "x2": 0.0}) + assert len([e for e in state.events if e.event_type == "censoring"]) == 1 + + +def test_proxy_gap_shift_changes_mapping_at_shift_eval(): + wrapped, state = apply_pathology( + _sphere(), [ProxyGapShift(shift_eval=2, bias=3.0, scale=2.0)], seed=0 + ) + point = {"x1": 1.0, "x2": 0.0} # raw = 1.0 + + before = [wrapped.evaluate(point) for _ in range(2)] + after = wrapped.evaluate(point) + + assert all(e.observed_value == 1.0 for e in before) + assert after.observed_value == pytest.approx(2.0 * 1.0 + 3.0) + assert after.raw_value == 1.0 + + events = [e for e in state.events if e.event_type == "proxy_gap_shift"] + assert len(events) == 1 + assert events[0].eval_index == 3 + + +def test_objective_shift_translates_argmin_but_preserves_optimum_value(): + # delta is in normalized units: x1 range is [-5, 5] so 0.1 -> +1.0 shift. + wrapped, state = apply_pathology( + _sphere(), [ObjectiveShift(shift_eval=0, delta={"x1": 0.1, "x2": 0.0})], seed=0 + ) + + at_old_argmin = wrapped.evaluate(ORIGIN) + at_new_argmin = wrapped.evaluate({"x1": 1.0, "x2": 0.0}) + + assert at_old_argmin.raw_value == pytest.approx(1.0) # (0 - 1)^2 + assert at_new_argmin.raw_value == pytest.approx(0.0) # optimum value preserved + + events = [e for e in state.events if e.event_type == "objective_shift"] + assert len(events) == 1 + assert events[0].eval_index == 1 + + +def test_composed_pathologies_apply_in_order(): + wrapped, _ = apply_pathology( + _sphere(), + [ + ObjectiveShift(shift_eval=0, delta={"x1": 0.1, "x2": 0.0}), + ProxyGapShift(shift_eval=0, bias=5.0, scale=1.0), + ], + seed=0, + ) + evaluation = wrapped.evaluate(ORIGIN) + assert evaluation.raw_value == pytest.approx(1.0) # shifted landscape + assert evaluation.observed_value == pytest.approx(6.0) # then proxy bias + assert wrapped.id == "sphere_2d@objective_shift+proxy_gap_shift" + + +def test_event_fields_are_complete_and_ids_unique(): + spec = SpatialFailure( + center={"x1": 0.5, "x2": 0.5}, radius=1.0, p_max=1.0, p_base=1.0 + ) + wrapped, state = apply_pathology(_sphere(), [spec], seed=0) + wrapped.evaluate(ORIGIN) + wrapped.evaluate(CORNER) + + assert len(state.events) == 2 + ids = [e.event_id for e in state.events] + assert len(set(ids)) == 2 + for event in state.events: + assert event.event_type == "spatial_failure" + assert event.eval_index in (1, 2) + assert event.severity > 0.0 + assert event.region is not None + assert isinstance(event.params, dict) + assert isinstance(event.details, dict) + + +def test_in_failure_region_helper(): + space = _sphere().space + region = {"center": {"x1": 0.5, "x2": 0.5}, "radius": 0.2} + assert in_failure_region(ORIGIN, space, region) is True + assert in_failure_region(CORNER, space, region) is False diff --git a/tests/test_methods_problems.py b/tests/test_methods_problems.py index fe87f04..6448d3b 100644 --- a/tests/test_methods_problems.py +++ b/tests/test_methods_problems.py @@ -33,6 +33,11 @@ def test_registry_non_empty(): ("ackley_5d", 0.0, 1e-9), ("rosenbrock_2d", 0.0, 1e-9), ("rastrigin_2d", 0.0, 1e-9), + ("early_stage_controllability", 0.0, 1e-9), + ("early_stage_hardware_zone", 0.0, 1e-9), + ("early_stage_objective_uncertainty", 0.0, 1e-9), + ("early_stage_batch_effect", 0.0, 1e-9), + ("early_stage_prior_warm_start", 0.0, 1e-9), ], ) def test_objective_at_optimum_equals_known_value(problem_id, expected, tol): @@ -45,6 +50,52 @@ def test_objective_at_optimum_equals_known_value(problem_id, expected, tol): assert math.isclose(p.optimum, expected, abs_tol=tol) +def test_early_stage_reports_are_attached_to_problem_spaces(): + for problem_id in ( + "early_stage_controllability", + "early_stage_hardware_zone", + "early_stage_objective_uncertainty", + "early_stage_batch_effect", + "early_stage_prior_warm_start", + ): + p = get_problem(problem_id) + report = p.space.protocol_template.get("early_stage_report") + assert report["contract_version"] == "early_stage_system_characterization.v1" + assert report["risk_flags"] + assert report["diagnostic_recommendations"] + + +def test_objective_uncertainty_exposes_misleading_observed_proxy(): + p = get_problem("early_stage_objective_uncertainty") + misleading_proxy = p.evaluate( + {"additive": "fast_yield", "temp": 86.0, "dwell_h": 1.2} + ) + true_optimum = p.evaluate(p.optimum_x) + + assert true_optimum.raw_value == 0.0 + assert misleading_proxy.raw_value > true_optimum.raw_value + assert misleading_proxy.optimizer_value < true_optimum.optimizer_value + assert "candidate_kpi_stability" in misleading_proxy.observation_objectives() + + +def test_batch_effect_exposes_biased_observed_objective_and_correction(): + p = get_problem("early_stage_batch_effect") + biased = p.evaluate( + { + "screen_protocol": "fast_screen", + "ligand": "L1", + "temp": 82.0, + "hold_h": 1.2, + } + ) + optimum = p.evaluate(p.optimum_x) + + assert optimum.raw_value == 0.0 + assert biased.optimizer_value < optimum.optimizer_value + assert biased.raw_value > optimum.raw_value + assert "corrected_objective" in biased.observation_objectives() + + def test_optimum_x_keys_match_space_dimensions(): for p in get_problems(): if p.optimum_x is None: diff --git a/tests/test_methods_report.py b/tests/test_methods_report.py new file mode 100644 index 0000000..83b01d8 --- /dev/null +++ b/tests/test_methods_report.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from benchmarks.methods.problems import ProblemTags +from benchmarks.methods.report import benchmark_family, family_summary +from benchmarks.methods.scoreboard import MethodScore + + +def _score( + backend: str, + regret: float, + *, + problem_id: str, + tags: ProblemTags, +) -> MethodScore: + return MethodScore( + problem_id=problem_id, + backend=backend, + n_seeds=3, + mean_regret=regret, + mean_auc=regret * 10.0, + mean_evals_to_target=None, + target_hit_rate=0.0, + regret_robustness=0.0, + mean_cost_s=0.01, + errors=0, + tags=tags, + ) + + +def test_benchmark_family_separates_early_stage_from_clean_bo(): + early = _score( + "helios_full", + 0.1, + problem_id="early", + tags=ProblemTags(2, False, 0.0, False, False, "early_stage_hardware"), + ) + clean = _score( + "gp_backend", + 0.1, + problem_id="clean", + tags=ProblemTags(2, False, 0.0, True, False, "convex"), + ) + + assert benchmark_family(early) == "early_stage_imperfect_data" + assert benchmark_family(clean) == "clean_low_dim_bo" + + +def test_family_summary_aggregates_by_family_and_backend(): + scores = [ + _score( + "helios_full", + 0.1, + problem_id="early_a", + tags=ProblemTags(2, False, 0.0, False, False, "early_stage_hardware"), + ), + _score( + "helios_full", + 0.3, + problem_id="early_b", + tags=ProblemTags(3, True, 0.0, False, True, "early_stage_objective_uncertainty"), + ), + _score( + "gp_backend", + 1.0, + problem_id="early_a", + tags=ProblemTags(2, False, 0.0, False, False, "early_stage_hardware"), + ), + ] + + rows = family_summary(scores) + helios = [ + row + for row in rows + if row["family"] == "early_stage_imperfect_data" + and row["backend"] == "helios_full" + ][0] + + assert helios["n_problems"] == 2 + assert helios["mean_regret"] == 0.2 diff --git a/tests/test_methods_runner.py b/tests/test_methods_runner.py index 6c46275..2ba2dba 100644 --- a/tests/test_methods_runner.py +++ b/tests/test_methods_runner.py @@ -14,6 +14,7 @@ def test_run_cell_well_formed(): assert trace.error is None assert len(trace.best_so_far) == 8 assert trace.evals == list(range(1, 9)) + assert len(trace.evaluation_history) == 8 assert trace.wall_s >= 0.0 @@ -47,3 +48,14 @@ def test_run_study_isolates_bad_backend(): traces = run_study(problems, ["definitely_not_a_backend"], [0], budget=4) assert len(traces) == 1 assert traces[0].error is not None + + +def test_runner_records_early_stage_evaluation_metadata(): + problem = get_problem("early_stage_controllability") + trace = run_cell(problem, "random_sampling", seed=3, budget=8) + + assert trace.error is None + assert len(trace.evaluation_history) == 8 + assert all("execution_success" in row for row in trace.evaluation_history) + assert all("observed_value" in row for row in trace.evaluation_history) + assert any("actual_temp" in row["metadata"] for row in trace.evaluation_history) diff --git a/tests/test_nexus_early_stage.py b/tests/test_nexus_early_stage.py new file mode 100644 index 0000000..049f002 --- /dev/null +++ b/tests/test_nexus_early_stage.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +import io +import json +from urllib.error import HTTPError + +from app.core.config import Settings +from app.services.campaign_mode import CampaignMode +from app.services.dynamic_action_space import ( + ActionShadowLabel, + ActionSpec, + build_action_space_snapshot, +) +from app.services.nexus_early_stage import ( + NexusEarlyStageAdapter, + NexusEarlyStageClient, + NexusEarlyStageErrorType, +) + + +class _FakeHTTPResponse: + def __init__(self, payload: dict, status: int = 200) -> None: + self.payload = payload + self.status = status + + def __enter__(self): # noqa: ANN204 + return self + + def __exit__(self, *args): # noqa: ANN002, ANN204 + return None + + def read(self) -> bytes: + return json.dumps(self.payload).encode("utf-8") + + +def _http_error(status: int, payload: dict) -> HTTPError: + return HTTPError( + url="http://nexus.test/api/early-stage/analyze", + code=status, + msg="bad", + hdrs={}, + fp=io.BytesIO(json.dumps(payload).encode("utf-8")), + ) + + +def _report(**overrides): + base = { + "contract_version": "early_stage_system_characterization.v1", + "recommended_campaign_mode": "optimization_ready", + "confidence": 0.8, + "risk_flags": [], + "diagnostic_recommendations": [], + } + base.update(overrides) + return base + + +def test_settings_normalizes_nexus_url(monkeypatch): + monkeypatch.setenv("NEXUS_URL", "http://nexus.test") + assert Settings().nexus_url == "http://nexus.test/api" + + monkeypatch.setenv("NEXUS_URL", "http://nexus.test/api") + assert Settings().nexus_url == "http://nexus.test/api" + + +def test_client_intake_success_extracts_analysis_report(monkeypatch): + def fake_urlopen(request, timeout): # noqa: ANN001, ANN202 + assert request.get_method() == "POST" + assert timeout == 1.5 + return _FakeHTTPResponse( + { + "analysis_report": _report( + recommended_campaign_mode="controllability_mapping", + risk_flags=["poor_controllability"], + ), + "observations": [{"iteration": 0}], + "parameter_specs": [{"name": "flow_rate"}], + } + ) + + monkeypatch.setattr("app.services.nexus_early_stage.urlopen", fake_urlopen) + + response = NexusEarlyStageClient( + base_url="http://nexus.test/api", + timeout_seconds=1.5, + ).intake({"campaign_id": "camp-1"}) + + assert response.ok is True + assert response.campaign_id == "camp-1" + assert response.contract_version == "early_stage_system_characterization.v1" + assert response.recommended_campaign_mode == "controllability_mapping" + assert response.risk_flags == ("poor_controllability",) + assert response.observations == ({"iteration": 0},) + + +def test_client_400_returns_typed_bad_request(monkeypatch): + def fake_urlopen(request, timeout): # noqa: ANN001, ANN202, ARG001 + raise _http_error(400, {"detail": "bad mapping"}) + + monkeypatch.setattr("app.services.nexus_early_stage.urlopen", fake_urlopen) + + response = NexusEarlyStageClient(base_url="http://nexus.test/api").analyze( + {"campaign_id": "camp-1"} + ) + + assert response.ok is False + assert response.status_code == 400 + assert response.error_type == NexusEarlyStageErrorType.BAD_REQUEST + assert response.error_message == "bad mapping" + + +def test_client_404_returns_typed_not_found(monkeypatch): + def fake_urlopen(request, timeout): # noqa: ANN001, ANN202, ARG001 + raise _http_error(404, {"detail": "missing campaign"}) + + monkeypatch.setattr("app.services.nexus_early_stage.urlopen", fake_urlopen) + + response = NexusEarlyStageClient(base_url="http://nexus.test/api").report("camp-1") + + assert response.ok is False + assert response.error_type == NexusEarlyStageErrorType.NOT_FOUND + assert response.error_message == "missing campaign" + + +def test_client_timeout_returns_typed_timeout(monkeypatch): + def fake_urlopen(request, timeout): # noqa: ANN001, ANN202, ARG001 + raise TimeoutError("slow nexus") + + monkeypatch.setattr("app.services.nexus_early_stage.urlopen", fake_urlopen) + + response = NexusEarlyStageClient(base_url="http://nexus.test/api").analyze( + {"campaign_id": "camp-1"} + ) + + assert response.ok is False + assert response.error_type == NexusEarlyStageErrorType.TIMEOUT + + +def test_client_unsupported_contract_degrades_without_dropping_report(monkeypatch): + def fake_urlopen(request, timeout): # noqa: ANN001, ANN202, ARG001 + return _FakeHTTPResponse( + {"report": _report(contract_version="early_stage_system_characterization.v2")} + ) + + monkeypatch.setattr("app.services.nexus_early_stage.urlopen", fake_urlopen) + + response = NexusEarlyStageClient(base_url="http://nexus.test/api").report("camp-1") + + assert response.ok is False + assert response.error_type == NexusEarlyStageErrorType.UNSUPPORTED_CONTRACT_VERSION + assert response.report is not None + assert response.contract_version == "early_stage_system_characterization.v2" + + +def test_adapter_unsupported_contract_requires_review_without_evidence(): + advice = NexusEarlyStageAdapter().adapt( + _report( + contract_version="early_stage_system_characterization.v2", + risk_flags=["poor_controllability"], + ) + ) + + assert advice.requires_operator_approval is True + assert advice.campaign_mode_hint is None + assert advice.evidence == () + assert advice.audit_metadata["error_type"] == ( + NexusEarlyStageErrorType.UNSUPPORTED_CONTRACT_VERSION + ) + + +def test_adapter_gates_bo_for_poor_controllability_and_preserves_audit(): + advice = NexusEarlyStageAdapter().adapt( + _report( + recommended_campaign_mode="optimization_ready", + risk_flags=["poor_controllability"], + diagnostic_recommendations=[ + {"action_type": "run_controllability_mapping", "priority": "high"} + ], + insights=["actual temperature lags target"], + ), + endpoint_used="/early-stage/analyze", + campaign_id="camp-1", + ) + + assert advice.campaign_mode_hint == CampaignMode.CONTROLLABILITY_MAPPING + assert advice.ordinary_bo_allowed is False + assert advice.requires_operator_approval is True + assert advice.audit_metadata["contract_version"] == ( + "early_stage_system_characterization.v1" + ) + assert advice.audit_metadata["risk_flags"] == ["poor_controllability"] + assert advice.audit_metadata["top_diagnostic_recommendations"] == [ + {"action_type": "run_controllability_mapping", "priority": "high"} + ] + assert any(e.target_action == "controllability_mapping" for e in advice.evidence) + assert "actual temperature lags target" in advice.operator_messages + + +def test_adapter_prioritizes_objective_missing_over_other_risks(): + advice = NexusEarlyStageAdapter().adapt( + _report( + recommended_campaign_mode="hardware_feasibility_discovery", + risk_flags=["hardware_failures_dominate", "objective_missing"], + ) + ) + + assert advice.campaign_mode_hint == CampaignMode.OBJECTIVE_DISCOVERY + assert advice.ordinary_bo_allowed is False + + +def test_adapter_maps_hardware_failures_and_danger_zone_adjustments(): + advice = NexusEarlyStageAdapter().adapt( + _report( + recommended_campaign_mode="optimization_ready", + risk_flags=["hardware_failures_dominate"], + feasibility_summary={ + "danger_zones": [{"parameter": "flow_rate", "lower": 7.0, "upper": 10.0}] + }, + hardware_summary={"worst_design_id": "thin-wall-alpha"}, + ) + ) + + assert advice.campaign_mode_hint == CampaignMode.HARDWARE_FEASIBILITY_DISCOVERY + assert advice.ordinary_bo_allowed is False + adjustment_types = {item.adjustment_type for item in advice.action_space_adjustments} + assert "reject_or_annotate_danger_zone" in adjustment_types + assert "route_by_hardware_design" in adjustment_types + assert any(item.reject_by_default for item in advice.action_space_adjustments) + + +def test_adapter_optimization_ready_allows_guarded_bo(): + advice = NexusEarlyStageAdapter().adapt(_report(confidence=0.9)) + + assert advice.campaign_mode_hint == CampaignMode.BO_OPTIMIZATION + assert advice.ordinary_bo_allowed is True + assert advice.requires_operator_approval is False + assert any(e.target_action == "exploit" for e in advice.evidence) + + +def test_new_campaign_modes_are_reachable_in_action_space(): + mode_decision = NexusEarlyStageAdapter().adapt( + _report(risk_flags=["objective_missing"]) + ).campaign_mode_hint + assert mode_decision == CampaignMode.OBJECTIVE_DISCOVERY + + from app.services.campaign_mode import CampaignModeDecision + + snapshot = build_action_space_snapshot( + mode_decision=CampaignModeDecision( + campaign_id="camp-1", + round_index=0, + mode=mode_decision, + priority_rank=1, + reason="objective missing", + ), + actions=[ + ActionSpec(name="rank_kpis", kind="objective_discovery"), + ActionSpec(name="run_bo", kind="optimization"), + ], + available_capabilities=[], + ) + + labels = {item.name: item.label for item in snapshot.assessments} + assert labels["rank_kpis"] == ActionShadowLabel.PREFERRED + assert labels["run_bo"] == ActionShadowLabel.RISKY diff --git a/tests/test_objective_state.py b/tests/test_objective_state.py index c67e13e..e82f6c9 100644 --- a/tests/test_objective_state.py +++ b/tests/test_objective_state.py @@ -7,11 +7,18 @@ from app.services.decision_outcome import CampaignDecisionOutcome from app.services.objective_models import ProxyGapAssessment, ProxyGapLevel from app.services.objective_state import ( + ObjectiveConfidenceMethod, ObjectiveState, ObjectiveStateUpdater, StoppingCriteria, + apply_evidence_to_objective_state, apply_outcome_to_objective_state, ) +from app.services.scientific_evidence import ( + ClaimAssessment, + ClaimStatus, + PromotionDecision, +) def _outcome( @@ -196,3 +203,97 @@ def test_max_consecutive_failures_triggers_shadow_stop_recommendation(): def test_import_smoke(): import app.services.objective_state # noqa: F401 + + +def _claim_assessment(probability: float = 0.96) -> ClaimAssessment: + return ClaimAssessment( + claim_id="objective-validity", + status=ClaimStatus.SUPPORTED, + prior_probability=0.5, + prior_version="v1", + prior_rationale_recorded=True, + posterior_probability=probability, + cumulative_log_bayes_factor=3.178054, + scored_evidence_count=2, + unscored_evidence_count=0, + prospective_evidence_count=2, + interventional_evidence_count=1, + preregistered_evidence_count=2, + independent_block_count=2, + safety_incident_count=0, + evidence_ids=["plate-a", "plate-b"], + ) + + +def test_evidence_posterior_replaces_heuristic_confidence_without_live_mutation(): + state = ObjectiveState(campaign_id="camp-1", primary_objective="robust_feasibility") + promotion = PromotionDecision( + claim_id="objective-validity", + evidence_criteria_satisfied=True, + human_approval_required=True, + human_approved=False, + promotion_allowed=False, + reasons=["explicit human approval is required"], + ) + + revised = apply_evidence_to_objective_state( + state, + _claim_assessment(), + promotion_decision=promotion, + trace_id="trace-evidence", + now=_NOW, + ) + + assert revised.objective_confidence == pytest.approx(0.96) + assert revised.objective_confidence_method == ( + ObjectiveConfidenceMethod.SCIENTIFIC_EVIDENCE_POSTERIOR + ) + assert revised.evidence_claim_id == "objective-validity" + assert revised.evidence_assessment.status == ClaimStatus.SUPPORTED + assert revised.promotion_decision.promotion_allowed is False + assert revised.revision_history[-1].source == "scientific_evidence_posterior" + assert revised.revision_history[-1].metadata["auto_applied"] is False + + +def test_operational_outcome_does_not_corrupt_bound_evidence_posterior(): + state = apply_evidence_to_objective_state( + ObjectiveState(campaign_id="camp-1", primary_objective="robust_feasibility"), + _claim_assessment(), + now=_NOW, + ) + + revised = ObjectiveStateUpdater().apply_outcome( + state, + _outcome(execution_success=False, failure_count=2, objective_delta=-1.0), + now=_NOW, + ) + + assert revised.objective_confidence == state.objective_confidence + assert "heuristic confidence update skipped" in revised.revision_history[-1].evidence[0] + assert revised.consecutive_failure_count == 1 + + +def test_objective_state_rejects_switching_to_an_unrelated_claim(): + state = apply_evidence_to_objective_state( + ObjectiveState(campaign_id="camp-1", primary_objective="robust_feasibility"), + _claim_assessment(), + now=_NOW, + ) + unrelated = _claim_assessment().model_copy(update={"claim_id": "different-claim"}) + + with pytest.raises(ValueError, match="already bound to another"): + apply_evidence_to_objective_state(state, unrelated, now=_NOW) + + +def test_objective_state_rejects_silent_prior_drift(): + state = apply_evidence_to_objective_state( + ObjectiveState(campaign_id="camp-1", primary_objective="robust_feasibility"), + _claim_assessment(), + now=_NOW, + ) + changed_prior = _claim_assessment().model_copy( + update={"prior_probability": 0.8, "prior_version": "v2"} + ) + + with pytest.raises(ValueError, match="prior changed after binding"): + apply_evidence_to_objective_state(state, changed_prior, now=_NOW) diff --git a/tests/test_pas_scientific_evidence.py b/tests/test_pas_scientific_evidence.py new file mode 100644 index 0000000..a60a10c --- /dev/null +++ b/tests/test_pas_scientific_evidence.py @@ -0,0 +1,408 @@ +from __future__ import annotations + +import json +from copy import deepcopy +from datetime import UTC, datetime +from io import BytesIO +from urllib.error import HTTPError + +import pytest + +from app.contracts.scientific_evidence import ( + ScientificEvidenceBundle, + ScientificEvidencePolicyMode, + ScientificEvidenceRecommendedAction, + ScientificEvidenceStatus, +) +from app.services.pas_scientific_evidence import ( + _PAS_OPENER, + PasScientificEvidenceAdapter, + PasScientificEvidenceClient, + PasScientificEvidenceErrorType, + _NoRedirectHandler, + assess_scientific_evidence, +) +from tests.fixtures.scientific_evidence import scientific_evidence_bundle_payload + + +class _FakeHTTPResponse: + def __init__(self, payload: bytes, *, status: int = 200) -> None: + self.payload = payload + self.status = status + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self, size: int = -1) -> bytes: + if size < 0: + return self.payload + return self.payload[:size] + + +def _bundle(**overrides) -> ScientificEvidenceBundle: + payload = scientific_evidence_bundle_payload() + payload.update(overrides) + return ScientificEvidenceBundle.model_validate(payload) + + +def _conflicting_bundle() -> ScientificEvidenceBundle: + payload = scientific_evidence_bundle_payload() + second_claim = deepcopy(payload["claims"][0]) + second_claim["claim_id"] = "claim-2" + second_claim["statement"] = "Lower flow rate does not improve uniformity." + second_claim["polarity"] = "negative" + second_claim["confidence"] = 0.9 + second_claim["source_refs"][0]["ref_id"] = "source-2" + second_claim["source_refs"][0]["source_id"] = "doi:10.1000/refutation" + second_claim["source_refs"][0]["paper_id"] = "doi:10.1000/refutation" + second_claim["source_refs"][0]["chunk_ids"] = ["chunk-20"] + payload["claims"].append(second_claim) + payload["conflicts"] = [ + { + "conflict_id": "conflict-1", + "claim_ids": ["claim-1", "claim-2"], + "reason": "The source-grounded conclusions disagree.", + "confidence": 0.9, + } + ] + return ScientificEvidenceBundle.model_validate(payload) + + +def test_client_sends_api_key_and_validates_success_response(monkeypatch): + captured = {} + response_body = json.dumps( + {"bundle": scientific_evidence_bundle_payload()} + ).encode() + + def fake_open(request, timeout): # noqa: ANN001, ANN202 + captured["request"] = request + captured["timeout"] = timeout + return _FakeHTTPResponse(response_body) + + monkeypatch.setattr( + "app.services.pas_scientific_evidence._open_pas_request", + fake_open, + ) + response = PasScientificEvidenceClient( + base_url="https://pas.test/api/v1", + timeout_seconds=2.5, + api_key="pas-secret", + max_bundle_bytes=32_768, + ).query({"objective": "maximize yield"}) + + assert response.ok is True + assert response.bundle is not None + assert response.bundle.bundle_id == "pas-bundle-1" + assert response.endpoint == "https://pas.test/api/v1/scientific-evidence/query" + assert captured["timeout"] == 2.5 + assert captured["request"].get_header("X-api-key") == "pas-secret" + + +def test_client_returns_typed_failures_without_raising(monkeypatch): + def timeout_open(_request, timeout): # noqa: ARG001 + raise TimeoutError + + monkeypatch.setattr( + "app.services.pas_scientific_evidence._open_pas_request", + timeout_open, + ) + client = PasScientificEvidenceClient( + base_url="https://pas.test", + max_bundle_bytes=1024, + ) + timeout = client.query({"query": "evidence"}) + assert timeout.ok is False + assert timeout.error_type == PasScientificEvidenceErrorType.TIMEOUT + + invalid_query = client.query({"score": float("nan")}) + assert invalid_query.ok is False + assert invalid_query.error_type == PasScientificEvidenceErrorType.BAD_REQUEST + + +def test_client_does_not_persist_remote_error_body(monkeypatch): + def error_open(_request, timeout): # noqa: ARG001 + raise HTTPError( + "https://pas.test/scientific-evidence/query", + 500, + "server error", + {}, + BytesIO(b'{"detail":"credential-that-must-not-persist"}'), + ) + + monkeypatch.setattr( + "app.services.pas_scientific_evidence._open_pas_request", + error_open, + ) + response = PasScientificEvidenceClient( + base_url="https://pas.test", + ).query({"query": "evidence"}) + + assert response.error_type == PasScientificEvidenceErrorType.UNAVAILABLE + assert response.error_message == "PAS returned HTTP 500." + assert "credential-that-must-not-persist" not in response.error_message + + +def test_client_rejects_unsupported_contract_and_oversized_response(monkeypatch): + unsupported = scientific_evidence_bundle_payload() + unsupported["contract_version"] = "scientific_evidence_bundle.v2" + + monkeypatch.setattr( + "app.services.pas_scientific_evidence._open_pas_request", + lambda *_args, **_kwargs: _FakeHTTPResponse( + json.dumps({"bundle": unsupported}).encode() + ), + ) + client = PasScientificEvidenceClient( + base_url="https://pas.test", + max_bundle_bytes=4096, + ) + response = client.query({"query": "evidence"}) + assert response.error_type == ( + PasScientificEvidenceErrorType.UNSUPPORTED_CONTRACT_VERSION + ) + + monkeypatch.setattr( + "app.services.pas_scientific_evidence._open_pas_request", + lambda *_args, **_kwargs: _FakeHTTPResponse(b"x" * 1025), + ) + oversized = PasScientificEvidenceClient( + base_url="https://pas.test", + max_bundle_bytes=1024, + ).query({"query": "evidence"}) + assert oversized.error_type == PasScientificEvidenceErrorType.OVERSIZED_PAYLOAD + + +def test_client_rejects_unsafe_or_unbounded_configuration(): + with pytest.raises(ValueError, match="absolute HTTP"): + PasScientificEvidenceClient(base_url="file:///tmp/pas.json") + with pytest.raises(ValueError, match="must use HTTPS"): + PasScientificEvidenceClient(base_url="http://pas.example") + with pytest.raises(ValueError, match="user information"): + PasScientificEvidenceClient(base_url="https://user:pass@pas.test") + with pytest.raises(ValueError, match="timeout must be positive"): + PasScientificEvidenceClient( + base_url="https://pas.test", + timeout_seconds=0, + ) + with pytest.raises(ValueError, match="at least 1024"): + PasScientificEvidenceClient( + base_url="https://pas.test", + max_bundle_bytes=100, + ) + with pytest.raises(ValueError, match="header newlines"): + PasScientificEvidenceClient( + base_url="https://pas.test", + api_key="unsafe\nheader", + ) + + +def test_pas_transport_disables_redirects(): + redirect_handler = next( + handler + for handler in _PAS_OPENER.handlers + if isinstance(handler, _NoRedirectHandler) + ) + + assert redirect_handler.redirect_request(None, None, None) is None + + +def test_assessment_classifies_usable_conflicting_mismatch_stale_and_empty(): + now = datetime(2026, 8, 1, tzinfo=UTC) + usable = assess_scientific_evidence( + _bundle(), + policy_mode=ScientificEvidencePolicyMode.SHADOW, + now=now, + ) + assert usable.status == ScientificEvidenceStatus.USABLE + assert usable.recommended_action == ScientificEvidenceRecommendedAction.NONE + assert usable.support_strength == 0.8 + assert usable.source_coverage == 1.0 + + conflict = assess_scientific_evidence( + _conflicting_bundle(), + policy_mode=ScientificEvidencePolicyMode.BOUNDED, + now=now, + ) + assert conflict.status == ScientificEvidenceStatus.CONFLICTING + assert conflict.recommended_action == ( + ScientificEvidenceRecommendedAction.RUN_VALIDATION + ) + assert conflict.requires_human_review is True + + mismatch_payload = scientific_evidence_bundle_payload() + mismatch_payload["claims"][0]["applicability"]["status"] = "mismatch" + mismatch = assess_scientific_evidence( + ScientificEvidenceBundle.model_validate(mismatch_payload), + now=now, + ) + assert mismatch.status == ScientificEvidenceStatus.APPLICABILITY_MISMATCH + assert mismatch.recommended_action == ( + ScientificEvidenceRecommendedAction.REQUEST_HUMAN_OBSERVATION + ) + + unknown_payload = scientific_evidence_bundle_payload() + unknown_payload["claims"][0]["applicability"]["status"] = "unknown" + unknown = assess_scientific_evidence( + ScientificEvidenceBundle.model_validate(unknown_payload), + now=now, + ) + assert unknown.status == ScientificEvidenceStatus.USABLE + assert unknown.requires_human_review is True + assert unknown.recommended_action == ( + ScientificEvidenceRecommendedAction.REQUEST_HUMAN_OBSERVATION + ) + + stale = assess_scientific_evidence( + _bundle(expires_at="2026-07-29T00:00:00Z"), + now=now, + ) + assert stale.status == ScientificEvidenceStatus.STALE + assert stale.recommended_action == ( + ScientificEvidenceRecommendedAction.QUERY_LITERATURE + ) + + empty = assess_scientific_evidence( + _bundle(claims=[], evidence_paths=[]), + now=now, + ) + assert empty.status == ScientificEvidenceStatus.INSUFFICIENT + + +def test_adapter_returns_only_validated_domain_assessment(): + advice = PasScientificEvidenceAdapter().adapt( + _bundle(), + policy_mode=ScientificEvidencePolicyMode.BOUNDED, + now=datetime(2026, 8, 1, tzinfo=UTC), + ) + + assert advice.bundle is not None + assert advice.assessment.policy_mode == ScientificEvidencePolicyMode.BOUNDED + assert advice.assessment.bundle_id == "pas-bundle-1" + assert advice.audit_metadata["contract_version"] == ( + "scientific_evidence_bundle.v1" + ) + assert "protocol" not in advice.audit_metadata + assert "execution_graph" not in advice.audit_metadata + + +def test_adapter_rejects_invalid_input_without_raising(): + advice = PasScientificEvidenceAdapter().adapt( + {"contract_version": "unexpected"}, + policy_mode=ScientificEvidencePolicyMode.SHADOW, + ) + + assert advice.bundle is None + assert advice.assessment.status == ScientificEvidenceStatus.INVALID + assert advice.assessment.recommended_action == ( + ScientificEvidenceRecommendedAction.NONE + ) + + +def test_pas_settings_default_off_and_independently_gated(monkeypatch, request): + from app.core.config import get_settings + + request.addfinalizer(get_settings.cache_clear) + for name in ( + "PAS_EVIDENCE_FETCH_ENABLED", + "PAS_EVIDENCE_SHADOW_ENABLED", + "PAS_EVIDENCE_INFLUENCE_ENABLED", + ): + monkeypatch.delenv(name, raising=False) + get_settings.cache_clear() + + settings = get_settings() + assert settings.pas_evidence_fetch_enabled is False + assert settings.pas_evidence_shadow_enabled is False + assert settings.pas_evidence_influence_enabled is False + + monkeypatch.setenv("PAS_EVIDENCE_FETCH_ENABLED", "true") + monkeypatch.setenv("PAS_EVIDENCE_SHADOW_ENABLED", "true") + get_settings.cache_clear() + settings = get_settings() + assert settings.pas_evidence_fetch_enabled is True + assert settings.pas_evidence_shadow_enabled is True + assert settings.pas_evidence_influence_enabled is False + + +def test_orchestrator_pas_collection_is_default_off(monkeypatch, request): + from app.agents.orchestrator import _maybe_collect_pas_scientific_evidence + from app.core.config import get_settings + + request.addfinalizer(get_settings.cache_clear) + monkeypatch.delenv("PAS_EVIDENCE_FETCH_ENABLED", raising=False) + monkeypatch.delenv("PAS_EVIDENCE_SHADOW_ENABLED", raising=False) + monkeypatch.delenv("PAS_EVIDENCE_INFLUENCE_ENABLED", raising=False) + get_settings.cache_clear() + + bundle, assessment, audit = _maybe_collect_pas_scientific_evidence( + supplied_bundle=scientific_evidence_bundle_payload(), + query_payload={"objective": "yield"}, + ) + + assert bundle is None + assert assessment is None + assert audit == {"policy_mode": "off", "collected": False} + + +def test_orchestrator_pas_collection_fails_closed(monkeypatch, request): + from app.agents.orchestrator import _maybe_collect_pas_scientific_evidence + from app.core.config import get_settings + + request.addfinalizer(get_settings.cache_clear) + monkeypatch.setenv("PAS_EVIDENCE_FETCH_ENABLED", "true") + monkeypatch.setenv("PAS_EVIDENCE_SHADOW_ENABLED", "true") + get_settings.cache_clear() + monkeypatch.setattr( + "app.agents.orchestrator.PasScientificEvidenceClient.query", + lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("offline")), + ) + + bundle, assessment, audit = _maybe_collect_pas_scientific_evidence( + supplied_bundle=None, + query_payload={"objective": "yield"}, + ) + + assert bundle is None + assert assessment is not None + assert assessment.status == ScientificEvidenceStatus.UNAVAILABLE + assert assessment.policy_mode == ScientificEvidencePolicyMode.SHADOW + assert audit["collected"] is False + + +def test_pas_shadow_gate_alone_records_contextual_trace(monkeypatch, request): + from app.agents.orchestrator import ( + _maybe_collect_pas_scientific_evidence, + _maybe_record_contextual_shadow_decision, + ) + from app.core.config import get_settings + + request.addfinalizer(get_settings.cache_clear) + monkeypatch.setenv("PAS_EVIDENCE_FETCH_ENABLED", "false") + monkeypatch.setenv("PAS_EVIDENCE_SHADOW_ENABLED", "true") + monkeypatch.setenv("PAS_EVIDENCE_INFLUENCE_ENABLED", "false") + monkeypatch.setenv("CONTEXTUAL_DECISION_SHADOW_ENABLED", "false") + monkeypatch.setenv("SCIENTIFIC_LEDGER_ENABLED", "false") + monkeypatch.setenv("CAMPAIGN_DECISION_AUTHORITY_ENABLED", "false") + monkeypatch.setenv("CLOSED_LOOP_DRIFT_MONITOR_ENABLED", "false") + get_settings.cache_clear() + + bundle, assessment, _audit = _maybe_collect_pas_scientific_evidence( + supplied_bundle=scientific_evidence_bundle_payload(), + query_payload={"objective": "yield"}, + ) + trace = _maybe_record_contextual_shadow_decision( + campaign_id="campaign-pas-shadow", + round_index=1, + strategy_selection_result={"backend": "bo_mcp"}, + scientific_evidence=bundle, + scientific_evidence_assessment=assessment, + ) + + assert trace is not None + assert trace.context.scientific_evidence.bundle_id == "pas-bundle-1" + assert trace.decision_plan.metadata["scientific_evidence_policy_mode"] == ( + "shadow" + ) diff --git a/tests/test_recovery_agent_episode.py b/tests/test_recovery_agent_episode.py index 02981b8..0ccf0f8 100644 --- a/tests/test_recovery_agent_episode.py +++ b/tests/test_recovery_agent_episode.py @@ -1,5 +1,7 @@ from __future__ import annotations +import pytest + async def test_recovery_agent_creates_episode_for_first_retry(): from app.agents.recovery_agent import RecoveryAgent, RecoveryInput @@ -60,3 +62,53 @@ async def test_recovery_agent_revises_episode_after_failed_attempt(): assert episode.attempts[0].result == "failed" assert episode.attempts[-1].action == "wait_and_retry" assert "transient communication failure" in episode.rejected_hypotheses + + +async def test_terminal_recovery_error_preserves_abort_episode(monkeypatch): + from app.agents.base import AgentResult + from app.agents.orchestrator import OrchestratorAgent, RecoveryExecutionError + from app.agents.recovery_agent import RecoveryEpisode, RecoveryOutput + + orchestrator = OrchestratorAgent() + + async def _failed_execution(**_kwargs): + return None, {"status": "failed", "error": "aspiration failed"} + + episode = RecoveryEpisode( + episode_id="recovery-abort-1", + original_error_type="execution_error", + current_error_type="execution_error", + phase="exit", + ) + + async def _abort(_input): + return AgentResult( + success=True, + output=RecoveryOutput( + decision="abort", + rationale="unsafe to retry", + phase="exit", + episode=episode, + terminal=True, + ), + ) + + monkeypatch.setattr(orchestrator, "_execute_real_run", _failed_execution) + monkeypatch.setattr(orchestrator, "_call_recovery_agent", _abort) + monkeypatch.setattr(orchestrator, "_emit", lambda *_args, **_kwargs: None) + + with pytest.raises(RecoveryExecutionError) as exc_info: + await orchestrator._execute_candidate_with_recovery( + campaign_id="campaign-1", + protocol={}, + inputs={}, + policy_snapshot={}, + objective_kpi="yield", + candidate_params={"temperature": 25.0}, + agent_trace=[], + round_num=1, + candidate_idx=0, + ) + + assert exc_info.value.failure_type == "recovery_abort" + assert exc_info.value.episode["episode_id"] == "recovery-abort-1" diff --git a/tests/test_reward_split.py b/tests/test_reward_split.py index 742e8f7..14e3f82 100644 --- a/tests/test_reward_split.py +++ b/tests/test_reward_split.py @@ -25,7 +25,7 @@ def test_campaign_new_fields_present_with_defaults(): assert reward.verifications # non-empty assert {v.name for v in reward.verifications} == { "execution", "failure", "safety", "objective", "proxy_gap", - "validation", "context", + "validation", "recovery", "context", } diff --git a/tests/test_scientific_evidence.py b/tests/test_scientific_evidence.py new file mode 100644 index 0000000..548ee38 --- /dev/null +++ b/tests/test_scientific_evidence.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +import math + +import pytest +from pydantic import ValidationError + +from app.services.scientific_evidence import ( + ClaimStatus, + EvidenceAssessmentPolicy, + EvidenceDesign, + EvidenceItem, + EvidenceSet, + PromotionCriteria, + PromotionDecision, + ScientificClaim, + ValidationCheck, + assess_claim_evidence, + evaluate_claim_promotion, +) + + +def _claim(**updates): + values = { + "claim_id": "scalarization-cliff", + "statement": "Hard scalarization destroys useful feasibility signal.", + "scope": "multi-drug solubilization campaign", + "prior_probability": 0.5, + "prior_rationale": "Balanced prior before prospective validation.", + "falsifying_observations": [ + "An independently validated constrained model does not improve predictive calibration." + ], + } + values.update(updates) + return ScientificClaim(**values) + + +def _evidence( + evidence_id: str, + *, + log_bayes_factor: float | None, + block_id: str, + design: EvidenceDesign = EvidenceDesign.PROSPECTIVE_INTERVENTIONAL, + falsifier_triggered: bool = False, +): + return EvidenceItem( + evidence_id=evidence_id, + claim_id="scalarization-cliff", + independence_key=f"independent-{evidence_id}", + design=design, + source="predeclared analysis", + log_bayes_factor=log_bayes_factor, + analysis_method="held-out likelihood ratio" if log_bayes_factor is not None else None, + dataset_hash=f"sha256:{evidence_id}", + registered_before_observation=True, + replicate_count=3, + block_ids=[block_id], + falsifier_triggered=falsifier_triggered, + ) + + +def test_independent_log_bayes_factors_update_posterior_odds(): + evidence = EvidenceSet( + claim_id="scalarization-cliff", + items=[ + _evidence("plate-a", log_bayes_factor=math.log(9.0), block_id="plate-a"), + _evidence("plate-b", log_bayes_factor=math.log(3.0), block_id="plate-b"), + ], + ) + + assessment = assess_claim_evidence( + _claim(), + evidence, + policy=EvidenceAssessmentPolicy( + support_probability=0.95, + min_scored_evidence=2, + min_prospective_evidence=2, + min_independent_blocks=2, + require_interventional_evidence=True, + ), + ) + + assert assessment.posterior_probability == pytest.approx(27 / 28) + assert assessment.status == ClaimStatus.SUPPORTED + assert assessment.interventional_evidence_count == 2 + assert assessment.preregistered_evidence_count == 2 + assert assessment.independent_block_count == 2 + assert assessment.unmet_requirements == [] + + +def test_descriptive_evidence_is_recorded_but_does_not_move_posterior(): + assessment = assess_claim_evidence( + _claim(prior_probability=0.4), + EvidenceSet( + claim_id="scalarization-cliff", + items=[_evidence("audit", log_bayes_factor=None, block_id="audit")], + ), + ) + + assert assessment.posterior_probability == pytest.approx(0.4) + assert assessment.scored_evidence_count == 0 + assert assessment.unscored_evidence_count == 1 + assert assessment.status == ClaimStatus.INCONCLUSIVE + assert assessment.warnings + + +def test_dependent_evidence_cannot_be_double_counted(): + first = _evidence("first", log_bayes_factor=1.0, block_id="plate-a") + second = _evidence("second", log_bayes_factor=1.0, block_id="plate-a").model_copy( + update={"independence_key": first.independence_key} + ) + + with pytest.raises(ValidationError, match="independence_key values must be unique"): + EvidenceSet(claim_id="scalarization-cliff", items=[first, second]) + + +def test_scored_evidence_requires_analysis_method(): + with pytest.raises(ValidationError, match="requires analysis_method"): + EvidenceItem( + evidence_id="untraceable", + claim_id="scalarization-cliff", + independence_key="plate-a", + design=EvidenceDesign.RETROSPECTIVE, + source="unknown", + log_bayes_factor=2.0, + ) + + +def test_predeclared_falsifier_refutes_even_when_other_evidence_supports(): + assessment = assess_claim_evidence( + _claim(), + EvidenceSet( + claim_id="scalarization-cliff", + items=[ + _evidence("support", log_bayes_factor=8.0, block_id="plate-a"), + _evidence( + "falsifier", + log_bayes_factor=0.0, + block_id="plate-b", + falsifier_triggered=True, + ), + ], + ), + ) + + assert assessment.posterior_probability > 0.99 + assert assessment.falsifier_triggered is True + assert assessment.status == ClaimStatus.REFUTED + + +def test_promotion_requires_evidence_checks_and_explicit_human_approval(): + assessment = assess_claim_evidence( + _claim(), + EvidenceSet( + claim_id="scalarization-cliff", + items=[ + _evidence("plate-a", log_bayes_factor=math.log(9.0), block_id="plate-a"), + _evidence("plate-b", log_bayes_factor=math.log(3.0), block_id="plate-b"), + ], + ), + policy=EvidenceAssessmentPolicy( + min_scored_evidence=2, + min_prospective_evidence=2, + min_independent_blocks=2, + require_interventional_evidence=True, + ), + ) + criteria = PromotionCriteria( + min_posterior_probability=0.95, + min_scored_evidence=2, + min_prospective_evidence=2, + min_interventional_evidence=1, + min_independent_blocks=2, + min_preregistered_evidence=2, + ) + calibration = ValidationCheck( + name="held-out feasibility calibration", + passed=True, + evidence_ids=["plate-a", "plate-b"], + ) + + waiting = evaluate_claim_promotion( + assessment, + criteria=criteria, + validation_checks=[calibration], + ) + approved = evaluate_claim_promotion( + assessment, + criteria=criteria, + validation_checks=[calibration], + human_approved=True, + ) + + assert waiting.evidence_criteria_satisfied is True + assert waiting.promotion_allowed is False + assert "explicit human approval is required" in waiting.reasons + assert approved.promotion_allowed is True + assert approved.auto_applied is False + assert approved.shadow_only is True + + +def test_promotion_model_forbids_auto_application(): + with pytest.raises(ValidationError, match="cannot be auto-applied"): + PromotionDecision( + claim_id="scalarization-cliff", + evidence_criteria_satisfied=True, + human_approval_required=False, + human_approved=False, + promotion_allowed=True, + auto_applied=True, + ) + + +def test_blocked_claim_cannot_be_supported(): + assessment = assess_claim_evidence( + _claim(blocked_reason="toxicity metadata is a random placeholder"), + EvidenceSet( + claim_id="scalarization-cliff", + items=[_evidence("plate-a", log_bayes_factor=10.0, block_id="plate-a")], + ), + ) + + assert assessment.status == ClaimStatus.BLOCKED + assert any("claim blocked" in reason for reason in assessment.unmet_requirements) + + +def test_missing_prior_rationale_blocks_default_promotion(): + assessment = assess_claim_evidence( + _claim(prior_rationale=None), + EvidenceSet( + claim_id="scalarization-cliff", + items=[ + _evidence("plate-a", log_bayes_factor=8.0, block_id="plate-a"), + _evidence("plate-b", log_bayes_factor=2.0, block_id="plate-b"), + ], + ), + policy=EvidenceAssessmentPolicy( + min_scored_evidence=2, + min_prospective_evidence=2, + min_independent_blocks=2, + ), + ) + + decision = evaluate_claim_promotion(assessment, human_approved=True) + + assert decision.promotion_allowed is False + assert "claim prior has no recorded rationale" in decision.reasons diff --git a/tests/test_scientific_evidence_contract.py b/tests/test_scientific_evidence_contract.py new file mode 100644 index 0000000..b80a1a5 --- /dev/null +++ b/tests/test_scientific_evidence_contract.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +from copy import deepcopy + +import pytest +from pydantic import ValidationError + +from app.contracts.scientific_evidence import ScientificEvidenceBundle +from tests.fixtures.scientific_evidence import scientific_evidence_bundle_payload + + +def test_valid_scientific_evidence_bundle_is_versioned_and_traceable(): + bundle = ScientificEvidenceBundle.model_validate( + scientific_evidence_bundle_payload() + ) + + assert bundle.contract_version == "scientific_evidence_bundle.v1" + assert bundle.authority == "advisory_only" + assert bundle.claims[0].source_refs[0].source_id == "doi:10.1000/example" + assert bundle.claims[0].source_refs[0].chunk_ids == ["chunk-10", "chunk-11"] + assert bundle.evidence_paths[0].claim_id == bundle.claims[0].claim_id + created_at = bundle.model_dump(mode="json")["created_at"] + assert created_at.endswith("Z") or created_at.endswith("+00:00") + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("contract_version", "scientific_evidence_bundle.v2"), + ("authority", "execution_authority"), + ], +) +def test_contract_rejects_unsupported_version_or_external_authority(field, value): + payload = scientific_evidence_bundle_payload() + payload[field] = value + + with pytest.raises(ValidationError): + ScientificEvidenceBundle.model_validate(payload) + + +@pytest.mark.parametrize( + "metadata", + [ + {"protocol": "execute-this"}, + {"nested": {"hardware_command": "move-axis"}}, + {"nested": {"executionGraph": {"node": "unsafe"}}}, + {"nested": {"protocol-template": "unsafe"}}, + {"nested": {"steps": [{"name": "dispense"}]}}, + ], +) +def test_contract_rejects_executable_metadata(metadata): + payload = scientific_evidence_bundle_payload() + payload["metadata"] = metadata + + with pytest.raises(ValidationError, match="cannot include executable field"): + ScientificEvidenceBundle.model_validate(payload) + + +def test_contract_rejects_unknown_claim_and_source_references(): + unknown_claim = scientific_evidence_bundle_payload() + unknown_claim["evidence_paths"][0]["claim_id"] = "missing-claim" + with pytest.raises(ValidationError, match="references unknown claim"): + ScientificEvidenceBundle.model_validate(unknown_claim) + + unknown_source = scientific_evidence_bundle_payload() + unknown_source["evidence_paths"][0]["source_ref_ids"] = ["missing-source"] + with pytest.raises(ValidationError, match="references sources outside claim"): + ScientificEvidenceBundle.model_validate(unknown_source) + + +def test_contract_rejects_conflicts_that_reference_unknown_claims(): + payload = scientific_evidence_bundle_payload() + second_claim = deepcopy(payload["claims"][0]) + second_claim["claim_id"] = "claim-2" + second_claim["source_refs"][0]["ref_id"] = "source-2" + payload["claims"].append(second_claim) + payload["conflicts"] = [ + { + "conflict_id": "conflict-1", + "claim_ids": ["claim-1", "missing-claim"], + "reason": "Sources disagree.", + "confidence": 0.9, + } + ] + + with pytest.raises(ValidationError, match="references unknown claims"): + ScientificEvidenceBundle.model_validate(payload) + + +def test_contract_supports_typed_local_experimental_sources(): + payload = scientific_evidence_bundle_payload() + payload["claims"][0]["namespace"] = "local_experimental_evidence" + payload["claims"][0]["source_refs"] = [ + { + "ref_id": "experiment-source-1", + "source_type": "experiment", + "source_id": "helios:campaign-1:round-4", + "experiment_id": "campaign-1-round-4", + "chunk_ids": ["result-packet-4"], + } + ] + payload["evidence_paths"][0]["source_ref_ids"] = ["experiment-source-1"] + + bundle = ScientificEvidenceBundle.model_validate(payload) + + assert bundle.claims[0].source_refs[0].paper_id is None + assert bundle.claims[0].source_refs[0].experiment_id == "campaign-1-round-4" + + +def test_contract_requires_source_type_specific_identifier(): + payload = scientific_evidence_bundle_payload() + payload["claims"][0]["source_refs"][0].pop("paper_id") + + with pytest.raises(ValidationError, match="require paper_id"): + ScientificEvidenceBundle.model_validate(payload) + + +def test_contract_rejects_duplicate_path_or_conflict_references(): + duplicate_path = scientific_evidence_bundle_payload() + duplicate_path["evidence_paths"][0]["source_ref_ids"] = [ + "source-1", + "source-1", + ] + with pytest.raises(ValidationError, match="must be unique"): + ScientificEvidenceBundle.model_validate(duplicate_path) + + duplicate_conflict = scientific_evidence_bundle_payload() + duplicate_conflict["conflicts"] = [ + { + "conflict_id": "conflict-1", + "claim_ids": ["claim-1", "claim-1"], + "reason": "Invalid self-conflict.", + "confidence": 0.9, + } + ] + with pytest.raises(ValidationError, match="must be unique"): + ScientificEvidenceBundle.model_validate(duplicate_conflict) + + +def test_contract_requires_timezone_aware_and_ordered_timestamps(): + naive = scientific_evidence_bundle_payload() + naive["created_at"] = "2026-07-28T12:00:00" + with pytest.raises(ValidationError, match="timezone-aware"): + ScientificEvidenceBundle.model_validate(naive) + + reversed_window = scientific_evidence_bundle_payload() + reversed_window["expires_at"] = "2026-07-27T00:00:00Z" + with pytest.raises(ValidationError, match="later than created_at"): + ScientificEvidenceBundle.model_validate(reversed_window) + + +def test_contract_rejects_unbounded_or_nonfinite_metadata(): + oversized = scientific_evidence_bundle_payload() + oversized["metadata"] = {f"key-{index}": index for index in range(33)} + with pytest.raises(ValidationError, match="exceeds 32 items"): + ScientificEvidenceBundle.model_validate(oversized) + + nonfinite = scientific_evidence_bundle_payload() + nonfinite["metadata"] = {"score": float("nan")} + with pytest.raises(ValidationError, match="must be finite"): + ScientificEvidenceBundle.model_validate(nonfinite) + + +@pytest.mark.parametrize( + "key", + ["api_key", "Authorization", "private-key", "accessToken"], +) +def test_contract_rejects_sensitive_metadata_keys(key): + payload = scientific_evidence_bundle_payload() + payload["metadata"] = {key: "must-not-persist"} + + with pytest.raises(ValidationError, match="sensitive field"): + ScientificEvidenceBundle.model_validate(payload) diff --git a/tests/test_scientific_evidence_ledger.py b/tests/test_scientific_evidence_ledger.py new file mode 100644 index 0000000..af77a40 --- /dev/null +++ b/tests/test_scientific_evidence_ledger.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import math +from pathlib import Path + +from app.services.hypothesis_experiment_planner import ( + DiscriminationExperiment, + ExperimentPrediction, + HypothesisPriorScenario, + rank_discrimination_experiments, +) +from app.services.scientific_evidence import ( + EvidenceAssessmentPolicy, + EvidenceDesign, + EvidenceItem, + EvidenceSet, + PromotionCriteria, + ScientificClaim, + assess_claim_evidence, + evaluate_claim_promotion, +) +from app.services.scientific_ledger import ScientificLedger, safe_path_component + + +def _evidence_bundle(): + claim = ScientificClaim( + claim_id="objective/validity", + statement="Robust feasibility is a better proxy than hard scalar desirability.", + scope="multi-drug solubilization", + prior_rationale="Balanced prior registered before the comparison.", + falsifying_observations=["No calibration improvement on an independent plate."], + ) + evidence = EvidenceSet( + claim_id=claim.claim_id, + items=[ + EvidenceItem( + evidence_id=f"plate-{suffix}", + claim_id=claim.claim_id, + independence_key=f"plate-{suffix}", + design=EvidenceDesign.PROSPECTIVE_INTERVENTIONAL, + source="held-out comparison", + log_bayes_factor=log_bf, + analysis_method="predictive likelihood ratio", + dataset_hash=f"sha256:{suffix}", + registered_before_observation=True, + replicate_count=3, + block_ids=[f"plate-{suffix}"], + ) + for suffix, log_bf in (("a", math.log(9)), ("b", math.log(3))) + ], + ) + assessment = assess_claim_evidence( + claim, + evidence, + policy=EvidenceAssessmentPolicy( + min_scored_evidence=2, + min_prospective_evidence=2, + min_independent_blocks=2, + require_interventional_evidence=True, + ), + ) + promotion = evaluate_claim_promotion( + assessment, + criteria=PromotionCriteria( + min_scored_evidence=2, + min_prospective_evidence=2, + min_interventional_evidence=1, + min_independent_blocks=2, + min_preregistered_evidence=2, + ), + ) + return claim, evidence, assessment, promotion + + +def test_ledger_records_claim_posterior_and_promotion_gate(tmp_path): + ledger = ScientificLedger(tmp_path / "ledger") + claim, evidence, assessment, promotion = _evidence_bundle() + + result = ledger.record_claim_evidence( + campaign_id="campaign-1", + claim=claim, + evidence=evidence, + assessment=assessment, + promotion_decision=promotion, + ) + + campaign_dir = Path(result.campaign_directory) + claim_path = campaign_dir / result.changed_paths[0] + content = claim_path.read_text() + assert "artifact_type: scientific_claim_evidence" in content + assert "Posterior probability" in content + assert "explicit human approval is required" in content + assert "Auto-applied: no" in content + assert (campaign_dir / "evidence/index.md").exists() + assert "objective/validity" in (campaign_dir / "evidence/index.md").read_text() + + +def test_claim_evidence_write_is_idempotent(tmp_path): + ledger = ScientificLedger(tmp_path / "ledger") + claim, evidence, assessment, promotion = _evidence_bundle() + kwargs = { + "campaign_id": "campaign-1", + "claim": claim, + "evidence": evidence, + "assessment": assessment, + "promotion_decision": promotion, + } + + first = ledger.record_claim_evidence(**kwargs) + second = ledger.record_claim_evidence(**kwargs) + + assert first.changed_paths + assert second.changed_paths == () + assert set(second.unchanged_paths) == { + f"evidence/claims/{safe_path_component(claim.claim_id)}.md", + "evidence/index.md", + } + + +def test_ledger_records_shadow_experiment_plan(tmp_path): + plan = rank_discrimination_experiments( + [ + DiscriminationExperiment( + experiment_id="anchor-replicate", + description="Repeat the feasible anchor across an independent plate.", + safety_approved=True, + predictions=[ + ExperimentPrediction( + hypothesis_id="scalar-cliff", + outcome_probabilities={"reproduces": 0.9, "fails": 0.1}, + ), + ExperimentPrediction( + hypothesis_id="assay-drift", + outcome_probabilities={"reproduces": 0.2, "fails": 0.8}, + ), + ], + ) + ], + [ + HypothesisPriorScenario( + scenario_id="balanced", + probabilities={"scalar-cliff": 0.5, "assay-drift": 0.5}, + ) + ], + plan_id="discovery-round-1", + ) + ledger = ScientificLedger(tmp_path / "ledger") + + result = ledger.record_experiment_plan(campaign_id="campaign-1", plan=plan) + + campaign_dir = Path(result.campaign_directory) + content = (campaign_dir / "evidence/plans/discovery-round-1.md").read_text() + assert "hypothesis_discrimination_plan" in content + assert "anchor-replicate" in content + assert "advisory and cannot execute" in content + assert "discovery-round-1" in (campaign_dir / "evidence/index.md").read_text() diff --git a/tests/test_scientific_ledger.py b/tests/test_scientific_ledger.py new file mode 100644 index 0000000..43b06f0 --- /dev/null +++ b/tests/test_scientific_ledger.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import subprocess +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import pytest + +from app.services.decision_trajectory import persist_campaign_trajectory +from app.services.scientific_ledger import ScientificLedger, safe_path_component +from tests.fixtures.scientific_ledger import decision_accounting, decision_trace + + +@pytest.fixture +def db_env(monkeypatch, request, tmp_path): + from app.core.config import get_settings + from app.core.db import init_db + + monkeypatch.setenv("DATA_DIR", str(tmp_path / "data")) + monkeypatch.setenv("DB_PATH", str(tmp_path / "data" / "orchestrator.db")) + monkeypatch.setenv("OBJECT_STORE_DIR", str(tmp_path / "objects")) + get_settings.cache_clear() + request.addfinalizer(get_settings.cache_clear) + init_db() + + +def test_pending_then_completed_lifecycle_builds_full_markdown_tree(tmp_path, db_env): + ledger = ScientificLedger(tmp_path / "ledger", workspace_root=Path.cwd()) + trace = decision_trace() + pending = ledger.record_pending(trace, campaign_metadata={"domain": "chemistry"}) + assert pending.status == "pending" + campaign_dir = Path(pending.campaign_directory) + assert (campaign_dir / "rounds/003/decision_003.md").exists() + assert (campaign_dir / "campaign.md").exists() + assert (campaign_dir / "policy.md").exists() + assert (campaign_dir / "policy_versions/v4.md").exists() + assert (campaign_dir / "nexus.md").exists() + assert (campaign_dir / "trajectory.md").exists() + assert (campaign_dir / "training_dataset.md").exists() + + accounting = decision_accounting() + persist_campaign_trajectory(accounting) + completed = ledger.record_completed( + accounting, + failures=[{"problem": "OT2 aspiration failed", "root_cause": "pipette offset"}], + recovery_events=[{"fix": "increase z offset 0.5mm", "result": "pass"}], + ) + assert completed.status == "completed" + card = (campaign_dir / "rounds/003/decision_003.md").read_text() + assert "status: completed" in card + assert "record_count: 1" in (campaign_dir / "training_dataset.md").read_text() + assert all(path.suffix == ".md" for path in campaign_dir.rglob("*.md")) + + +def test_scientific_memory_search_finds_failure_without_embeddings(tmp_path, db_env): + ledger = ScientificLedger(tmp_path / "ledger") + accounting = decision_accounting() + persist_campaign_trajectory(accounting) + ledger.record_completed( + accounting, + failures=[{"root_cause": "pipette offset", "error": "aspiration failed"}], + ) + hits = ledger.search("pipette offset") + assert hits + assert hits[0].campaign_id == "campaign-32" + assert hits[0].path == "rounds/003/failure.md" + + +def test_rlvr_export_uses_typed_trajectory_not_markdown_scraping(tmp_path, db_env): + ledger = ScientificLedger(tmp_path / "ledger") + accounting = decision_accounting() + persist_campaign_trajectory(accounting) + records = ledger.export_rlvr_records("campaign-32") + assert len(records) == 1 + assert records[0]["schema_version"] == "helios.rlvr/v1" + assert records[0]["chosen_action"] == "propose_candidates" + assert records[0]["candidate_actions"][0]["name"] == "validation" + assert records[0]["reward"]["reward"] == accounting.reward.reward + assert ledger.export_rlvr_jsonl("campaign-32").count("\n") == 0 + + +def test_campaign_id_is_path_safe_and_collision_resistant(tmp_path): + ledger = ScientificLedger(tmp_path / "ledger") + trace = decision_trace(campaign_id="../../danger") + result = ledger.record_pending(trace) + campaign_dir = Path(result.campaign_directory) + assert campaign_dir.is_relative_to((tmp_path / "ledger").resolve()) + assert ".." not in campaign_dir.name + assert safe_path_component("a/b") != safe_path_component("a-b") + + +def test_campaign_directory_rejects_symlink_escape(tmp_path): + ledger = ScientificLedger(tmp_path / "ledger") + campaigns = tmp_path / "ledger" / "campaigns" + campaigns.mkdir(parents=True) + outside = tmp_path / "outside" + outside.mkdir() + (campaigns / "escaped").symlink_to(outside, target_is_directory=True) + + with pytest.raises(ValueError, match="escapes the ledger root"): + ledger.campaign_directory("escaped") + + +def test_concurrent_idempotent_writes_leave_no_partial_files(tmp_path, db_env): + ledger = ScientificLedger(tmp_path / "ledger") + accounting = decision_accounting() + persist_campaign_trajectory(accounting) + + with ThreadPoolExecutor(max_workers=6) as executor: + results = list(executor.map(lambda _: ledger.record_completed(accounting), range(12))) + + campaign_dir = Path(results[0].campaign_directory) + assert (campaign_dir / "rounds/003/decision_003.md").read_text().endswith("\n") + assert not list(campaign_dir.rglob("*.tmp")) + assert any(result.changed_paths for result in results) + assert any(not result.changed_paths for result in results) + + +def test_ledger_git_records_pending_and_completed_transitions(tmp_path, db_env): + ledger = ScientificLedger( + tmp_path / "ledger", + git_enabled=True, + git_auto_init=True, + ) + trace = decision_trace() + pending = ledger.record_pending(trace) + assert pending.git_commit is not None + assert pending.git_commit.committed is True + + accounting = decision_accounting() + persist_campaign_trajectory(accounting) + completed = ledger.record_completed(accounting) + assert completed.git_commit is not None + assert completed.git_commit.committed is True + + log = subprocess.run( + ["git", "-C", completed.campaign_directory, "log", "--format=%s"], + check=True, + capture_output=True, + text=True, + ).stdout.splitlines() + assert log == ["outcome: finalize round 003", "decision: record round 003 pending"] + + +def test_search_validates_query_and_limit(tmp_path): + ledger = ScientificLedger(tmp_path / "ledger") + with pytest.raises(ValueError, match="must not be empty"): + ledger.search(" ") + with pytest.raises(ValueError, match="between 1 and 1000"): + ledger.search("offset", limit=0) + + +def test_search_does_not_follow_markdown_symlinks(tmp_path): + ledger = ScientificLedger(tmp_path / "ledger") + campaign = ledger.campaign_directory("campaign-1") + campaign.mkdir(parents=True) + outside = tmp_path / "outside.md" + outside.write_text("# Secret\npipette offset", encoding="utf-8") + (campaign / "linked.md").symlink_to(outside) + + assert ledger.search("pipette offset") == [] diff --git a/tests/test_scientific_ledger_config.py b/tests/test_scientific_ledger_config.py new file mode 100644 index 0000000..0e0f713 --- /dev/null +++ b/tests/test_scientific_ledger_config.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from app.core.config import Settings + + +def test_scientific_ledger_defaults_to_markdown_on_git_off(monkeypatch, tmp_path): + monkeypatch.setenv("DATA_DIR", str(tmp_path / "data")) + monkeypatch.delenv("SCIENTIFIC_LEDGER_ROOT", raising=False) + monkeypatch.delenv("SCIENTIFIC_LEDGER_ENABLED", raising=False) + monkeypatch.delenv("SCIENTIFIC_LEDGER_GIT_ENABLED", raising=False) + settings = Settings() + assert settings.scientific_ledger_root == tmp_path / "data" / "scientific_ledger" + assert settings.scientific_ledger_enabled is True + assert settings.scientific_ledger_git_enabled is False + assert settings.scientific_ledger_git_auto_init is True + + +def test_scientific_ledger_settings_are_configurable(monkeypatch, tmp_path): + root = tmp_path / "campaign-ledger" + monkeypatch.setenv("SCIENTIFIC_LEDGER_ROOT", str(root)) + monkeypatch.setenv("SCIENTIFIC_LEDGER_ENABLED", "false") + monkeypatch.setenv("SCIENTIFIC_LEDGER_GIT_ENABLED", "true") + monkeypatch.setenv("SCIENTIFIC_LEDGER_GIT_AUTO_INIT", "false") + settings = Settings() + assert settings.scientific_ledger_root == root + assert settings.scientific_ledger_enabled is False + assert settings.scientific_ledger_git_enabled is True + assert settings.scientific_ledger_git_auto_init is False diff --git a/tests/test_scientific_ledger_git.py b/tests/test_scientific_ledger_git.py new file mode 100644 index 0000000..5bfff3d --- /dev/null +++ b/tests/test_scientific_ledger_git.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import subprocess + +import pytest + +from app.services.scientific_ledger_git import ScientificLedgerGit + + +def test_campaign_local_git_initializes_and_commits_only_markdown(tmp_path): + campaign = tmp_path / "campaign-32" + campaign.mkdir() + card = campaign / "decision.md" + card.write_text("# Decision\n") + ignored = campaign / "runtime.json" + ignored.write_text('{"internal": true}') + + backend = ScientificLedgerGit(campaign, auto_init=True) + first = backend.commit([card], "decision: record pending") + assert first.committed is True + assert first.commit_sha + tracked = subprocess.run( + ["git", "-C", str(campaign), "ls-files"], + check=True, + capture_output=True, + text=True, + ).stdout.splitlines() + assert tracked == ["decision.md"] + remotes = subprocess.run( + ["git", "-C", str(campaign), "remote"], + check=True, + capture_output=True, + text=True, + ).stdout.splitlines() + assert remotes == [] + + second = backend.commit([card], "decision: duplicate") + assert second.committed is False + assert second.reason == "no_changes" + + +def test_git_backend_refuses_non_markdown_and_outside_paths(tmp_path): + campaign = tmp_path / "campaign" + campaign.mkdir() + backend = ScientificLedgerGit(campaign, auto_init=True) + json_path = campaign / "record.json" + json_path.write_text("{}") + outside = tmp_path / "outside.md" + outside.write_text("# Outside") + + with pytest.raises(ValueError, match="only stage Markdown"): + backend.commit([json_path], "bad") + with pytest.raises(ValueError, match="inside the campaign"): + backend.commit([outside], "bad") + + git_metadata = campaign / ".git" / "metadata.md" + git_metadata.parent.mkdir(exist_ok=True) + git_metadata.write_text("# Internal") + with pytest.raises(ValueError, match="may not stage Git metadata"): + backend.commit([git_metadata], "bad") + + +def test_git_backend_does_not_use_parent_source_repository(tmp_path): + parent = tmp_path / "parent" + parent.mkdir() + subprocess.run(["git", "-C", str(parent), "init"], check=True, capture_output=True) + campaign = parent / "ledger" / "campaign" + campaign.mkdir(parents=True) + card = campaign / "decision.md" + card.write_text("# Decision") + + disabled = ScientificLedgerGit(campaign, auto_init=False).commit([card], "decision") + assert disabled.committed is False + assert disabled.reason == "git_repository_unavailable" + assert not (campaign / ".git").exists() diff --git a/tests/test_scientific_ledger_runtime.py b/tests/test_scientific_ledger_runtime.py new file mode 100644 index 0000000..471700b --- /dev/null +++ b/tests/test_scientific_ledger_runtime.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from app.services.scientific_ledger_runtime import ( + finalize_scientific_decision, + record_pending_scientific_decision, + should_capture_decision_trace, +) +from tests.fixtures.scientific_ledger import decision_trace + + +@pytest.fixture +def runtime_env(monkeypatch, request, tmp_path): + from app.core.config import get_settings + from app.core.db import init_db + + monkeypatch.setenv("DATA_DIR", str(tmp_path / "data")) + monkeypatch.setenv("DB_PATH", str(tmp_path / "data" / "orchestrator.db")) + monkeypatch.setenv("OBJECT_STORE_DIR", str(tmp_path / "objects")) + monkeypatch.setenv("SCIENTIFIC_LEDGER_ROOT", str(tmp_path / "ledger")) + monkeypatch.setenv("SCIENTIFIC_LEDGER_ENABLED", "true") + monkeypatch.setenv("SCIENTIFIC_LEDGER_GIT_ENABLED", "false") + get_settings.cache_clear() + request.addfinalizer(get_settings.cache_clear) + init_db() + return tmp_path + + +def test_runtime_bridge_records_pending_and_closes_full_accounting(runtime_env): + from app.services.decision_trajectory import load_trajectories + + trace = decision_trace() + pending = record_pending_scientific_decision(trace) + assert pending is not None + card = Path(pending.campaign_directory) / "rounds/003/decision_003.md" + assert "status: pending" in card.read_text() + + result = finalize_scientific_decision( + trace, + observed_action="propose_candidates", + observed_backend="bo_mcp", + candidate_count=4, + execution_success=True, + failure_count=1, + objective_delta=0.2, + recovery_attempted=True, + recovery_success=True, + observations=[{"yield": 0.84}], + failures=[{"failure_type": "hardware", "root_cause": "pipette offset"}], + recovery_events=[{"fix": "increase z offset 0.5mm", "result": "pass"}], + ) + assert result.trajectory_id.startswith("traj-") + assert result.accounting.reward.recovery_reward == 0.1 + assert result.ledger_result is not None + assert "status: completed" in card.read_text() + rows = load_trajectories("campaign-32") + assert len(rows) == 1 + assert rows[0]["trajectory"]["outcome"]["recovery_success"] is True + + +def test_runtime_bridge_persists_typed_accounting_when_markdown_disabled( + runtime_env, monkeypatch +): + from app.core.config import get_settings + from app.services.decision_trajectory import load_trajectories + + monkeypatch.setenv("SCIENTIFIC_LEDGER_ENABLED", "false") + get_settings.cache_clear() + trace = decision_trace(campaign_id="typed-only", trace_id="typed-only-trace") + result = finalize_scientific_decision(trace, execution_success=False) + assert result.ledger_result is None + assert len(load_trajectories("typed-only")) == 1 + + +def test_runtime_finalize_aligns_trace_with_observed_action(runtime_env): + trace = decision_trace() + result = finalize_scientific_decision( + trace, + observed_action="recover_failure", + execution_success=False, + recovery_attempted=True, + recovery_success=False, + ) + + stored_trace = result.accounting.trace + assert stored_trace.actual_action == "recover_failure" + assert stored_trace.comparison["actual_action"] == "recover_failure" + assert stored_trace.would_change_route is True + assert result.accounting.outcome.observed_action == "recover_failure" + + +def test_trace_capture_gate_includes_markdown(runtime_env, monkeypatch): + from app.core.config import get_settings + + assert should_capture_decision_trace() is True + monkeypatch.setenv("SCIENTIFIC_LEDGER_ENABLED", "false") + monkeypatch.setenv("CONTEXTUAL_DECISION_SHADOW_ENABLED", "false") + get_settings.cache_clear() + assert should_capture_decision_trace() is False + + +def test_trace_capture_gate_includes_live_authority(runtime_env, monkeypatch): + from app.core.config import get_settings + + monkeypatch.setenv("SCIENTIFIC_LEDGER_ENABLED", "false") + monkeypatch.setenv("CONTEXTUAL_DECISION_SHADOW_ENABLED", "false") + monkeypatch.setenv("CAMPAIGN_DECISION_AUTHORITY_ENABLED", "true") + get_settings.cache_clear() + + assert should_capture_decision_trace() is True diff --git a/tests/test_scientific_memory_api.py b/tests/test_scientific_memory_api.py new file mode 100644 index 0000000..6bd0f5e --- /dev/null +++ b/tests/test_scientific_memory_api.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from tests.fixtures.scientific_ledger import decision_accounting + + +@pytest.fixture +def ledger_env(monkeypatch, request, tmp_path): + from app.core.config import get_settings + from app.core.db import init_db + from app.services.decision_trajectory import persist_campaign_trajectory + from app.services.scientific_ledger import get_scientific_ledger + + monkeypatch.setenv("DATA_DIR", str(tmp_path / "data")) + monkeypatch.setenv("DB_PATH", str(tmp_path / "data" / "orchestrator.db")) + monkeypatch.setenv("OBJECT_STORE_DIR", str(tmp_path / "objects")) + monkeypatch.setenv("SCIENTIFIC_LEDGER_ROOT", str(tmp_path / "ledger")) + get_settings.cache_clear() + request.addfinalizer(get_settings.cache_clear) + init_db() + accounting = decision_accounting() + persist_campaign_trajectory(accounting) + ledger = get_scientific_ledger() + ledger.record_completed( + accounting, + failures=[{"root_cause": "pipette offset", "result": "aspiration failed"}], + ) + return ledger + + +async def test_scientific_search_endpoint_returns_markdown_hit(ledger_env): + from app.api.v1.endpoints.memory import scientific_memory_search + + response = await scientific_memory_search("pipette offset", None, 10) + assert response["count"] >= 1 + assert any(hit["path"].endswith("failure.md") for hit in response["hits"]) + + +def test_scientific_routes_are_mounted_on_v1_api(ledger_env): + from app.main import app + + with TestClient(app) as client: + response = client.get( + "/api/v1/memory/scientific/search", + params={"q": "pipette offset", "campaign_id": "campaign-32"}, + ) + + assert response.status_code == 200 + assert response.json()["count"] >= 1 + + +async def test_scientific_artifact_endpoint_reads_markdown(ledger_env): + from app.api.v1.endpoints.memory import scientific_memory_artifact + + response = await scientific_memory_artifact("campaign-32", "rounds/003/decision_003.md") + assert response.media_type == "text/markdown" + assert b"# Decision Card 003" in response.body + + +async def test_scientific_artifact_endpoint_blocks_traversal(ledger_env, tmp_path): + from app.api.v1.endpoints.memory import scientific_memory_artifact + + (tmp_path / "secret.md").write_text("secret") + with pytest.raises(HTTPException) as exc_info: + await scientific_memory_artifact("campaign-32", "../../secret.md") + assert exc_info.value.status_code == 400 + + +async def test_scientific_rlvr_endpoint_returns_jsonl(ledger_env): + from app.api.v1.endpoints.memory import scientific_memory_rlvr + + response = await scientific_memory_rlvr("campaign-32") + assert response.media_type == "application/x-ndjson" + assert b'"schema_version": "helios.rlvr/v1"' in response.body + + +def test_fixture_wrote_only_expected_root(ledger_env): + assert Path(ledger_env.root).name == "ledger" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..46bf905 --- /dev/null +++ b/uv.lock @@ -0,0 +1,2467 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.13' and python_full_version < '3.15'", + "python_full_version == '3.12.*'", + "python_full_version < '3.12'", +] + +[[package]] +name = "aionotify" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/16/81a26a64d728e76eea073cd0316f3e8885cca312247a9ba9af64d7c47e64/aionotify-0.3.1.tar.gz", hash = "sha256:9651e1373873c75786101330e302e114f85b6e8b5ad70b491497c8b3609a8449", size = 11291, upload-time = "2024-05-15T17:05:41.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/6a/b43cb72f72ad650225854b630ab569b74071764fb562b88264aed4c9e350/aionotify-0.3.1-py2.py3-none-any.whl", hash = "sha256:25816a9eef030c774beaee22189a24e29bc43f81cebe574ef723851eaf89ddee", size = 7429, upload-time = "2024-05-15T17:05:27.184Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" }, + { url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" }, + { url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "audioop-lts" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/53/946db57842a50b2da2e0c1e34bd37f36f5aadba1a929a3971c5d7841dbca/audioop_lts-0.2.2.tar.gz", hash = "sha256:64d0c62d88e67b98a1a5e71987b7aa7b5bcffc7dcee65b635823dbdd0a8dbbd0", size = 30686, upload-time = "2025-08-05T16:43:17.409Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/d4/94d277ca941de5a507b07f0b592f199c22454eeaec8f008a286b3fbbacd6/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800", size = 46523, upload-time = "2025-08-05T16:42:20.836Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5a/656d1c2da4b555920ce4177167bfeb8623d98765594af59702c8873f60ec/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303", size = 27455, upload-time = "2025-08-05T16:42:22.283Z" }, + { url = "https://files.pythonhosted.org/packages/1b/83/ea581e364ce7b0d41456fb79d6ee0ad482beda61faf0cab20cbd4c63a541/audioop_lts-0.2.2-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:9a13dc409f2564de15dd68be65b462ba0dde01b19663720c68c1140c782d1d75", size = 26997, upload-time = "2025-08-05T16:42:23.849Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3b/e8964210b5e216e5041593b7d33e97ee65967f17c282e8510d19c666dab4/audioop_lts-0.2.2-cp313-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51c916108c56aa6e426ce611946f901badac950ee2ddaf302b7ed35d9958970d", size = 85844, upload-time = "2025-08-05T16:42:25.208Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2e/0a1c52faf10d51def20531a59ce4c706cb7952323b11709e10de324d6493/audioop_lts-0.2.2-cp313-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47eba38322370347b1c47024defbd36374a211e8dd5b0dcbce7b34fdb6f8847b", size = 85056, upload-time = "2025-08-05T16:42:26.559Z" }, + { url = "https://files.pythonhosted.org/packages/75/e8/cd95eef479656cb75ab05dfece8c1f8c395d17a7c651d88f8e6e291a63ab/audioop_lts-0.2.2-cp313-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba7c3a7e5f23e215cb271516197030c32aef2e754252c4c70a50aaff7031a2c8", size = 93892, upload-time = "2025-08-05T16:42:27.902Z" }, + { url = "https://files.pythonhosted.org/packages/5c/1e/a0c42570b74f83efa5cca34905b3eef03f7ab09fe5637015df538a7f3345/audioop_lts-0.2.2-cp313-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def246fe9e180626731b26e89816e79aae2276f825420a07b4a647abaa84becc", size = 96660, upload-time = "2025-08-05T16:42:28.9Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/8a0ae607ca07dbb34027bac8db805498ee7bfecc05fd2c148cc1ed7646e7/audioop_lts-0.2.2-cp313-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e160bf9df356d841bb6c180eeeea1834085464626dc1b68fa4e1d59070affdc3", size = 79143, upload-time = "2025-08-05T16:42:29.929Z" }, + { url = "https://files.pythonhosted.org/packages/12/17/0d28c46179e7910bfb0bb62760ccb33edb5de973052cb2230b662c14ca2e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4b4cd51a57b698b2d06cb9993b7ac8dfe89a3b2878e96bc7948e9f19ff51dba6", size = 84313, upload-time = "2025-08-05T16:42:30.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/ba/bd5d3806641564f2024e97ca98ea8f8811d4e01d9b9f9831474bc9e14f9e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4a53aa7c16a60a6857e6b0b165261436396ef7293f8b5c9c828a3a203147ed4a", size = 93044, upload-time = "2025-08-05T16:42:31.959Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5e/435ce8d5642f1f7679540d1e73c1c42d933331c0976eb397d1717d7f01a3/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:3fc38008969796f0f689f1453722a0f463da1b8a6fbee11987830bfbb664f623", size = 78766, upload-time = "2025-08-05T16:42:33.302Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/b909e76b606cbfd53875693ec8c156e93e15a1366a012f0b7e4fb52d3c34/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:15ab25dd3e620790f40e9ead897f91e79c0d3ce65fe193c8ed6c26cffdd24be7", size = 87640, upload-time = "2025-08-05T16:42:34.854Z" }, + { url = "https://files.pythonhosted.org/packages/30/e7/8f1603b4572d79b775f2140d7952f200f5e6c62904585d08a01f0a70393a/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:03f061a1915538fd96272bac9551841859dbb2e3bf73ebe4a23ef043766f5449", size = 86052, upload-time = "2025-08-05T16:42:35.839Z" }, + { url = "https://files.pythonhosted.org/packages/b5/96/c37846df657ccdda62ba1ae2b6534fa90e2e1b1742ca8dcf8ebd38c53801/audioop_lts-0.2.2-cp313-abi3-win32.whl", hash = "sha256:3bcddaaf6cc5935a300a8387c99f7a7fbbe212a11568ec6cf6e4bc458c048636", size = 26185, upload-time = "2025-08-05T16:42:37.04Z" }, + { url = "https://files.pythonhosted.org/packages/34/a5/9d78fdb5b844a83da8a71226c7bdae7cc638861085fff7a1d707cb4823fa/audioop_lts-0.2.2-cp313-abi3-win_amd64.whl", hash = "sha256:a2c2a947fae7d1062ef08c4e369e0ba2086049a5e598fda41122535557012e9e", size = 30503, upload-time = "2025-08-05T16:42:38.427Z" }, + { url = "https://files.pythonhosted.org/packages/34/25/20d8fde083123e90c61b51afb547bb0ea7e77bab50d98c0ab243d02a0e43/audioop_lts-0.2.2-cp313-abi3-win_arm64.whl", hash = "sha256:5f93a5db13927a37d2d09637ccca4b2b6b48c19cd9eda7b17a2e9f77edee6a6f", size = 24173, upload-time = "2025-08-05T16:42:39.704Z" }, + { url = "https://files.pythonhosted.org/packages/58/a7/0a764f77b5c4ac58dc13c01a580f5d32ae8c74c92020b961556a43e26d02/audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:73f80bf4cd5d2ca7814da30a120de1f9408ee0619cc75da87d0641273d202a09", size = 47096, upload-time = "2025-08-05T16:42:40.684Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ed/ebebedde1a18848b085ad0fa54b66ceb95f1f94a3fc04f1cd1b5ccb0ed42/audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:106753a83a25ee4d6f473f2be6b0966fc1c9af7e0017192f5531a3e7463dce58", size = 27748, upload-time = "2025-08-05T16:42:41.992Z" }, + { url = "https://files.pythonhosted.org/packages/cb/6e/11ca8c21af79f15dbb1c7f8017952ee8c810c438ce4e2b25638dfef2b02c/audioop_lts-0.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fbdd522624141e40948ab3e8cdae6e04c748d78710e9f0f8d4dae2750831de19", size = 27329, upload-time = "2025-08-05T16:42:42.987Z" }, + { url = "https://files.pythonhosted.org/packages/84/52/0022f93d56d85eec5da6b9da6a958a1ef09e80c39f2cc0a590c6af81dcbb/audioop_lts-0.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:143fad0311e8209ece30a8dbddab3b65ab419cbe8c0dde6e8828da25999be911", size = 92407, upload-time = "2025-08-05T16:42:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/87/1d/48a889855e67be8718adbc7a01f3c01d5743c325453a5e81cf3717664aad/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfbbc74ec68a0fd08cfec1f4b5e8cca3d3cd7de5501b01c4b5d209995033cde9", size = 91811, upload-time = "2025-08-05T16:42:45.325Z" }, + { url = "https://files.pythonhosted.org/packages/98/a6/94b7213190e8077547ffae75e13ed05edc488653c85aa5c41472c297d295/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfcac6aa6f42397471e4943e0feb2244549db5c5d01efcd02725b96af417f3fe", size = 100470, upload-time = "2025-08-05T16:42:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e9/78450d7cb921ede0cfc33426d3a8023a3bda755883c95c868ee36db8d48d/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:752d76472d9804ac60f0078c79cdae8b956f293177acd2316cd1e15149aee132", size = 103878, upload-time = "2025-08-05T16:42:47.576Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e2/cd5439aad4f3e34ae1ee852025dc6aa8f67a82b97641e390bf7bd9891d3e/audioop_lts-0.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:83c381767e2cc10e93e40281a04852facc4cd9334550e0f392f72d1c0a9c5753", size = 84867, upload-time = "2025-08-05T16:42:49.003Z" }, + { url = "https://files.pythonhosted.org/packages/68/4b/9d853e9076c43ebba0d411e8d2aa19061083349ac695a7d082540bad64d0/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c0022283e9556e0f3643b7c3c03f05063ca72b3063291834cca43234f20c60bb", size = 90001, upload-time = "2025-08-05T16:42:50.038Z" }, + { url = "https://files.pythonhosted.org/packages/58/26/4bae7f9d2f116ed5593989d0e521d679b0d583973d203384679323d8fa85/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a2d4f1513d63c795e82948e1305f31a6d530626e5f9f2605408b300ae6095093", size = 99046, upload-time = "2025-08-05T16:42:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/b2/67/a9f4fb3e250dda9e9046f8866e9fa7d52664f8985e445c6b4ad6dfb55641/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c9c8e68d8b4a56fda8c025e538e639f8c5953f5073886b596c93ec9b620055e7", size = 84788, upload-time = "2025-08-05T16:42:52.198Z" }, + { url = "https://files.pythonhosted.org/packages/70/f7/3de86562db0121956148bcb0fe5b506615e3bcf6e63c4357a612b910765a/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:96f19de485a2925314f5020e85911fb447ff5fbef56e8c7c6927851b95533a1c", size = 94472, upload-time = "2025-08-05T16:42:53.59Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/fd772bf9078ae1001207d2df1eef3da05bea611a87dd0e8217989b2848fa/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e541c3ef484852ef36545f66209444c48b28661e864ccadb29daddb6a4b8e5f5", size = 92279, upload-time = "2025-08-05T16:42:54.632Z" }, + { url = "https://files.pythonhosted.org/packages/4f/41/affea7181592ab0ab560044632571a38edaf9130b84928177823fbf3176a/audioop_lts-0.2.2-cp313-cp313t-win32.whl", hash = "sha256:d5e73fa573e273e4f2e5ff96f9043858a5e9311e94ffefd88a3186a910c70917", size = 26568, upload-time = "2025-08-05T16:42:55.627Z" }, + { url = "https://files.pythonhosted.org/packages/28/2b/0372842877016641db8fc54d5c88596b542eec2f8f6c20a36fb6612bf9ee/audioop_lts-0.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9191d68659eda01e448188f60364c7763a7ca6653ed3f87ebb165822153a8547", size = 30942, upload-time = "2025-08-05T16:42:56.674Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/baf2b9cc7e96c179bb4a54f30fcd83e6ecb340031bde68f486403f943768/audioop_lts-0.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c174e322bb5783c099aaf87faeb240c8d210686b04bd61dfd05a8e5a83d88969", size = 24603, upload-time = "2025-08-05T16:42:57.571Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/413b5a2804091e2c7d5def1d618e4837f1cb82464e230f827226278556b7/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f9ee9b52f5f857fbaf9d605a360884f034c92c1c23021fb90b2e39b8e64bede6", size = 47104, upload-time = "2025-08-05T16:42:58.518Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/daa3308dc6593944410c2c68306a5e217f5c05b70a12e70228e7dd42dc5c/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:49ee1a41738a23e98d98b937a0638357a2477bc99e61b0f768a8f654f45d9b7a", size = 27754, upload-time = "2025-08-05T16:43:00.132Z" }, + { url = "https://files.pythonhosted.org/packages/4e/86/c2e0f627168fcf61781a8f72cab06b228fe1da4b9fa4ab39cfb791b5836b/audioop_lts-0.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b00be98ccd0fc123dcfad31d50030d25fcf31488cde9e61692029cd7394733b", size = 27332, upload-time = "2025-08-05T16:43:01.666Z" }, + { url = "https://files.pythonhosted.org/packages/c7/bd/35dce665255434f54e5307de39e31912a6f902d4572da7c37582809de14f/audioop_lts-0.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6d2e0f9f7a69403e388894d4ca5ada5c47230716a03f2847cfc7bd1ecb589d6", size = 92396, upload-time = "2025-08-05T16:43:02.991Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d2/deeb9f51def1437b3afa35aeb729d577c04bcd89394cb56f9239a9f50b6f/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9b0b8a03ef474f56d1a842af1a2e01398b8f7654009823c6d9e0ecff4d5cfbf", size = 91811, upload-time = "2025-08-05T16:43:04.096Z" }, + { url = "https://files.pythonhosted.org/packages/76/3b/09f8b35b227cee28cc8231e296a82759ed80c1a08e349811d69773c48426/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b267b70747d82125f1a021506565bdc5609a2b24bcb4773c16d79d2bb260bbd", size = 100483, upload-time = "2025-08-05T16:43:05.085Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/05b48a935cf3b130c248bfdbdea71ce6437f5394ee8533e0edd7cfd93d5e/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0337d658f9b81f4cd0fdb1f47635070cc084871a3d4646d9de74fdf4e7c3d24a", size = 103885, upload-time = "2025-08-05T16:43:06.197Z" }, + { url = "https://files.pythonhosted.org/packages/83/80/186b7fce6d35b68d3d739f228dc31d60b3412105854edb975aa155a58339/audioop_lts-0.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:167d3b62586faef8b6b2275c3218796b12621a60e43f7e9d5845d627b9c9b80e", size = 84899, upload-time = "2025-08-05T16:43:07.291Z" }, + { url = "https://files.pythonhosted.org/packages/49/89/c78cc5ac6cb5828f17514fb12966e299c850bc885e80f8ad94e38d450886/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0d9385e96f9f6da847f4d571ce3cb15b5091140edf3db97276872647ce37efd7", size = 89998, upload-time = "2025-08-05T16:43:08.335Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/6401888d0c010e586c2ca50fce4c903d70a6bb55928b16cfbdfd957a13da/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:48159d96962674eccdca9a3df280e864e8ac75e40a577cc97c5c42667ffabfc5", size = 99046, upload-time = "2025-08-05T16:43:09.367Z" }, + { url = "https://files.pythonhosted.org/packages/de/f8/c874ca9bb447dae0e2ef2e231f6c4c2b0c39e31ae684d2420b0f9e97ee68/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fefe5868cd082db1186f2837d64cfbfa78b548ea0d0543e9b28935ccce81ce9", size = 84843, upload-time = "2025-08-05T16:43:10.749Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/0323e66f3daebc13fd46b36b30c3be47e3fc4257eae44f1e77eb828c703f/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:58cf54380c3884fb49fdd37dfb7a772632b6701d28edd3e2904743c5e1773602", size = 94490, upload-time = "2025-08-05T16:43:12.131Z" }, + { url = "https://files.pythonhosted.org/packages/98/6b/acc7734ac02d95ab791c10c3f17ffa3584ccb9ac5c18fd771c638ed6d1f5/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:088327f00488cdeed296edd9215ca159f3a5a5034741465789cad403fcf4bec0", size = 92297, upload-time = "2025-08-05T16:43:13.139Z" }, + { url = "https://files.pythonhosted.org/packages/13/c3/c3dc3f564ce6877ecd2a05f8d751b9b27a8c320c2533a98b0c86349778d0/audioop_lts-0.2.2-cp314-cp314t-win32.whl", hash = "sha256:068aa17a38b4e0e7de771c62c60bbca2455924b67a8814f3b0dee92b5820c0b3", size = 27331, upload-time = "2025-08-05T16:43:14.19Z" }, + { url = "https://files.pythonhosted.org/packages/72/bb/b4608537e9ffcb86449091939d52d24a055216a36a8bf66b936af8c3e7ac/audioop_lts-0.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a5bf613e96f49712073de86f20dbdd4014ca18efd4d34ed18c75bd808337851b", size = 31697, upload-time = "2025-08-05T16:43:15.193Z" }, + { url = "https://files.pythonhosted.org/packages/f6/22/91616fe707a5c5510de2cac9b046a30defe7007ba8a0c04f9c08f27df312/audioop_lts-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd", size = 25206, upload-time = "2025-08-05T16:43:16.444Z" }, +] + +[[package]] +name = "brotli" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/ef/f285668811a9e1ddb47a18cb0b437d5fc2760d537a2fe8a57875ad6f8448/brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744", size = 863110, upload-time = "2025-11-05T18:38:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/50/62/a3b77593587010c789a9d6eaa527c79e0848b7b860402cc64bc0bc28a86c/brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f", size = 445438, upload-time = "2025-11-05T18:38:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/cd/e1/7fadd47f40ce5549dc44493877db40292277db373da5053aff181656e16e/brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd", size = 1534420, upload-time = "2025-11-05T18:38:15.111Z" }, + { url = "https://files.pythonhosted.org/packages/12/8b/1ed2f64054a5a008a4ccd2f271dbba7a5fb1a3067a99f5ceadedd4c1d5a7/brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe", size = 1632619, upload-time = "2025-11-05T18:38:16.094Z" }, + { url = "https://files.pythonhosted.org/packages/89/5a/7071a621eb2d052d64efd5da2ef55ecdac7c3b0c6e4f9d519e9c66d987ef/brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a", size = 1426014, upload-time = "2025-11-05T18:38:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/26/6d/0971a8ea435af5156acaaccec1a505f981c9c80227633851f2810abd252a/brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b", size = 1489661, upload-time = "2025-11-05T18:38:18.41Z" }, + { url = "https://files.pythonhosted.org/packages/f3/75/c1baca8b4ec6c96a03ef8230fab2a785e35297632f402ebb1e78a1e39116/brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3", size = 1599150, upload-time = "2025-11-05T18:38:19.792Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1a/23fcfee1c324fd48a63d7ebf4bac3a4115bdb1b00e600f80f727d850b1ae/brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae", size = 1493505, upload-time = "2025-11-05T18:38:20.913Z" }, + { url = "https://files.pythonhosted.org/packages/36/e5/12904bbd36afeef53d45a84881a4810ae8810ad7e328a971ebbfd760a0b3/brotli-1.2.0-cp311-cp311-win32.whl", hash = "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03", size = 334451, upload-time = "2025-11-05T18:38:21.94Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/ecb5761b989629a4758c394b9301607a5880de61ee2ee5fe104b87149ebc/brotli-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24", size = 369035, upload-time = "2025-11-05T18:38:22.941Z" }, + { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" }, + { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" }, + { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" }, + { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" }, + { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/3a/54536704f507d4573bf9161c4d0dd3dd59b6d85e48c664e901b6844d8e33/coverage-7.15.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036", size = 221414, upload-time = "2026-07-15T18:53:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d9/8ba925d29743e3577b21e4d8c11a702b76bc93c41e7fdfd1177af63d4b8d/coverage-7.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660", size = 221913, upload-time = "2026-07-15T18:53:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/a855f3aa0187f2b431ade4e4791b77b56282cfb5d201c83ec26a31b5b36a/coverage-7.15.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589", size = 252332, upload-time = "2026-07-15T18:53:55.467Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d3/13ac97b4370640ba3452fc8559b06cc2f479ce3ba4a0b632a73e44c38a7d/coverage-7.15.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee", size = 254243, upload-time = "2026-07-15T18:53:57.055Z" }, + { url = "https://files.pythonhosted.org/packages/88/83/5eca144942d8d0659d3f55176517f4a59cdc65eefd17146a0770935a3ebd/coverage-7.15.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0", size = 256352, upload-time = "2026-07-15T18:53:58.83Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ba/d3db2e01a50fc88cdb4c0f19542bcf6f61489e34dc9aa3538413e2459a38/coverage-7.15.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487", size = 258313, upload-time = "2026-07-15T18:54:00.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/b3/aba83416e9177df28e5186d856c19158c59fc0e7e814aaa61a4a2354ad1b/coverage-7.15.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f", size = 252449, upload-time = "2026-07-15T18:54:02.456Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a5/4b00ecac0194431ab451b0f6710f8e2517d04cef60f821b14dec4637d575/coverage-7.15.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1", size = 254043, upload-time = "2026-07-15T18:54:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/75/b6/cfa209b4313ee7f1b34da47efcd789ea51c024ad35af390e00f5a3c10a2e/coverage-7.15.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5", size = 252107, upload-time = "2026-07-15T18:54:06.745Z" }, + { url = "https://files.pythonhosted.org/packages/36/67/e8cac5a6954038c98d7fe7eb9802afe7ab3ecb637bb7cc00e69b4148b56d/coverage-7.15.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0", size = 255873, upload-time = "2026-07-15T18:54:08.48Z" }, + { url = "https://files.pythonhosted.org/packages/2c/92/395cca9f330a86c3fe3471d73e2c102116c4c58fdc619dbbc125c6e93a54/coverage-7.15.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad", size = 251826, upload-time = "2026-07-15T18:54:10.083Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/3e91b20295439652424f426b7086ec5bf4fbe3f604c73eda22b986c4fd6b/coverage-7.15.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db", size = 252735, upload-time = "2026-07-15T18:54:11.878Z" }, + { url = "https://files.pythonhosted.org/packages/a5/eb/8c07839005e5e3c6b3877d3a6e2a80ce766589f31dd2b6882b78d59a7b8c/coverage-7.15.2-cp311-cp311-win32.whl", hash = "sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9", size = 223500, upload-time = "2026-07-15T18:54:13.525Z" }, + { url = "https://files.pythonhosted.org/packages/2e/98/59d83c257cd59f0fbaf9d9ddb26b744a576760dfd1ae16e516408894a02b/coverage-7.15.2-cp311-cp311-win_amd64.whl", hash = "sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688", size = 223973, upload-time = "2026-07-15T18:54:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/ea/09/2d285c8bef5c4f695d120c1c96dc11715638aa8e134069f210bb6a62a9fe/coverage-7.15.2-cp311-cp311-win_arm64.whl", hash = "sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934", size = 223519, upload-time = "2026-07-15T18:54:16.803Z" }, + { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, + { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, + { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, + { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, + { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, + { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, + { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, + { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" }, + { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" }, + { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" }, + { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" }, + { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" }, + { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cuda-bindings" +version = "13.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-pathfinder", marker = "python_full_version < '3.15'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" }, + { url = "https://files.pythonhosted.org/packages/95/7a/c5e3c34a409b148f5c0f5a4ea374158f95d488862c1dffedf9aa5c639df9/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04436a9364059c84b8f9636f359eccda1cf814341f5b670c71d80d2f79dbc708", size = 6674166, upload-time = "2026-05-29T23:11:45.478Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/5e7dba1ba576dd73da5dee894ca076ca5e959450dfff66d6d510a255d1f7/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7855c4868aabc0cfae28abbe83d56734bdfbd08f08fc234ac1912a12858bf49", size = 6025351, upload-time = "2026-05-29T23:11:49.685Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/6d2e9047d1fb243dbaa364b01e0297534b9ed7fd27dba1c9f361519cf69b/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e32d08f71ebcdf00f0f41eab2eb37e8da94c8ed411cc9f7f7a019ce6b34abe3a", size = 6657965, upload-time = "2026-05-29T23:11:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6e/2394f8163360f8391f8f1b7e72d300a82724edb81a7b7084c799fbd4c91f/cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9efb21c1ee64981e184b9e0ba5eb3179e5ba3d4b51665a6cb52b8ef3d01a7cbf", size = 5920504, upload-time = "2026-05-29T23:11:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/34/c2/ef9b6a63f7dc432712a462c816662e662e00d38caa9b861c8c2588195d03/cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2732904099e0a4d4db774a5fc6d91ee95fae065b4d2ecabb4968c5fe2406c9d7", size = 6476660, upload-time = "2026-05-29T23:11:59.188Z" }, + { url = "https://files.pythonhosted.org/packages/b1/81/bff68ce829999c1e4209c761bbf903b1c06ec570416ddb25020864ad5907/cuda_bindings-13.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ab2f74ed65bfef4163ba07a8db16f1085e0729291db12a2423aff84ee8278b8", size = 6013639, upload-time = "2026-05-29T23:12:03.509Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e0/c8a1f0c8f9ffdea4f5fe6dbab89b326cef4d85caf489dad39e209da89416/cuda_bindings-13.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd4c814d311ec08c981f6dded1dbe7d4b371067ee4f6c14cccec4bde9590f80", size = 6534419, upload-time = "2026-05-29T23:12:05.633Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/83b1f563925b290f2d11a01a77a84013ba56052fe3653a5bef3ccfbb43d6/cuda_bindings-13.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3c772dfff49681541d59630c90f858e173ac926b9c593a2b7123f2a1043cc76", size = 5809771, upload-time = "2026-05-29T23:12:10.422Z" }, + { url = "https://files.pythonhosted.org/packages/12/20/e79b4bfe98f075195afb6343d41c498f9dbd2d161d7021d4d28bceb83581/cuda_bindings-13.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36febb7c1079d68a981dbbd8d5a67235b399802b82075c9388624719607e52b9", size = 6358584, upload-time = "2026-05-29T23:12:12.767Z" }, +] + +[[package]] +name = "cuda-pathfinder" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/b4/d088047afe39827556df21118cac9ffd20cc3f968c99a7681494d1eb333c/cuda_pathfinder-1.6.0-py3-none-any.whl", hash = "sha256:1503af579d8379c24bdd65528379bc57039b0455be9f5f9686cf8e473a1fce51", size = 54591, upload-time = "2026-07-21T15:03:56.224Z" }, +] + +[[package]] +name = "cuda-toolkit" +version = "13.0.3.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/c7/a79086a62c98befcdb8349656c6f114e2db3b8b2422f6e25c97a7f2a9a3c/cuda_toolkit-13.0.3.0-py2.py3-none-any.whl", hash = "sha256:d693caaa261214ddd7dbb60d68e71cbed884e68c2be7509778f3051da0b91c3f", size = 2512, upload-time = "2026-04-14T00:50:08.173Z" }, +] + +[package.optional-dependencies] +cublas = [ + { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, +] +cudart = [ + { name = "nvidia-cuda-runtime", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, +] +cufft = [ + { name = "nvidia-cufft", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, +] +cufile = [ + { name = "nvidia-cufile", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] +cupti = [ + { name = "nvidia-cuda-cupti", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, +] +curand = [ + { name = "nvidia-curand", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, +] +cusolver = [ + { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-cusolver", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, +] +cusparse = [ + { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, +] +nvjitlink = [ + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, +] +nvrtc = [ + { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, +] +nvtx = [ + { name = "nvidia-nvtx", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, +] + +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + +[[package]] +name = "filelock" +version = "3.32.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/57/3ba6e6cb097f85b855b00163d169f35365f44277df044dcf96d55b8f62a3/filelock-3.32.2.tar.gz", hash = "sha256:c33351e1f49cae33414acbc6d56784e6ecee82514ec90795da1161fc4836b5b8", size = 217172, upload-time = "2026-07-29T22:46:04.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl", hash = "sha256:87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82", size = 98830, upload-time = "2026-07-29T22:46:03.52Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/78/f34251dadb8f3921264a1d9b8946f5e542014ee2614b285261b4e40e6775/fsspec-2026.7.0.tar.gz", hash = "sha256:c803c40f4cf860b49dea58ee3e1c33cb9c790520e233537e1340049f89b82a88", size = 317040, upload-time = "2026-07-28T16:34:51.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/3c/6a2bf344106328fd04963664a60b9bb6496fc25df8e962fcdc1367285fb9/fsspec-2026.7.0-py3-none-any.whl", hash = "sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279", size = 206583, upload-time = "2026-07-28T16:34:49.538Z" }, +] + +[[package]] +name = "gradio" +version = "6.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, + { name = "brotli" }, + { name = "fastapi" }, + { name = "gradio-client" }, + { name = "groovy" }, + { name = "hf-gradio" }, + { name = "httpx" }, + { name = "huggingface-hub" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "numpy" }, + { name = "orjson" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "pillow" }, + { name = "pydantic" }, + { name = "pydub" }, + { name = "python-multipart" }, + { name = "pytz" }, + { name = "pyyaml" }, + { name = "safehttpx" }, + { name = "semantic-version" }, + { name = "starlette" }, + { name = "tomlkit" }, + { name = "typer" }, + { name = "typing-extensions" }, + { name = "uvicorn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/26/d3c9f9e5e7b0bdca8c9ef30cd29e7d2e070d4416518dd17698bfba25fd50/gradio-6.21.0.tar.gz", hash = "sha256:a27fe156ce971469b5104e8d8878f1cb136ae022b18abb1d99bce41f882e4e4a", size = 44534521, upload-time = "2026-07-29T19:39:40.589Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8f/13d40a099606f843c47d874586f50b28eabb3ee9b64c07105491cc57d05f/gradio-6.21.0-py3-none-any.whl", hash = "sha256:2342e186dc7c28c2681a4c84c386c88cf907894a77966511cc3d8f05363ca8f2", size = 30699108, upload-time = "2026-07-29T19:39:37.602Z" }, +] + +[[package]] +name = "gradio-client" +version = "2.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fsspec" }, + { name = "httpx" }, + { name = "huggingface-hub" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/a2/09b994dca27f17ffc0fdd33ee34f1f455a2ac5eec298499d0c21ebb6d918/gradio_client-2.6.0.tar.gz", hash = "sha256:e648110efa31347bb8b1abda150a7a975b40a9658fdd8562803e2ad6a300d033", size = 60674, upload-time = "2026-07-29T19:39:50.915Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/29/80957aef2f107e5357cb3d8d5d07f40302962c9b829f9e8f970b80bec502/gradio_client-2.6.0-py3-none-any.whl", hash = "sha256:4493a6425560dd23d1eb5427a13290819135c664c5ed9f184eb517b1ea263291", size = 61654, upload-time = "2026-07-29T19:39:49.788Z" }, +] + +[[package]] +name = "groovy" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/36/bbdede67400277bef33d3ec0e6a31750da972c469f75966b4930c753218f/groovy-0.1.2.tar.gz", hash = "sha256:25c1dc09b3f9d7e292458aa762c6beb96ea037071bf5e917fc81fb78d2231083", size = 17325, upload-time = "2025-02-28T20:24:56.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/27/3d6dcadc8a3214d8522c1e7f6a19554e33659be44546d44a2f7572ac7d2a/groovy-0.1.2-py3-none-any.whl", hash = "sha256:7f7975bab18c729a257a8b1ae9dcd70b7cafb1720481beae47719af57c35fa64", size = 14090, upload-time = "2025-02-28T20:24:55.152Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "helios-sdl" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "fastapi" }, + { name = "httpx" }, + { name = "numpy" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.optional-dependencies] +all = [ + { name = "anyio" }, + { name = "gradio" }, + { name = "httpx" }, + { name = "mypy" }, + { name = "opentrons" }, + { name = "pyserial" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "requests" }, + { name = "ruff" }, + { name = "torch" }, +] +demo = [ + { name = "requests" }, +] +dev = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] +frontend = [ + { name = "gradio" }, +] +hardware = [ + { name = "opentrons" }, + { name = "pyserial" }, +] +ml = [ + { name = "torch" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", marker = "extra == 'dev'", specifier = ">=4.0.0" }, + { name = "fastapi", specifier = ">=0.115.0" }, + { name = "gradio", marker = "extra == 'frontend'", specifier = ">=4.0.0" }, + { name = "helios-sdl", extras = ["hardware", "ml", "frontend", "demo", "dev"], marker = "extra == 'all'" }, + { name = "httpx", specifier = ">=0.27.0" }, + { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10.0" }, + { name = "numpy", specifier = ">=1.24.0" }, + { name = "opentrons", marker = "extra == 'hardware'", specifier = ">=7.0.0" }, + { name = "pydantic", specifier = ">=2.8.0" }, + { name = "pyserial", marker = "extra == 'hardware'", specifier = ">=3.5" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0.0" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "requests", marker = "extra == 'demo'", specifier = ">=2.28.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" }, + { name = "torch", marker = "extra == 'ml'", specifier = ">=2.0.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" }, +] +provides-extras = ["hardware", "ml", "frontend", "demo", "dev", "all"] + +[[package]] +name = "hf-gradio" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gradio-client" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/86/c9694b7cfada5780e75769e60dc161a161f4dd7fc91b61db5e3a3338bef9/hf_gradio-0.4.1.tar.gz", hash = "sha256:a017d942618f0d495a58ee4563047fa04bef614c00e0cb789a9a6d0633cffa7b", size = 6560, upload-time = "2026-04-22T14:01:32.334Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/2d/afff2ee87e75d8eb85c92bb8cf0e15b05c23c2ebd8fd8dec781d8601ed7f/hf_gradio-0.4.1-py3-none-any.whl", hash = "sha256:76b8cb8be6abe62d74c1ad2d35b42f0629db89aa9e1a8d033cecfe7c856eeab3", size = 4482, upload-time = "2026-04-17T19:53:31.827Z" }, +] + +[[package]] +name = "hf-xet" +version = "1.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/39/67be8d71f900d9a55761b6022821d6679fb56c64f1b6063d5af2c2606727/hf_xet-1.5.2.tar.gz", hash = "sha256:73044bd31bae33c984af832d19c752a0dffb67518fee9ddbd91d616e1101cf47", size = 903674, upload-time = "2026-07-16T17:29:56.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/be/525eabac5d1736b679c39e342ecd4292534012546a2d18f0043c8e3b6021/hf_xet-1.5.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:4a5ecb9cda8512ba2aa8ee5d37c87a1422992165892d653098c7b90247481c3b", size = 4064284, upload-time = "2026-07-16T17:29:29.907Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3f/699749dd78442480eda4e4fca494284b0e3542e4063cc37654d5fdc929e6/hf_xet-1.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8764488197c1d7b1378c8438c18d2eea902e150dbca0b0f0d2d32603fb9b5576", size = 3828537, upload-time = "2026-07-16T17:29:31.549Z" }, + { url = "https://files.pythonhosted.org/packages/22/d7/2658ac0a5b9f4664ca27ce31bd015044fe9dea50ed455fb5197aba819c11/hf_xet-1.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d7446f72abbf7e01ca5ff131786bc2e74a56393462c17a6bf1e303fbab81db4", size = 4417133, upload-time = "2026-07-16T17:29:33.391Z" }, + { url = "https://files.pythonhosted.org/packages/d9/58/8343f3cb63c8fa058d576136df3871550f7d5214a8f048a7ea2eab6ac906/hf_xet-1.5.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:580e59e29bf37aece1f2b68537de1e3fb04f43a23d910dcf6f128280b5bfbba4", size = 4212613, upload-time = "2026-07-16T17:29:34.989Z" }, + { url = "https://files.pythonhosted.org/packages/0c/33/a968f4e4535037b36941ec00714625fb60e026302407e7e26ca9f3e65f4e/hf_xet-1.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bee28c619622d36968056532fd49cf2b35ca75099b1d616c31a618a893491380", size = 4412710, upload-time = "2026-07-16T17:29:36.646Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/9e33981173dbaf194ba0015202b02d467b624d44d4eba89e1bf06c0d2995/hf_xet-1.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e396ab0faf6298199ad7a95305c3ca8498cb825978a6485be6d00587ee4ec577", size = 4628455, upload-time = "2026-07-16T17:29:38.352Z" }, + { url = "https://files.pythonhosted.org/packages/e9/4b/cc682832de4264a03880a2d1b5ec3e1fab3bf307f508817250baafdb9996/hf_xet-1.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fd3add255549e8ef58fa35b2e42dc016961c050600444e7d77d030ba6b57120e", size = 3979044, upload-time = "2026-07-16T17:29:40.329Z" }, + { url = "https://files.pythonhosted.org/packages/ea/09/b2cdf2a0fb39a08af3222b96092a36bd3b40c54123eef07de4422e870971/hf_xet-1.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d6f9c58549407b84b9a5383afd68db0acc42345326a3159990b36a5ca8a20e4e", size = 3808037, upload-time = "2026-07-16T17:29:42.357Z" }, + { url = "https://files.pythonhosted.org/packages/de/ba/2b70603c7552db82baeb2623e2336898304a17328845151be4fe1f48d420/hf_xet-1.5.2-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f922b8f5fb84f1dd3d7ab7a1316354a1bca9b1c73ecfc19c76e51a2a49d29799", size = 4033760, upload-time = "2026-07-16T17:29:43.884Z" }, + { url = "https://files.pythonhosted.org/packages/60/ac/b097a86a1e4a6098f3a79382643ab09d5733d87ccc864877ad1e12b49b70/hf_xet-1.5.2-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:045f84440c55cdeb659cf1a1dd48c77bcd0d2e93632e2fea8f2c3bdee79f38ed", size = 3841438, upload-time = "2026-07-16T17:29:45.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/35/db860aa3a0780660324a506ad4b3d322ddc6ecbba4b9340aed0942cbf21c/hf_xet-1.5.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db78c39c83d6279daddc98e2238f373ab8980685556d42472b4ec51abcf03e8c", size = 4428006, upload-time = "2026-07-16T17:29:46.996Z" }, + { url = "https://files.pythonhosted.org/packages/af/6b/832dd980af4b0c3ae0660e309285f2ffcdff2faa38129390dbb47aa4a3f9/hf_xet-1.5.2-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7db73c810500c54c6760be8c39d4b2e476974de85424c50063efc22fdda13025", size = 4221099, upload-time = "2026-07-16T17:29:48.525Z" }, + { url = "https://files.pythonhosted.org/packages/9e/05/ae50f0d34e3254e6c3e208beb2519f6b8673016fc4b3643badaf6450d186/hf_xet-1.5.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6395cfe3c9cbead4f16b31808b0e67eac428b66c656f856e99636adaddea878f", size = 4420766, upload-time = "2026-07-16T17:29:50.092Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/c050bc2743a2bcd68928bfee157b08681667a164a24ec95fbfcfcd717e08/hf_xet-1.5.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cde8cd167126bb6109b2ceb19b844433a4988643e8f3e01dd9dd0e4a34535097", size = 4636716, upload-time = "2026-07-16T17:29:51.62Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f8/68b01c5c2edb56ac9a67b3d076ffddcb90867abaee923923eb34e7a14e76/hf_xet-1.5.2-cp38-abi3-win_amd64.whl", hash = "sha256:ecf63d1cb69a9a7319910f8f83fcf9b46e7a32dfcf4b8f8eeddb55f647306e65", size = 3988373, upload-time = "2026-07-16T17:29:53.395Z" }, + { url = "https://files.pythonhosted.org/packages/39/c6/988383e9dc17294d536fcbcd6fd16eed882e411ad16c954984a53e47b09c/hf_xet-1.5.2-cp38-abi3-win_arm64.whl", hash = "sha256:1da28519496eb7c8094c11e4d25509b4a468457a0302d58136099db2fd9a671d", size = 3816957, upload-time = "2026-07-16T17:29:54.991Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" }, + { url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" }, + { url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "huggingface-hub" +version = "1.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/db/3582597f8be0d34bd6881365a26d390854f12893eabdd62dd36de9df5a47/huggingface_hub-1.26.0.tar.gz", hash = "sha256:c8cd4e2df1ba9402f77fce9b509ec1d52debb502551789473f34016acc14e361", size = 936665, upload-time = "2026-07-30T14:12:04.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/bb/63a644c75b545f3ff394b822e9bd1c4a9586489c618b77a4d8a44a33a23b/huggingface_hub-1.26.0-py3-none-any.whl", hash = "sha256:e8cca670caa5d8dfa7e45bf45e86b466698198cd8150c021bcdb4a86b9252364", size = 780357, upload-time = "2026-07-30T14:12:01.998Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.17.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "pyrsistent" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/3d/ca032d5ac064dff543aa13c984737795ac81abc9fb130cd2fcff17cfabc7/jsonschema-4.17.3.tar.gz", hash = "sha256:0f864437ab8b6076ba6707453ef8f98a6a0d512a80e93f8abdb676f737ecb60d", size = 297785, upload-time = "2022-11-29T20:37:47.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/97/c698bd9350f307daad79dd740806e1a59becd693bd11443a0f531e3229b3/jsonschema-4.17.3-py3-none-any.whl", hash = "sha256:a870ad254da1a8ca84b6a2905cac29d265f805acc57af304784962a2aa6508f6", size = 90379, upload-time = "2022-11-29T20:37:45.842Z" }, +] + +[[package]] +name = "librt" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/25/a6498964cfeec270c468cffdc118f69c29b412593610d55fa1327ca51ff4/librt-0.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1b5a7bbff495baedbd9b916c367d66854008f8f3b575908ded477c499dc60082", size = 148029, upload-time = "2026-07-08T12:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/dc86d1bffd8e0c2818bace29d9f7783cfbb8e0673bf3673b5bbd5bbe0420/librt-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34bc7938b9fdf14fe32a406c19c71faf894c5cee7e7474bd0be2f17200b82d14", size = 153036, upload-time = "2026-07-08T12:24:47.257Z" }, + { url = "https://files.pythonhosted.org/packages/29/3f/b923826660f02f286186cd9303d52bb05ced0a13708edc104dc8480920e3/librt-0.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f40e56b61b41be5f7dec938cfeffd660668cf4b5e72c78e7bd671d66b7bc2c79", size = 493062, upload-time = "2026-07-08T12:24:48.483Z" }, + { url = "https://files.pythonhosted.org/packages/88/87/6c0980a9c9b1302cb68d108906697b89eceb55889bb1dcf77c109aa56ca5/librt-0.13.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:9c5d02b89de5acd0379a51ec44a89476fb03df6145442e1c8ecd6bee2f91b176", size = 485510, upload-time = "2026-07-08T12:24:49.727Z" }, + { url = "https://files.pythonhosted.org/packages/32/81/795ae3b9df5dd94079fb807e38191855e023e8c6249014ae6bc3f0d9a490/librt-0.13.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7db9a3ff32ef5f7d1703d93831a3316cdf0b537de6a1cc03cc8fdd09b9194e89", size = 515909, upload-time = "2026-07-08T12:24:51.135Z" }, + { url = "https://files.pythonhosted.org/packages/20/e5/182de15abce8907108a6fdb41487de65beb5099b74dc5841b19b099168db/librt-0.13.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3dbb2a31882456cadc7053378e81ad7ed7693db4ac9f98ab5f81ef034aa8ec9f", size = 508620, upload-time = "2026-07-08T12:24:52.358Z" }, + { url = "https://files.pythonhosted.org/packages/32/03/33978d32db76e1f66377e8f78e42a2ca3c162143331677d1f50bbad36cfb/librt-0.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c6014e3c80f9c1fe268ef8b0e0ef113bac672cc032f2f93866e7ddad4f3e663d", size = 530363, upload-time = "2026-07-08T12:24:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f5/b291fbd2d00f7d8287bcbf67b5aa0c6afed4bc26cef23e079629c47a2c04/librt-0.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:091b60a4d2174fc1ec5c34cdc0b72efb6224753d76b7da61ebeab7a191aec8bd", size = 534209, upload-time = "2026-07-08T12:24:55.138Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/6f41f17939d191bc21609f220da8509316bc62797f078545fe83be522e78/librt-0.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:66cb1138f384a191a6d75f986064841fcfdc0cea98f7bd9c9ab9b38049917588", size = 514254, upload-time = "2026-07-08T12:24:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/af/c2/2e4befa5410a7443019c14abccc94ff619797171f6b72013635fb87f31d7/librt-0.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:17221a7569f8f292aa0014226e48aa25b8c2b08da18088cd230953d0ea0f9cd1", size = 557611, upload-time = "2026-07-08T12:24:57.561Z" }, + { url = "https://files.pythonhosted.org/packages/ab/54/8b69f81448417adbc040a2185f4e2eece1e1994b7dcfaeed4662b30f98a5/librt-0.13.0-cp311-cp311-win32.whl", hash = "sha256:fc67741da44c6eaa90e01eafb586bbba9b51eb5b6ed381ee6f5ae72eb3316d21", size = 104906, upload-time = "2026-07-08T12:24:58.806Z" }, + { url = "https://files.pythonhosted.org/packages/76/5a/f4aaf37b50f2fde12c8c663b83fdd499cdc24f957f19543d7414bfcc9e25/librt-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:cc99dfb62b23c9207c33d0be8a2e2af7a42e21e6ea388b380a0c948c7b88953b", size = 125852, upload-time = "2026-07-08T12:25:00.065Z" }, + { url = "https://files.pythonhosted.org/packages/f2/99/bf1820e6feeabc2f218c24450ec0c995d6a91e8ba0fd3caf042c9e8adb2a/librt-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:40ccd13c252d3fe473ffc8a57be7565abc8b64cf1b108344c859d5164f7f3e0c", size = 111832, upload-time = "2026-07-08T12:25:01.148Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" }, + { url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" }, + { url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" }, + { url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" }, + { url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" }, + { url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/b8550c75775127fd31a5f20e8775997f7b527ad661fc8ddccd7497c064f7/librt-0.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f19e181de5b3a1148bb3420b8c4b0b0ea0fce6950099724ad151d6cea5acc180", size = 491054, upload-time = "2026-07-08T12:25:41.905Z" }, + { url = "https://files.pythonhosted.org/packages/30/14/4d0204867623df3f33f86efd3d3692ba5e01321443f4d6eab35a22697618/librt-0.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22034924f5b42d5a56371cf271771bfeaabf235a7a8b6264bef2d20013f786c6", size = 523006, upload-time = "2026-07-08T12:25:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/c45fc9a260934696bace1ac5df1e148ac92bd71767aee3bf7cd7a4534f4c/librt-0.13.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7897db4e95e22468bdda33d8e012ceacd0182abf001e6389d763f0def6286b9", size = 515058, upload-time = "2026-07-08T12:25:44.541Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/50c5ce45b326854ef8fa6ae4c36cf5142e5c55315eaf9e51d0ae73ac4da3/librt-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ce61b3746545029d4f5c17d6bd74b676254ad98433086c846ffb5e8fa73f007", size = 534025, upload-time = "2026-07-08T12:25:45.825Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/08c413c8f93fc13b8103624fce38e5caa86cd08cbbc8465870ab287af54b/librt-0.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:46c330e82565962c761dbce7941be2cff7db674ee807455a8d0cadc5f9b759b0", size = 540557, upload-time = "2026-07-08T12:25:47.059Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/93af71fb4a364952210051811dd4e40174e79656b050c89cacac18af3330/librt-0.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:375f5af8f99cbaa99dd293af986e3d57caabc9ba81a5d3f021603764854197a1", size = 523201, upload-time = "2026-07-08T12:25:48.392Z" }, + { url = "https://files.pythonhosted.org/packages/c1/6e/9766f07b676a4889d9f8bc2864e9ba5fff165653143ef4dda7df6aa34d16/librt-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9320d34c3376ae204b2cd176e8d4883a013934e0aef822f1aed9c536490c275d", size = 565740, upload-time = "2026-07-08T12:25:49.678Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1e/664e3472ce2b6e10e9b83f29d4a36eb982ff6b5a169ae7567bba3a4c4ff5/librt-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9af313c66157a69dc69ea0059a66961692250e0dc95af9c385a48ffb770a0d16", size = 81611, upload-time = "2026-07-08T12:25:50.857Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/8582a4d65e2234673685e07309d02c230b28a85724eb0acbf13f019b7f6e/librt-0.13.0-cp314-cp314-win32.whl", hash = "sha256:f2a7253458e34f33543551394ae4fe104b497ec2a65ac266074de64c1df82e37", size = 100106, upload-time = "2026-07-08T12:25:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/63/ce/0cb99efe6086b46cd985dc26672166fae312a239690e75871f7fafbd3fc5/librt-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:a3dfe4edf10e8ed7e55b026a8bfc2c2a8704218b659cd4bffdf604fab966dc39", size = 121209, upload-time = "2026-07-08T12:25:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/26/85/4f3ccb083a3c9b0d42e223acdb3c3f507953324a59cdcab4826e8e2e3b89/librt-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:68a5faee4bba381cb93b5961f684a514cf0053cb92308ff9c792c2fea0b174c6", size = 106404, upload-time = "2026-07-08T12:25:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/b2/77/333191499538c8e8189de7a4cba8e6f49ee949fd6d6e6324b21fd1522466/librt-0.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a38fb81d8376dfa2f8963b265fec07637802b0d01e2a127c19c66cb070fb24f5", size = 159231, upload-time = "2026-07-08T12:25:55.432Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/2aa83758f22c278b837a1d8025898434ce2b8bff36678d5330ecaef56dff/librt-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4c8d9bd5abce34b2e75edb3bf37ab0f34e49b1f915a40ae8468eb7c85bc5b46", size = 161300, upload-time = "2026-07-08T12:25:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c0/86791e936553ca763d6b3c2fb4d31d596cd00e14fa631c283a40ba01559a/librt-0.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:387e2f1d27e89bffe0d3f520f0da0662c973fd607ca16c1808f8a5085419485e", size = 582056, upload-time = "2026-07-08T12:25:58.144Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d3/a9ec15984a185e000c4d2a16ba28bd623124ad4c38a10974c7ff78e3a893/librt-0.13.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:4f6db193d2e5e0ed60359b9a5a682cd67205d0d3b1e459a867dd4b5c4e7eaa7a", size = 562758, upload-time = "2026-07-08T12:25:59.544Z" }, + { url = "https://files.pythonhosted.org/packages/3c/af/dbe36b78b19c06a55097f99305e4ea9458e2273e6ae16a3cbecaad7ee978/librt-0.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d38604854e8d22faadf683ec6c02bb0f886e2ba56ef981a1c36ee275f21ea22", size = 602095, upload-time = "2026-07-08T12:26:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a8/2966891b4dd2830f5203fbee92ac2c4947653a2390ba73dfa44244fad025/librt-0.13.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:371f7ce73026815dafd51c50ce38416e91428b28c4b2ec97cd39271164b0045c", size = 593452, upload-time = "2026-07-08T12:26:02.352Z" }, + { url = "https://files.pythonhosted.org/packages/61/f5/4df8bfc8405ecf8c0d525b4d69636f694bdd8620b313ec8b76e54a5926cc/librt-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3aaedf52171bee90860704c560bc798fe83b76247df47568e0197e9b13c735a0", size = 623729, upload-time = "2026-07-08T12:26:04.294Z" }, + { url = "https://files.pythonhosted.org/packages/d6/13/9ac202dffc8db06f75d06c08c2f9f6ff054be67d21272dcc078fa1cc0c57/librt-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96bad8725a4f196a798366c25ce075d1f7543a4ec045ffc13e6a7ec095cdab04", size = 617077, upload-time = "2026-07-08T12:26:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/ebe38610716aee5cb28efd95089bb90192096179802779381e1c5dcf239c/librt-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6bf6a559ffe4a93bbea6cf31ddf01a7fd9ba342ef51f27beb178e318b74acd61", size = 599561, upload-time = "2026-07-08T12:26:07.21Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5c/c2e72e236fff7abc716d5b1753b8b8cd3ea85ac46fe17d2e7c51d4e1c723/librt-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:301067672387902c55f94b51d5022304b36c966ea9fe1f21caab99a9bef487c9", size = 645511, upload-time = "2026-07-08T12:26:08.562Z" }, + { url = "https://files.pythonhosted.org/packages/0c/99/6203ce619dee940d6bfbe099ec3fe4be00a68e9d60f70abf906cf124fe66/librt-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:5fdcf34f86de8fb66d7dc7589f96ba91c4aa46671200d400e6fd6f109a483f18", size = 104357, upload-time = "2026-07-08T12:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/52/dd/843b6314087c41657c7036d7914d8f294bdf9b580aa8513ea0588c8e9a3d/librt-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:260c33e92263fa629b4f6d3c51967a1c2158fe6c33237aaa3ebeac586b085259", size = 126998, upload-time = "2026-07-08T12:26:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/b9/d75b3082b05f1b3028828aeb18e74ae5ab0a0936051bbf1f32f59f654747/mypy-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3419d00717afbc5265b50dd14b1278f29ea4884dd398ab67873489ac093fd329", size = 14838725, upload-time = "2026-07-13T11:32:44.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/50/79a65c6ea6e115bc73296038a4543b2d5c91f07912b918a2c616a2514bba/mypy-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cfca8ee88544090f86b6dcce05ec55d66eb48a762412ac2507810ba4bd793b6f", size = 13911128, upload-time = "2026-07-13T11:32:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/90/48/e11ed7716c26953ca321f726e452e374dbf81a6f2b8b212ec02af29b6b8f/mypy-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75cbb4b9ef04a0c84a957f07abc4504fbf64b8dcc145675101f2d3a78a4b1d6a", size = 14146742, upload-time = "2026-07-13T11:33:03.313Z" }, + { url = "https://files.pythonhosted.org/packages/06/72/6807565b1c4861ef66f7fdd98b51c61556356eab80235717b46c53bb8627/mypy-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:982e3d53dd23d0a4cef67dd66791fdbede0cf38f9eb617bf47663554c51e1e36", size = 15081418, upload-time = "2026-07-13T11:31:13.899Z" }, + { url = "https://files.pythonhosted.org/packages/00/80/1ea14c5d80e589e415973db3e47c78c2219a305b808b2b506395342c1d79/mypy-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85c5385b93012ffa3b31479ab579aef5415f4f3a32c6cf1ae07a984d2a0ff461", size = 15328164, upload-time = "2026-07-13T11:31:35.723Z" }, + { url = "https://files.pythonhosted.org/packages/37/28/8223157404a3d51920078459c37f80fbdc590e1d8ea049dc5ce48643022a/mypy-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:13b1b16e2fa39f3b2e33fb1c468abc7a69369fa2e886b4b87b5afc81472325cd", size = 11136472, upload-time = "2026-07-13T11:27:37.018Z" }, + { url = "https://files.pythonhosted.org/packages/6f/cc/ea27e5959c5f258585a756b252031f3b313583d81b5064b2bebc41d3706b/mypy-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5cd2f027a972a4a5f2278a11fac9747f5f81a53a30b714d74950b6807e55568", size = 10135800, upload-time = "2026-07-13T11:30:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" }, + { url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" }, + { url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" }, + { url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" }, + { url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" }, + { url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" }, + { url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" }, + { url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" }, + { url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" }, + { url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" }, + { url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" }, + { url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "numpy" +version = "1.26.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129, upload-time = "2024-02-06T00:26:44.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/57/baae43d14fe163fa0e4c47f307b6b2511ab8d7d30177c491960504252053/numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71", size = 20630554, upload-time = "2024-02-05T23:51:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2e/151484f49fd03944c4a3ad9c418ed193cfd02724e138ac8a9505d056c582/numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef", size = 13997127, upload-time = "2024-02-05T23:52:15.314Z" }, + { url = "https://files.pythonhosted.org/packages/79/ae/7e5b85136806f9dadf4878bf73cf223fe5c2636818ba3ab1c585d0403164/numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e", size = 14222994, upload-time = "2024-02-05T23:52:47.569Z" }, + { url = "https://files.pythonhosted.org/packages/3a/d0/edc009c27b406c4f9cbc79274d6e46d634d139075492ad055e3d68445925/numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5", size = 18252005, upload-time = "2024-02-05T23:53:15.637Z" }, + { url = "https://files.pythonhosted.org/packages/09/bf/2b1aaf8f525f2923ff6cfcf134ae5e750e279ac65ebf386c75a0cf6da06a/numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a", size = 13885297, upload-time = "2024-02-05T23:53:42.16Z" }, + { url = "https://files.pythonhosted.org/packages/df/a0/4e0f14d847cfc2a633a1c8621d00724f3206cfeddeb66d35698c4e2cf3d2/numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a", size = 18093567, upload-time = "2024-02-05T23:54:11.696Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b7/a734c733286e10a7f1a8ad1ae8c90f2d33bf604a96548e0a4a3a6739b468/numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20", size = 5968812, upload-time = "2024-02-05T23:54:26.453Z" }, + { url = "https://files.pythonhosted.org/packages/3f/6b/5610004206cf7f8e7ad91c5a85a8c71b2f2f8051a0c0c4d5916b76d6cbb2/numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2", size = 15811913, upload-time = "2024-02-05T23:54:53.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/12/8f2020a8e8b8383ac0177dc9570aad031a3beb12e38847f7129bacd96228/numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218", size = 20335901, upload-time = "2024-02-05T23:55:32.801Z" }, + { url = "https://files.pythonhosted.org/packages/75/5b/ca6c8bd14007e5ca171c7c03102d17b4f4e0ceb53957e8c44343a9546dcc/numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b", size = 13685868, upload-time = "2024-02-05T23:55:56.28Z" }, + { url = "https://files.pythonhosted.org/packages/79/f8/97f10e6755e2a7d027ca783f63044d5b1bc1ae7acb12afe6a9b4286eac17/numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b", size = 13925109, upload-time = "2024-02-05T23:56:20.368Z" }, + { url = "https://files.pythonhosted.org/packages/0f/50/de23fde84e45f5c4fda2488c759b69990fd4512387a8632860f3ac9cd225/numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed", size = 17950613, upload-time = "2024-02-05T23:56:56.054Z" }, + { url = "https://files.pythonhosted.org/packages/4c/0c/9c603826b6465e82591e05ca230dfc13376da512b25ccd0894709b054ed0/numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a", size = 13572172, upload-time = "2024-02-05T23:57:21.56Z" }, + { url = "https://files.pythonhosted.org/packages/76/8c/2ba3902e1a0fc1c74962ea9bb33a534bb05984ad7ff9515bf8d07527cadd/numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0", size = 17786643, upload-time = "2024-02-05T23:57:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/28/4a/46d9e65106879492374999e76eb85f87b15328e06bd1550668f79f7b18c6/numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110", size = 5677803, upload-time = "2024-02-05T23:58:08.963Z" }, + { url = "https://files.pythonhosted.org/packages/16/2e/86f24451c2d530c88daf997cb8d6ac622c1d40d19f5a031ed68a4b73a374/numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818", size = 15517754, upload-time = "2024-02-05T23:58:36.364Z" }, +] + +[[package]] +name = "nvidia-cublas" +version = "13.1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cuda-nvrtc" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/154ca20c38269e05eff77c1464e6c1da89f50a6390b565e9d82e06bc11e1/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:37936a16db8fe4ac1f065c2139360608a543a09275cb1a1af612e08cfa065436", size = 423138758, upload-time = "2026-04-08T18:46:58.655Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2a/80353b103fc20ce05ef51e928daed4b6015db4aaa9162ed0997090fe2250/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151", size = 10310827, upload-time = "2025-09-04T08:26:42.012Z" }, + { url = "https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8", size = 10715597, upload-time = "2025-09-04T08:26:51.312Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc" +version = "13.0.88" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575", size = 90215200, upload-time = "2025-09-04T08:28:44.204Z" }, + { url = "https://files.pythonhosted.org/packages/b7/dc/6bb80850e0b7edd6588d560758f17e0550893a1feaf436807d64d2da040f/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b", size = 43015449, upload-time = "2025-09-04T08:28:20.239Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime" +version = "13.0.96" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/4f/17d7b9b8e285199c58ce28e31b5c5bbaa4d8271af06a89b6405258245de2/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55", size = 2261060, upload-time = "2025-10-09T08:55:15.78Z" }, + { url = "https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548", size = 2243632, upload-time = "2025-10-09T08:55:36.117Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu13" +version = "9.20.0.48" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5e/edb9c0ae051602c3ccaffe424256463636d639e27d7f302dde9975ef9e7a/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0c45dd8eeb50b603f07995b1b300c62ffe6a1980482b82b3bcf94a4ca9d49304", size = 366173588, upload-time = "2026-03-09T19:29:34.474Z" }, +] + +[[package]] +name = "nvidia-cufft" +version = "12.0.0.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3", size = 214085489, upload-time = "2025-09-04T08:31:56.044Z" }, +] + +[[package]] +name = "nvidia-cufile" +version = "1.15.1.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44", size = 1223672, upload-time = "2025-09-04T08:32:22.779Z" }, + { url = "https://files.pythonhosted.org/packages/ab/73/cc4a14c9813a8a0d509417cf5f4bdaba76e924d58beb9864f5a7baceefbf/nvidia_cufile-1.15.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1", size = 1136992, upload-time = "2025-09-04T08:32:14.119Z" }, +] + +[[package]] +name = "nvidia-curand" +version = "10.4.0.35" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/72/7c2ae24fb6b63a32e6ae5d241cc65263ea18d08802aaae087d9f013335a2/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a", size = 61962106, upload-time = "2025-08-04T10:21:41.128Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc", size = 59544258, upload-time = "2025-08-04T10:22:03.992Z" }, +] + +[[package]] +name = "nvidia-cusolver" +version = "12.0.4.66" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas" }, + { name = "nvidia-cusparse" }, + { name = "nvidia-nvjitlink" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112", size = 200941980, upload-time = "2025-09-04T08:33:22.767Z" }, +] + +[[package]] +name = "nvidia-cusparse" +version = "12.6.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, + { url = "https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b", size = 145942937, upload-time = "2025-09-04T08:33:58.029Z" }, +] + +[[package]] +name = "nvidia-cusparselt-cu13" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/e1/cdc1797eadf82d3a9a575a19b33fdc871a97edbec42c00b5b5e914f4aff4/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:4dca476c50bf4780d46cd0bfbd82e2bc10a08e4fef7950917ce8d7578d22a23f", size = 221051344, upload-time = "2025-09-05T18:49:51.289Z" }, + { url = "https://files.pythonhosted.org/packages/34/7d/2661f2fb3ac4302f3a246f5fc030213ac60c1fe0bce84f9783dbd831dbb7/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:786ce87568c303fadb5afcc7102d454cd3040d75f6f8626f5db460d1871f4dd0", size = 170148586, upload-time = "2025-09-05T18:50:50.248Z" }, +] + +[[package]] +name = "nvidia-nccl-cu13" +version = "2.29.7" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/0d/daf50d44177ee0cbc7ff0a0c91eb5ff676c82be42f9a970bc7597f440c3a/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:674a12383e3c38a1bcccae7d4f3633b37852230b6047883cb2f4c2d1b36d9bf5", size = 206014712, upload-time = "2026-03-03T05:34:20.843Z" }, + { url = "https://files.pythonhosted.org/packages/67/f4/58e4e91b6919367c7aafb8e36fce9aad1a3047e536bf7e2fd560927d3a4c/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:edd81538446786ec3b73972543e53bb43bcaf0bfc8ef76cb679fcc390ffe136d", size = 205976000, upload-time = "2026-03-03T05:36:24.472Z" }, +] + +[[package]] +name = "nvidia-nvjitlink" +version = "13.3.33" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/ee/580ca6f29dcab0221db8706badca1bbbb084f1975c4d4e83329c3a7e31f0/nvidia_nvjitlink-13.3.33-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:26a6de7fb4c8fdaa7703d3dad720d6d427ddfea5c48a528fd97c11733ad830e5", size = 40742423, upload-time = "2026-05-26T16:54:51.613Z" }, + { url = "https://files.pythonhosted.org/packages/69/30/45414e35ff2eee7db3da037e5707037ccf9d2b5218ffbdb055ea4d5aa98a/nvidia_nvjitlink-13.3.33-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ce48b37dfeb3cb1eae4cf85adacb47d7a6539ea2272870c9a3628ce275c2037e", size = 39168635, upload-time = "2026-05-26T16:54:13.906Z" }, +] + +[[package]] +name = "nvidia-nvshmem-cu13" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/0f/05cc9c720236dcd2db9c1ab97fff629e96821be2e63103569da0c9b72f19/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9", size = 60215947, upload-time = "2025-09-06T00:32:20.022Z" }, + { url = "https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80", size = 60412546, upload-time = "2025-09-06T00:32:41.564Z" }, +] + +[[package]] +name = "nvidia-nvtx" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4", size = 148047, upload-time = "2025-09-04T08:29:01.761Z" }, + { url = "https://files.pythonhosted.org/packages/a8/64/3708a90d1ebe202ffdeb7185f878a3c84d15c2b2c31858da2ce0583e2def/nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6", size = 148878, upload-time = "2025-09-04T08:28:53.627Z" }, +] + +[[package]] +name = "opentrons" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aionotify" }, + { name = "anyio" }, + { name = "click" }, + { name = "jsonschema" }, + { name = "numpy" }, + { name = "opentrons-shared-data" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyro5" }, + { name = "pyserial" }, + { name = "pyusb" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/48/f22a3fb809e709f680c0a1c575ed903666edf1dae91f1980a743e6f373f2/opentrons-9.1.1.tar.gz", hash = "sha256:f08a7486248e98d59d28b360e2ab7b7fdf6a88d113a5f6146002606d29aa9166", size = 2341328, upload-time = "2026-07-13T16:03:47.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/03/0c1f472c8118ed16421f3afea8c93c2eb36acc7e12cc46dc9a89aa76bb2b/opentrons-9.1.1-py3-none-any.whl", hash = "sha256:9df04a72006ec814f874d68774464533bd3858419c60552883bbde67cd6981ee", size = 1970912, upload-time = "2026-07-13T16:03:45.234Z" }, +] + +[[package]] +name = "opentrons-shared-data" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema" }, + { name = "numpy" }, + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/ee/2d4b19c496aa01f4b54c4a4bfe6151ccd79be5617cd84004e4e0a9cb6cff/opentrons_shared_data-9.1.1.tar.gz", hash = "sha256:8d9d90fa0c73d3b771bf68a0079b7accd02f811e131717b6f6e4a376f6405935", size = 771441, upload-time = "2026-07-13T16:00:54.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/0c/fd9f2706cea62d39ebe8d1b5199eb7af22a747428a72c927711634bf6b04/opentrons_shared_data-9.1.1-py3-none-any.whl", hash = "sha256:c30e88272f0e5f77b5e923c78e070f5b70361f3bedbf1f6eec6a2d6dd7bbf78e", size = 1290657, upload-time = "2026-07-13T16:00:52.336Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/51/3fb9e65ae76ee97bd611869a503fa3fc0a6e81dd8b737cf3003f682df7ff/orjson-3.11.9-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:f01c4818b3fc9b0da8e096722a84318071eaa118df35f6ed2344da0e73a5444f", size = 228522, upload-time = "2026-05-06T15:09:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/16/fa/9d54b07cb3f3b0bfd57841478e42d7a0ece4a9f49f9907eecf5a45461687/orjson-3.11.9-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:3ebca4179031ee716ed076ffadc29428e900512f6fccee8614c9983157fcf19c", size = 128463, upload-time = "2026-05-06T15:09:37.063Z" }, + { url = "https://files.pythonhosted.org/packages/88/b1/6ceafc2eefd0a553e3be77ce6c49d107e772485d9568629376171c50e634/orjson-3.11.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48ee05097750de0ff69ed5b7bbcf0732182fd57a24043dcc2a1da780a5ead3a5", size = 132306, upload-time = "2026-05-06T15:09:38.299Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/f11311285324a40aab1e3031385c50b635a7cd0734fdaf60c7e89a696f60/orjson-3.11.9-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6082706765a95a6680d812e1daf1c0cfe8adec7831b3ff3b625693f3b461b1c", size = 127988, upload-time = "2026-05-06T15:09:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/9e/85/0ef63bcf1337f44031ce9b91b1919563f62a37527b3ea4368bb15a22e5d7/orjson-3.11.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:277fefe9d76ee17eb14debf399e3533d4d63b5f677a4d3719eb763536af1f4bd", size = 135188, upload-time = "2026-05-06T15:09:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/05/94/b0d27090ea8a2095db3c2bd1b1c96f96f19bbb494d7fef33130e846e613d/orjson-3.11.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03db380e3780fa0015ed776a90f20e8e20bb11dde13b216ce19e5718e3dfba62", size = 145937, upload-time = "2026-05-06T15:09:42.249Z" }, + { url = "https://files.pythonhosted.org/packages/09/eb/75d50c29c05b8054013e221e598820a365c8e64065312e75e202ed880709/orjson-3.11.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33d7d766701847dc6729846362dc27895d2f2d2251264f9d10e7cb9878194877", size = 132758, upload-time = "2026-05-06T15:09:43.945Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/360686f39348aa88827cb6fbf7dc606fd41c831a35235e1abf1db8e3a9e6/orjson-3.11.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:147302878da387104b66bb4a8b0227d1d487e976ce41a8501916161072ed87b1", size = 133971, upload-time = "2026-05-06T15:09:45.239Z" }, + { url = "https://files.pythonhosted.org/packages/0e/30/3178eb16f3221aeef068b6f1f1ebe05f656ea5c6dffe9f6c917329fe17a3/orjson-3.11.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3513550321f8c8c811a7c3297b8a630e82dc08e4c10216d07703c997776236cd", size = 141685, upload-time = "2026-05-06T15:09:46.858Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f1/ff2f19ed0225f9680fafa42febca3570dd59444ebf190980738d376214c2/orjson-3.11.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c5d001196b89fa9cf0a4ab79766cd835b991a166e4b621ba95089edc50c429ff", size = 415167, upload-time = "2026-05-06T15:09:48.312Z" }, + { url = "https://files.pythonhosted.org/packages/9b/61/863bddf0da6e9e586765414debd54b4e58db05f560902b6d00658cb88636/orjson-3.11.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:16969c9d369c98eb084889c6e4d2d39b77c7eb38ceccf8da2a9fff62ae908980", size = 147913, upload-time = "2026-05-06T15:09:49.733Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4081492586d75b073d60c5271a8d0f05a0955cabf1e34c8473f6fcd84235/orjson-3.11.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:63e0efbc991250c0b3143488fa57d95affcabbfc63c99c48d625dd37779aafe2", size = 136959, upload-time = "2026-05-06T15:09:51.311Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bd/70b6ab193594d7abb875320c0a7c8335e846f28968c432c31042409c3c8d/orjson-3.11.9-cp311-cp311-win32.whl", hash = "sha256:14ed654580c1ed2bc217352ec82f91b047aef82951aa71c7f64e0dcb03c0e180", size = 131533, upload-time = "2026-05-06T15:09:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/3f/17/1a1a228183d62d1b77e2c30d210f47dd4768b310ebe1607c63e3c0e3a71e/orjson-3.11.9-cp311-cp311-win_amd64.whl", hash = "sha256:57ea77fb70a448ce87d18fca050193202a3da5e54598f6501ca5476fb66cfe02", size = 127106, upload-time = "2026-05-06T15:09:54.204Z" }, + { url = "https://files.pythonhosted.org/packages/b8/95/285de5fa296d09681ee9c546cd4a8aeb773b701cf343dc125994f4d52953/orjson-3.11.9-cp311-cp311-win_arm64.whl", hash = "sha256:19b72ed11572a2ee51a67a903afbe5af504f84ed6f529c0fe44b0ab3fb5cc697", size = 126848, upload-time = "2026-05-06T15:09:55.551Z" }, + { url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" }, + { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" }, + { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" }, + { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" }, + { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, + { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, + { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, + { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, + { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, + { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, + { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, + { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, + { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pandas" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" }, + { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" }, + { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" }, + { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702, upload-time = "2025-09-29T23:19:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, + { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, + { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, + { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, + { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, + { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, + { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, + { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, + { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, + { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, + { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, + { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, + { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + +[[package]] +name = "pydub" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/9a/e6bca0eed82db26562c73b5076539a4a08d3cffd19c3cc5913a3e61145fd/pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f", size = 38326, upload-time = "2021-03-10T02:09:54.659Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6", size = 32327, upload-time = "2021-03-10T02:09:53.503Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyro5" +version = "5.17" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "serpent" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/b9/312055148dbd9d0c829d30e0fbc0cff9bcc6c6311bada80737879c7f5cc6/pyro5-5.17.tar.gz", hash = "sha256:cfac69638d80943aff9cc5f1466755dd0fef8aed0bb4bda41b5eb045818ce6fc", size = 268643, upload-time = "2026-06-19T23:40:52.703Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/62/878386c290088c6f614bf50e59c362e23c8aaa86de9a1d74d4ee67108736/pyro5-5.17-py3-none-any.whl", hash = "sha256:0e08866d46fe4e315fd1a6403c66e354a6bc75c5cc3d19cd3495bb292ece4eaa", size = 79715, upload-time = "2026-06-19T23:40:51.28Z" }, +] + +[[package]] +name = "pyrsistent" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/3a/5031723c09068e9c8c2f0bc25c3a9245f2b1d1aea8396c787a408f2b95ca/pyrsistent-0.20.0.tar.gz", hash = "sha256:4c48f78f62ab596c679086084d0dd13254ae4f3d6c72a83ffdf5ebdef8f265a4", size = 103642, upload-time = "2023-10-25T21:06:56.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/63/7544dc7d0953294882a5c587fb1b10a26e0c23d9b92281a14c2514bac1f7/pyrsistent-0.20.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0f3b1bcaa1f0629c978b355a7c37acd58907390149b7311b5db1b37648eb6958", size = 83481, upload-time = "2023-10-25T21:06:15.238Z" }, + { url = "https://files.pythonhosted.org/packages/ae/a0/49249bc14d71b1bf2ffe89703acfa86f2017c25cfdabcaea532b8c8a5810/pyrsistent-0.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cdd7ef1ea7a491ae70d826b6cc64868de09a1d5ff9ef8d574250d0940e275b8", size = 120222, upload-time = "2023-10-25T21:06:17.144Z" }, + { url = "https://files.pythonhosted.org/packages/a1/94/9808e8c9271424120289b9028a657da336ad7e43da0647f62e4f6011d19b/pyrsistent-0.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cae40a9e3ce178415040a0383f00e8d68b569e97f31928a3a8ad37e3fde6df6a", size = 120002, upload-time = "2023-10-25T21:06:18.727Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f6/9ecfb78b2fc8e2540546db0fe19df1fae0f56664a5958c21ff8861b0f8da/pyrsistent-0.20.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6288b3fa6622ad8a91e6eb759cfc48ff3089e7c17fb1d4c59a919769314af224", size = 116850, upload-time = "2023-10-25T21:06:20.424Z" }, + { url = "https://files.pythonhosted.org/packages/83/c8/e6d28bc27a0719f8eaae660357df9757d6e9ca9be2691595721de9e8adfc/pyrsistent-0.20.0-cp311-cp311-win32.whl", hash = "sha256:7d29c23bdf6e5438c755b941cef867ec2a4a172ceb9f50553b6ed70d50dfd656", size = 60775, upload-time = "2023-10-25T21:06:21.815Z" }, + { url = "https://files.pythonhosted.org/packages/98/87/c6ef52ff30388f357922d08de012abdd3dc61e09311d88967bdae23ab657/pyrsistent-0.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:59a89bccd615551391f3237e00006a26bcf98a4d18623a19909a2c48b8e986ee", size = 63306, upload-time = "2023-10-25T21:06:22.874Z" }, + { url = "https://files.pythonhosted.org/packages/15/ee/ff2ed52032ac1ce2e7ba19e79bd5b05d152ebfb77956cf08fcd6e8d760ea/pyrsistent-0.20.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:09848306523a3aba463c4b49493a760e7a6ca52e4826aa100ee99d8d39b7ad1e", size = 83537, upload-time = "2023-10-25T21:06:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/80/f1/338d0050b24c3132bcfc79b68c3a5f54bce3d213ecef74d37e988b971d8a/pyrsistent-0.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a14798c3005ec892bbada26485c2eea3b54109cb2533713e355c806891f63c5e", size = 122615, upload-time = "2023-10-25T21:06:25.815Z" }, + { url = "https://files.pythonhosted.org/packages/07/3a/e56d6431b713518094fae6ff833a04a6f49ad0fbe25fb7c0dc7408e19d20/pyrsistent-0.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b14decb628fac50db5e02ee5a35a9c0772d20277824cfe845c8a8b717c15daa3", size = 122335, upload-time = "2023-10-25T21:06:28.631Z" }, + { url = "https://files.pythonhosted.org/packages/4a/bb/5f40a4d5e985a43b43f607250e766cdec28904682c3505eb0bd343a4b7db/pyrsistent-0.20.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e2c116cc804d9b09ce9814d17df5edf1df0c624aba3b43bc1ad90411487036d", size = 118510, upload-time = "2023-10-25T21:06:30.718Z" }, + { url = "https://files.pythonhosted.org/packages/1c/13/e6a22f40f5800af116c02c28e29f15c06aa41cb2036f6a64ab124647f28b/pyrsistent-0.20.0-cp312-cp312-win32.whl", hash = "sha256:e78d0c7c1e99a4a45c99143900ea0546025e41bb59ebc10182e947cf1ece9174", size = 60865, upload-time = "2023-10-25T21:06:32.742Z" }, + { url = "https://files.pythonhosted.org/packages/75/ef/2fa3b55023ec07c22682c957808f9a41836da4cd006b5f55ec76bf0fbfa6/pyrsistent-0.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:4021a7f963d88ccd15b523787d18ed5e5269ce57aa4037146a2377ff607ae87d", size = 63239, upload-time = "2023-10-25T21:06:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/23/88/0acd180010aaed4987c85700b7cc17f9505f3edb4e5873e4dc67f613e338/pyrsistent-0.20.0-py3-none-any.whl", hash = "sha256:c55acc4733aad6560a7f5f818466631f07efc001fd023f34a6c203f8b6df0f0b", size = 58106, upload-time = "2023-10-25T21:06:54.387Z" }, +] + +[[package]] +name = "pyserial" +version = "3.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6cb66a5b57197850f919f59e558159a4dd3a818f5082/pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", size = 159125, upload-time = "2020-11-23T03:59:15.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pytz" +version = "2026.3.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/48/fb042503b6ca6cd271261dc559fd6432f7d8c713153e9ec5c591af4dfc1c/pytz-2026.3.post1.tar.gz", hash = "sha256:2211d3fcf9a797d3405cac96ac7f61d80e6a644f72a3309607282fe8a2010c5d", size = 319745, upload-time = "2026-07-25T15:12:07.385Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/7b/39c34ca613b0b198cb866466651b26b045e2009864c5183c979a3b83f383/pytz-2026.3.post1-py2.py3-none-any.whl", hash = "sha256:dd95840dd199baea12d9cc096a1d452caa6596a1c1e4b5f3dbd1541855d5e815", size = 508283, upload-time = "2026-07-25T15:12:05.782Z" }, +] + +[[package]] +name = "pyusb" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/6e/433a5614132576289b8643fe598dd5d51b16e130fd591564be952e15bb45/pyusb-1.2.1.tar.gz", hash = "sha256:a4cc7404a203144754164b8b40994e2849fde1cfff06b08492f12fff9d9de7b9", size = 75292, upload-time = "2021-07-09T02:58:46.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/a8/4982498b2ab44d1fcd5c49f07ea3795eab01601dc143b009d333fcace3b9/pyusb-1.2.1-py3-none-any.whl", hash = "sha256:2b4c7cb86dbadf044dfb9d3a4ff69fd217013dbe78a792177a3feb172449ea36", size = 58439, upload-time = "2021-07-09T02:58:44.894Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" }, + { url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" }, + { url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" }, + { url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" }, + { url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" }, + { url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" }, + { url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, +] + +[[package]] +name = "safehttpx" +version = "0.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/d1/4282284d9cf1ee873607a46442da977fc3c985059315ab23610be31d5885/safehttpx-0.1.7.tar.gz", hash = "sha256:db201c0978c41eddb8bb480f3eee59dd67304fdd91646035e9d9a720049a9d23", size = 10385, upload-time = "2025-10-24T18:30:09.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/a3/0f0b7d78e2f1eb9e8e1afbff1d2bff8d60144aee17aca51c065b516743dd/safehttpx-0.1.7-py3-none-any.whl", hash = "sha256:c4f4a162db6993464d7ca3d7cc4af0ffc6515a606dfd220b9f82c6945d869cde", size = 8959, upload-time = "2025-10-24T18:30:08.733Z" }, +] + +[[package]] +name = "semantic-version" +version = "2.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/31/f2289ce78b9b473d582568c234e104d2a342fd658cc288a7553d83bb8595/semantic_version-2.10.0.tar.gz", hash = "sha256:bdabb6d336998cbb378d4b9db3a4b56a1e3235701dc05ea2690d9a997ed5041c", size = 52289, upload-time = "2022-05-26T13:35:23.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/23/8146aad7d88f4fcb3a6218f41a60f6c2d4e3a72de72da1825dc7c8f7877c/semantic_version-2.10.0-py2.py3-none-any.whl", hash = "sha256:de78a3b8e0feda74cabc54aab2da702113e33ac9d9eb9d2389bcf1f58b7d9177", size = 15552, upload-time = "2022-05-26T13:35:21.206Z" }, +] + +[[package]] +name = "serpent" +version = "1.43" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/bb/58ab4dc0382c2386355cbcb7ffa490e542159de6012d500a602a6d0ed535/serpent-1.43.tar.gz", hash = "sha256:62dc242fd4ea2a50339f4f5aaaf6ecc55605ee74770d7eb2031e760d90a0d114", size = 91641, upload-time = "2026-05-30T16:30:13.881Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/b0/75745c948bc814d145cc46cb6da219c7acf3875822d47f08d6323ccf0458/serpent-1.43-py3-none-any.whl", hash = "sha256:7907f40151da126d57bbd675364d23898efa6b6601d79379cbae800236586b54", size = 9787, upload-time = "2026-05-30T16:30:12.027Z" }, +] + +[[package]] +name = "setuptools" +version = "83.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, +] + +[[package]] +name = "torch" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-bindings", marker = "python_full_version < '3.15' and sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cublas", "cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "triton", marker = "python_full_version < '3.15' and sys_platform == 'linux'" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/fe/cba54dc58523434919b66f13a667e36e436deddd77ca519e96553617d4ec/torch-2.13.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:e76f9bcecc52b8ff711239a2f7547d5353df95878ab232f0773c1d95928b92f8", size = 111187938, upload-time = "2026-07-08T16:05:17.065Z" }, + { url = "https://files.pythonhosted.org/packages/c2/59/1e3160e18e12aa3038390efab3ce02b36a9d4d6a527ecdd8520dca2e68d8/torch-2.13.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:092790c696a760c729fd5722835f50b9d81fd7c8f141571f3f3cf4081a8f664c", size = 427199369, upload-time = "2026-07-08T16:04:51.054Z" }, + { url = "https://files.pythonhosted.org/packages/01/79/1f2d34ad7034ee1c7ffc1cf8bf0f8213af2a81df6ecdb3997ecec107c09d/torch-2.13.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:60fcdcb2f3876e21146cb4524ef06397d727ca9ad5f020818547e25075fe3cb7", size = 526574961, upload-time = "2026-07-08T16:04:07.075Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fd/0f2ce40f58aefbdb3392f9acce3c8171940943ae2d661f70558bfa73befb/torch-2.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:a0d8b11f16a48d60e2015d8213aa0390744cbebb98e58b62b3514dddc656e330", size = 122015870, upload-time = "2026-07-08T16:05:27.59Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3a/ed0f4d4d1dcde03bced7aac9a28e800abcdc0cbd06b6775044c9fbd877b7/torch-2.13.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:2fe228aba290d14b9f31b049be550dbd469c3fd3013d7a19705b30454da97027", size = 111213045, upload-time = "2026-07-08T16:05:22.997Z" }, + { url = "https://files.pythonhosted.org/packages/df/a9/f6a2a4d763ff1df02e9a64c477029db614295bc9367f4131223791ccc243/torch-2.13.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:572df8be8ffb4599c88cbd6a0726f1f854f4da65d2e3c09f0e2c2283333cd6d4", size = 427210998, upload-time = "2026-07-08T16:04:37.708Z" }, + { url = "https://files.pythonhosted.org/packages/f3/82/fea946351658e6534db52d2cc12bc53087cbf87f9440c5f180f367c1950b/torch-2.13.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:796633c4cdf0fe2cdced72d8f88f22e73dbcfce83132763162f6d4bff13b820b", size = 526605292, upload-time = "2026-07-08T16:04:22.81Z" }, + { url = "https://files.pythonhosted.org/packages/21/d6/e8f3c6f7e01f626f77259de9860d2a78bc84c40539e28e79b7e98b0bb659/torch-2.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:024c6cc0c1b085f2f91f20a3dc27b0471d021c31ce84b81be3afdc39f791fd9d", size = 122057313, upload-time = "2026-07-08T16:03:53.43Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fa/c1c10b7aff4a9a3e8956d4f0a5f468fa6db7abc3208805719076772b4833/torch-2.13.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:33449899ce5496c1b84b4853179d94fd102028ae1407314d9fb956bb79e70d09", size = 111213743, upload-time = "2026-07-08T16:03:28.579Z" }, + { url = "https://files.pythonhosted.org/packages/11/18/9ecb37b56293a0be8d80f810bf672a72fe7e02f8b475d5ef1b9bf8a0d748/torch-2.13.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:1e09d6a722504957c694faceca843acde562786df1144ebcc5a74075ec7f6005", size = 427213008, upload-time = "2026-07-08T16:03:44.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5a/7c50ba1b7b713d71d34669c6d13dab0a11531a3eceb0307a5162dbfec0f7/torch-2.13.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:a3a9a21312872af8a26950b2c15680335a386a1f56ed03e780653d78b9607e9e", size = 526602329, upload-time = "2026-07-08T16:03:12.649Z" }, + { url = "https://files.pythonhosted.org/packages/91/3d/e7adcc6aaf36961cd18f56cf8ad0f3058c3a5c84ccf391762176c94581b8/torch-2.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:49b58f1e2c52440abb6f17c28f0335fe6c6d01ad1a7f55b0183b81e4b34d64e6", size = 122057920, upload-time = "2026-07-08T16:03:01.808Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/6dcc7f0c07052102dd36f83cbc5800842a909c8c3fbf1a7f8a5844954de9/torch-2.13.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d849b390e07d8d333ce8ecaf91b273c656c598379a19c9acf1318a883f6b391c", size = 111227066, upload-time = "2026-07-08T16:03:33.6Z" }, + { url = "https://files.pythonhosted.org/packages/e9/09/2c10e8cd0e00fa5d23c052df6ce467eaa7182399f5e0f824f1e4ff42ccae/torch-2.13.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:a3893dc2da0a972a8ca5d698c85a9f967559ac5f8ee1797b77408aa8734d073c", size = 427226309, upload-time = "2026-07-08T16:02:53.127Z" }, + { url = "https://files.pythonhosted.org/packages/76/c6/22c2102bbef14ca6a6cb4c20e42f088e49c5f812be4e160ae57502e325f9/torch-2.13.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:49f1ea385c754e54919408a9bb3b5a72b0b755bbe2c916c1d6f70afbec4908a2", size = 526614507, upload-time = "2026-07-08T16:02:16.441Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0c/7d1deb6bce5bc3e6042caf39100ac768eba3b9a098e1dddd16f75bd6489b/torch-2.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f8573e3ce9ebcd53fe922f01077a6085ccdfbe5f12fd215883a9d87d7a744fd", size = 122051871, upload-time = "2026-07-08T16:03:23.521Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ce/aa8b7f9949d32e0f2f624f342bc3b48112c1b8a130288465938bc83bcbf9/torch-2.13.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:c28def70706c2f9ecc752574766e8ae4da9b810ab6676b611166761a78a9f1e1", size = 111537025, upload-time = "2026-07-08T16:02:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/69/d1/491e3a0389430946145888b0203f2b6a759ce2a61481b96a85c2da4f2ced/torch-2.13.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:31061ff56ed8fbf26c749806905aeb749ebeb819810fd5d52508aa5afd90dddc", size = 427219769, upload-time = "2026-07-08T16:02:31.18Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1d/38006e045bf0a1fc28ef01e757c554e59e59a8770c284bc4f47b14e60441/torch-2.13.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:cc26eead4cf51d0b544e31e364dcf000846549c273bd148936fe9d24d29acb92", size = 526571320, upload-time = "2026-07-08T16:01:59.348Z" }, + { url = "https://files.pythonhosted.org/packages/56/94/655c91992a882bd5071aa0b5d22a07dbb130d801e872be97c0b627a7c693/torch-2.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a7de8a313090dc5c7d7ba4bfe5c3be222528f9a4dba1acc83bddb1157360c4b8", size = 122306773, upload-time = "2026-07-08T16:02:39.832Z" }, +] + +[[package]] +name = "tqdm" +version = "4.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, +] + +[[package]] +name = "triton" +version = "3.7.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/f9/19d842d06a08559534fa1eaab6ca551b1bcf40f06620bddec1babaa2772d/triton-3.7.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4a0e1cd4c4a76370ed74a8432a53cea28716827d19e40ffc732233e35ceb3f6", size = 184664887, upload-time = "2026-06-17T20:03:42.913Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5e/fce69606f7f240297f163e25539906732b199530d486ce67ae319877e821/triton-3.7.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6744957e9fd610a29680ec2346057d0c86948ed3812468670719f391e94b44a5", size = 197701306, upload-time = "2026-06-17T19:53:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/94/fa/f856e24deb462d5f18bd4b5a746957862ab9b6ee5834bda60605ec348366/triton-3.7.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9497f2e696ee368862a181a90b2dcc03ca978cc4f602abd67c7d81022a6988e1", size = 184692359, upload-time = "2026-06-17T20:03:48.288Z" }, + { url = "https://files.pythonhosted.org/packages/c4/6f/fb96d15db6f36d6eae4cafb998c2e0353bf59d7c4ea1662d7497f269134a/triton-3.7.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e40869937a68206ec70d7f25bb7ec6433cb083f9135e1f36dbd318dc449a728", size = 197719725, upload-time = "2026-06-17T19:53:20.419Z" }, + { url = "https://files.pythonhosted.org/packages/00/42/c5089d4d9327fcd1e862c599cc2927f39418f84dd11a84cb2ccff9d4787a/triton-3.7.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cdbfc09d9ec58bc5e68321525653220de7515c199e7a8097a97c85e62b52cd0a", size = 184694629, upload-time = "2026-06-17T20:03:53.444Z" }, + { url = "https://files.pythonhosted.org/packages/07/42/2c3ac59253ae8892b6f307875263dd23dc875cdf732d3aea40d6d41fb7cb/triton-3.7.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58c0e131da05134a2a4788ccbcc0c1105cf0f54c8e98f19e34cd465396dc15eb", size = 197729241, upload-time = "2026-06-17T19:53:27.801Z" }, + { url = "https://files.pythonhosted.org/packages/40/71/e01aa7ad573883ed9456f130226babdec70b005e098c4d6226a6238e761b/triton-3.7.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe4ea396a06171f1f1f58cbd39c70b09294398f7dd7c620939bab54ad6f934fa", size = 184705764, upload-time = "2026-06-17T20:03:59.064Z" }, + { url = "https://files.pythonhosted.org/packages/a4/09/5683146fda6a2b569deb78ccfd8fbfea8bfe55f726b081c0a6bb18dd6f28/triton-3.7.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2020153b08280415ec0da6607834e79166442147e78e144df06b508c75b186d2", size = 197729537, upload-time = "2026-06-17T19:53:35.516Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f8/448220c3092019f9fdfab39ec47985968181d67da34b44f6a7f6280a5cbb/triton-3.7.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c58e4c61f0c73b5dba3b5d19b4a7093c32f90dc18b2a7f121a7c16ccd31107b7", size = 184814760, upload-time = "2026-06-17T20:04:04.984Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ac/229b7d4589d2e5937310e72c6d46e89599d16a4a12b479ffa1499fee8eb8/triton-3.7.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10ba85fa2cca4a2fbdeb36bf1cb082f2c252bda55bf9fccd74f65ec5bc647e68", size = 197824404, upload-time = "2026-06-17T19:53:42.772Z" }, +] + +[[package]] +name = "typer" +version = "0.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/78/fda3361b56efc27944f24225f6ecd13d96d6fcfe37bd0eb34e2f4c63f9fc/typer-0.27.0.tar.gz", hash = "sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5", size = 203430, upload-time = "2026-07-15T19:21:07.007Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl", hash = "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1", size = 122716, upload-time = "2026-07-15T19:21:05.553Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.52.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/c8/2d307868453a4bca6e64fa3581d122ae0748a0869c53f159339def179c7c/uvicorn-0.52.0.tar.gz", hash = "sha256:ca8876ad6c1983f394157c168b39d52f6dd56dabf5602fa0982751cffc2293ae", size = 97504, upload-time = "2026-07-29T08:45:34.065Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/e6/b5c0630ace9757232aec07112be8146b812787db52141ff9d50674aa7634/uvicorn-0.52.0-py3-none-any.whl", hash = "sha256:3d887809810b89ed33501bcf0a9aba469b06ecd608158efce04bd6b48d8c9b08", size = 79058, upload-time = "2026-07-29T08:45:32.492Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, + { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, +] + +[[package]] +name = "websockets" +version = "17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/ea/c0f7924f7ccf005d6ad1f829971762ae751727497d6db1977ba5a635314f/websockets-17.0.tar.gz", hash = "sha256:6bbe83c4ef52a7533d2d8c6a3512b93722fd0db6bc6bc638d45edd49ef201444", size = 183456, upload-time = "2026-07-29T18:07:16.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/66/1fa9cd9c0e2e77f74c5b9391f5e154b939efbf9695eb5e5bb72e1d993669/websockets-17.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ddd0444e942d1f42ea2ab5c38f6f9dddfd6782a5bda0a29e210b414dda7e3636", size = 212719, upload-time = "2026-07-29T18:04:23.164Z" }, + { url = "https://files.pythonhosted.org/packages/1b/63/43d85076ba399257685c79726309c1367c9d6a133ef620b8fe1d166d7324/websockets-17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f1edeb9d17bbd4e5bb45c230fc77cd140e4b445d6daaf395910c72aa703e3606", size = 210403, upload-time = "2026-07-29T18:04:24.885Z" }, + { url = "https://files.pythonhosted.org/packages/7f/75/b98ec2482ac7f82c6a098d0350ed6d206032944230d8a18284c700fb2455/websockets-17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37f79808bf93a97c040ccb4dbee77ea1527d0fc3656077001428409866a06784", size = 210681, upload-time = "2026-07-29T18:04:26.675Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8d/6d37513adec534af9ed1f3f990be3e42aab2ec062d4730b24f01dc85d8f9/websockets-17.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd1b0bdb6f6692baad8dbc366886c9ecd167862ccfce4d227cd05f6ef26698d7", size = 219745, upload-time = "2026-07-29T18:04:28.085Z" }, + { url = "https://files.pythonhosted.org/packages/0e/51/a083d572986f8532369e8a376452bfdbb403899e7ac18c4982a05ee8123b/websockets-17.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cf609755e58e3eee3f105dac839d5a57687d67ade20752b4459402a96fe1c216", size = 220018, upload-time = "2026-07-29T18:04:29.489Z" }, + { url = "https://files.pythonhosted.org/packages/ec/fc/399ff59d88a6378f1f6a291676c0c0b0bd287617584ab49968ed91c05f90/websockets-17.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65fc0f621c801762ad16f95f6728c2498b4a2a9244938635d79e72234887cd1c", size = 221252, upload-time = "2026-07-29T18:04:31.04Z" }, + { url = "https://files.pythonhosted.org/packages/e8/67/eb0c001332545a7616c6f32110c11a46185e3df305e507cdc3970f1a3807/websockets-17.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cf2a17a24719b3666130cc42f4c22c5f067c94d78981a2895b5782687ac91978", size = 224544, upload-time = "2026-07-29T18:04:32.658Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/348ead1b20ddac653797f7a3681395189e8d2d6815844d6ef845e1d46dd8/websockets-17.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90aba12b1e2e9b79c6f7a56fbd16bcbbeab23ef51c11122b346f5cc4cfd9b10d", size = 221814, upload-time = "2026-07-29T18:04:34.239Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8f/22a9185f219cd21583ad1d7292061a867af03f9c3cb76b24ff8532efacb9/websockets-17.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ef569c690e1a7de6b218c1a8fba5a5b8560d6d141fa76e0e865e1c98fa4b140", size = 220586, upload-time = "2026-07-29T18:04:35.625Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ce/fbf20ff14a52e03ec76a706d2e768d9b0e6dd5f20bccafc214df854b89eb/websockets-17.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb6a5c404a3982c1ea834a758558c0b13f4917c78658a6e87eb728fb268b0f4c", size = 217880, upload-time = "2026-07-29T18:04:37.092Z" }, + { url = "https://files.pythonhosted.org/packages/d4/2b/8663a96e9765074a9d76fb3dc336d7d3d51eef19866248b374f01fd24a49/websockets-17.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b3c20b64398f0a0ce4a8b7caf6988e738de3eda2d7049e42ce655c137cc987d9", size = 220741, upload-time = "2026-07-29T18:04:38.588Z" }, + { url = "https://files.pythonhosted.org/packages/e2/37/6e37383539995c3cb2924af89541c771b85158930e6ce5fd059b0bf37a39/websockets-17.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:7018d5c1a0e161237aa52e282aaf2364daf45f0b792b212f6d3c1bc85a03ae36", size = 219332, upload-time = "2026-07-29T18:04:40.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/d5d42031a3ee438018ad3874f52104ea1144caa9edc455871d90fc3d9a1e/websockets-17.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e8e4545866fe949e932e0a895471b06d2784c6e0fcd35b3c7da02d7600d766f7", size = 220100, upload-time = "2026-07-29T18:04:41.495Z" }, + { url = "https://files.pythonhosted.org/packages/da/71/4763704b3b80757ed926d8d0cc06542e90a9e41aebd379324c950fcafc4f/websockets-17.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a9273bc1a7441ffd7a0bb63cf21cbe56bc046744cc4df24df060fe6806fb1c81", size = 221145, upload-time = "2026-07-29T18:04:43.094Z" }, + { url = "https://files.pythonhosted.org/packages/fe/96/12bd7d70842c2a4f4894d2905ddcd7078509e468a78c8efeada2836db0d8/websockets-17.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:95143a62308b1d2b81157ea8ebce502a8b07087f6c47226175f23a5e2358c09e", size = 218724, upload-time = "2026-07-29T18:04:44.873Z" }, + { url = "https://files.pythonhosted.org/packages/14/6b/d8ff625ac0c6fdba6cf1eb0d884aa618db864aacba992fceaafd977c9a53/websockets-17.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6ad3fad2a03731b788d7003e2f7603772a1cbe701a840a6acaa8305b7605bfbf", size = 219757, upload-time = "2026-07-29T18:04:46.415Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6d/c4fb9895b1e57e548b60905a40b9e8dba4098b32bfae54c3a415512ad777/websockets-17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e0aec4d4fc61ce7a24912026be07a6329a5d7b8c9012c45b573ab78878fe4e41", size = 219993, upload-time = "2026-07-29T18:04:47.947Z" }, + { url = "https://files.pythonhosted.org/packages/d2/a9/8cb56af6c9d123a7f1b61694d1c5405a3742bb89108bbdfb3255fd0d9b11/websockets-17.0-cp311-cp311-win32.whl", hash = "sha256:577be42e4cbe01cfbaf322b7a4998c0a0124d11582d34774f7226911a35c32bd", size = 213202, upload-time = "2026-07-29T18:04:49.495Z" }, + { url = "https://files.pythonhosted.org/packages/0f/42/0987257ab1ffce8492800c409106a3c2b4d247d6f93023a0f5de9f33680a/websockets-17.0-cp311-cp311-win_amd64.whl", hash = "sha256:d2f9829d91acf2863c1fb97e39095f5423b5f704fb1e478379ccc27a0c58df0c", size = 213499, upload-time = "2026-07-29T18:04:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/fa/70/3a62e87a178317739dba28f870c329e1cd34ee6ba051f3c936f7582d5c9b/websockets-17.0-cp311-cp311-win_arm64.whl", hash = "sha256:525488db5030b4c9bb03328269ab803a6f43a2232fc12e67c3a6b5c422ea96e3", size = 213430, upload-time = "2026-07-29T18:04:52.297Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e3/e4f27930a556ea4039487415ed7100ce96d607b29dfc65ac309168695ba4/websockets-17.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:6312d9926196483550c0ad83459595dd02dd816fa0523ec91dac5601b35de2da", size = 212744, upload-time = "2026-07-29T18:04:54.041Z" }, + { url = "https://files.pythonhosted.org/packages/e6/14/2bcbc1805f1b42b94fa6fc81e7a0d1ffc1029d938cf9ce4b8e3a48875116/websockets-17.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12a21ef5e185f9e0c1c9ad23649aca411b04e49e030287f0a47b889d9e1724a9", size = 210425, upload-time = "2026-07-29T18:04:55.613Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9d/a88e66b7b8581f433b990f20738045093bfc15dd3b8b939980daf793121d/websockets-17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e219be64a9dff86d33b3314ecc6c42289a2d8a447821931012f874b2cc3c70a9", size = 210692, upload-time = "2026-07-29T18:04:56.944Z" }, + { url = "https://files.pythonhosted.org/packages/e3/8e/f8565de07cb99b9e9f21a6932ce87d28cd65e06bf8b9e6cfc795d7fb12ea/websockets-17.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:98e4882f2f37b4efa7e1c41eb97db1e86384b6252135ab8f5794656cb3bec1ae", size = 220018, upload-time = "2026-07-29T18:04:58.304Z" }, + { url = "https://files.pythonhosted.org/packages/be/7c/883fddde356c9366bbb1abc9a16d02e20515aadb89de3364c5dd7b9cc360/websockets-17.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:abfa93514d5d7fe50988c4b6092585da0e9a737c1063530cf62fecfe93f7acf0", size = 220295, upload-time = "2026-07-29T18:04:59.958Z" }, + { url = "https://files.pythonhosted.org/packages/9a/18/2b2c71d158206b759e79a2e606ad057a3e3f01e05353a676081417ea9bc2/websockets-17.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aacbf208ef605c463e5cc888d26e25b68732baa171990339c1b4e2880f7b60dd", size = 221533, upload-time = "2026-07-29T18:05:01.734Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/d58c3f516dcfed9d98804fa25c679958df32286bfabd6029dabeec5f1ce7/websockets-17.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fdea04f18e814a15ef115356392624f8a694f29bb6b8ed65828a6d53eeb96654", size = 224312, upload-time = "2026-07-29T18:05:03.166Z" }, + { url = "https://files.pythonhosted.org/packages/77/49/33946a85a09638f046c2db6506fe53aee35f71fcef9347d343ce668c9cb5/websockets-17.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d7c3b3c1fda46b2d40d57503278755f3ad47f09eec57c4f6145cd80f1c8beecf", size = 222169, upload-time = "2026-07-29T18:05:04.635Z" }, + { url = "https://files.pythonhosted.org/packages/61/e3/e2441326cd2132b4861ff1a0b03671dedacdca6e7996e913137ec1b4ad26/websockets-17.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da8b74ac47a129bcb82f40aab234ead2d31ed20566e6e75d1929ac4d61f22a55", size = 220924, upload-time = "2026-07-29T18:05:06.252Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ee/ae47d5aace0b71c7e038d00f1651086cd32fa44190f179182c58a6c5b795/websockets-17.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6e43040c1f6b0e0fced4a3020693f32914e4d57605be63da30c197bfa118c6d7", size = 218171, upload-time = "2026-07-29T18:05:07.655Z" }, + { url = "https://files.pythonhosted.org/packages/6a/99/2872777a8d96c4bc546bc79a22acd7db57aa2acddcbd3527c83515c7d789/websockets-17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddfc7ae598004778e8e092580aafec16ae9f8f16ebf0c178bb76292db6e8dd", size = 220970, upload-time = "2026-07-29T18:05:09.071Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8e/64472cc08da2e6ed2ee40c372abfe090e7d368965aa861dc32382aba051d/websockets-17.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:180837e1f4f82fb4779fe4561d246a55028d01f7f41c4a00b24117804d382f14", size = 219572, upload-time = "2026-07-29T18:05:10.548Z" }, + { url = "https://files.pythonhosted.org/packages/a9/df/61c12777165b02a578e4a0055ccbcb48bad92f3ae4373b2bb449a28ceebf/websockets-17.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4736675b7079a09b04558f1e5613dacb71165ff9868b7dd01c2488159ca5c089", size = 220342, upload-time = "2026-07-29T18:05:12.006Z" }, + { url = "https://files.pythonhosted.org/packages/58/bc/e6e60c01b6100ac9f9a1afd3391a5f3e0c72eee536429d001c4be3af7004/websockets-17.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1922e2124f7eb7ca7ba203973a0b8b3f598447efe6937feaf63fbb1775341eb8", size = 221450, upload-time = "2026-07-29T18:05:13.436Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d3/64cb3002bbb6ee592591f668a2c802deccc183fbf5a41071145bdb133d57/websockets-17.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a3cfb0ea471e325b596e9259d2f35f3040ecd1896e2d608649f25748929febc0", size = 219002, upload-time = "2026-07-29T18:05:14.894Z" }, + { url = "https://files.pythonhosted.org/packages/55/08/0877015b5b252d83c7f441023e11293fd0d0be9dc05c792c5f91712c8eec/websockets-17.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1a44cbbf2ab144f1ce5268c1dc4a541e9ed0cd35a892d38a9a52e3d01456cbf7", size = 219983, upload-time = "2026-07-29T18:05:16.539Z" }, + { url = "https://files.pythonhosted.org/packages/57/f8/271327f8fa4c07326ba9c79c9daea81e4c043029c6df48bbddfb0bf46649/websockets-17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3c59f7a03967dcdb490098a7e684b1e691f8032835f8176d9cb3cbc654773381", size = 220259, upload-time = "2026-07-29T18:05:18.217Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8b/31e77872bc730124acd9e0af977667b9805c4450519e9bd220e4450f4749/websockets-17.0-cp312-cp312-win32.whl", hash = "sha256:67e3de3a5abbea437cd73505a2220a3fa37b3e38b68c7dd410de6fadb9492dc5", size = 213205, upload-time = "2026-07-29T18:05:19.575Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/5a5706da118fe90038a529ca43557092c1f5665876b00570d777bd19cfff/websockets-17.0-cp312-cp312-win_amd64.whl", hash = "sha256:5f7cef3e552397fc4313b1caf4fe1fabf53dfde4e4153aa1a74d73b5a246794b", size = 213502, upload-time = "2026-07-29T18:05:21.023Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d9/fd6d3c80f548dbae84687f9c50b26407707e63d624ba2edc6736c0aa68fc/websockets-17.0-cp312-cp312-win_arm64.whl", hash = "sha256:499e8536471f07de659bc3f003f1fcef60da953de8ffc26d01253828f6b0a003", size = 213430, upload-time = "2026-07-29T18:05:22.369Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/ad36c2cd987b89447e2216d19355306eb9a66a9ce4fbcfb22924ade347a1/websockets-17.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:29a24b93f223c701053db3e07416769f64ac69bc2204131d286ca9e309f78012", size = 212738, upload-time = "2026-07-29T18:05:23.902Z" }, + { url = "https://files.pythonhosted.org/packages/26/03/c89dc12a6fd49948b2aa0cda77765859c1310f6ec2ad50fc43d15851fa7a/websockets-17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:005d06fe6af0071625a41c231848342da013709738cae9c22031d396b85fa875", size = 210420, upload-time = "2026-07-29T18:05:25.362Z" }, + { url = "https://files.pythonhosted.org/packages/27/df/9fdf5fd50ab0b9db8fdd4037d54064703f5f99a8c34c995b3d25a8099c65/websockets-17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1feba08ed3370fad0efc1295b5b314115b920b8014d1fc20d3535dada44c155", size = 210682, upload-time = "2026-07-29T18:05:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/6e/71/e56676f18dc9b906018aa8e9e106080edb81240df12672a71b2a0273677f/websockets-17.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8d8b6160b46996d2821659ae6fcf9aa20b2641bc7a08972b15308c65b0764295", size = 220067, upload-time = "2026-07-29T18:05:28.481Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a5/0d742c23f1ba6e60c5cb0fd402f89a5faeeee3c23c8dffcc3308125b124c/websockets-17.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ce75f71335f3d682d37ff7464d1e1c20a065794108087ddcf3404aa03ba91295", size = 220352, upload-time = "2026-07-29T18:05:29.982Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c9/43201b9fbc5c58f89e0bee12c14a67d847a453449d8ba95f29adab128855/websockets-17.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d5721fc96349667b623d6e1209f3c111667946d346715023013b11681d8d37b", size = 221589, upload-time = "2026-07-29T18:05:31.394Z" }, + { url = "https://files.pythonhosted.org/packages/76/37/c226a8bf87376165fe15e0fa2ab1557433463ed279a9e17e899c77cb307e/websockets-17.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dd09cacb19f2e6d7e01c9e8d870ab40e4d4b1d59508646e74cdb963bbb73730a", size = 223030, upload-time = "2026-07-29T18:05:32.868Z" }, + { url = "https://files.pythonhosted.org/packages/96/e8/b7b7cad3d1bfff2c60c51bd64a3e29f48c988b1e7f1731fe9e89b09dcfa5/websockets-17.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d599bf4fab7e1bc1c009a966c8ded26c97cb8983410ab6d404f21b2e750557c9", size = 222216, upload-time = "2026-07-29T18:05:34.512Z" }, + { url = "https://files.pythonhosted.org/packages/09/ca/6b1dab07811b26bd79b85788aaf1d14acdeb2bc0252d2e18999e46e9f834/websockets-17.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:46a13ca29de8d60ef9cc6cba58e9c4e65a19a0cf25140576285f561f23827044", size = 220971, upload-time = "2026-07-29T18:05:36.021Z" }, + { url = "https://files.pythonhosted.org/packages/95/25/7943eeb82ba8f323f36c0b52f471ea012b563af1e50bfe15230fd973ac7d/websockets-17.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c153840709258daef58a13a0e4cf78b5d838d5b15261de0d49f6ec1fd2538d44", size = 218227, upload-time = "2026-07-29T18:05:37.582Z" }, + { url = "https://files.pythonhosted.org/packages/7c/39/a88e72a5b8ff80e4f7c1c5ddb335d64432650252a5856935fc6fe3065869/websockets-17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63609c513bc5f8757e8ecb0eb788afc54825807cf151216ce7d3359576899b70", size = 221034, upload-time = "2026-07-29T18:05:39.347Z" }, + { url = "https://files.pythonhosted.org/packages/4a/14/a8bfd634a5dad970a946aca76de7c9e8e717b8f9960e290d20b6f21d5931/websockets-17.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:e2b977c946503cd3182a7f7cf3d18255d682580400cf4ecdeeccad435b5d2bfe", size = 219632, upload-time = "2026-07-29T18:05:41.066Z" }, + { url = "https://files.pythonhosted.org/packages/19/c9/9cfca56b5a216b001c9d3dd2351f2e3af7b967473b89df7aae656d61e048/websockets-17.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5668320cde66fa7737a26e894fda39e0ad76d4edf96832650cab84370c561ad0", size = 220401, upload-time = "2026-07-29T18:05:43.589Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2f75906e489049cd3420c46511054f95fb063a54dcf99483cc063e14a713/websockets-17.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9a2e5e26e649b0786b8e696c41a8a3147a4c68c79fe6e0b1f07bbefeba054d56", size = 221503, upload-time = "2026-07-29T18:05:45.1Z" }, + { url = "https://files.pythonhosted.org/packages/3f/04/8d95434937e1fbaa0fee8bcf764867e9ccf8d42abb8a159c2681dd68a112/websockets-17.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42fec6309ac1c20e45982460321468858f2b2cbc66d1919cfa04663e0aaaefcb", size = 219063, upload-time = "2026-07-29T18:05:46.57Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ee/3217cee93eaccf717c291d678a0594a5388555b024b9f46b0555fc25a812/websockets-17.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d648a61bfd3e2f3be8643a27eded0c7fe4e178670ee1534061f1235f2c857be1", size = 220017, upload-time = "2026-07-29T18:05:48.074Z" }, + { url = "https://files.pythonhosted.org/packages/3f/9d/bf0c9c0905b3b6e4eaf9cdf37361d38c2707815baf6c0bbf69fc873ddb76/websockets-17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d802fd1ff5d1e1773d815c5fee634b9e94e9829afb4fdbcfc8dab39c648095d", size = 220299, upload-time = "2026-07-29T18:05:49.549Z" }, + { url = "https://files.pythonhosted.org/packages/b9/03/33fe4e800d3bc72101cff3c148de55ac73eb51bbae142e6aafaf835901cf/websockets-17.0-cp313-cp313-win32.whl", hash = "sha256:c2786b3cc77a84afa612c2c60fc20c22b576ec46e7ae1e79cc14ad43cd1ed05a", size = 213194, upload-time = "2026-07-29T18:05:51.085Z" }, + { url = "https://files.pythonhosted.org/packages/bd/18/6c358b4611ce7a1c438bcb6cf7dbe9be32993c1c785d1a9cef495ab34e6e/websockets-17.0-cp313-cp313-win_amd64.whl", hash = "sha256:aa9b082460c6775f98179aa78d9186ff68ad69eca8edd30c816e689190e1bf6b", size = 213503, upload-time = "2026-07-29T18:05:52.581Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d0/e51d30d7a9b1ecb3135871b4faece90bee14cf0c754881583e3a5b9a30a1/websockets-17.0-cp313-cp313-win_arm64.whl", hash = "sha256:169412f60a48be88350dc5e89a446de89c11d2c6f6a9c62b6ab796e1b490d7d8", size = 213435, upload-time = "2026-07-29T18:05:54.3Z" }, + { url = "https://files.pythonhosted.org/packages/df/6c/ff0c7950af50bae08ce0ae68bbf3fe72710851566693a709231cea9f3fd4/websockets-17.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:94bbd0c509cdbc2cfd245cc5442b2bb6f2a9df6e60a0d9e4f9d1b1926e30dbbd", size = 212783, upload-time = "2026-07-29T18:05:55.773Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4f/1a4f4129c9a8827559eacb4769b78bd856080cf84b8e7c09ae721802f65e/websockets-17.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:bb43ca37efbc140e1e6f1acf8acf7e85569f48fad588ce95e7f8bc723ec506c8", size = 210471, upload-time = "2026-07-29T18:05:57.255Z" }, + { url = "https://files.pythonhosted.org/packages/4e/34/a086c3caf087cc6a3965a09835c856c8e5a870bb611e1ccf6d73f73494aa/websockets-17.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b0958c062f61b05ebc226d4fc8ccf8a10cbd109db06c745a91fee6218fea77e9", size = 210690, upload-time = "2026-07-29T18:05:58.746Z" }, + { url = "https://files.pythonhosted.org/packages/34/2d/0cb31555e1a22c82e1a72e87db1c158a9ef5b71edc16dc30b43ecd60d1de/websockets-17.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:95f3bfa818c458ea6caf5420cd4b9b487b3a61e411fd55e2d5848aa553da15ea", size = 220071, upload-time = "2026-07-29T18:06:00.199Z" }, + { url = "https://files.pythonhosted.org/packages/39/9a/c231a7395aaea78179b660ff06337608db2114cf0e8c172b6e13234459b9/websockets-17.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ce14ded954d5fdf3a173d951f1a17cfa40456f8cb4289fdc5ed49348351b7a7", size = 220423, upload-time = "2026-07-29T18:06:01.729Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e4/5a61bc45103267ac116c646f632532b31a12b64392b91c8d63cbf0f6845f/websockets-17.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbfb30a6123a2851cb4a4cacc468dabf8d9f335f63f6cd8dd1a23be7c315979e", size = 221669, upload-time = "2026-07-29T18:06:03.385Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/f69a14158ac5d2ef47ce435fb25c72ab95f8483db7def5c11d1732f9b108/websockets-17.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ce99ec8fe4509021bffcdd473651ddfe9064ed142ec83f84eec1c2bf2fe6ad37", size = 223041, upload-time = "2026-07-29T18:06:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/6d/22/e24745306baa56abafeaae99975f8dfe4e531f07a198da741ffbf8dcb662/websockets-17.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3bf6df721d343cf628bce98ca23fa36a7b374c9a022f37bbb55a200a242e4afe", size = 222273, upload-time = "2026-07-29T18:06:06.736Z" }, + { url = "https://files.pythonhosted.org/packages/68/1c/ab93e8018e3102268082c5ccb14f7f77795173c918f023cd01d764790ab7/websockets-17.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3c5c1ddd419ae6f61b8f26ea3577f8f6b75c90bfee563cd2feedf773414cea5a", size = 221019, upload-time = "2026-07-29T18:06:08.222Z" }, + { url = "https://files.pythonhosted.org/packages/ae/07/11414c237d046204de8fca6a1ec4cfffe152c3c5c0fed537cfb88b641226/websockets-17.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c9ed428a473c0d54bb8d60d76928a88fc7cbad8581e60996005185c28b755cf2", size = 218280, upload-time = "2026-07-29T18:06:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/01/0b/fc29062bd253ffc0e19279afb7a85df76d0e84d9adb4bc07932138d52fc7/websockets-17.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:78c73aeaaad88633494a5d3e8aa6a2dbc28aad160cdcd99f29f4f2bb3d8842e8", size = 221095, upload-time = "2026-07-29T18:06:11.712Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7b/5c1aaadd1d392a15a3637225128ad15e41f8c170c8c925323b61dc085bf9/websockets-17.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9c986364dfb39d10a1d06deee2552e89163d9642a9c9175a41bdc8e136ef89a6", size = 219606, upload-time = "2026-07-29T18:06:13.242Z" }, + { url = "https://files.pythonhosted.org/packages/c0/e0/108c722318f8e55570b9705b930d51a4b4ff1bd24d830059d8cbafbdc6b8/websockets-17.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:76431676743151e985ad9f8ae0ca4372ae3ca2e8462f9227ec9bcf6f8b84c762", size = 220392, upload-time = "2026-07-29T18:06:14.884Z" }, + { url = "https://files.pythonhosted.org/packages/71/0a/9ff02d0c71dcb2b3562fc81487e622dbe482e20a45959c37179dd428b3da/websockets-17.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:cfaadf6866cf62edab1c1b8bedf09b80255af90ec00b0eb0da55407d9ec8f260", size = 221564, upload-time = "2026-07-29T18:06:16.434Z" }, + { url = "https://files.pythonhosted.org/packages/25/7f/d3a12c95e509a612d79efa78be50d94663385b11b2345f70ae3b2f210386/websockets-17.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:954b80f73046bc79b694c8c13d7f4429da149183ed45f171008f780048a37f6d", size = 219119, upload-time = "2026-07-29T18:06:17.99Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1b/e33c4027444df9b279807feb87d9312f7ca5fea09e103e53fce21e307ed0/websockets-17.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a973940286a570d22a6b65b5531ab6e0d6e4485379bcfc11d239a4ab14f28392", size = 220069, upload-time = "2026-07-29T18:06:19.465Z" }, + { url = "https://files.pythonhosted.org/packages/0e/81/6a65d5971b7e328cdb6d503bde0b4063bfea7caab8acfb7837b2876e2fc5/websockets-17.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c60792e8a1004cc1aba943c4671d35432f903bc57ff338092de4e4062b4a4f3", size = 220363, upload-time = "2026-07-29T18:06:20.928Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b0/2d47c5004c696dc749de93fd1af5730b296a619454efbaf8520bbe65962e/websockets-17.0-cp314-cp314-win32.whl", hash = "sha256:19ef9a3d55b8176ba6b71b6eb11373ccaa2b674162ced5c7ee26dc90d912fbcc", size = 212734, upload-time = "2026-07-29T18:06:22.533Z" }, + { url = "https://files.pythonhosted.org/packages/f3/94/3d3e1c0016f2938ca026172df97f4a84f6d546f422dc4b6cf07ebdbd1a17/websockets-17.0-cp314-cp314-win_amd64.whl", hash = "sha256:bd902b19f9ff1e88dcf9939500dba8da791b8102da93deceafb696659c7c1f94", size = 213079, upload-time = "2026-07-29T18:06:24.554Z" }, + { url = "https://files.pythonhosted.org/packages/46/9d/3a24ef81d8e05beab88bc36d1ed2695ec59c91194fa40f47fbffbccfbbfa/websockets-17.0-cp314-cp314-win_arm64.whl", hash = "sha256:9a7acf1542a53350d4623c023e4944e5fe3bd9ee6b4385b86fd6287d8d549d81", size = 212958, upload-time = "2026-07-29T18:06:26.372Z" }, + { url = "https://files.pythonhosted.org/packages/9d/91/88c7e6b9f1acbe80643f9189c06c8084a6a81e3653fdbb7aafadaabcc4bf/websockets-17.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:583416c24586432ee8a745cca4727efc2d4682c453f69d79debacbde72863160", size = 213116, upload-time = "2026-07-29T18:06:28.095Z" }, + { url = "https://files.pythonhosted.org/packages/f6/3e/ade0e4181523b906fde2097813583a06c54360a38f3730eb86cf12843979/websockets-17.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:208ba355ab37f488b5d19b1c3a70240c88ffb9ce8407ff991f702e5781bbb5c4", size = 210650, upload-time = "2026-07-29T18:06:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/ff/00/75e805330de2413de10c80adb4e46d83b029a168434cb08f8b7a39733e1c/websockets-17.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ce88616250de9fa206c17a484d07ba2fdba94daefedfd7a8ffa689b0c5ec1fe7", size = 210847, upload-time = "2026-07-29T18:06:31.533Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/63db81708c3b688dfc7f66a9a35a0b09a818b78c6588d5b2745c481c9bbd/websockets-17.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:14a6c9aaed860f9cd1d3fb71b37b38a436b864f2e78ff605491f43da959227fb", size = 220434, upload-time = "2026-07-29T18:06:33.173Z" }, + { url = "https://files.pythonhosted.org/packages/07/cf/b98becac799a2bb4d5e9f197642f1bc82d586ab66314aafe459814cb2d44/websockets-17.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fc5b304b0100aabb46613e6c911fcbb959e5542fd94c89a1e5df704bf703c6ec", size = 220717, upload-time = "2026-07-29T18:06:34.854Z" }, + { url = "https://files.pythonhosted.org/packages/7e/56/c443f81b483de8f40e00cf41037a14ea4f32e1a67110d1be9293cb8980da/websockets-17.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea0aaf55be94d587f2b895938434d24d809bd34762407a84de67a42cbfe9af61", size = 221891, upload-time = "2026-07-29T18:06:36.499Z" }, + { url = "https://files.pythonhosted.org/packages/ee/c5/97b101b5afef7c527d7f22484abc1f949447d5a6e55d50b78ce90745b8f0/websockets-17.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15452af52e7e536cd240c0da28605247d0629da828643f5e7d1fd119e7256197", size = 224033, upload-time = "2026-07-29T18:06:39Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/2a1e0f66aca3142ea244caa1f03af49616ff43f61a2ab8a60b8da40c6954/websockets-17.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:79bdaf80414d0c0bf86a016dc6fce803e1cde9046cd900298d74690109c5f118", size = 222462, upload-time = "2026-07-29T18:06:40.635Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/1538ef951aff7616dffaf7cc64cf64e1ddafddcd8ff0ee3d77aedc9c3ce8/websockets-17.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:51e89a46eb1b7c824e8dd85f2a4544503385af68d1017b4a42800523ac35382c", size = 221192, upload-time = "2026-07-29T18:06:42.453Z" }, + { url = "https://files.pythonhosted.org/packages/06/4c/27deb9b47b06fa891798a33c4ef1be5d02f8b3045c313798abb79f56510e/websockets-17.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a34089ead0fd516f4fa0ad4fedad445520f2144f1764d54b8cda07c466edfb49", size = 218746, upload-time = "2026-07-29T18:06:44.346Z" }, + { url = "https://files.pythonhosted.org/packages/e8/03/14ff4635d6afbf23724234e362354d58e128d2a67fea6de3bb9426ae3024/websockets-17.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c3874b45bb5d235c607c910c5721e2f7b3e7a47cc876e0c37108f55554820a69", size = 221443, upload-time = "2026-07-29T18:06:46.254Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1f/6fe2474ce511c604336b29b1798fe01d7688b24568736fbe4d4f09666742/websockets-17.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d306f1f15f06f879b43036fc4ece102630ca1d48d7cd2ff79f02fc66ae5db5e8", size = 219933, upload-time = "2026-07-29T18:06:48.018Z" }, + { url = "https://files.pythonhosted.org/packages/87/be/faba0fc471d3bab1d1d63f10e7ff7cba97d580af8f4b9219a6274b664c1a/websockets-17.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b853c76629b92576e905ca46249435f04ce41cffdda3df3aac378132b40a33ce", size = 220822, upload-time = "2026-07-29T18:06:49.663Z" }, + { url = "https://files.pythonhosted.org/packages/b6/92/5fc01c01d6cce63002329c6d4d3a7b2ac6f758b10e198065273a88d60461/websockets-17.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9d0d77ce8e8080daf411eaa0889b834ee1defd076e386e55a90a75f0187a2008", size = 221843, upload-time = "2026-07-29T18:06:51.241Z" }, + { url = "https://files.pythonhosted.org/packages/9c/18/dae84b24f45852ecfcd734e4a85550e639af1b58bc1f5214dcb1a7e58346/websockets-17.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:27a95b0d35c0f88da71adf52d263f7b6ed23914cd459477cf0b13d2b52a48d48", size = 219534, upload-time = "2026-07-29T18:06:52.86Z" }, + { url = "https://files.pythonhosted.org/packages/a9/1d/1efb52128dc311812127ea337b729a89a945be5d65a75a5dba1f3c4f1d7e/websockets-17.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c3796b7fb9605dd9df50cd09091c0e9612d30707ba2bcf0371a3a4c5d25219c9", size = 220306, upload-time = "2026-07-29T18:06:54.698Z" }, + { url = "https://files.pythonhosted.org/packages/02/6f/06920cefcfd4adea34565e60b2c08eef0265e6a97e6743586fb9a088da8a/websockets-17.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:41435357c5e80b63085c8e26b8ab2c44963bdd9c4b131c5ef352d3c9107e8c78", size = 220735, upload-time = "2026-07-29T18:06:56.484Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e2/8ad920e410bc7b64f82ca697e31eb71dae995c28cb7761c5ce4a201e2be3/websockets-17.0-cp314-cp314t-win32.whl", hash = "sha256:ede2d4b60d4acc8a4c03b5392808c2b074e38c99b08bcbb45373f1459aef2934", size = 212865, upload-time = "2026-07-29T18:06:58.169Z" }, + { url = "https://files.pythonhosted.org/packages/56/23/286f283a0fbf64cb43dc15f53022c36e749dfae5e70ad1e58ea76813a656/websockets-17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:85849eff1a1a39caf82a73c853006e01eb9a080cb03ba9022a8d72839ac3d671", size = 213206, upload-time = "2026-07-29T18:06:59.816Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f0/b48652b29d781850d0f685f680935a7ae2b2a6d9668f6f4ad7876ef0684d/websockets-17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:8122f76dc4418fa7cb1cd015444871469e277ea845761169009ca4167835f6a8", size = 213122, upload-time = "2026-07-29T18:07:01.758Z" }, + { url = "https://files.pythonhosted.org/packages/28/d8/7879b3a9d00343f9574ffdf5b854419a33b5bae8a96a20b2583ef502e892/websockets-17.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cad3963bc9664468223b9e75734a04b1092e5e6947783d9162877c7be68091d2", size = 210337, upload-time = "2026-07-29T18:07:03.635Z" }, + { url = "https://files.pythonhosted.org/packages/44/aa/e38fe356c3cb92af10894e7e3affed5bd831af5d4ed7fbe64fe1b00213c4/websockets-17.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:162188a53ffb58b175dc41bc9aee1232b87205e46591cb327be71315f8630bec", size = 210610, upload-time = "2026-07-29T18:07:05.297Z" }, + { url = "https://files.pythonhosted.org/packages/f6/2f/0681ddc3a07af06e1be2b2954b6cb07f9caf67c69ada44a81e029573cfd4/websockets-17.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4e95999b19cd99b01d401937f2adebc515b815fa2c7cfb043fc64cd0cdf2d3", size = 211560, upload-time = "2026-07-29T18:07:07.602Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6f/8630e03816889034aed3765a4de67839b36f04acdca52648ced6b690f89e/websockets-17.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2314ae31ab4a629cac708ece44e28d88fae9fbb1bd4bb5b21718b7ac4ec7e91", size = 211454, upload-time = "2026-07-29T18:07:09.347Z" }, + { url = "https://files.pythonhosted.org/packages/d7/1c/a8d02a7a9f92804daba7f861ba539cf6c25765d8b2a7d7c8ea355c79deb8/websockets-17.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60ed4a3b760ed8db9a0c2c01ad65b2c253603b0edd7236ef24dbe363e417f31b", size = 212348, upload-time = "2026-07-29T18:07:11.29Z" }, + { url = "https://files.pythonhosted.org/packages/6e/14/ac6da556d66c5f5fcf21e2f8468cd303262ae46a7f460bb481425d77ed42/websockets-17.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:69852d81e27f53bb69db752c55ecbbb73a0988692c654bafd1651d3e51441476", size = 213586, upload-time = "2026-07-29T18:07:13.442Z" }, + { url = "https://files.pythonhosted.org/packages/9d/b4/9b5bd8ad82a7ace4e4a497aed083b6a9bf9076b1ea1a0bf5831686b4af71/websockets-17.0-py3-none-any.whl", hash = "sha256:0c24d62cafaca7dc1631e9f3bf0672fa83f010e66a2aeff4d00727b18addcd8e", size = 206871, upload-time = "2026-07-29T18:07:15.156Z" }, +]