From 22577e7af0c11d2ad18ac19286d581be71a2723a Mon Sep 17 00:00:00 2001 From: Sissi Feng Date: Fri, 3 Jul 2026 15:39:18 -0400 Subject: [PATCH 1/9] =?UTF-8?q?feat(optimization):=20Phase=20A=20=E2=80=94?= =?UTF-8?q?=20verifiable=20decision=20reward=20+=20trajectory=20foundation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn the reward from an opaque scalar into a per-signal, auditable, RLVR-ready record, and start persisting decision trajectories. This is the foundation for the self-evolving evaluation loop (Phases B/C). - New app/services/verifiable_reward.py: single source of truth for the reward components both calculators duplicated. Each signal exposes a RewardVerification (name/passed/score/evidence/verifier_type). Tri-state passed; verifier errors recorded, never swallowed. process/outcome split. - CampaignDecisionRewardCalculator + LoopRewardCalculator now delegate to the shared core (DRY). Loop keeps its no-positive-clamp objective via positive_clamp=False — reward values are bit-for-bit unchanged (regression guarded). - CampaignDecisionReward + LoopReward gain rubric_version, process_reward, outcome_reward, verifications (backward-compatible defaults). - New decision_trajectories table (+ trajectory_schema_version) and app/services/decision_trajectory.py: append-only persistence + JSONL export. - DetailedEventEmitter.emit_decision_reward streams the per-signal verifier report so the inner evaluation loop is visible in /lab. Tests: 893 passed, ruff clean, mypy clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/core/db.py | 25 ++ app/services/decision_outcome.py | 112 +++++---- app/services/decision_trajectory.py | 110 +++++++++ app/services/detailed_event_emitter.py | 23 ++ app/services/loop_engineering.py | 128 ++++++---- app/services/verifiable_reward.py | 310 +++++++++++++++++++++++++ app/static/lab.js | 21 ++ tests/test_decision_reward_event.py | 56 +++++ tests/test_decision_trajectory.py | 116 +++++++++ tests/test_reward_split.py | 90 +++++++ tests/test_verifiable_reward.py | 191 +++++++++++++++ 11 files changed, 1103 insertions(+), 79 deletions(-) create mode 100644 app/services/decision_trajectory.py create mode 100644 app/services/verifiable_reward.py create mode 100644 tests/test_decision_reward_event.py create mode 100644 tests/test_decision_trajectory.py create mode 100644 tests/test_reward_split.py create mode 100644 tests/test_verifiable_reward.py diff --git a/app/core/db.py b/app/core/db.py index d6e86df..2abf6ef 100644 --- a/app/core/db.py +++ b/app/core/db.py @@ -486,6 +486,31 @@ def init_db() -> None: CREATE INDEX IF NOT EXISTS idx_campaign_metrics_cid ON campaign_metrics(campaign_id, round_number); + -- Decision trajectories (RLVR wedge): append-only record of each scored + -- decision — state, candidate actions, selected action, per-signal verifier + -- report, process/outcome split, final reward. Feeds offline policy + -- evaluation and group-relative ranking. No FK: decision ids are not always + -- rows in campaign_state, and the trajectory must survive independently. + + CREATE TABLE IF NOT EXISTS decision_trajectories ( + id TEXT PRIMARY KEY, + campaign_id TEXT NOT NULL, + trace_id TEXT, + round_index INTEGER, + layer TEXT NOT NULL, + rubric_version TEXT NOT NULL, + reward REAL NOT NULL, + process_reward REAL NOT NULL DEFAULT 0, + outcome_reward REAL NOT NULL DEFAULT 0, + verifier_report_json TEXT NOT NULL DEFAULT '[]', + trajectory_json TEXT NOT NULL DEFAULT '{}', + trajectory_schema_version TEXT NOT NULL DEFAULT '1', + created_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_decision_traj_cid + ON decision_trajectories(campaign_id); + -- QueryPlan cache (DB Retrieval Agent) CREATE TABLE IF NOT EXISTS query_plan_cache ( diff --git a/app/services/decision_outcome.py b/app/services/decision_outcome.py index 4cdbd2c..e4b25cc 100644 --- a/app/services/decision_outcome.py +++ b/app/services/decision_outcome.py @@ -14,6 +14,36 @@ from pydantic import BaseModel, Field, field_validator from app.services.decision_trace import CampaignDecisionTrace +from app.services.verifiable_reward import ( + RUBRIC_VERSION_DEFAULT, + RewardVerification, + split_reward, + verify_context, + verify_execution, + verify_failure, + verify_objective, + verify_proxy_gap, + verify_safety, + verify_validation, +) +from app.services.verifiable_reward import ( + clamp as _shared_clamp, +) +from app.services.verifiable_reward import ( + execution_score as _shared_execution, +) +from app.services.verifiable_reward import ( + objective_score as _shared_objective, +) +from app.services.verifiable_reward import ( + proxy_gap_score as _shared_proxy_gap, +) +from app.services.verifiable_reward import ( + round_component as _shared_round_component, +) +from app.services.verifiable_reward import ( + validation_score as _shared_validation, +) __all__ = [ "CampaignDecisionAccounting", @@ -68,6 +98,13 @@ class CampaignDecisionReward(BaseModel): proxy_gap_reward: float = 0.0 validation_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 + # and persisted rows loading unchanged. + rubric_version: str = RUBRIC_VERSION_DEFAULT + process_reward: float = 0.0 + outcome_reward: float = 0.0 + verifications: list[RewardVerification] = Field(default_factory=list) rationale: str metadata: dict[str, Any] = Field(default_factory=dict) @@ -132,26 +169,30 @@ class CampaignDecisionRewardCalculator: """Calculate deterministic reward components from observed outcomes.""" def calculate(self, outcome: CampaignDecisionOutcome) -> CampaignDecisionReward: - execution_reward = _execution_reward(outcome.execution_success) - failure_penalty = _round_component(-0.1 * outcome.failure_count) - safety_penalty = _round_component(-0.5 * outcome.safety_incident_count) - objective_reward = _objective_reward(outcome.objective_delta) - proxy_gap_reward = _proxy_gap_reward(outcome.proxy_gap_delta) - validation_reward = _validation_reward(outcome.validation_success) - context_reward = ( - 0.1 if outcome.context_request_fulfilled is True else 0.0 - ) - raw_reward = ( - execution_reward - + failure_penalty - + safety_penalty - + objective_reward - + proxy_gap_reward - + validation_reward - + context_reward - ) + # Build the per-signal verifications once; every scalar component and the + # process/outcome split derive from them, so there is a single source of + # truth and the numbers stay bit-identical to the pre-wedge calculator. + verifications = [ + verify_execution(outcome.execution_success), + verify_failure(outcome.failure_count), + verify_safety(outcome.safety_incident_count), + verify_objective(outcome.objective_delta), + verify_proxy_gap(outcome.proxy_gap_delta), + verify_validation(outcome.validation_success), + verify_context(outcome.context_request_fulfilled), + ] + scores = {v.name: v.score for v in verifications} + execution_reward = scores["execution"] + failure_penalty = scores["failure"] + safety_penalty = scores["safety"] + objective_reward = scores["objective"] + proxy_gap_reward = scores["proxy_gap"] + validation_reward = scores["validation"] + context_reward = scores["context"] + raw_reward = sum(v.score for v in verifications) reward = _clamp(raw_reward) regret = max(0.0, -reward) + process_reward, outcome_reward = split_reward(verifications) return CampaignDecisionReward( trace_id=outcome.trace_id, reward=reward, @@ -162,6 +203,10 @@ def calculate(self, outcome: CampaignDecisionOutcome) -> CampaignDecisionReward: proxy_gap_reward=proxy_gap_reward, validation_reward=validation_reward, context_reward=context_reward, + rubric_version=RUBRIC_VERSION_DEFAULT, + process_reward=process_reward, + outcome_reward=outcome_reward, + verifications=verifications, rationale=_reward_rationale( execution_reward=execution_reward, failure_penalty=failure_penalty, @@ -217,36 +262,23 @@ def build_campaign_decision_accounting(**kwargs: Any) -> CampaignDecisionAccount return CampaignDecisionAccountingBuilder().build(**kwargs) +# Reward components delegate to the shared verifiable-reward core so the two +# calculators (here and loop_engineering) share one source of truth. Values are +# bit-for-bit identical to the former local copies (regression IRON RULE). def _execution_reward(execution_success: bool | None) -> float: - if execution_success is True: - return 0.2 - if execution_success is False: - return -0.3 - return 0.0 + return _shared_execution(execution_success) def _objective_reward(objective_delta: float | None) -> float: - if objective_delta is None: - return 0.0 - if objective_delta > 0: - return _round_component(min(objective_delta, 1.0) * 0.3) - return _round_component(objective_delta * 0.3) + return _shared_objective(objective_delta) def _proxy_gap_reward(proxy_gap_delta: float | None) -> float: - if proxy_gap_delta is None: - return 0.0 - if proxy_gap_delta < 0: - return _round_component(abs(proxy_gap_delta) * 0.3) - return _round_component(-proxy_gap_delta * 0.3) + return _shared_proxy_gap(proxy_gap_delta) def _validation_reward(validation_success: bool | None) -> float: - if validation_success is True: - return 0.2 - if validation_success is False: - return -0.2 - return 0.0 + return _shared_validation(validation_success) def _reward_rationale( @@ -282,8 +314,8 @@ def _reward_rationale( def _clamp(value: float) -> float: - return _round_component(max(-1.0, min(1.0, value))) + return _shared_clamp(value) def _round_component(value: float) -> float: - return round(value, 10) + return _shared_round_component(value) diff --git a/app/services/decision_trajectory.py b/app/services/decision_trajectory.py new file mode 100644 index 0000000..b88c5f9 --- /dev/null +++ b/app/services/decision_trajectory.py @@ -0,0 +1,110 @@ +"""Append-only persistence for scored decisions (RLVR wedge, Phase A / T4). + +Turns the in-memory ``CampaignDecisionAccounting`` (trace + outcome + verifiable +reward) into a durable trajectory row, and exports the accumulated rows as JSONL +for offline policy evaluation and group-relative ranking (Phases B/C). + + accounting ──► persist_campaign_trajectory() ──► decision_trajectories row + decision_trajectories ──► export_trajectories_jsonl() ──► one JSON obj / line + +Append-only by construction: every call inserts a fresh uuid-keyed row; nothing +is updated or deleted. Provenance is the point — a decision's score and the +per-signal verifier report that produced it must remain auditable forever. +""" +from __future__ import annotations + +import json +from typing import Any +from uuid import uuid4 + +from app.core import db +from app.services.decision_outcome import CampaignDecisionAccounting + +__all__ = [ + "TRAJECTORY_SCHEMA_VERSION", + "persist_campaign_trajectory", + "load_trajectories", + "export_trajectories_jsonl", +] + +TRAJECTORY_SCHEMA_VERSION = "1" + + +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 + reward = accounting.reward + 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"), + } + + def _insert(conn: Any) -> None: + conn.execute( + """ + INSERT INTO decision_trajectories ( + id, campaign_id, trace_id, round_index, layer, + rubric_version, reward, process_reward, outcome_reward, + verifier_report_json, trajectory_json, + trajectory_schema_version, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + row_id, + outcome.campaign_id, + outcome.trace_id, + outcome.round_index, + layer, + reward.rubric_version, + reward.reward, + reward.process_reward, + reward.outcome_reward, + db.json_dumps(verifier_report), + db.json_dumps(trajectory), + TRAJECTORY_SCHEMA_VERSION, + db.utcnow_iso(), + ), + ) + + db.run_txn(_insert) + return row_id + + +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: + if campaign_id is None: + rows = conn.execute( + "SELECT * FROM decision_trajectories ORDER BY created_at, id" + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM decision_trajectories " + "WHERE campaign_id = ? ORDER BY created_at, id", + (campaign_id,), + ).fetchall() + result: list[dict[str, Any]] = [] + for row in rows: + record = dict(row) + record["verifier_report"] = db.parse_json( + record.pop("verifier_report_json", None), [] + ) + record["trajectory"] = db.parse_json( + record.pop("trajectory_json", None), {} + ) + result.append(record) + return result + + +def export_trajectories_jsonl(campaign_id: str | None = None) -> str: + """Serialize trajectories as JSONL (one JSON object per line).""" + rows = load_trajectories(campaign_id) + return "\n".join(json.dumps(row, sort_keys=True) for row in rows) diff --git a/app/services/detailed_event_emitter.py b/app/services/detailed_event_emitter.py index 1d4d0f6..254a301 100644 --- a/app/services/detailed_event_emitter.py +++ b/app/services/detailed_event_emitter.py @@ -48,6 +48,29 @@ def emit_agent_decision(self, agent_name: str, decision: str, reasoning: str): "timestamp": time.time(), }) + def emit_decision_reward(self, reward, *, decision_id: str = None): + """Emit the per-signal verifiable reward for one decision (RLVR wedge). + + Makes the inner evaluation loop visible on the event stream: not just + "reward = 0.55" but every verifier's passed/score/evidence, plus the + process/outcome split and the rubric version that scored it. Duck-typed + over CampaignDecisionReward / LoopReward to avoid a service dependency. + """ + verifications = [ + v.model_dump() for v in getattr(reward, "verifications", []) or [] + ] + self.emit(self.campaign_id, { + "type": "decision_reward", + "indent": self.indent_level, + "decision_id": decision_id, + "rubric_version": getattr(reward, "rubric_version", None), + "reward": getattr(reward, "reward", None), + "process_reward": getattr(reward, "process_reward", None), + "outcome_reward": getattr(reward, "outcome_reward", None), + "verifications": verifications, + "timestamp": time.time(), + }) + def emit_agent_result(self, agent_name: str, success: bool, message: str, data: dict = None): """Agent完成""" self.indent_level = max(0, self.indent_level - 1) diff --git a/app/services/loop_engineering.py b/app/services/loop_engineering.py index 39a3d3a..54a7c65 100644 --- a/app/services/loop_engineering.py +++ b/app/services/loop_engineering.py @@ -13,6 +13,52 @@ from pydantic import BaseModel, Field, field_validator +from app.services.verifiable_reward import ( + RUBRIC_VERSION_DEFAULT as _RUBRIC_VERSION_DEFAULT, +) +from app.services.verifiable_reward import ( + RewardVerification as _RewardVerification, +) +from app.services.verifiable_reward import ( + clamp as _shared_clamp, +) +from app.services.verifiable_reward import ( + execution_score as _shared_execution, +) +from app.services.verifiable_reward import ( + objective_score as _shared_objective, +) +from app.services.verifiable_reward import ( + recovery_score as _shared_recovery, +) +from app.services.verifiable_reward import ( + round_component as _shared_round_component, +) +from app.services.verifiable_reward import ( + split_reward as _split_reward, +) +from app.services.verifiable_reward import ( + validation_score as _shared_validation, +) +from app.services.verifiable_reward import ( + verify_execution as _verify_execution, +) +from app.services.verifiable_reward import ( + verify_failure as _verify_failure, +) +from app.services.verifiable_reward import ( + verify_objective as _verify_objective, +) +from app.services.verifiable_reward import ( + verify_recovery as _verify_recovery, +) +from app.services.verifiable_reward import ( + verify_safety as _verify_safety, +) +from app.services.verifiable_reward import ( + verify_validation as _verify_validation, +) + __all__ = [ "LoopDecision", "LoopEpisode", @@ -100,6 +146,12 @@ class LoopReward(BaseModel): recovery_reward: float = 0.0 failure_penalty: float = 0.0 safety_penalty: float = 0.0 + # Phase A (RLVR wedge): rubric versioning, process/outcome split, and the + # per-signal verifiable records. Defaults keep older callers unchanged. + rubric_version: str = _RUBRIC_VERSION_DEFAULT + process_reward: float = 0.0 + outcome_reward: float = 0.0 + verifications: list[_RewardVerification] = Field(default_factory=list) rationale: str metadata: dict[str, Any] = Field(default_factory=dict) @@ -188,25 +240,31 @@ def calculate( iteration_id: str, outcome: LoopOutcome, ) -> LoopReward: - execution_reward = _execution_reward(outcome.execution_success) - objective_reward = _objective_reward(outcome.objective_delta) - validation_reward = _validation_reward(outcome.validation_success) - recovery_reward = _recovery_reward( - attempted=outcome.recovery_attempted, - success=outcome.recovery_success, - ) - failure_penalty = _round_component(-0.1 * outcome.failure_count) - safety_penalty = _round_component(-0.5 * outcome.safety_incident_count) - raw_reward = ( - execution_reward - + objective_reward - + validation_reward - + recovery_reward - + failure_penalty - + safety_penalty - ) + # Per-signal verifications are the single source of truth; the loop layer + # scales the raw objective delta (positive_clamp=False). Scalars and the + # process/outcome split derive from them, bit-identical to before. + verifications = [ + _verify_execution(outcome.execution_success), + _verify_objective(outcome.objective_delta, positive_clamp=False), + _verify_validation(outcome.validation_success), + _verify_recovery( + attempted=outcome.recovery_attempted, + success=outcome.recovery_success, + ), + _verify_failure(outcome.failure_count), + _verify_safety(outcome.safety_incident_count), + ] + scores = {v.name: v.score for v in verifications} + execution_reward = scores["execution"] + objective_reward = scores["objective"] + validation_reward = scores["validation"] + recovery_reward = scores["recovery"] + failure_penalty = scores["failure"] + safety_penalty = scores["safety"] + raw_reward = sum(v.score for v in verifications) reward = _clamp(raw_reward) regret = max(0.0, -reward) + process_reward, outcome_reward = _split_reward(verifications) return LoopReward( iteration_id=iteration_id, reward=reward, @@ -217,6 +275,10 @@ def calculate( recovery_reward=recovery_reward, failure_penalty=failure_penalty, safety_penalty=safety_penalty, + rubric_version=_RUBRIC_VERSION_DEFAULT, + process_reward=process_reward, + outcome_reward=outcome_reward, + verifications=verifications, rationale=_reward_rationale( execution_reward=execution_reward, objective_reward=objective_reward, @@ -382,36 +444,24 @@ def summarize_loop_replay( return LoopReplayAnalyzer().analyze(iterations, **kwargs) +# Reward components delegate to the shared verifiable-reward core. The loop +# layer scales the raw objective delta (no positive clamp) — hence +# positive_clamp=False. Values are bit-for-bit identical to the former local +# copies (regression IRON RULE). def _execution_reward(value: bool | None) -> float: - if value is True: - return 0.2 - if value is False: - return -0.3 - return 0.0 + return _shared_execution(value) def _objective_reward(delta: float | None) -> float: - if delta is None: - return 0.0 - return _round_component(float(delta) * 0.3) + return _shared_objective(delta, positive_clamp=False) def _validation_reward(value: bool | None) -> float: - if value is True: - return 0.2 - if value is False: - return -0.2 - return 0.0 + return _shared_validation(value) def _recovery_reward(*, attempted: bool, success: bool | None) -> float: - if not attempted: - return 0.0 - if success is True: - return 0.1 - if success is False: - return -0.1 - return 0.0 + return _shared_recovery(attempted=attempted, success=success) def _reward_rationale( @@ -460,7 +510,7 @@ def _mean(values: list[float]) -> float: def _round_component(value: float) -> float: - return round(value, 10) + return _shared_round_component(value) def _round_metric(value: float) -> float: @@ -468,4 +518,4 @@ def _round_metric(value: float) -> float: def _clamp(value: float) -> float: - return _round_component(max(-1.0, min(1.0, value))) + return _shared_clamp(value) diff --git a/app/services/verifiable_reward.py b/app/services/verifiable_reward.py new file mode 100644 index 0000000..bdf4b45 --- /dev/null +++ b/app/services/verifiable_reward.py @@ -0,0 +1,310 @@ +"""Shared, verifiable reward core (Phase A / RLVR wedge). + +Single source of truth for the deterministic reward *components* that both +``decision_outcome.CampaignDecisionRewardCalculator`` and +``loop_engineering.LoopRewardCalculator`` compute. Historically each file +carried its own verbatim copies of these helpers; this module de-duplicates +them and, on top of the raw scalar, exposes each signal as a +``RewardVerification`` — an auditable ``(passed, score, evidence)`` record that +makes the reward RLVR-ready (per-signal verifiable) instead of a single opaque +number. + +Design invariants (regression IRON RULE): the scalar returned by each +``*_score`` function is bit-for-bit identical to the value the legacy helpers +produced, so the two calculators can delegate here without changing any +existing reward number. + + signal ──► *_score() ──► float (what the legacy calculators need) + └─► verify_*() ──► RewardVerification(passed, score, evidence) + +``passed`` is tri-state: + True — the "good" condition held (execution succeeded, objective improved) + False — the "bad" condition held (execution failed, safety incident) + None — the signal was not observed / not applicable (delta is None) + +A verifier that raises is never swallowed: ``run_verifier`` converts it into a +``passed=None`` record carrying the error in ``evidence`` (CLAUDE.md rule 8). +""" +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Literal + +from pydantic import BaseModel, Field + +__all__ = [ + "RewardVerification", + "VerifierType", + "PROCESS_SIGNALS", + "OUTCOME_SIGNALS", + "RUBRIC_VERSION_DEFAULT", + "round_component", + "clamp", + "execution_score", + "objective_score", + "proxy_gap_score", + "validation_score", + "failure_score", + "safety_score", + "recovery_score", + "context_score", + "verify_execution", + "verify_objective", + "verify_proxy_gap", + "verify_validation", + "verify_failure", + "verify_safety", + "verify_recovery", + "verify_context", + "run_verifier", + "split_reward", +] + +VerifierType = Literal[ + "unit_test", + "state_transition", + "safety_rule", + "outcome_metric", + "retrospective_audit", +] + +# Legacy default so historical reward records (which predate rubric +# versioning) load with a stable, comparable version tag. +RUBRIC_VERSION_DEFAULT = "v0.1_static" + + +class RewardVerification(BaseModel): + """One auditable signal within a decision's reward. + + ``score`` is the signal's contribution to the (pre-clamp) raw reward. + ``passed`` records the qualitative verdict (see module docstring). + ``evidence`` carries the raw inputs so a reviewer can reproduce the verdict. + """ + + name: str + passed: bool | None + score: float + verifier_type: VerifierType + evidence: dict[str, Any] = Field(default_factory=dict) + + +# --------------------------------------------------------------------------- +# Rounding (canonical — matches both legacy files exactly) +# --------------------------------------------------------------------------- + + +def round_component(value: float) -> float: + return round(value, 10) + + +def clamp(value: float) -> float: + return round_component(max(-1.0, min(1.0, value))) + + +# --------------------------------------------------------------------------- +# Scalar component scores (bit-for-bit identical to the legacy helpers) +# --------------------------------------------------------------------------- + + +def execution_score(execution_success: bool | None) -> float: + if execution_success is True: + return 0.2 + if execution_success is False: + return -0.3 + return 0.0 + + +def objective_score( + objective_delta: float | None, *, positive_clamp: bool = True +) -> float: + """Objective-improvement reward, coefficient 0.3. + + ``positive_clamp=True`` (decision layer) caps a positive delta at 1.0 before + scaling; ``positive_clamp=False`` (loop layer) scales the raw delta. The two + layers historically differed only in this clamp — expressing both here keeps + a single source of truth without changing either layer's numbers. + """ + if objective_delta is None: + return 0.0 + if positive_clamp and objective_delta > 0: + return round_component(min(objective_delta, 1.0) * 0.3) + return round_component(objective_delta * 0.3) + + +def proxy_gap_score(proxy_gap_delta: float | None) -> float: + if proxy_gap_delta is None: + return 0.0 + if proxy_gap_delta < 0: + return round_component(abs(proxy_gap_delta) * 0.3) + return round_component(-proxy_gap_delta * 0.3) + + +def validation_score(validation_success: bool | None) -> float: + if validation_success is True: + return 0.2 + if validation_success is False: + return -0.2 + return 0.0 + + +def failure_score(failure_count: int) -> float: + return round_component(-0.1 * failure_count) + + +def safety_score(safety_incident_count: int) -> float: + return round_component(-0.5 * safety_incident_count) + + +def recovery_score(*, attempted: bool, success: bool | None) -> float: + if not attempted: + return 0.0 + if success is True: + return 0.1 + if success is False: + return -0.1 + return 0.0 + + +def context_score(context_request_fulfilled: bool | None) -> float: + return 0.1 if context_request_fulfilled is True else 0.0 + + +# --------------------------------------------------------------------------- +# Verifiers: scalar + qualitative verdict + reproducible evidence +# --------------------------------------------------------------------------- + + +def verify_execution(execution_success: bool | None) -> RewardVerification: + return RewardVerification( + name="execution", + passed=execution_success, + score=execution_score(execution_success), + verifier_type="state_transition", + evidence={"execution_success": execution_success}, + ) + + +def verify_objective( + objective_delta: float | None, *, positive_clamp: bool = True +) -> RewardVerification: + passed = None if objective_delta is None else objective_delta > 0 + return RewardVerification( + name="objective", + passed=passed, + score=objective_score(objective_delta, positive_clamp=positive_clamp), + verifier_type="outcome_metric", + evidence={"objective_delta": objective_delta, "positive_clamp": positive_clamp}, + ) + + +def verify_proxy_gap(proxy_gap_delta: float | None) -> RewardVerification: + # Reducing the proxy gap (negative delta) is the desirable direction. + passed = None if proxy_gap_delta is None else proxy_gap_delta < 0 + return RewardVerification( + name="proxy_gap", + passed=passed, + score=proxy_gap_score(proxy_gap_delta), + verifier_type="outcome_metric", + evidence={"proxy_gap_delta": proxy_gap_delta}, + ) + + +def verify_validation(validation_success: bool | None) -> RewardVerification: + return RewardVerification( + name="validation", + passed=validation_success, + score=validation_score(validation_success), + verifier_type="outcome_metric", + evidence={"validation_success": validation_success}, + ) + + +def verify_failure(failure_count: int) -> RewardVerification: + return RewardVerification( + name="failure", + passed=failure_count == 0, + score=failure_score(failure_count), + verifier_type="outcome_metric", + evidence={"failure_count": failure_count}, + ) + + +def verify_safety(safety_incident_count: int) -> RewardVerification: + return RewardVerification( + name="safety", + passed=safety_incident_count == 0, + score=safety_score(safety_incident_count), + verifier_type="safety_rule", + evidence={"safety_incident_count": safety_incident_count}, + ) + + +def verify_recovery(*, attempted: bool, success: bool | None) -> RewardVerification: + if not attempted: + passed: bool | None = None + else: + passed = success + return RewardVerification( + name="recovery", + passed=passed, + score=recovery_score(attempted=attempted, success=success), + verifier_type="state_transition", + evidence={"attempted": attempted, "success": success}, + ) + + +def verify_context(context_request_fulfilled: bool | None) -> RewardVerification: + passed = True if context_request_fulfilled is True else None + return RewardVerification( + name="context", + passed=passed, + score=context_score(context_request_fulfilled), + verifier_type="state_transition", + evidence={"context_request_fulfilled": context_request_fulfilled}, + ) + + +def run_verifier( + name: str, + fn: Callable[[], RewardVerification], + *, + verifier_type: VerifierType, +) -> RewardVerification: + """Run a verifier, converting any exception into an auditable failure. + + Never swallows the error: it lands in ``evidence['error']`` with + ``passed=None`` and ``score=0.0`` so a broken verifier degrades the record + rather than the whole reward (CLAUDE.md rule 8: fail loudly, not silently). + """ + try: + return fn() + except Exception as exc: # noqa: BLE001 — deliberately broad; recorded, not hidden + return RewardVerification( + name=name, + passed=None, + score=0.0, + verifier_type=verifier_type, + evidence={"error": f"{type(exc).__name__}: {exc}"}, + ) + + +# --------------------------------------------------------------------------- +# process / outcome split (design A2) +# --------------------------------------------------------------------------- +# outcome — observed results of having acted +# process — whether the move was reasonable given the state +# Net: process_reward + outcome_reward == sum(scores) (pre-clamp raw reward). + +OUTCOME_SIGNALS: frozenset[str] = frozenset( + {"execution", "objective", "validation", "failure", "safety", "recovery"} +) +PROCESS_SIGNALS: frozenset[str] = frozenset({"proxy_gap", "context"}) + + +def split_reward( + verifications: list[RewardVerification], +) -> tuple[float, float]: + """Return ``(process_reward, outcome_reward)`` from a verification list.""" + process = sum(v.score for v in verifications if v.name in PROCESS_SIGNALS) + outcome = sum(v.score for v in verifications if v.name in OUTCOME_SIGNALS) + return round_component(process), round_component(outcome) diff --git a/app/static/lab.js b/app/static/lab.js index d11fcab..b47cfc2 100644 --- a/app/static/lab.js +++ b/app/static/lab.js @@ -535,6 +535,8 @@ function connectSSE(campaignId) { // Detailed execution events 'agent_decision', 'tool_call', 'hardware_action', 'protocol_step', 'safety_check', 'thinking', 'log', + // Verifiable decision reward (RLVR wedge — inner evaluation loop) + 'decision_reward', // Instrument status 'instrument_status', // Agent decision trees @@ -880,6 +882,25 @@ function handleSSEEvent(type, data) { break; } + case 'decision_reward': { + // Verifiable reward — surface the inner evaluation loop: total, + // process/outcome split, rubric version, and every signal's verdict. + if (roundId) { + const verifications = data.verifications || []; + const mark = (p) => (p === true ? '✅' : p === false ? '❌' : '➖'); + const signals = verifications + .map((v) => `${mark(v.passed)} ${v.name}=${v.score}`) + .join(' '); + const rubric = data.rubric_version || 'v0.1_static'; + const title = `🧪 reward ${data.reward} ` + + `(process ${data.process_reward} / outcome ${data.outcome_reward}) ` + + `[${rubric}]`; + addDetailStep(roundId, 'decision', 'evaluator', + title, signals, data.indent || 0); + } + break; + } + case 'tool_call': { // Tool invocation - add as detail step if (roundId) { diff --git a/tests/test_decision_reward_event.py b/tests/test_decision_reward_event.py new file mode 100644 index 0000000..00a596e --- /dev/null +++ b/tests/test_decision_reward_event.py @@ -0,0 +1,56 @@ +"""T6: the per-signal verifiable reward is emitted on the event stream so the +inner evaluation loop is visible in /lab (not just an opaque total).""" +from __future__ import annotations + +from app.services.decision_outcome import ( + CampaignDecisionOutcome, + CampaignDecisionRewardCalculator, +) +from app.services.detailed_event_emitter import DetailedEventEmitter + + +def test_emit_decision_reward_carries_verifier_report(): + events: list[tuple[str, dict]] = [] + emitter = DetailedEventEmitter("camp-1", lambda cid, evt: events.append((cid, evt))) + + reward = CampaignDecisionRewardCalculator().calculate( + CampaignDecisionOutcome( + trace_id="t-1", + campaign_id="camp-1", + round_index=0, + execution_success=True, + objective_delta=0.5, + proxy_gap_delta=-0.4, + ) + ) + emitter.emit_decision_reward(reward, decision_id="t-1") + + assert len(events) == 1 + cid, evt = events[0] + assert cid == "camp-1" + assert evt["type"] == "decision_reward" + assert evt["decision_id"] == "t-1" + assert evt["rubric_version"] == reward.rubric_version + assert evt["reward"] == reward.reward + assert evt["process_reward"] == reward.process_reward + assert evt["outcome_reward"] == reward.outcome_reward + # every signal's verifier record is present with passed/score/evidence + names = {v["name"] for v in evt["verifications"]} + assert {"execution", "objective", "proxy_gap"} <= names + for v in evt["verifications"]: + assert set(v) >= {"name", "passed", "score", "evidence", "verifier_type"} + + +def test_emit_handles_reward_without_verifications(): + events: list[tuple[str, dict]] = [] + emitter = DetailedEventEmitter("camp-1", lambda cid, evt: events.append((cid, evt))) + + class _Bare: + verifications = [] + rubric_version = "v0.1_static" + reward = 0.0 + process_reward = 0.0 + outcome_reward = 0.0 + + emitter.emit_decision_reward(_Bare()) + assert events[0][1]["verifications"] == [] diff --git a/tests/test_decision_trajectory.py b/tests/test_decision_trajectory.py new file mode 100644 index 0000000..4231e9a --- /dev/null +++ b/tests/test_decision_trajectory.py @@ -0,0 +1,116 @@ +"""T4: append-only trajectory persistence + JSONL export.""" +from __future__ import annotations + +import json + +import pytest + + +@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 _accounting(campaign_id="camp-t4", trace_id="trace-t4", **outcome_kw): + from app.services.decision_layer import CampaignDecisionLayer + from app.services.decision_outcome import ( + CampaignDecisionAccountingBuilder, + CampaignDecisionOutcomeBuilder, + ) + from app.services.decision_trace import CampaignDecisionTraceBuilder + from app.services.round_context import CampaignRoundContextBuilder + + context = CampaignRoundContextBuilder().build( + campaign_id=campaign_id, + round_index=1, + strategy_selection_result={ + "campaign_intent": "optimize", + "optimization_mode": "exploit", + "candidate_generation_backend": "bo_mcp", + "confidence": 0.75, + }, + ) + plan = CampaignDecisionLayer().decide(context) + trace = CampaignDecisionTraceBuilder().build( + trace_id=trace_id, + context=context, + decision_plan=plan, + actual_action="propose_candidates", + ) + outcome = CampaignDecisionOutcomeBuilder().build( + trace=trace, + execution_success=True, + objective_delta=0.5, + **outcome_kw, + ) + return CampaignDecisionAccountingBuilder().build(trace=trace, outcome=outcome) + + +def test_persist_then_load_roundtrip(db_env): + from app.services.decision_trajectory import ( + TRAJECTORY_SCHEMA_VERSION, + load_trajectories, + persist_campaign_trajectory, + ) + + acc = _accounting() + row_id = persist_campaign_trajectory(acc) + assert row_id.startswith("traj-") + + rows = load_trajectories("camp-t4") + assert len(rows) == 1 + row = rows[0] + assert row["campaign_id"] == "camp-t4" + assert row["trace_id"] == "trace-t4" + assert row["layer"] == "campaign" + assert row["trajectory_schema_version"] == TRAJECTORY_SCHEMA_VERSION + assert row["reward"] == acc.reward.reward + # per-signal verifier report is preserved + names = {v["name"] for v in row["verifier_report"]} + assert "execution" in names and "objective" in names + # full replayable unit is stored + assert row["trajectory"]["outcome"]["campaign_id"] == "camp-t4" + + +def test_append_only_accumulates(db_env): + from app.services.decision_trajectory import ( + load_trajectories, + persist_campaign_trajectory, + ) + + persist_campaign_trajectory(_accounting(trace_id="a")) + persist_campaign_trajectory(_accounting(trace_id="b")) + rows = load_trajectories("camp-t4") + assert len(rows) == 2 # nothing overwritten + assert {r["trace_id"] for r in rows} == {"a", "b"} + + +def test_export_jsonl_is_one_object_per_line(db_env): + from app.services.decision_trajectory import ( + export_trajectories_jsonl, + persist_campaign_trajectory, + ) + + persist_campaign_trajectory(_accounting(trace_id="a")) + persist_campaign_trajectory(_accounting(trace_id="b")) + text = export_trajectories_jsonl("camp-t4") + lines = text.splitlines() + assert len(lines) == 2 + for line in lines: + obj = json.loads(line) # each line is valid JSON + assert obj["campaign_id"] == "camp-t4" + assert "verifier_report" in obj + + +def test_export_empty_when_no_rows(db_env): + from app.services.decision_trajectory import export_trajectories_jsonl + + assert export_trajectories_jsonl("nope") == "" diff --git a/tests/test_reward_split.py b/tests/test_reward_split.py new file mode 100644 index 0000000..742e8f7 --- /dev/null +++ b/tests/test_reward_split.py @@ -0,0 +1,90 @@ +"""T3: rubric_version + process/outcome split + per-signal verifications on the +two reward DTOs, and regression that the total reward is unchanged.""" +from __future__ import annotations + +from app.services.decision_outcome import ( + CampaignDecisionOutcome, + CampaignDecisionRewardCalculator, +) +from app.services.loop_engineering import LoopOutcome, LoopRewardCalculator +from app.services.verifiable_reward import RUBRIC_VERSION_DEFAULT + + +def _campaign_outcome(**kw): + base = dict(trace_id="t-1", campaign_id="c-1", round_index=0) + base.update(kw) + return CampaignDecisionOutcome(**base) + + +# --- campaign decision reward ------------------------------------------- + + +def test_campaign_new_fields_present_with_defaults(): + reward = CampaignDecisionRewardCalculator().calculate(_campaign_outcome()) + assert reward.rubric_version == RUBRIC_VERSION_DEFAULT + assert reward.verifications # non-empty + assert {v.name for v in reward.verifications} == { + "execution", "failure", "safety", "objective", "proxy_gap", + "validation", "context", + } + + +def test_campaign_process_plus_outcome_equals_raw(): + reward = CampaignDecisionRewardCalculator().calculate( + _campaign_outcome( + execution_success=True, + objective_delta=0.5, + proxy_gap_delta=-0.4, + validation_success=True, + context_request_fulfilled=True, + ) + ) + raw = reward.metadata["raw_reward"] + assert round(reward.process_reward + reward.outcome_reward, 10) == round(raw, 10) + # process = proxy_gap + context; outcome = execution + objective + validation + assert reward.process_reward == round( + reward.proxy_gap_reward + reward.context_reward, 10 + ) + + +def test_campaign_reward_value_regression(): + # Known outcome: exec True (0.2) + objective 0.5 (0.15) + validation True (0.2) + # = 0.55, no clamp. This must not change under the refactor. + reward = CampaignDecisionRewardCalculator().calculate( + _campaign_outcome( + execution_success=True, objective_delta=0.5, validation_success=True + ) + ) + assert reward.reward == 0.55 + + +# --- loop reward --------------------------------------------------------- + + +def test_loop_new_fields_present(): + reward = LoopRewardCalculator().calculate( + iteration_id="it-1", outcome=LoopOutcome(execution_success=True) + ) + assert reward.rubric_version == RUBRIC_VERSION_DEFAULT + assert {v.name for v in reward.verifications} == { + "execution", "objective", "validation", "recovery", "failure", "safety", + } + + +def test_loop_objective_not_positive_clamped(): + # Loop layer scales the raw delta: objective_delta=2.0 -> 2.0*0.3 = 0.6 + # (decision layer would clamp positive to 1.0 -> 0.3). This divergence must + # survive the shared-core refactor. + reward = LoopRewardCalculator().calculate( + iteration_id="it-1", outcome=LoopOutcome(objective_delta=2.0) + ) + assert reward.objective_reward == 0.6 + + +def test_loop_process_plus_outcome_equals_raw(): + reward = LoopRewardCalculator().calculate( + iteration_id="it-1", + outcome=LoopOutcome(execution_success=True, objective_delta=0.3), + ) + raw = reward.metadata["raw_reward"] + assert round(reward.process_reward + reward.outcome_reward, 10) == round(raw, 10) diff --git a/tests/test_verifiable_reward.py b/tests/test_verifiable_reward.py new file mode 100644 index 0000000..ac7e65b --- /dev/null +++ b/tests/test_verifiable_reward.py @@ -0,0 +1,191 @@ +"""Tests for the shared verifiable-reward core (Phase A / RLVR wedge). + +These lock the RewardVerification contract and prove the canonical component +scores match the values the two legacy calculators (decision_outcome, +loop_engineering) already produce, so the delegation refactor is a bit-for-bit +no-op (regression IRON RULE). +""" +from __future__ import annotations + +import pytest + +from app.services.verifiable_reward import ( + OUTCOME_SIGNALS, + PROCESS_SIGNALS, + RewardVerification, + run_verifier, + split_reward, + verify_context, + verify_execution, + verify_failure, + verify_objective, + verify_proxy_gap, + verify_recovery, + verify_safety, + verify_validation, +) + +# --- RewardVerification contract ----------------------------------------- + + +def test_verification_shape(): + v = verify_execution(True) + assert isinstance(v, RewardVerification) + assert v.name == "execution" + assert v.passed is True + assert v.score == 0.2 + assert v.verifier_type == "state_transition" + assert "execution_success" in v.evidence + + +# --- execution: True / False / None (passed tri-state) ------------------- + + +@pytest.mark.parametrize( + "value,passed,score", + [(True, True, 0.2), (False, False, -0.3), (None, None, 0.0)], +) +def test_execution(value, passed, score): + v = verify_execution(value) + assert v.passed is passed + assert v.score == score + + +# --- objective: sign-based, coefficient 0.3, clamp positive at 1.0 ------- + + +@pytest.mark.parametrize( + "delta,passed,score", + [ + (None, None, 0.0), + (0.5, True, round(0.5 * 0.3, 10)), + (2.0, True, round(1.0 * 0.3, 10)), # positive clamped to 1.0 before scaling + (-0.4, False, round(-0.4 * 0.3, 10)), + ], +) +def test_objective(delta, passed, score): + v = verify_objective(delta) + assert v.passed is passed + assert v.score == score + assert v.verifier_type == "outcome_metric" + + +# --- proxy_gap: reduction (negative delta) is good ----------------------- + + +@pytest.mark.parametrize( + "delta,passed,score", + [ + (None, None, 0.0), + (-0.5, True, round(0.5 * 0.3, 10)), + (0.5, False, round(-0.5 * 0.3, 10)), + ], +) +def test_proxy_gap(delta, passed, score): + v = verify_proxy_gap(delta) + assert v.passed is passed + assert v.score == score + + +# --- validation / failure / safety / recovery / context ------------------ + + +@pytest.mark.parametrize( + "value,passed,score", + [(True, True, 0.2), (False, False, -0.2), (None, None, 0.0)], +) +def test_validation(value, passed, score): + v = verify_validation(value) + assert v.passed is passed + assert v.score == score + + +@pytest.mark.parametrize( + "count,passed,score", + [(0, True, 0.0), (2, False, round(-0.1 * 2, 10))], +) +def test_failure(count, passed, score): + v = verify_failure(count) + assert v.passed is passed + assert v.score == score + + +@pytest.mark.parametrize( + "count,passed,score", + [(0, True, 0.0), (1, False, round(-0.5 * 1, 10))], +) +def test_safety(count, passed, score): + v = verify_safety(count) + assert v.passed is passed + assert v.score == score + assert v.verifier_type == "safety_rule" + + +@pytest.mark.parametrize( + "attempted,success,passed,score", + [ + (False, None, None, 0.0), + (True, True, True, 0.1), + (True, False, False, -0.1), + (True, None, None, 0.0), + ], +) +def test_recovery(attempted, success, passed, score): + v = verify_recovery(attempted=attempted, success=success) + assert v.passed is passed + assert v.score == score + + +@pytest.mark.parametrize( + "fulfilled,passed,score", + [(True, True, 0.1), (False, None, 0.0), (None, None, 0.0)], +) +def test_context(fulfilled, passed, score): + v = verify_context(fulfilled) + assert v.passed is passed + assert v.score == score + + +# --- verifier error handling: never swallow, passed=None + evidence ------ + + +def test_run_verifier_catches_and_marks_none(): + def boom(): + raise ValueError("kaboom") + + v = run_verifier("explode", boom, verifier_type="outcome_metric") + assert v.passed is None + assert v.score == 0.0 + assert "error" in v.evidence + assert "kaboom" in v.evidence["error"] + + +def test_run_verifier_passes_through_success(): + v = run_verifier("ok", lambda: verify_execution(True), verifier_type="state_transition") + assert v.passed is True + assert v.score == 0.2 + + +# --- process / outcome split sums to the total --------------------------- + + +def test_split_reward_partitions_signals(): + verifications = [ + verify_execution(True), # outcome + verify_objective(0.5), # outcome + verify_proxy_gap(-0.5), # process + verify_context(True), # process + ] + process, outcome = split_reward(verifications) + expected_process = verify_proxy_gap(-0.5).score + verify_context(True).score + expected_outcome = verify_execution(True).score + verify_objective(0.5).score + assert process == round(expected_process, 10) + assert outcome == round(expected_outcome, 10) + + +def test_signal_partition_is_total(): + # every declared signal is classified exactly once (no orphans, no dupes) + assert PROCESS_SIGNALS.isdisjoint(OUTCOME_SIGNALS) + all_signals = PROCESS_SIGNALS | OUTCOME_SIGNALS + assert {"execution", "objective", "validation", "failure", "safety", + "proxy_gap", "context", "recovery"} <= all_signals From 8126360318422a10a4835f284b0c79564e800628 Mon Sep 17 00:00:00 2001 From: Sissi Feng Date: Fri, 3 Jul 2026 15:45:50 -0400 Subject: [PATCH 2/9] =?UTF-8?q?feat(optimization):=20Phase=20B=20=E2=80=94?= =?UTF-8?q?=20evolving=20evaluator=20(rubric,=20scientist=20feedback,=20re?= =?UTF-8?q?trospective=20+=20offline=20eval)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make "what counts as a good decision" itself evolve, on top of Phase A's verifiable reward. All of B operates over the recorded verifications, so a decision can be re-scored under any rubric without rerunning the campaign. - B1 app/services/rubric.py: versioned per-signal weight multipliers. v0.1_static is the identity rubric (reproduces Phase A exactly); v0.2_campaign_aware reweights by CampaignMode (optimization values objective, safety-tightening values safety, etc.). rescore() applies a rubric to verifications. - B2 app/services/scientist_feedback.py: constrained feedback taxonomy (mechanistic_value, safety_concern, ...) mapped to signals; folds a feedback batch into a derived v0.3_feedback_adaptive rubric (human supervision reshapes the evaluator). - B3 app/services/decision_evaluation.py retrospective_audit(): re-score stored trajectories under a hindsight rubric, flag decisions the immediate reward under-/over-estimated. Explicit trigger only. - B4 offline_policy_evaluation(): compare rubrics over accumulated trajectories, gated at >=50 to avoid noise. Tests: 908 passed, ruff clean, mypy clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/services/decision_evaluation.py | 139 ++++++++++++++++++++++++++++ app/services/rubric.py | 124 +++++++++++++++++++++++++ app/services/scientist_feedback.py | 85 +++++++++++++++++ tests/test_decision_evaluation.py | 115 +++++++++++++++++++++++ tests/test_rubric.py | 95 +++++++++++++++++++ tests/test_scientist_feedback.py | 61 ++++++++++++ 6 files changed, 619 insertions(+) create mode 100644 app/services/decision_evaluation.py create mode 100644 app/services/rubric.py create mode 100644 app/services/scientist_feedback.py create mode 100644 tests/test_decision_evaluation.py create mode 100644 tests/test_rubric.py create mode 100644 tests/test_scientist_feedback.py diff --git a/app/services/decision_evaluation.py b/app/services/decision_evaluation.py new file mode 100644 index 0000000..db0bfaf --- /dev/null +++ b/app/services/decision_evaluation.py @@ -0,0 +1,139 @@ +"""Retrospective decision evaluation + offline policy evaluation (Phase B / B3+B4). + +Both operate over the append-only ``decision_trajectories`` recorded in Phase A. +Because every trajectory carries its per-signal verifier report, a decision can +be re-scored under any rubric long after it was made — no campaign rerun. + +B3 — retrospective audit (growloop.md §5): a decision that scored low at the +time (no objective gain) can be high-value in hindsight (it ruled out a +mechanism). Re-scoring stored verifications under a hindsight rubric surfaces +decisions the immediate reward under- or over-estimated. + + trajectories ──► rescore(verifications, rubric) ──► RetrospectiveRecord[] + +B4 — offline policy evaluation: compare rubrics (proxy for policies) over the +accumulated trajectories. Gated on a minimum trajectory count so the comparison +harness is not built on noise (CLAUDE.md rule 15 — no premature abstraction). + +Trigger for B3 is explicit (campaign-end batch or a manual endpoint), never an +implicit scheduler. +""" +from __future__ import annotations + +from pydantic import BaseModel + +from app.services.decision_trajectory import load_trajectories +from app.services.rubric import STATIC_RUBRIC, Rubric, rescore +from app.services.verifiable_reward import RewardVerification, round_component + +__all__ = [ + "RETROSPECTIVE_DIVERGENCE_THRESHOLD", + "MIN_TRAJECTORIES_FOR_OFFLINE_EVAL", + "RetrospectiveRecord", + "PolicyEvaluation", + "OfflinePolicyEvaluation", + "retrospective_audit", + "offline_policy_evaluation", +] + +# A decision is flagged when hindsight and immediate reward diverge by more +# than this absolute amount. +RETROSPECTIVE_DIVERGENCE_THRESHOLD = 0.1 + +# Don't run the policy-comparison harness until there's enough signal. +MIN_TRAJECTORIES_FOR_OFFLINE_EVAL = 50 + + +class RetrospectiveRecord(BaseModel): + """One decision re-scored in hindsight.""" + + decision_id: str + immediate_reward: float + retrospective_reward: float + delta: float + verdict: str # "underestimated" | "overestimated" | "stable" + rubric_version: str + + +class PolicyEvaluation(BaseModel): + """Aggregate score for one rubric (policy) over the trajectory set.""" + + rubric_version: str + mean_reward: float + trajectory_count: int + + +class OfflinePolicyEvaluation(BaseModel): + """Result of comparing rubrics offline (or why it was skipped).""" + + ran: bool + trajectory_count: int + reason: str | None = None + policies: list[PolicyEvaluation] = [] + + +def _verifications(row: dict) -> list[RewardVerification]: + return [RewardVerification(**v) for v in row.get("verifier_report", [])] + + +def _verdict(delta: float) -> str: + if delta > RETROSPECTIVE_DIVERGENCE_THRESHOLD: + return "underestimated" + if delta < -RETROSPECTIVE_DIVERGENCE_THRESHOLD: + return "overestimated" + return "stable" + + +def retrospective_audit( + campaign_id: str | None = None, rubric: Rubric = STATIC_RUBRIC +) -> list[RetrospectiveRecord]: + """Re-score stored decisions under *rubric* and flag divergence from the + immediate reward. Explicit trigger only (campaign-end batch / manual).""" + records: list[RetrospectiveRecord] = [] + for row in load_trajectories(campaign_id): + immediate = row["reward"] + retro = rescore(_verifications(row), rubric).total + delta = round_component(retro - immediate) + records.append( + RetrospectiveRecord( + decision_id=row.get("trace_id") or row["id"], + immediate_reward=immediate, + retrospective_reward=retro, + delta=delta, + verdict=_verdict(delta), + rubric_version=rubric.version, + ) + ) + return records + + +def offline_policy_evaluation( + rubrics: list[Rubric], + campaign_id: str | None = None, + *, + min_trajectories: int = MIN_TRAJECTORIES_FOR_OFFLINE_EVAL, +) -> OfflinePolicyEvaluation: + """Compare rubrics over accumulated trajectories, gated on volume.""" + rows = load_trajectories(campaign_id) + n = len(rows) + if n < min_trajectories: + return OfflinePolicyEvaluation( + ran=False, + trajectory_count=n, + reason=( + f"insufficient trajectories: {n} < {min_trajectories} " + "(offline policy evaluation gated to avoid noise)" + ), + ) + verifs = [_verifications(row) for row in rows] + policies = [ + PolicyEvaluation( + rubric_version=rubric.version, + mean_reward=round_component( + sum(rescore(v, rubric).total for v in verifs) / n + ), + trajectory_count=n, + ) + for rubric in rubrics + ] + return OfflinePolicyEvaluation(ran=True, trajectory_count=n, policies=policies) diff --git a/app/services/rubric.py b/app/services/rubric.py new file mode 100644 index 0000000..e97e657 --- /dev/null +++ b/app/services/rubric.py @@ -0,0 +1,124 @@ +"""Phase-aware, versioned evaluation rubric (Phase B / B1). + +A rubric is a set of per-signal weight multipliers applied over the reward +*verifications* produced by ``verifiable_reward``. Because it operates on the +already-recorded verifications (not raw campaign state), a decision can be +re-scored under any rubric after the fact — the mechanism behind the evolving +evaluation loop and offline policy evaluation (B3/B4). + + verifications ──► rescore(·, rubric) ──► WeightedReward(process, outcome, total) + +``v0.1_static`` is the identity rubric (all weights 1.0) and reproduces the +Phase A reward exactly, so introducing rubrics changes nothing until a caller +opts into a phase-aware version. + +Design intent (growloop.md §1): what counts as a *good* decision depends on the +campaign's phase. Early/diagnostic phases value information and safety; the +optimization phase values objective gains; validation phases value validation. +The weights below encode that, keyed off ``CampaignMode``. +""" +from __future__ import annotations + +from pydantic import BaseModel, Field + +from app.services.campaign_mode import CampaignMode +from app.services.verifiable_reward import ( + OUTCOME_SIGNALS, + PROCESS_SIGNALS, + RewardVerification, + round_component, +) + +__all__ = [ + "Rubric", + "WeightedReward", + "STATIC_RUBRIC", + "rescore", + "rubric_for_mode", + "get_rubric", +] + + +class Rubric(BaseModel): + """Versioned per-signal weight multipliers over reward verifications.""" + + version: str + weights: dict[str, float] = Field(default_factory=dict) + + def weight(self, signal_name: str) -> float: + """Multiplier for a signal; unspecified signals default to 1.0.""" + return self.weights.get(signal_name, 1.0) + + +class WeightedReward(BaseModel): + """Result of applying a rubric to a set of verifications.""" + + rubric_version: str + process_reward: float + outcome_reward: float + total: float + + +def rescore( + verifications: list[RewardVerification], rubric: Rubric +) -> WeightedReward: + """Re-score verifications under *rubric*, returning the weighted split.""" + process = sum( + v.score * rubric.weight(v.name) + for v in verifications + if v.name in PROCESS_SIGNALS + ) + outcome = sum( + v.score * rubric.weight(v.name) + for v in verifications + if v.name in OUTCOME_SIGNALS + ) + process = round_component(process) + outcome = round_component(outcome) + return WeightedReward( + rubric_version=rubric.version, + process_reward=process, + outcome_reward=outcome, + total=round_component(process + outcome), + ) + + +# --------------------------------------------------------------------------- +# Rubric registry +# --------------------------------------------------------------------------- + +# Identity rubric — reproduces Phase A exactly. +STATIC_RUBRIC = Rubric(version="v0.1_static", weights={}) + +# Campaign-aware rubric family (v0.2). Each mode emphasises the signals that +# matter most for that scientific activity. Only signals that deviate from 1.0 +# are listed; everything else stays at identity weight. +_MODE_WEIGHTS: dict[CampaignMode, dict[str, float]] = { + CampaignMode.BO_OPTIMIZATION: {"objective": 2.0, "proxy_gap": 1.5}, + CampaignMode.VALIDATION: {"validation": 2.0}, + CampaignMode.CALIBRATION: {"context": 1.5, "proxy_gap": 1.5}, + CampaignMode.FAILURE_DIAGNOSIS: {"failure": 2.0, "proxy_gap": 1.5}, + CampaignMode.LITERATURE_CONTEXT_SEEKING: {"context": 2.0}, + CampaignMode.HUMAN_OBSERVATION_REQUEST: {"safety": 1.5}, + CampaignMode.SAFETY_CONSTRAINT_TIGHTENING: {"safety": 2.0, "failure": 1.5}, + CampaignMode.STOP_RECOMMENDED: {"objective": 1.5, "validation": 1.5}, +} + + +def rubric_for_mode(mode: CampaignMode) -> Rubric: + """Return the campaign-aware (v0.2) rubric for a campaign mode.""" + return Rubric( + version=f"v0.2_campaign_aware:{mode.value}", + weights=dict(_MODE_WEIGHTS.get(mode, {})), + ) + + +def get_rubric(version: str) -> Rubric: + """Look up a rubric by version string. Raises KeyError if unknown.""" + if version == STATIC_RUBRIC.version: + return STATIC_RUBRIC + prefix = "v0.2_campaign_aware:" + if version.startswith(prefix): + mode_value = version[len(prefix):] + return rubric_for_mode(CampaignMode(mode_value)) + raise KeyError(version) diff --git a/app/services/scientist_feedback.py b/app/services/scientist_feedback.py new file mode 100644 index 0000000..eab82b8 --- /dev/null +++ b/app/services/scientist_feedback.py @@ -0,0 +1,85 @@ +"""Structured scientist feedback → rubric adaptation (Phase B / B2). + +Scientists judge decisions in ways a fixed reward cannot capture ("this didn't +improve the objective but it tested a key mechanism"; "this looked safe but the +prep window is too narrow"). Rather than let an LLM free-interpret such notes, +B2 constrains them to a small taxonomy, maps each to the reward signal it bears +on, and folds a batch of feedback into a derived ``v0.3_feedback_adaptive`` +rubric. This closes the inner loop: human supervision reshapes what counts as a +good decision (growloop.md §2). + + ScientistFeedback[] ──► apply_feedback_to_rubric(base) ──► adapted Rubric + +Feedback raises the *attention* (weight) the evaluator pays to the affected +signal, scaled by the scientist's confidence — whether the note is positive or +negative, the lesson is "weigh this signal more here". +""" +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field + +from app.services.rubric import Rubric + +__all__ = [ + "FeedbackType", + "ScientistFeedback", + "FEEDBACK_SIGNAL_MAP", + "apply_feedback_to_rubric", +] + +FeedbackType = Literal[ + "mechanistic_value", + "safety_concern", + "feasibility_concern", + "novelty_value", + "validation_need", + "proxy_mismatch", + "resource_cost_concern", +] + +# Which reward signal each feedback type bears on. ``None`` means the taxonomy +# entry has no current signal correspondence (recorded, but no weight change) +# — a placeholder for signals that arrive with later phases (e.g. resource cost). +FEEDBACK_SIGNAL_MAP: dict[str, str | None] = { + "mechanistic_value": "context", + "safety_concern": "safety", + "feasibility_concern": "failure", + "novelty_value": "context", + "validation_need": "validation", + "proxy_mismatch": "proxy_gap", + "resource_cost_concern": None, +} + +# How strongly one unit of confidence shifts a signal's weight. +_ATTENTION_STEP = 0.5 + + +class ScientistFeedback(BaseModel): + """One structured scientist judgment about a decision.""" + + feedback_type: FeedbackType + decision_quality_signal: Literal["positive", "negative"] + confidence: float = Field(ge=0.0, le=1.0) + affected_metric: str | None = None + note: str | None = None + + +def apply_feedback_to_rubric( + base: Rubric, feedback: list[ScientistFeedback] +) -> Rubric: + """Fold scientist feedback into a derived ``v0.3_feedback_adaptive`` rubric. + + Each feedback item raises the weight of its mapped signal by + ``confidence * step``. Feedback whose type has no signal correspondence is + ignored for weighting (still meaningful upstream as a labelled record). + """ + weights = dict(base.weights) + for item in feedback: + signal = FEEDBACK_SIGNAL_MAP.get(item.feedback_type) + if signal is None: + continue + current = weights.get(signal, 1.0) + weights[signal] = round(current + item.confidence * _ATTENTION_STEP, 10) + return Rubric(version="v0.3_feedback_adaptive", weights=weights) diff --git a/tests/test_decision_evaluation.py b/tests/test_decision_evaluation.py new file mode 100644 index 0000000..7de677a --- /dev/null +++ b/tests/test_decision_evaluation.py @@ -0,0 +1,115 @@ +"""B3+B4: retrospective audit + gated offline policy evaluation over trajectories.""" +from __future__ import annotations + +import pytest + +from app.services.campaign_mode import CampaignMode + + +@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 _persist_one(trace_id="t", objective_delta=0.5, safety=0): + from app.services.decision_layer import CampaignDecisionLayer + from app.services.decision_outcome import ( + CampaignDecisionAccountingBuilder, + CampaignDecisionOutcomeBuilder, + ) + from app.services.decision_trace import CampaignDecisionTraceBuilder + from app.services.decision_trajectory import persist_campaign_trajectory + from app.services.round_context import CampaignRoundContextBuilder + + context = CampaignRoundContextBuilder().build( + campaign_id="camp-b", + round_index=1, + strategy_selection_result={ + "campaign_intent": "optimize", + "optimization_mode": "exploit", + "candidate_generation_backend": "bo_mcp", + "confidence": 0.75, + }, + ) + plan = CampaignDecisionLayer().decide(context) + trace = CampaignDecisionTraceBuilder().build( + trace_id=trace_id, context=context, decision_plan=plan, + actual_action="propose_candidates", + ) + outcome = CampaignDecisionOutcomeBuilder().build( + trace=trace, execution_success=True, + objective_delta=objective_delta, safety_incident_count=safety, + ) + acc = CampaignDecisionAccountingBuilder().build(trace=trace, outcome=outcome) + return persist_campaign_trajectory(acc) + + +# --- B3: retrospective audit -------------------------------------------- + + +def test_retrospective_static_is_stable(db_env): + from app.services.decision_evaluation import retrospective_audit + + _persist_one(objective_delta=0.5) + records = retrospective_audit("camp-b") + assert len(records) == 1 + # same rubric as scored → no divergence + assert records[0].verdict == "stable" + assert records[0].delta == 0.0 + + +def test_retrospective_underestimated_under_reweighting(db_env): + from app.services.decision_evaluation import retrospective_audit + from app.services.rubric import rubric_for_mode + + # objective delta scored modestly at the time; an optimization-phase rubric + # (objective x2) values it higher in hindsight → underestimated. + _persist_one(objective_delta=0.5) + records = retrospective_audit( + "camp-b", rubric_for_mode(CampaignMode.BO_OPTIMIZATION) + ) + assert records[0].retrospective_reward > records[0].immediate_reward + assert records[0].verdict == "underestimated" + + +# --- B4: offline policy evaluation (gated) ------------------------------ + + +def test_offline_eval_gated_when_too_few(db_env): + from app.services.decision_evaluation import offline_policy_evaluation + from app.services.rubric import STATIC_RUBRIC + + _persist_one(trace_id="only-one") + result = offline_policy_evaluation([STATIC_RUBRIC], "camp-b") + assert result.ran is False + assert result.trajectory_count == 1 + assert "insufficient" in result.reason + + +def test_offline_eval_runs_past_threshold(db_env): + from app.services.decision_evaluation import offline_policy_evaluation + from app.services.rubric import STATIC_RUBRIC, rubric_for_mode + + for i in range(3): + _persist_one(trace_id=f"t{i}", objective_delta=0.5) + result = offline_policy_evaluation( + [STATIC_RUBRIC, rubric_for_mode(CampaignMode.BO_OPTIMIZATION)], + "camp-b", + min_trajectories=3, # lower gate for the test + ) + assert result.ran is True + assert result.trajectory_count == 3 + versions = {p.rubric_version for p in result.policies} + assert "v0.1_static" in versions + # optimization rubric scores objective-improving decisions higher on average + by_v = {p.rubric_version: p.mean_reward for p in result.policies} + opt_v = next(v for v in by_v if v.startswith("v0.2")) + assert by_v[opt_v] > by_v["v0.1_static"] diff --git a/tests/test_rubric.py b/tests/test_rubric.py new file mode 100644 index 0000000..1dc31e5 --- /dev/null +++ b/tests/test_rubric.py @@ -0,0 +1,95 @@ +"""B1: phase-aware, versioned rubric applied over verifiable-reward signals. + +The rubric reweights *existing* verifications, so a decision can be re-scored +under a new rubric without rerunning the campaign — the seed of the evolving +evaluation loop. +""" +from __future__ import annotations + +import pytest + +from app.services.campaign_mode import CampaignMode +from app.services.rubric import ( + STATIC_RUBRIC, + Rubric, + WeightedReward, + get_rubric, + rescore, + rubric_for_mode, +) +from app.services.verifiable_reward import ( + split_reward, + verify_execution, + verify_objective, + verify_proxy_gap, + verify_safety, + verify_validation, +) + + +def _verifications(): + return [ + verify_execution(True), + verify_objective(0.5), + verify_proxy_gap(-0.4), + verify_validation(True), + verify_safety(1), + ] + + +# --- v0.1_static is the identity rubric (regression: matches Phase A) ---- + + +def test_static_rubric_is_identity(): + v = _verifications() + weighted = rescore(v, STATIC_RUBRIC) + process, outcome = split_reward(v) + assert weighted.process_reward == process + assert weighted.outcome_reward == outcome + assert weighted.total == round(process + outcome, 10) + assert weighted.rubric_version == "v0.1_static" + assert isinstance(weighted, WeightedReward) + + +def test_missing_weight_defaults_to_one(): + r = Rubric(version="v-partial", weights={"objective": 2.0}) + assert r.weight("objective") == 2.0 + assert r.weight("execution") == 1.0 # unspecified → identity + + +# --- phase-aware reweighting changes the score in the expected direction - + + +def test_safety_mode_amplifies_safety_penalty(): + v = _verifications() # safety incident → negative safety score + static = rescore(v, STATIC_RUBRIC) + safety_mode = rescore(v, rubric_for_mode(CampaignMode.SAFETY_CONSTRAINT_TIGHTENING)) + # safety is weighted up, so a safety incident hurts more (lower total) + assert safety_mode.total < static.total + assert safety_mode.rubric_version != "v0.1_static" + + +def test_optimization_mode_amplifies_objective(): + v = [verify_objective(0.5)] + static = rescore(v, STATIC_RUBRIC) + opt = rescore(v, rubric_for_mode(CampaignMode.BO_OPTIMIZATION)) + assert opt.total > static.total # objective improvement weighted up + + +def test_every_mode_maps_to_a_rubric(): + for mode in CampaignMode: + r = rubric_for_mode(mode) + assert isinstance(r, Rubric) + assert r.version # non-empty version tag + + +# --- registry lookup ----------------------------------------------------- + + +def test_get_rubric_by_version_roundtrips(): + assert get_rubric("v0.1_static") is STATIC_RUBRIC + + +def test_get_rubric_unknown_raises(): + with pytest.raises(KeyError): + get_rubric("v-does-not-exist") diff --git a/tests/test_scientist_feedback.py b/tests/test_scientist_feedback.py new file mode 100644 index 0000000..4dbd1da --- /dev/null +++ b/tests/test_scientist_feedback.py @@ -0,0 +1,61 @@ +"""B2: structured scientist feedback folds into a v0.3 adaptive rubric.""" +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from app.services.rubric import STATIC_RUBRIC, rescore +from app.services.scientist_feedback import ( + ScientistFeedback, + apply_feedback_to_rubric, +) +from app.services.verifiable_reward import verify_validation + + +def test_feedback_raises_affected_signal_weight(): + fb = [ + ScientistFeedback( + feedback_type="validation_need", + decision_quality_signal="positive", + confidence=0.8, + ) + ] + adapted = apply_feedback_to_rubric(STATIC_RUBRIC, fb) + assert adapted.version == "v0.3_feedback_adaptive" + assert adapted.weight("validation") == 1.0 + 0.8 * 0.5 # 1.4 + assert adapted.weight("execution") == 1.0 # untouched + + +def test_adapted_rubric_changes_rescore(): + v = [verify_validation(True)] # +0.2 + static = rescore(v, STATIC_RUBRIC) + fb = [ + ScientistFeedback( + feedback_type="validation_need", + decision_quality_signal="positive", + confidence=1.0, + ) + ] + adapted = rescore(v, apply_feedback_to_rubric(STATIC_RUBRIC, fb)) + assert adapted.total > static.total # validation weighted up + + +def test_feedback_without_signal_is_ignored_for_weighting(): + fb = [ + ScientistFeedback( + feedback_type="resource_cost_concern", + decision_quality_signal="negative", + confidence=1.0, + ) + ] + adapted = apply_feedback_to_rubric(STATIC_RUBRIC, fb) + assert adapted.weights == {} # no signal correspondence → no weight change + + +def test_confidence_out_of_range_rejected(): + with pytest.raises(ValidationError): + ScientistFeedback( + feedback_type="safety_concern", + decision_quality_signal="negative", + confidence=1.5, + ) From 7718702c256f69b8f271362cdb0af7a94711d2db Mon Sep 17 00:00:00 2001 From: Sissi Feng Date: Fri, 3 Jul 2026 15:58:34 -0400 Subject: [PATCH 3/9] =?UTF-8?q?feat(optimization):=20Phase=20C=20=E2=80=94?= =?UTF-8?q?=20space/objective=20evolution=20via=20derived=20overlay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Break fixed-objective, fixed-boundary BO: an advisor proposes reframing the objective or widening the search space, as a first-class, provenance-tracked, safety-gated overlay — without mutating the versioned TaskContract. - C3 app/services/space_overlay.py: ObjectiveOverlay / BoundaryOverlay / SpaceOverlay. derive_contract() applies an overlay to a NEW contract, base never mutated, lineage recorded in contract_id + migrated_from. - C2 review_space_change(): gates a deliberate space change — must reference real dimensions, may only WIDEN bounds (never silently shrink), escalates to a human on large expansion, low confidence, or require_human_approval. Categorically distinct from decision_policy._bounds_violation (which rejects hallucinated out-of-bounds candidates in the current space). - C1 app/services/space_evolution.py SpaceEvolutionAdvisor: deterministic, mock-safe proposals from a Nexus fingerprint (widen a plateaued dimension; add a secondary KPI on proxy mismatch). - C4 group_relative_rank(): GRPO-style advantage-over-group-mean ranking of a round's proposals/candidates. Tests: 923 passed, ruff clean, mypy clean (new files). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/services/space_evolution.py | 153 ++++++++++++++++++++++++++ app/services/space_overlay.py | 187 ++++++++++++++++++++++++++++++++ tests/test_space_evolution.py | 96 ++++++++++++++++ tests/test_space_overlay.py | 152 ++++++++++++++++++++++++++ 4 files changed, 588 insertions(+) create mode 100644 app/services/space_evolution.py create mode 100644 app/services/space_overlay.py create mode 100644 tests/test_space_evolution.py create mode 100644 tests/test_space_overlay.py diff --git a/app/services/space_evolution.py b/app/services/space_evolution.py new file mode 100644 index 0000000..6394972 --- /dev/null +++ b/app/services/space_evolution.py @@ -0,0 +1,153 @@ +"""Space/Objective evolution advisor + group-relative ranking (Phase C / C1+C4). + +C1 — the advisory agent that proposes breaking a fixed objective/boundary. Given +a Nexus problem fingerprint (``ProblemProfiler`` output) plus the base contract, +it emits ``SpaceOverlay`` proposals: widen a stalled dimension, or add a +secondary KPI when the proxy is mismatched. Deterministic and mock-safe — the +intelligence is in the rules, not in prompt glue, so the loop still closes under +``LLM_PROVIDER=mock``. An LLM can later enrich the reasoning, but never gates it. + +C4 — group-relative ranking (GRPO-style): within one round's candidate/proposal +set, score each by its advantage over the group mean. Absolute reward is hard to +calibrate in science; "which of these is better, here" is easier and is the +signal used to decide which proposal is worth expensive robot time. + + fingerprint + contract ──► SpaceEvolutionAdvisor.propose() ──► SpaceOverlay[] + [(id, reward), ...] ──► group_relative_rank() ──► ranked[] +""" +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel + +from app.contracts.task_contract import TaskContract +from app.services.space_overlay import ( + BoundaryOverlay, + ObjectiveOverlay, + SpaceOverlay, +) + +__all__ = [ + "SpaceEvolutionAdvisor", + "RankedItem", + "group_relative_rank", +] + +_DEFAULT_CONFIDENCE = 0.75 +_WIDEN_FRACTION = 0.5 # widen a stalled dimension by 50% on each side + + +class SpaceEvolutionAdvisor: + """Propose objective reframes / boundary expansions from a fingerprint.""" + + def propose( + self, fingerprint: dict[str, Any], contract: TaskContract + ) -> list[SpaceOverlay]: + proposals: list[SpaceOverlay] = [] + confidence = float(fingerprint.get("confidence", _DEFAULT_CONFIDENCE)) + + # 1) Plateau → widen the first numeric dimension with finite bounds. + if self._is_plateaued(fingerprint): + dim = self._first_numeric_dimension(contract) + if dim is not None: + span = dim.max_value - dim.min_value + pad = span * _WIDEN_FRACTION + proposals.append( + SpaceOverlay( + proposal_id=f"widen-{dim.param_name}", + reason=( + f"objective plateaued; widen '{dim.param_name}' bounds " + f"by {int(_WIDEN_FRACTION * 100)}% to escape the local basin" + ), + confidence=confidence, + expected_gain="new optima outside the current box", + boundary_overlays=[ + BoundaryOverlay( + param_name=dim.param_name, + new_min=dim.min_value - pad, + new_max=dim.max_value + pad, + ) + ], + ) + ) + + # 2) Proxy mismatch → add a secondary KPI to reframe the objective. + if self._proxy_mismatched(fingerprint): + kpi = fingerprint.get("suggested_secondary_kpi") or ( + f"{contract.objective.primary_kpi}_robustness" + ) + proposals.append( + SpaceOverlay( + proposal_id=f"reframe-{kpi}", + reason=( + "proxy mismatch: the primary KPI diverges from true value; " + f"add secondary KPI '{kpi}' to reframe the objective" + ), + confidence=confidence, + expected_gain="objective better tracks real scientific value", + objective_overlay=ObjectiveOverlay(add_secondary_kpis=[kpi]), + ) + ) + + return proposals + + @staticmethod + def _is_plateaued(fingerprint: dict[str, Any]) -> bool: + return bool( + fingerprint.get("plateaued") + or fingerprint.get("improvement_stalled") + or fingerprint.get("regime") == "plateau" + ) + + @staticmethod + def _proxy_mismatched(fingerprint: dict[str, Any]) -> bool: + return bool( + fingerprint.get("proxy_mismatch") + or fingerprint.get("proxy_gap") == "high" + ) + + @staticmethod + def _first_numeric_dimension(contract: TaskContract): + for dim in contract.exploration_space.dimensions: + if ( + dim.param_type in ("number", "integer") + and dim.min_value is not None + and dim.max_value is not None + and dim.max_value > dim.min_value + ): + return dim + return None + + +class RankedItem(BaseModel): + """One item scored relative to its group.""" + + id: str + reward: float + advantage: float # reward - group_mean + rank: int # 1 = best + + +def group_relative_rank(items: list[dict[str, Any]]) -> list[RankedItem]: + """Rank items by advantage over the group mean (GRPO-style). + + ``items`` is a list of ``{"id": str, "reward": float}``. Returns them sorted + best-first with a 1-based rank and each item's advantage. Empty in → empty out. + """ + if not items: + return [] + mean = sum(float(it["reward"]) for it in items) / len(items) + scored = [ + RankedItem( + id=str(it["id"]), + reward=float(it["reward"]), + advantage=round(float(it["reward"]) - mean, 10), + rank=0, + ) + for it in items + ] + scored.sort(key=lambda r: r.reward, reverse=True) + for i, item in enumerate(scored, start=1): + item.rank = i + return scored diff --git a/app/services/space_overlay.py b/app/services/space_overlay.py new file mode 100644 index 0000000..044a776 --- /dev/null +++ b/app/services/space_overlay.py @@ -0,0 +1,187 @@ +"""Derived-objective / boundary overlays over a TaskContract (Phase C / C3+C2). + +The headline of the self-evolving platform: break fixed-objective, fixed-boundary +BO. But mutating a versioned ``TaskContract`` mid-campaign would break every +downstream consumer and force a schema migration. So a proposed change is +expressed as a *derived overlay* — a first-class, provenance-carrying object that +layers on top of the base contract. ``derive_contract`` produces a NEW contract +with the overlay applied; the base is never mutated. + + base TaskContract + SpaceOverlay ──► derive_contract() ──► derived TaskContract + └─► review_space_change() ──► verdict (gate) + +C2 gate: a boundary overlay is a *deliberate* request to widen the search space. +This is categorically different from ``decision_policy._bounds_violation``, which +rejects a candidate that falls outside the CURRENT space (a hallucinated +out-of-bounds point). ``review_space_change`` evaluates the deliberate proposal: +it must reference real dimensions, must only ever WIDEN bounds (never silently +shrink), and escalates to a human when the contract requires approval, when the +advisor's confidence is low, or when the expansion is large. +""" +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field + +from app.contracts.task_contract import TaskContract + +__all__ = [ + "ObjectiveOverlay", + "BoundaryOverlay", + "SpaceOverlay", + "SpaceChangeVerdict", + "derive_contract", + "review_space_change", + "LARGE_EXPANSION_RATIO", + "MIN_AUTO_APPROVE_CONFIDENCE", +] + +# An expansion that more than triples a dimension's original range is "large" +# and always escalates to a human. +LARGE_EXPANSION_RATIO = 3.0 +# Below this advisor confidence, a space change always escalates to a human. +MIN_AUTO_APPROVE_CONFIDENCE = 0.7 + + +class ObjectiveOverlay(BaseModel): + """A derived change to the objective (never mutates the base).""" + + new_primary_kpi: str | None = None + add_secondary_kpis: list[str] = Field(default_factory=list) + + +class BoundaryOverlay(BaseModel): + """A proposed widening of one dimension's numeric bounds.""" + + param_name: str + new_min: float | None = None + new_max: float | None = None + + +class SpaceOverlay(BaseModel): + """A first-class, auditable proposal to reframe objective / widen bounds.""" + + proposal_id: str + reason: str + confidence: float = Field(ge=0.0, le=1.0) + expected_gain: str | None = None + objective_overlay: ObjectiveOverlay | None = None + boundary_overlays: list[BoundaryOverlay] = Field(default_factory=list) + status: Literal["proposed", "approved", "needs_human", "rejected", "applied"] = ( + "proposed" + ) + + +class SpaceChangeVerdict(BaseModel): + """Result of gating a space-change proposal.""" + + status: Literal["approved", "needs_human", "rejected"] + reason: str + + +def _dim_by_name(contract: TaskContract, name: str): + for dim in contract.exploration_space.dimensions: + if dim.param_name == name: + return dim + return None + + +def review_space_change( + overlay: SpaceOverlay, contract: TaskContract +) -> SpaceChangeVerdict: + """Gate a deliberate space-change proposal against the base contract. + + Distinct from ``_bounds_violation`` (which rejects out-of-bounds *candidates* + in the current space): this evaluates a proposal to *change* the space. + """ + escalate = False + for bo in overlay.boundary_overlays: + dim = _dim_by_name(contract, bo.param_name) + if dim is None: + return SpaceChangeVerdict( + status="rejected", + reason=f"unknown dimension '{bo.param_name}'", + ) + # Must only ever widen — a proposal that shrinks the space is not this + # channel's job and is rejected outright. + if bo.new_min is not None and dim.min_value is not None and bo.new_min > dim.min_value: + return SpaceChangeVerdict( + status="rejected", + reason=f"'{bo.param_name}' new_min {bo.new_min} shrinks lower bound {dim.min_value}", + ) + if bo.new_max is not None and dim.max_value is not None and bo.new_max < dim.max_value: + return SpaceChangeVerdict( + status="rejected", + reason=f"'{bo.param_name}' new_max {bo.new_max} shrinks upper bound {dim.max_value}", + ) + # Large expansion escalates. + if ( + dim.min_value is not None + and dim.max_value is not None + and bo.new_min is not None + and bo.new_max is not None + ): + old_range = dim.max_value - dim.min_value + new_range = bo.new_max - bo.new_min + if old_range > 0 and new_range / old_range > LARGE_EXPANSION_RATIO: + escalate = True + + if contract.safety_envelope.require_human_approval: + escalate = True + if overlay.confidence < MIN_AUTO_APPROVE_CONFIDENCE: + escalate = True + + if escalate: + return SpaceChangeVerdict( + status="needs_human", + reason="space change requires human sign-off " + "(safety policy, low confidence, or large expansion)", + ) + return SpaceChangeVerdict(status="approved", reason="within auto-approve policy") + + +def derive_contract(base: TaskContract, overlay: SpaceOverlay) -> TaskContract: + """Return a NEW contract with *overlay* applied. Base is never mutated. + + The derived contract records its lineage in ``contract_id`` and + ``migrated_from`` so the overlay is auditable and replayable. + """ + objective = base.objective + if overlay.objective_overlay is not None: + oo = overlay.objective_overlay + objective = objective.model_copy( + update={ + "primary_kpi": oo.new_primary_kpi or objective.primary_kpi, + "secondary_kpis": list( + dict.fromkeys([*objective.secondary_kpis, *oo.add_secondary_kpis]) + ), + } + ) + + dimensions = [d.model_copy(deep=True) for d in base.exploration_space.dimensions] + for bo in overlay.boundary_overlays: + for dim in dimensions: + if dim.param_name == bo.param_name: + update: dict[str, float] = {} + if bo.new_min is not None: + update["min_value"] = bo.new_min + if bo.new_max is not None: + update["max_value"] = bo.new_max + if update: + idx = dimensions.index(dim) + dimensions[idx] = dim.model_copy(update=update) + + exploration_space = base.exploration_space.model_copy( + update={"dimensions": dimensions} + ) + + return base.model_copy( + deep=True, + update={ + "contract_id": f"{base.contract_id}+ovl-{overlay.proposal_id}", + "migrated_from": base.contract_id, + "objective": objective, + "exploration_space": exploration_space, + }, + ) diff --git a/tests/test_space_evolution.py b/tests/test_space_evolution.py new file mode 100644 index 0000000..2e6e9b7 --- /dev/null +++ b/tests/test_space_evolution.py @@ -0,0 +1,96 @@ +"""Phase C / C1+C4: space-evolution advisor + group-relative ranking.""" +from __future__ import annotations + +from app.contracts.task_contract import ( + DimensionDef, + ExplorationSpace, + HumanGatePolicy, + ObjectiveSpec, + SafetyEnvelope, + StopCondition, + TaskContract, +) +from app.services.space_evolution import SpaceEvolutionAdvisor, group_relative_rank + + +def _contract(): + return TaskContract( + contract_id="c-1", + created_at="2026-07-03T00:00:00Z", + created_by="test", + objective=ObjectiveSpec( + objective_type="single", primary_kpi="overpotential", direction="minimize" + ), + exploration_space=ExplorationSpace( + dimensions=[ + DimensionDef( + param_name="fe_ratio", param_type="number", + min_value=0.0, max_value=1.0, + ), + ] + ), + stop_conditions=StopCondition(max_rounds=30), + safety_envelope=SafetyEnvelope(), + human_gate=HumanGatePolicy(), + protocol_pattern_id="p-1", + ) + + +# --- C1: advisor --------------------------------------------------------- + + +def test_plateau_proposes_boundary_widening(): + proposals = SpaceEvolutionAdvisor().propose({"plateaued": True}, _contract()) + assert len(proposals) == 1 + p = proposals[0] + assert p.boundary_overlays[0].param_name == "fe_ratio" + # widened 50% each side of range 1.0 + assert p.boundary_overlays[0].new_min == -0.5 + assert p.boundary_overlays[0].new_max == 1.5 + + +def test_proxy_mismatch_proposes_objective_reframe(): + proposals = SpaceEvolutionAdvisor().propose({"proxy_gap": "high"}, _contract()) + assert len(proposals) == 1 + assert proposals[0].objective_overlay.add_secondary_kpis == [ + "overpotential_robustness" + ] + + +def test_both_signals_produce_two_proposals(): + proposals = SpaceEvolutionAdvisor().propose( + {"plateaued": True, "proxy_mismatch": True}, _contract() + ) + assert len(proposals) == 2 + + +def test_quiet_fingerprint_proposes_nothing(): + assert SpaceEvolutionAdvisor().propose({}, _contract()) == [] + + +def test_suggested_secondary_kpi_is_used(): + proposals = SpaceEvolutionAdvisor().propose( + {"proxy_gap": "high", "suggested_secondary_kpi": "faradaic_efficiency"}, + _contract(), + ) + assert proposals[0].objective_overlay.add_secondary_kpis == ["faradaic_efficiency"] + + +# --- C4: group-relative ranking ----------------------------------------- + + +def test_ranks_by_reward_with_advantage(): + ranked = group_relative_rank( + [{"id": "a", "reward": 0.2}, {"id": "b", "reward": 0.8}, {"id": "c", "reward": 0.5}] + ) + assert [r.id for r in ranked] == ["b", "c", "a"] + assert [r.rank for r in ranked] == [1, 2, 3] + # advantage is reward - mean(0.5) + by_id = {r.id: r.advantage for r in ranked} + assert by_id["b"] == 0.3 + assert by_id["a"] == -0.3 + assert by_id["c"] == 0.0 + + +def test_empty_group_returns_empty(): + assert group_relative_rank([]) == [] diff --git a/tests/test_space_overlay.py b/tests/test_space_overlay.py new file mode 100644 index 0000000..2373a29 --- /dev/null +++ b/tests/test_space_overlay.py @@ -0,0 +1,152 @@ +"""Phase C / C3+C2: derived-objective overlay + gated space-change review.""" +from __future__ import annotations + +from app.contracts.task_contract import ( + DimensionDef, + ExplorationSpace, + HumanGatePolicy, + ObjectiveSpec, + SafetyEnvelope, + StopCondition, + TaskContract, +) +from app.services.space_overlay import ( + BoundaryOverlay, + ObjectiveOverlay, + SpaceOverlay, + derive_contract, + review_space_change, +) + + +def _contract(require_human=False): + return TaskContract( + contract_id="c-1", + created_at="2026-07-03T00:00:00Z", + created_by="test", + objective=ObjectiveSpec( + objective_type="single", primary_kpi="overpotential", direction="minimize" + ), + exploration_space=ExplorationSpace( + dimensions=[ + DimensionDef( + param_name="fe_ratio", param_type="number", + min_value=0.0, max_value=1.0, + ), + DimensionDef( + param_name="catalyst", param_type="categorical", + choices=["a", "b"], + ), + ] + ), + stop_conditions=StopCondition(max_rounds=30), + safety_envelope=SafetyEnvelope(require_human_approval=require_human), + human_gate=HumanGatePolicy(), + protocol_pattern_id="p-1", + ) + + +# --- C3: derive_contract never mutates the base ------------------------- + + +def test_boundary_overlay_derives_without_mutating_base(): + base = _contract() + overlay = SpaceOverlay( + proposal_id="widen-fe", + reason="plateau", + confidence=0.9, + boundary_overlays=[BoundaryOverlay(param_name="fe_ratio", new_min=-0.5, new_max=1.5)], + ) + derived = derive_contract(base, overlay) + + d_fe = next(d for d in derived.exploration_space.dimensions if d.param_name == "fe_ratio") + assert d_fe.min_value == -0.5 and d_fe.max_value == 1.5 + # base untouched + b_fe = next(d for d in base.exploration_space.dimensions if d.param_name == "fe_ratio") + assert b_fe.min_value == 0.0 and b_fe.max_value == 1.0 + # lineage recorded + assert derived.contract_id == "c-1+ovl-widen-fe" + assert derived.migrated_from == "c-1" + + +def test_objective_overlay_adds_secondary_kpi(): + base = _contract() + overlay = SpaceOverlay( + proposal_id="reframe", + reason="proxy mismatch", + confidence=0.9, + objective_overlay=ObjectiveOverlay(add_secondary_kpis=["overpotential_robustness"]), + ) + derived = derive_contract(base, overlay) + assert "overpotential_robustness" in derived.objective.secondary_kpis + assert base.objective.secondary_kpis == [] # base untouched + + +# --- C2: review_space_change gate --------------------------------------- + + +def test_unknown_dimension_rejected(): + v = review_space_change( + SpaceOverlay( + proposal_id="p", reason="r", confidence=0.9, + boundary_overlays=[BoundaryOverlay(param_name="nope", new_max=2.0)], + ), + _contract(), + ) + assert v.status == "rejected" and "unknown dimension" in v.reason + + +def test_shrinking_bound_rejected(): + v = review_space_change( + SpaceOverlay( + proposal_id="p", reason="r", confidence=0.9, + boundary_overlays=[BoundaryOverlay(param_name="fe_ratio", new_max=0.5)], + ), + _contract(), + ) + assert v.status == "rejected" and "shrinks" in v.reason + + +def test_small_expansion_high_confidence_approved(): + v = review_space_change( + SpaceOverlay( + proposal_id="p", reason="r", confidence=0.9, + boundary_overlays=[BoundaryOverlay(param_name="fe_ratio", new_min=-0.2, new_max=1.2)], + ), + _contract(), + ) + assert v.status == "approved" + + +def test_large_expansion_escalates(): + # original range 1.0; new range 5.0 → >3x → needs_human + v = review_space_change( + SpaceOverlay( + proposal_id="p", reason="r", confidence=0.9, + boundary_overlays=[BoundaryOverlay(param_name="fe_ratio", new_min=-2.0, new_max=3.0)], + ), + _contract(), + ) + assert v.status == "needs_human" + + +def test_low_confidence_escalates(): + v = review_space_change( + SpaceOverlay( + proposal_id="p", reason="r", confidence=0.5, + boundary_overlays=[BoundaryOverlay(param_name="fe_ratio", new_max=1.1)], + ), + _contract(), + ) + assert v.status == "needs_human" + + +def test_require_human_approval_escalates(): + v = review_space_change( + SpaceOverlay( + proposal_id="p", reason="r", confidence=0.95, + boundary_overlays=[BoundaryOverlay(param_name="fe_ratio", new_max=1.1)], + ), + _contract(require_human=True), + ) + assert v.status == "needs_human" From 2f5403f18b4439fee1a25b0e76b4a4da2a62a8be Mon Sep 17 00:00:00 2001 From: Sissi Feng Date: Tue, 7 Jul 2026 11:02:59 -0400 Subject: [PATCH 4/9] docs: reposition HELIOS as adaptive campaign decision layer --- README.md | 72 ++++++++++++++---------- app/agents/base.py | 2 +- app/optimization/__init__.py | 7 ++- app/services/system_validation_report.py | 15 +++-- docs/HELIOS_ARCHITECTURE_VALIDATION.md | 11 ++-- docs/adaptive_campaign_substrate.md | 9 +-- docs/agent_architecture.md | 49 ++++++++-------- docs/development_progress.md | 15 +++-- pyproject.toml | 2 +- tests/test_system_validation_report.py | 8 ++- 10 files changed, 108 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index c0bd3a1..5f2370b 100644 --- a/README.md +++ b/README.md @@ -13,15 +13,19 @@

-# HELIOS — Holistic Experiment Learning Intelligent Orchestration System +# HELIOS — Hierarchical Experimental Learning and Intelligent Optimization System -HELIOS is an **agent-native orchestrator** for self-driving laboratories (SDLs). It composes 27 specialist agents into 4 cooperating swarms behind a single natural-language interface — so scientists describe experiments in plain language, and HELIOS plans, validates, executes, and iterates autonomously, closing the loop between hypothesis and hardware. +Helios (Hierarchical Experimental Learning and Intelligent Optimization System) is an orchestrator-agnostic adaptive campaign decision layer for closed-loop experimentation. -Most "AI lab assistants" are LLM wrappers: a human stays in the driver's seat and the model translates their words into button clicks. HELIOS inverts that — **agents are the operators**. Contracts, leases, skills, the event bus, and recovery are all designed for agents as first-class citizens. The cleanest test: mock out the LLM (`LLM_PROVIDER=mock`) and the entire campaign loop still closes, because the intelligence lives in the orchestration and optimization layers, not in prompt glue. +Helios decides which campaign-level action should happen next, including optimization strategy selection, validation, failure-aware recovery, context acquisition, human/LLM query, dynamic objective/constraint handling, and future scale/fidelity-aware decisions. + +HELIOS sits above lab orchestrators, optimizers, simulators, and hardware runtimes. It does not require any one execution backend to be the center of the system: PUDA, Opentrons, BO MCP, Nexus, local Bayesian optimization, simulation, or future scale/fidelity services can all appear as tools, backends, evidence sources, or execution paths. HELIOS keeps the campaign-level decision authority: what to try next, when to validate, when to recover, when to ask for context, and when to tighten objectives or constraints. + +Most "AI lab assistants" are LLM wrappers: a human stays in the driver's seat and the model translates their words into button clicks. HELIOS inverts that at the campaign layer. Contracts, leases, skills, the event bus, and recovery are all designed so decisions are typed, auditable, replayable, and backend-agnostic. The cleanest test: mock out the LLM (`LLM_PROVIDER=mock`) and the campaign decision loop still closes, because the intelligence lives in the adaptive decision and optimization layers, not in prompt glue. --- -## How a Campaign Flows Through the Agents +## How a Campaign Flows Through HELIOS What actually happens between a scientist typing one sentence and HELIOS handing back an optimized recipe: @@ -31,8 +35,8 @@ The user writes, e.g., *"Screen OER catalysts to minimize overpotential at 10 mA **2. Devices are exposed as skills.** Every instrument registers a skill (`agent/skills/*.md`): its primitives, their typed parameters, a safety class (e.g. `HAZARDOUS`), and precondition/effect contracts ("channel must be idle"). Agents can only invoke declared primitives with type-checked arguments — **the machine's capabilities are fixed, so the LLM cannot hallucinate an operation that doesn't exist**. New instruments are onboarded by the **OnboardingAgent**, which discovers primitives, generates integration code and the skill definition, and writes them to disk after human review — hours, not weeks. -**3. The orchestrator runs the round loop.** -The **OrchestratorAgent** takes over and drives one pipeline per round, each stage a dedicated agent: +**3. The decision layer runs the round loop.** +The internal **OrchestratorAgent** drives one pipeline per round, but it is an implementation component rather than the product boundary. The adaptive campaign decision layer decides whether the next campaign-level action is optimization, validation, recovery, context acquisition, human/LLM query, objective/constraint revision, or a future scale/fidelity transition: ``` PlannerAgent expands the contract into a round plan @@ -65,11 +69,12 @@ Results, uncertainties, and decision chains land in campaign memory — queryabl ## Features - **Natural language intake** — a multi-turn clarification dialogue turns a free-text experiment description into a versioned, schema-validated `TaskContract` -- **Agent-native orchestration** — 27 specialist agents grouped into 4 swarms (Scientist / Engineer / Analyst / Validator), composed as a stage graph that branches, retries, and remembers; all cross-agent calls go through a ControlPlane with agent leases and a full audit trail +- **Adaptive campaign decision layer** — orchestrator-agnostic campaign control that decides what should happen next across optimization, validation, recovery, context acquisition, human/LLM query, dynamic objectives/constraints, and future scale/fidelity choices +- **Typed internal agent services** — 27 specialist agents grouped into 4 swarms (Scientist / Engineer / Analyst / Validator), composed as a stage graph that branches, retries, and remembers; all cross-agent calls go through a ControlPlane with agent leases and a full audit trail - **Devices as skills** — instruments expose typed primitives with safety classes and precondition/effect contracts; agents cannot invoke operations that don't exist - **Real-time reasoning stream** — every agent step emits SSE events with exactly-once delivery and DB-backed replay; the browser shows a live decision tree of what each agent considered and why - **Hardware agnostic** — runs in `simulated` mode for development; switches to live Opentrons OT-2, PLC relays, and electrochemistry sensors by changing one env var -- **Context-aware dynamic strategy** — a scientific campaign meta-controller selects campaign intent, optimization mode, and candidate-generation backend from scientific context, objective hierarchy, failure attribution, backend memory, Nexus diagnostics, BO MCP availability, and shadow learning signals +- **Context-aware campaign policy** — campaign intent, optimization mode, candidate backend, validation, recovery, context requests, and objective/constraint handling are selected from scientific context, objective hierarchy, failure attribution, backend memory, Nexus diagnostics, BO MCP availability, and shadow learning signals - **Safety-first** — contract safety envelopes, per-primitive safety classes, preflight checks before every round, and human-in-the-loop gates as resumable pause states - **Durable execution** — SQLite-backed campaign checkpoints survive restarts; crashed campaigns resume from the last completed round @@ -77,23 +82,26 @@ Results, uncertainties, and decision chains land in campaign memory — queryabl ## Architecture -### Four Layers +### Four Internal Layers | Layer | Role | Key Components | |-------|------|----------------| -| **L3 Orchestration** | Task contracts, admission control, agent lease pool | `OrchestratorAgent`, `ControlPlane`, `RequirementParserAgent` | -| **L2 Planning** | Experimental design & adaptive strategy | `PlannerAgent`, `DesignAgent`, `SafetyAgent`, inner RL strategy router | +| **L3 Campaign Decision** | Task contracts, campaign action choice, admission control, agent lease pool | `OrchestratorAgent`, `ControlPlane`, `RequirementParserAgent`, `decision_layer` | +| **L2 Planning & Policy** | Experimental design, adaptive strategy, validation/recovery/context choices | `PlannerAgent`, `DesignAgent`, `SafetyAgent`, strategy router | | **L1 Execution** | Protocol compilation & hardware abstraction | `CompilerAgent`, `CodeWriterAgent`, `DeckLayoutAgent`, hardware dispatcher | | **L0 Evidence** | Campaign memory, uncertainty, causal updates | `AnalyzerAgent`, `SensingAgent`, `MonitorAgent`, `RecoveryAgent` | -### Dynamic Strategy Meta-Controller +### Adaptive Campaign Decision Layer -HELIOS dynamic strategy selection is a context-aware scientific campaign -meta-controller. It uses scientific context, objective hierarchy, typed failure +HELIOS is positioned as an orchestrator-agnostic adaptive campaign decision +layer. It 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 choose -three things each round: `CampaignIntent`, `OptimizationMode`, and the -candidate-generation backend. +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 also owns validation, failure-aware recovery, context +acquisition, human/LLM query, dynamic objective/constraint handling, and future +scale/fidelity-aware decisions. The default live path is conservative: rule-based, auditable, and bounded by explicit safety gates. Learning-based policies do not replace this path by @@ -148,8 +156,8 @@ The merged optimization stack is split by authority boundary: ### Adaptive Campaign Substrate (shadow) -On top of the meta-controller sits a **shadow-only** adaptive decision -substrate that, per round, proposes a scientific-activity `CampaignMode` +Alongside the live policy sits a **shadow-only** adaptive decision substrate +that, per round, proposes a scientific-activity `CampaignMode` (optimization, validation, calibration, failure diagnosis, context seeking, human observation, safety-constraint tightening, stop), assesses the action space, and scores candidate value-of-information — as an **advisory** artifact @@ -163,12 +171,13 @@ by `ADAPTIVE_SUBSTRATE_SHADOW_ENABLED` (default off). See ### Agent Model -HELIOS is an **autonomous multi-agent experimentation system**, not an "LLM -agent". Agents are typed input→output services orchestrated in a deterministic -L3→L2→L1→L0 pipeline; the per-round optimization/decision loop is **LLM-free by -design** (classical BO/GP + rule-based scoring + optional non-LLM learned -policies). The LLM is used only at the language/knowledge boundary (NL→plan, -NL→code, priors, post-run review) and never steers a live round. See +HELIOS is an **adaptive campaign decision layer** implemented with typed +specialist services, not an "LLM agent". Agents are typed input→output services +coordinated in a deterministic L3→L2→L1→L0 pipeline; the per-round +optimization/decision loop is **LLM-free by design** (classical BO/GP + +rule-based scoring + optional non-LLM learned policies). The LLM is used only +at the language/knowledge boundary (NL→plan, NL→code, priors, post-run review) +and never steers a live round. See [docs/agent_architecture.md](docs/agent_architecture.md). ### Agent Roster @@ -405,12 +414,15 @@ ruff format app/ ### Validation Evidence Pack -HELIOS is framed as an agent-native, hierarchical, graph/state-machine routed, -role-based multi-agent SDL controller. The live campaign controller remains +HELIOS is framed as an orchestrator-agnostic adaptive campaign decision layer. +Its internal implementation is hierarchical, graph/state-machine routed, and +role-based, but the product boundary is campaign-level decision authority rather +than ownership of any single orchestrator. The live campaign policy remains rule-based and auditable by default; Nexus and BO MCP are optimization -advisor/backend/tool paths, not top-level 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. +advisor/backend/tool paths, not top-level 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`. It is a static evidence pack for the diff --git a/app/agents/base.py b/app/agents/base.py index 32a67df..e2fa158 100644 --- a/app/agents/base.py +++ b/app/agents/base.py @@ -1,4 +1,4 @@ -"""Base Agent protocol for the HELIOS multi-agent orchestrator. +"""Base Agent protocol for HELIOS's internal campaign decision services. All agents implement the same interface. Agents are Python classes, not microservices. Communication is via typed Pydantic models (contracts). diff --git a/app/optimization/__init__.py b/app/optimization/__init__.py index 64e0f26..ce2f32b 100644 --- a/app/optimization/__init__.py +++ b/app/optimization/__init__.py @@ -1,9 +1,10 @@ """HELIOS optimization-intelligence integration layer. -HELIOS delegates *optimization intelligence* (algorithm portfolio, problem +HELIOS can delegate *optimization intelligence* (algorithm portfolio, problem profiling, candidate generation) to Nexus (``optimization_copilot``) while -retaining authority over the scientific campaign loop: validation, safety, -recovery, execution, and provenance. +retaining authority as the adaptive campaign decision layer: optimization +strategy, validation, safety, recovery, context acquisition, objective and +constraint handling, execution routing, and provenance. Importing this package is safe even when Nexus is not installed -- the Nexus backends simply report ``is_available() is False`` and HELIOS falls back to its diff --git a/app/services/system_validation_report.py b/app/services/system_validation_report.py index a7f5b18..4726809 100644 --- a/app/services/system_validation_report.py +++ b/app/services/system_validation_report.py @@ -67,19 +67,22 @@ def to_markdown(self) -> str: def build_architecture_summary() -> dict[str, Any]: return { "claim": ( - "HELIOS is agent-native, hierarchical, graph/state-machine routed, " - "role-based multi-agent controller." + "HELIOS is an orchestrator-agnostic adaptive campaign decision " + "layer for closed-loop experimentation." ), - "controller_shape": "hierarchical graph/state-machine routing with specialist role boundaries", + "implementation_shape": "hierarchical graph/state-machine routing with specialist role boundaries", "agent_contract": "shared typed campaign state, traces, outcomes, and safety/proposal contracts", - "decision_authority": "campaign controller and auditable strategy policy retain decision authority", + "decision_authority": ( + "adaptive campaign policy retains authority over optimization strategy, validation, " + "failure-aware recovery, context acquisition, human/LLM query, and objective/constraint handling" + ), "runtime_boundary": "reporting-only evidence; no runtime behavior is modified", } def build_dynamic_strategy_summary() -> dict[str, Any]: return { - "default_controller": "Live controller remains rule-based / auditable by default.", + "default_controller": "Live campaign policy remains rule-based / auditable by default.", "decision_trace": "StrategyDecision and StrategyTrace record intent, mode, backend, evidence, proposals, outcome, and reward.", "dynamic_context": ( "CampaignContext includes objective hierarchy, failure taxonomy, route, budget, " @@ -96,7 +99,7 @@ def build_backend_integration_summary() -> dict[str, Any]: "HELIOS uses lightweight in-process Nexus optimization core/advisor paths where configured; " "server/API/MCP/platform/LLM Nexus components stay outside the default runtime path." ), - "bomcp_boundary": "BO MCP is backend/tool, not the top-level controller.", + "bomcp_boundary": "BO MCP is backend/tool, not campaign decision authority.", "fallback_behavior": "Backend failures degrade through typed failure attribution and deterministic fallback paths.", "default_behavior": "Default BO MCP/Nexus/backend behavior remains unchanged by this report layer.", } diff --git a/docs/HELIOS_ARCHITECTURE_VALIDATION.md b/docs/HELIOS_ARCHITECTURE_VALIDATION.md index 94b7a02..f42c061 100644 --- a/docs/HELIOS_ARCHITECTURE_VALIDATION.md +++ b/docs/HELIOS_ARCHITECTURE_VALIDATION.md @@ -4,14 +4,15 @@ This evidence pack summarizes the current HELIOS architecture and validation bou ## Architecture -- HELIOS is agent-native, hierarchical, graph/state-machine routed, and role-based. -- The campaign controller coordinates specialist roles through typed state, traces, outcomes, and safety/proposal contracts. -- The live controller remains rule-based and auditable by default. +- HELIOS is an orchestrator-agnostic adaptive campaign decision layer for closed-loop experimentation. +- The campaign decision layer coordinates specialist roles through typed state, traces, outcomes, and safety/proposal contracts. +- The live campaign policy remains rule-based and auditable by default. -## Optimization And Backend Boundaries +## Decision Layer And Backend Boundaries - Nexus is an advisor/backend/evidence source, not campaign decision authority. -- BO MCP is a backend/tool, not the top-level controller. +- BO MCP is a backend/tool, not campaign decision authority. +- Lab orchestrators, simulators, and execution runtimes are action channels, not the campaign decision layer. - Backend failures are represented through typed failure attribution and deterministic fallback paths. - Default BO MCP/Nexus/backend behavior is not changed by the validation-report layer. diff --git a/docs/adaptive_campaign_substrate.md b/docs/adaptive_campaign_substrate.md index 779a0b7..f3dd04e 100644 --- a/docs/adaptive_campaign_substrate.md +++ b/docs/adaptive_campaign_substrate.md @@ -4,10 +4,11 @@ Status: **shadow-only, observational**. Nothing in this document affects live routing, strategy selection, candidate selection, or action execution. Every component is gated, deterministic, JSON-safe, replayable, and fail-open. -This is the code map for the campaign-level adaptive decision *substrate* added -on top of the existing dynamic strategy meta-controller. It answers, per round, -"what kind of scientific activity does the campaign need next, and how should -the action space and candidate value be viewed?" — as an **advisory** artifact +This is the code map for a campaign-level adaptive decision *substrate* aligned +with HELIOS's new positioning: an orchestrator-agnostic adaptive campaign +decision layer for closed-loop experimentation. It answers, per round, "what +kind of scientific activity does the campaign need next, and how should the +action space and candidate value be viewed?" — as an **advisory** artifact recorded alongside the campaign, never as a control signal. --- diff --git a/docs/agent_architecture.md b/docs/agent_architecture.md index 37fc979..a2c2ad2 100644 --- a/docs/agent_architecture.md +++ b/docs/agent_architecture.md @@ -1,17 +1,18 @@ -# HELIOS Agent Architecture — and where the LLM sits +# HELIOS Agent Architecture Inside the Decision Layer — and where the LLM sits ## TL;DR -HELIOS is an **autonomous experimentation agent**, not an "LLM agent". Its -per-round decision loop is **LLM-free by design**: strategy and candidate +HELIOS is an **orchestrator-agnostic adaptive campaign decision layer**, not an +"LLM agent". Its per-round decision loop is **LLM-free by design**: campaign +action choice, strategy selection, validation/recovery decisions, and candidate selection are classical Bayesian optimization + rule-based scoring + optional -learned (non-LLM) policies. The LLM is used **only at the language/knowledge -boundary** (turning human intent into plans/code, injecting priors, scoring -runs after the fact) and never steers a live optimization round. +learned (non-LLM) policies. The LLM is used **only at the +language/knowledge boundary** (turning human intent into plans/code, injecting +priors, scoring runs after the fact) and never steers a live optimization round. -If someone asks "so where is the agent?" — the agency is in the closed loop -that **perceives → decides → acts on real instruments → learns**, under bounded -authority, not in a chat model calling tools. +If someone asks "so where is the agent?" — the agency is in the campaign-level +closed loop that **perceives → decides → acts through an orchestrator/runtime → +learns**, under bounded authority, not in a chat model calling tools. --- @@ -69,16 +70,18 @@ explicitly configured to `anthropic`/`openai`. ## Where the agency lives -HELIOS is agentic in the classical (autonomous-systems) sense — a goal-directed -closed loop: +HELIOS is agentic in the classical (autonomous-systems) sense — a +goal-directed campaign decision loop: - **Perceive** — diagnostics (uncertainty, noise, convergence, drift), QC results, typed failure signals. -- **Decide** — campaign intent / optimization mode / backend, under **bounded - authority** (HELIOS keeps campaign-level decision authority; Nexus and BO are - backends + advisors, not the top decision-maker). -- **Act** — compile and execute protocols on real or simulated hardware; recover - from failures. +- **Decide** — campaign intent, optimization mode, backend, validation, + recovery, context acquisition, human/LLM query, and objective/constraint + handling under **bounded authority** (HELIOS keeps campaign-level decision + authority; Nexus, BO, simulators, and lab orchestrators are backends + + advisors, not the top decision-maker). +- **Act** — compile and execute protocols through real or simulated + orchestrators/runtimes; recover from failures. - **Learn** — candidate / failure-zone memory, contextual bandit, optional RL, replay/evaluation. @@ -118,10 +121,10 @@ on purpose. ## One-paragraph answer to "where is the agent?" -> HELIOS is an autonomous experimentation agent: it closed-loop plans, executes -> on real instruments, senses, recovers, and adapts from feedback, built as a -> multi-agent system of typed specialist agents. It is deliberately **not** an -> "LLM agent" — the LLM works only at the human-language and domain-knowledge -> boundary (intent → plan, NL → code, priors), while the scientific decision -> loop stays deterministic, auditable, and safe. Agency comes from autonomy + -> action + goal-directed adaptation, not from an LLM running in a while-loop. +> HELIOS is an orchestrator-agnostic adaptive campaign decision layer: it +> closed-loop decides what campaign-level action should happen next, including +> optimization, validation, recovery, context acquisition, human/LLM query, and +> objective/constraint adaptation. It is implemented with typed specialist +> services and is deliberately **not** an "LLM agent" — the LLM works only at +> the human-language and domain-knowledge boundary, while the scientific +> decision loop stays deterministic, auditable, and safe. diff --git a/docs/development_progress.md b/docs/development_progress.md index 436c26c..77a03c8 100644 --- a/docs/development_progress.md +++ b/docs/development_progress.md @@ -17,11 +17,14 @@ Legend: **not started** · **partial** (some infra exists, not wired/proven). SAFETY_CONSTRAINT_TIGHTENING), DynamicActionSpace, Value-of-Information, aggregate snapshot, shadow-trace comparison. See [adaptive_campaign_substrate.md](adaptive_campaign_substrate.md). -- **Dynamic strategy meta-controller** — two-layer action taxonomy - (`CampaignIntent` + `OptimizationMode`), phase posterior, evidence-based - scoring, safety gates, Nexus optimization-intelligence evidence, backend - recommendations, and replay/validation accounting. See README -> - Architecture. +- **Adaptive campaign decision layer** — orchestrator-agnostic campaign action + selection across optimization strategy, validation, failure-aware recovery, + context acquisition, human/LLM query, dynamic objective/constraint handling, + 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. - **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 @@ -79,7 +82,7 @@ Path (from v2.md): rule selector → +decision trace → contextual bandit → | # | Item | Origin | Status | Notes | |---|------|--------|--------|-------| | D1 | **Offline meta-policy proof** — imitation / offline RL / policy evaluation / counterfactual replay showing learned policy ≥ heuristic | v2 P5 | partial | `policy_evaluation` / `learned_policy` / RL selectors exist; the *proof* and promotion do not | -| D2 | **Trained meta-RL policy network** — guardrailed meta-controller (propose → rule/safety validate → execute → trace), gated by offline-eval evidence | v2 P6 | not started | Only after D1 + stable reward + replay env + hard guardrails | +| D2 | **Trained meta-RL policy network** — guardrailed campaign decision policy (propose → rule/safety validate → execute → trace), gated by offline-eval evidence | v2 P6 | not started | Only after D1 + stable reward + replay env + hard guardrails | --- diff --git a/pyproject.toml b/pyproject.toml index 42cfebd..2f01e17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "helios-sdl" version = "0.1.0" -description = "HELIOS — agent-native orchestrator for self-driving laboratories: typed contracts, specialist agent swarms, safety gates, durable campaigns, and research-grade Bayesian optimization" +description = "HELIOS — orchestrator-agnostic adaptive campaign decision layer for closed-loop experimentation: typed contracts, specialist services, safety gates, durable campaigns, and research-grade optimization" readme = "README.md" license = {text = "MIT"} requires-python = ">=3.11" diff --git a/tests/test_system_validation_report.py b/tests/test_system_validation_report.py index a1b10a2..ec1d22a 100644 --- a/tests/test_system_validation_report.py +++ b/tests/test_system_validation_report.py @@ -81,10 +81,12 @@ def test_report_states_architecture_and_dynamic_strategy_boundaries(): dynamic = build_dynamic_strategy_summary() text = _flatten_text({"architecture": architecture, "dynamic": dynamic}).lower() - assert "agent-native" in text + assert "orchestrator-agnostic adaptive campaign decision layer" in text + assert "closed-loop experimentation" in text assert "hierarchical" in text assert "graph/state-machine" in text - assert "role-based multi-agent controller" in text + assert "specialist role boundaries" in text + assert "human/llm query" in text assert "rule-based / auditable by default" in text assert "strategydecision" in text assert "strategytrace" in text @@ -95,7 +97,7 @@ def test_report_states_nexus_and_bomcp_are_not_campaign_authority(): text = _flatten_text(backend).lower() assert "nexus is advisor/backend/evidence source, not campaign decision authority" in text - assert "bo mcp is backend/tool, not the top-level controller" in text + assert "bo mcp is backend/tool, not campaign decision authority" in text def test_report_states_learned_policy_is_not_default_live_override(): From c338b0cb6535030367ef6af013fd55ba9eae20b3 Mon Sep 17 00:00:00 2001 From: Sissi Feng Date: Tue, 7 Jul 2026 14:33:55 -0400 Subject: [PATCH 5/9] docs: narrow README to decision layer scope --- README.md | 596 ++++++++++-------------------------------------------- 1 file changed, 111 insertions(+), 485 deletions(-) diff --git a/README.md b/README.md index 5f2370b..e21ea70 100644 --- a/README.md +++ b/README.md @@ -2,431 +2,155 @@ HELIOS

-

- - H electrochem cell  ·  - E spectrometer  ·  - L pipette  ·  - I UR5 arm  ·  - O OT-2 deck  ·  - S pump - -

- # HELIOS — Hierarchical Experimental Learning and Intelligent Optimization System Helios (Hierarchical Experimental Learning and Intelligent Optimization System) is an orchestrator-agnostic adaptive campaign decision layer for closed-loop experimentation. Helios decides which campaign-level action should happen next, including optimization strategy selection, validation, failure-aware recovery, context acquisition, human/LLM query, dynamic objective/constraint handling, and future scale/fidelity-aware decisions. -HELIOS sits above lab orchestrators, optimizers, simulators, and hardware runtimes. It does not require any one execution backend to be the center of the system: PUDA, Opentrons, BO MCP, Nexus, local Bayesian optimization, simulation, or future scale/fidelity services can all appear as tools, backends, evidence sources, or execution paths. HELIOS keeps the campaign-level decision authority: what to try next, when to validate, when to recover, when to ask for context, and when to tighten objectives or constraints. - -Most "AI lab assistants" are LLM wrappers: a human stays in the driver's seat and the model translates their words into button clicks. HELIOS inverts that at the campaign layer. Contracts, leases, skills, the event bus, and recovery are all designed so decisions are typed, auditable, replayable, and backend-agnostic. The cleanest test: mock out the LLM (`LLM_PROVIDER=mock`) and the campaign decision loop still closes, because the intelligence lives in the adaptive decision and optimization layers, not in prompt glue. +HELIOS treats optimizers, simulators, automation services, and external systems as downstream tools, backends, or evidence sources. This README focuses on the campaign decision layer: the typed inputs it consumes, the actions it can recommend, the evidence it records, and the replay/validation path that keeps those decisions auditable. --- -## How a Campaign Flows Through HELIOS +## What HELIOS Decides -What actually happens between a scientist typing one sentence and HELIOS handing back an optimized recipe: +For each campaign round, HELIOS turns scientific context into a bounded campaign-level decision: -**1. One sentence in → a typed contract out.** -The user writes, e.g., *"Screen OER catalysts to minimize overpotential at 10 mA/cm²; Fe/Co/Ni ratios are tunable; budget 30 rounds."* The **conversation engine** and **RequirementParserAgent** run a multi-turn clarification dialogue — missing KPI? unclear bounds? which steps need a human signature? — and emit a **`TaskContract`**: a versioned, schema-validated object holding the objective, the exploration space, stop conditions, a safety envelope, and the human-gate policy. From this point on, every agent works off the contract, not the chat history. Contracts carry `schema_version` and migrate forward automatically, so a campaign archived months ago still loads after upgrades. +- **Optimize** — choose the optimization strategy, mode, backend, and candidate-generation path. +- **Validate** — decide when proxy progress needs mechanism, repeatability, or higher-fidelity validation. +- **Recover** — route failure-aware recovery when results, constraints, measurements, or backends look unreliable. +- **Acquire context** — ask for literature, prior-campaign evidence, diagnostics, or missing experimental context. +- **Ask a human or LLM** — request human observation or LLM-supported context only at the language/knowledge boundary. +- **Revise objectives or constraints** — handle dynamic objective hierarchy, proxy gaps, constraints, and safety envelopes. +- **Escalate scale or fidelity** — provide the decision surface for future scale/fidelity-aware campaign moves. -**2. Devices are exposed as skills.** -Every instrument registers a skill (`agent/skills/*.md`): its primitives, their typed parameters, a safety class (e.g. `HAZARDOUS`), and precondition/effect contracts ("channel must be idle"). Agents can only invoke declared primitives with type-checked arguments — **the machine's capabilities are fixed, so the LLM cannot hallucinate an operation that doesn't exist**. New instruments are onboarded by the **OnboardingAgent**, which discovers primitives, generates integration code and the skill definition, and writes them to disk after human review — hours, not weeks. +The output is an auditable campaign decision envelope with evidence, rationale, selected policy/action, optional candidate portfolio, expected value, and replayable outcome accounting. -**3. The decision layer runs the round loop.** -The internal **OrchestratorAgent** drives one pipeline per round, but it is an implementation component rather than the product boundary. The adaptive campaign decision layer decides whether the next campaign-level action is optimization, validation, recovery, context acquisition, human/LLM query, objective/constraint revision, or a future scale/fidelity transition: +--- + +## Decision Flow ``` -PlannerAgent expands the contract into a round plan -DesignAgent proposes candidates — an inner RL loop picks the search - strategy per round (LHS early, GP+EI/MES mid, refinement late) -SafetyAgent checks every candidate against the contract's safety envelope -CompilerAgent compiles recipes into an executable hardware DAG -Execution layer dispatches to OT-2, PLC pumps, potentiostat (or simulation) -SensingAgent / QC + analysis of returning data -AnalyzerAgent -StopAgent converged? budget exhausted? continue or stop +TaskContract / campaign context + | + v +RoundContext + objective state + failure history + backend memory + | + v +Adaptive campaign policy + | + +--> CampaignIntent / OptimizationMode / backend recommendation + +--> validation, recovery, context, human/LLM, objective/constraint action + | + v +Decision trace + evidence + reward/outcome + replay record ``` -All agents share one shape: a common `BaseAgent` base class, typed Pydantic I/O, and a mandatory **decision tree** record per decision (options considered, choice, rationale). Cross-agent calls go through the **ControlPlane** — lease first, call second, audit always — which is why 27 agents don't collapse into chaos. - -**4. Hard problems convene a swarm.** -When the pipeline hits something that needs judgment — an anomalous data pattern, a hypothesis worth stress-testing — the **SwarmFactory** spawns an ephemeral consult: **Scientist** (hypotheses), **Engineer** (feasibility/cost), **Analyst** (data interpretation), **Validator** (tries to break the other three). They coordinate over a shared **blackboard** and disband when done. Racing swarms and adversarial hypothesis loops let cheap compute fight it out before expensive robot time is spent. - -**5. Two things run through everything: safety and the event bus.** -Safety appears at three levels — the contract's safety envelope, per-primitive safety classes and preconditions, and the per-round SafetyAgent; human approval is a first-class **pause state**, resumable from checkpoint. Meanwhile every agent action streams onto the **event bus**, persists to the DB, and broadcasts live over **SSE** with exactly-once delivery — watch agents think in the browser, replay the full decision chain afterwards. - -**6. Failure is a planned-for state.** -Contracts isolate failures to their layer; the **RecoveryAgent** fixes forward (re-plans the round, not the campaign). If the whole process dies, campaigns resume from their last SQLite checkpoint via `/resume`. - -**7. The data stays alive.** -Results, uncertainties, and decision chains land in campaign memory — queryable in natural language ("which recipe won last week? plot its CV"). **RGPE transfer learning** warm-starts the next related campaign with interpretable ranking weights: the more HELIOS runs, the smarter the next campaign starts. +The live path is conservative by design: rule-based, auditable, and bounded by explicit safety gates. Learning-based policies do not replace it by default. They move through replay evaluation, shadow records, canary runs, promotion gates, and approval workflows before they can influence live decisions. --- -## Features +## Core Capabilities -- **Natural language intake** — a multi-turn clarification dialogue turns a free-text experiment description into a versioned, schema-validated `TaskContract` -- **Adaptive campaign decision layer** — orchestrator-agnostic campaign control that decides what should happen next across optimization, validation, recovery, context acquisition, human/LLM query, dynamic objectives/constraints, and future scale/fidelity choices -- **Typed internal agent services** — 27 specialist agents grouped into 4 swarms (Scientist / Engineer / Analyst / Validator), composed as a stage graph that branches, retries, and remembers; all cross-agent calls go through a ControlPlane with agent leases and a full audit trail -- **Devices as skills** — instruments expose typed primitives with safety classes and precondition/effect contracts; agents cannot invoke operations that don't exist -- **Real-time reasoning stream** — every agent step emits SSE events with exactly-once delivery and DB-backed replay; the browser shows a live decision tree of what each agent considered and why -- **Hardware agnostic** — runs in `simulated` mode for development; switches to live Opentrons OT-2, PLC relays, and electrochemistry sensors by changing one env var -- **Context-aware campaign policy** — campaign intent, optimization mode, candidate backend, validation, recovery, context requests, and objective/constraint handling are selected from scientific context, objective hierarchy, failure attribution, backend memory, Nexus diagnostics, BO MCP availability, and shadow learning signals -- **Safety-first** — contract safety envelopes, per-primitive safety classes, preflight checks before every round, and human-in-the-loop gates as resumable pause states -- **Durable execution** — SQLite-backed campaign checkpoints survive restarts; crashed campaigns resume from the last completed round +- **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, 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/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. +- **LLM boundary discipline** — LLMs can help translate intent, gather context, or generate review notes; they do not steer the live optimization loop. --- ## Architecture -### Four Internal Layers - -| Layer | Role | Key Components | -|-------|------|----------------| -| **L3 Campaign Decision** | Task contracts, campaign action choice, admission control, agent lease pool | `OrchestratorAgent`, `ControlPlane`, `RequirementParserAgent`, `decision_layer` | -| **L2 Planning & Policy** | Experimental design, adaptive strategy, validation/recovery/context choices | `PlannerAgent`, `DesignAgent`, `SafetyAgent`, strategy router | -| **L1 Execution** | Protocol compilation & hardware abstraction | `CompilerAgent`, `CodeWriterAgent`, `DeckLayoutAgent`, hardware dispatcher | -| **L0 Evidence** | Campaign memory, uncertainty, causal updates | `AnalyzerAgent`, `SensingAgent`, `MonitorAgent`, `RecoveryAgent` | +| 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` | +| **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 is positioned as an orchestrator-agnostic adaptive campaign decision -layer. It 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 also owns validation, failure-aware recovery, context -acquisition, human/LLM query, dynamic objective/constraint handling, and future -scale/fidelity-aware decisions. - -The default live path is conservative: rule-based, auditable, and bounded by -explicit safety gates. Learning-based policies do not replace this path by -default. They are introduced progressively through replay evaluation, shadow -records, canary runs, promotion gates, and explicit approval workflows. Each -round is backed by `StrategyTrace`, `StrategyEvidence`, `StrategyOutcome`, -`StrategyReward`, typed `FailureEvent` attribution, and replay/validation -records so strategy changes remain inspectable after the fact. +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. -### Loop Engineering Layer +### Optimization Code Map -HELIOS treats the campaign loop itself as an engineered object, not just a -control-flow pattern. The loop-engineering layer records each -observe-decide-act-evaluate unit as a replayable episode: loop spec, signals, -decision, outcome, reward, and replay summary. This makes real workflow data -usable for offline evaluation, shadow/canary promotion, failure attribution, -and future policy improvement without changing the live execution path. +The optimization stack is split by authority boundary: -The first pure service layer is `app/services/loop_engineering.py`. It is -dependency-light and side-effect-free by design: it does not call PUDA, mutate -campaign state, write to the database, or promote learned policies. Runtime -hooks can feed it later with PUDA responses, strategy traces, artifacts, and -observations. +- `app/services/optimization_intelligence.py` enriches strategy selection with optional Nexus diagnostics, similar-campaign evidence, and backend recommendations. It emits structured evidence; it does not choose a live candidate. +- `app/optimization/nexus_provider.py` and `app/optimization/nexus_backend.py` adapt Nexus profiling and `nexus_*` algorithm plugins behind HELIOS provider/backend interfaces. Nexus remains an advisor/backend, not campaign authority. +- `app/optimization/service.py`, `app/optimization/pool_service.py`, and `app/optimization/candidate_pool.py` build the multi-source candidate portfolio. +- `app/optimization/decision_policy.py` is the hard gate and arbitration authority for concrete candidates. It enforces bounds, deduplication, safety hook results, and ranks survivors with the strategy decision's utility model. +- `app/optimization/loop_integration.py` is the campaign-loop seam. Deep candidate-pool arbitration is controlled by `ENABLE_CANDIDATE_ARBITRATION` and defaults off. +- `app/optimization/provenance.py` records selected portfolios, rejected candidates, scored pools, and strategy decisions so "why this candidate, not that one?" can be audited. -### Optimization Code Map +### Adaptive Campaign Substrate (shadow) -The merged optimization stack is split by authority boundary: - -- `app/services/optimization_intelligence.py` enriches strategy selection with - optional Nexus diagnostics, similar-campaign evidence, and backend - recommendations. It emits structured evidence; it does not choose a live - candidate. -- `app/optimization/nexus_provider.py` and `app/optimization/nexus_backend.py` - adapt Nexus profiling and `nexus_*` algorithm plugins behind HELIOS provider - and backend interfaces. Nexus remains an advisor/backend, not campaign - authority. -- `app/optimization/service.py`, `app/optimization/pool_service.py`, and - `app/optimization/candidate_pool.py` build the multi-source candidate - portfolio: Nexus top-k, local baseline, archetype-scored candidates, BO MCP - hints, replicates, and recovery probes where available. -- `app/optimization/decision_policy.py` is the hard gate and arbitration - authority for concrete candidates. It enforces bounds, deduplication, safety - hook results, and then ranks survivors with the strategy decision's utility - model. -- `app/optimization/loop_integration.py` is the campaign-loop seam. Deep - candidate-pool arbitration is controlled by `ENABLE_CANDIDATE_ARBITRATION` - and defaults off; when disabled or failed, the loop keeps the legacy - generation path. -- `app/optimization/provenance.py` records the selected portfolio, rejected - candidates, scored pool, and strategy decision so "why this candidate, not - that one?" can be audited after the round. +The shadow-only adaptive substrate proposes a scientific-activity `CampaignMode`, assesses the action space, and scores candidate value-of-information as an advisory artifact. It changes no routing by default and is gated by `ADAPTIVE_SUBSTRATE_SHADOW_ENABLED`. -### Adaptive Campaign Substrate (shadow) +See [docs/adaptive_campaign_substrate.md](docs/adaptive_campaign_substrate.md). -Alongside the live policy sits a **shadow-only** adaptive decision substrate -that, per round, proposes a scientific-activity `CampaignMode` -(optimization, validation, calibration, failure diagnosis, context seeking, -human observation, safety-constraint tightening, stop), assesses the action -space, and scores candidate value-of-information — as an **advisory** artifact -recorded next to the campaign, never as a control signal. It is composed of -`objective_state`, `failure_attribution`, `campaign_mode`, -`dynamic_action_space`, `value_of_information`, and -`adaptive_campaign_substrate`, and is reconciled against the legacy contextual -decision track by `shadow_trace_comparison`. It changes no routing and is gated -by `ADAPTIVE_SUBSTRATE_SHADOW_ENABLED` (default off). See -[docs/adaptive_campaign_substrate.md](docs/adaptive_campaign_substrate.md). - -### Agent Model - -HELIOS is an **adaptive campaign decision layer** implemented with typed -specialist services, not an "LLM agent". Agents are typed input→output services -coordinated in a deterministic L3→L2→L1→L0 pipeline; the per-round -optimization/decision loop is **LLM-free by design** (classical BO/GP + -rule-based scoring + optional non-LLM learned policies). The LLM is used only -at the language/knowledge boundary (NL→plan, NL→code, priors, post-run review) -and never steers a live round. See -[docs/agent_architecture.md](docs/agent_architecture.md). - -### Agent Roster - -| Agent | Purpose | -|-------|---------| -| `Orchestrator` | Root coordinator; drives the campaign loop | -| `PlannerAgent` | Generates experimental designs (DoE, LHS, prior-guided) | -| `SafetyAgent` | Preflight safety checks; blocks non-compliant rounds | -| `SimulationAgent` | Physics simulation before hardware execution | -| `AnalyzerAgent` | Post-round analytics; convergence detection; KPI tracking | -| `CompilerAgent` | High-level plan → OT-2 protocol code | -| `CodeWriterAgent` | AST-to-Python code generation | -| `NLPCodeAgent` | Natural language → protocol code | -| `MonitorAgent` | Real-time sensor monitoring; anomaly detection | -| `SensingAgent` | QC data collection and validation | -| `RecoveryAgent` | Execution error recovery (fix-forward or abort) | -| `CleaningAgent` | Equipment cleaning protocol generation | -| `OnboardingAgent` | New device initialization and configuration | -| `QueryAgent` | Historical data retrieval from structured DSL | -| `InverseDesignAgent` | Goal-driven parameter synthesis (Nexus integration) | -| `StrategySelector` | Chooses optimization algorithm per campaign phase | -| `SwarmAgent` | Multi-agent sub-task coordination | - -### Four Specialist Swarms - -| Swarm | Members | Focus | -|-------|---------|-------| -| **ScientistSwarm** | Planner + Design | Hypothesis generation, experimental design | -| **EngineerSwarm** | Compiler + CodeWriter | Protocol compilation, code generation | -| **AnalystSwarm** | Analyzer + Monitor | Data analysis, metrics computation | -| **ValidatorSwarm** | Safety + Sensing | Safety checks, QC validation | +### Loop Engineering Layer ---- +The loop-engineering layer records each observe-decide-act-evaluate unit as a replayable episode: loop spec, signals, decision, outcome, reward, and replay summary. This makes workflow data usable for offline evaluation, shadow/canary promotion, failure attribution, and future policy improvement without changing the live path. + +The first pure service layer is `app/services/loop_engineering.py`. It is dependency-light and side-effect-free by design: it does not call downstream services, mutate campaign state, write to the database, or promote learned policies. -## Quick Start +--- -### Simulated Mode (no hardware required) +## Developer Setup ```bash -# 1. Clone git clone https://github.com/SissiFeng/HELIOS.git cd HELIOS - -# 2. Configure -cp .env.example .env -# defaults are fine for simulation - -# 3. Run -docker compose up - -# UI: http://localhost:8000/lab -# API docs: http://localhost:8000/docs +pip install -e ".[dev]" ``` -### Live Hardware Mode +Run the focused validation for the current positioning/reporting boundary: ```bash -# Edit .env -ADAPTER_MODE=live -ROBOT_IP= # Opentrons OT-2 HTTP API -RELAY_PORT=/dev/ttyUSB0 # or 'auto' for auto-detect -SQUIDSTAT_PORT=auto - -# Start both services (main + hardware recovery bridge) -docker compose --profile hardware up +pytest tests/test_system_validation_report.py ``` -### Manual Python Setup +Run the broader decision-layer tests as needed: ```bash -# Base (simulated only) -pip install -e . - -# With hardware drivers -pip install -e ".[hardware]" - -# With ML strategies (DQN/PPO) -pip install -e ".[ml]" - -# Full install -pip install -e ".[all]" - -# Run -ADAPTER_MODE=simulated LLM_PROVIDER=mock \ - uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +pytest \ + tests/test_decision_layer.py \ + tests/test_decision_trace.py \ + tests/test_decision_outcome.py \ + tests/test_decision_replay.py \ + tests/test_verifiable_reward.py \ + tests/test_policy_evaluation.py \ + tests/test_adaptive_campaign_substrate.py \ + tests/test_shadow_trace_comparison.py \ + tests/test_backend_memory.py \ + tests/test_candidate_pool.py ``` --- -## Configuration - -All configuration is via environment variables (`.env` file or shell). +## Decision-Layer Configuration | Variable | Default | Description | |----------|---------|-------------| -| `ADAPTER_MODE` | `simulated` | `simulated` — no hardware; `live` — real devices | -| `ADAPTER_DRY_RUN` | `true` | When `true`, hardware commands are logged but not sent | -| `LLM_PROVIDER` | `mock` | `mock` (testing), `anthropic`, or `openai` | -| `LLM_API_KEY` | — | API key for chosen LLM provider | -| `LLM_MODEL` | `claude-sonnet-4-20250514` | Model ID passed to provider | -| `ROBOT_IP` | — | OT-2 / OT-2 Flex HTTP API address | -| `RELAY_PORT` | `auto` | Serial port for relay controller | -| `SQUIDSTAT_PORT` | `auto` | Serial port for Squidstat potentiostat | -| `HELIOS_PORT` | `8000` | Main service port | -| `RECOVERY_PORT` | `8001` | Hardware recovery bridge port | -| `DB_PATH` | `/app/data/orchestrator.db` | SQLite database path | -| `CONTEXTUAL_DECISION_SHADOW_ENABLED` | `false` | Record the legacy contextual decision shadow trace per round (observational; no routing effect) | -| `ADAPTIVE_SUBSTRATE_SHADOW_ENABLED` | `false` | Record the adaptive campaign substrate shadow snapshot per round (observational; no routing effect) | - ---- - -## API Overview - -Base URL: `http://localhost:8000` - -### Campaign Lifecycle - -| Method | Path | Description | -|--------|------|-------------| -| `POST` | `/api/v1/orchestrate/start` | Start a campaign from a `TaskContract` | -| `POST` | `/api/v1/orchestrate/from-session/{session_id}` | Start a campaign from an init conversation session | -| `GET` | `/api/v1/orchestrate/{campaign_id}/status` | Query campaign state and progress | -| `POST` | `/api/v1/orchestrate/{campaign_id}/stop` | Cancel a running campaign | -| `POST` | `/api/v1/orchestrate/{campaign_id}/resume` | Resume a paused/crashed campaign from its checkpoint | -| `GET` | `/api/v1/orchestrate/{campaign_id}/events/stream` | SSE event stream (supports `Last-Event-ID` replay) | - -### Natural Language Interface - -| Method | Path | Description | -|--------|------|-------------| -| `POST` | `/api/v1/nl/parse` | Parse free-text description → `TaskContract` | - -### Initialization & Onboarding - -| Method | Path | Description | -|--------|------|-------------| -| `POST` | `/api/v1/init/start` | Start interactive setup session | -| `POST` | `/api/v1/init/{session_id}/respond` | Respond to initialization prompts | -| `POST` | `/api/v1/onboarding/discover` | Auto-discover primitives for a new instrument | -| `POST` | `/api/v1/onboarding/generate` | Generate integration code for a new instrument | -| `POST` | `/api/v1/onboarding/confirm` | Approve safety/config confirmations | -| `POST` | `/api/v1/onboarding/write` | Write approved integration files to disk | - -### Data & Metrics - -| Method | Path | Description | -|--------|------|-------------| -| `GET` | `/api/v1/campaigns` | List all campaigns | -| `GET` | `/api/v1/runs` | List experiment runs | -| `GET` | `/api/v1/metrics` | Campaign KPI metrics | -| `POST` | `/api/v1/query` | Query historical data with DSL | -| `GET` | `/api/v1/capabilities` | Available primitives and templates | - -### Human-in-the-Loop - -| Method | Path | Description | -|--------|------|-------------| -| `POST` | `/api/v1/confirmations/{request_id}/respond` | Approve or reject a pending action | -| `POST` | `/api/v1/evolution/proposals/{proposal_id}/approve` | Approve a candidate proposal | - -### Health - -| Method | Path | Description | -|--------|------|-------------| -| `GET` | `/health` | Liveness probe (always 200 OK) | -| `GET` | `/api/v1/health/ready` | Readiness (DB + event bus) | -| `GET` | `/api/v1/health/detail` | Full diagnostic status | - -Full interactive docs at `http://localhost:8000/docs`. +| `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 | +| `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 | --- -## Frontend Lab UI +## Validation Evidence -The primary UI is a single-page app at `http://localhost:8000/lab`. +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. -**Three-column layout:** - -``` -┌────────────────────┬──────────────────────────┬──────────────────────┐ -│ Instrument Bar │ Agent Pipeline │ Context Panel │ -│ (active devices) │ Round 1 │ (selected step) │ -│ │ ├─ Safety Check ✓ │ │ -│ [Squidstat] │ ├─ Simulation ✓ │ Decision Tree: │ -│ [OT-2] │ ├─ Execution ✓ │ ├─ Strategy: LHS │ -│ │ ├─ Analysis ✓ │ ├─ Rounds: 20 │ -│ NL Input │ Round 2 │ └─ Convergence: … │ -│ [text area] │ ├─ Safety Check … │ │ -│ [Run Campaign] │ └─ ... │ Thinking Log │ -└────────────────────┴──────────────────────────┴──────────────────────┘ -``` - -**Key UI behaviors:** -- Paste any free-text experiment description and click **Run Campaign** -- The pipeline panel populates in real time via SSE as each agent starts/finishes -- Click any step to see the **decision tree** in the Context Panel — what each agent considered, which option it chose, and why -- Instrument chips in the Instrument Bar reflect live hardware status - ---- - -## Testing - -```bash -# All tests -pytest tests/ - -# Verbose with coverage -pytest -v --cov=app tests/ - -# Specific module -pytest tests/test_multi_agent_v3.py -``` - -| Test File | Coverage Area | -|-----------|--------------| -| `test_agent_runtime_integration.py` | Multi-agent orchestration, pause/approval workflows | -| `test_multi_agent_v3.py` | ControlPlane, agent leasing, concurrency | -| `test_durable_execution.py` | Campaign lifecycle: duplicate-start guard, recovery, bounded retention | -| `test_sse_events.py` | Exactly-once SSE delivery, replay/live handover, queue cleanup | -| `test_e2e_study.py` | Full campaign end-to-end (simulated) | -| `test_gp_surrogate.py` | GP surrogate model and acquisition functions | -| `test_simulation.py` | Protocol simulation engine | -| `test_mission_control.py` | Mission/workflow API | -| `test_requirement_parser_agent.py` | Natural-language requirement parsing | - -Type checking and lint: - -```bash -mypy app/ -ruff check app/ -ruff format app/ -``` - -### Validation Evidence Pack - -HELIOS is framed as an orchestrator-agnostic adaptive campaign decision layer. -Its internal implementation is hierarchical, graph/state-machine routed, and -role-based, but the product boundary is campaign-level decision authority rather -than ownership of any single orchestrator. The live campaign policy remains -rule-based and auditable by default; Nexus and BO MCP are optimization -advisor/backend/tool paths, not top-level 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`. It is a static evidence pack for the -current validation boundary, not a runtime-generated artifact. +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. Run the validation suite: @@ -449,146 +173,48 @@ pytest \ tests/test_backend_selection.py ``` -Full `pytest` is expected to pass. Full-repository `ruff check .` currently has -legacy lint debt in older modules and vendored/benchmark-style test areas. The -clean ruff boundary for the new validation/reporting files is: - -```bash -ruff check \ - app/optimization/candidate_memory.py \ - app/optimization/failure_zone_memory.py \ - app/services/campaign_state.py \ - app/services/system_validation_report.py \ - tests/test_candidate_memory.py \ - tests/test_failure_zone_memory.py \ - tests/test_system_validation_report.py \ - tests/test_offline_closed_loop_sdl.py \ - tests/test_offline_scenario_benchmarks.py \ - tests/test_policy_evolution_workflow_e2e.py -``` - --- -## Project Structure +## Repository Map ``` HELIOS/ ├── app/ -│ ├── agents/ # 27 specialist agents + swarm/control-plane runtime -│ ├── api/v1/endpoints/ # FastAPI route handlers -│ ├── optimization/ # Nexus/local provider facade, candidate pools, arbitration -│ ├── services/ # 85+ domain services -│ │ ├── bayesian_opt.py # Bayesian Optimization (Ax) -│ │ ├── campaign_loop.py # Campaign execution loop -│ │ ├── campaign_events.py # SSE event persistence & replay -│ │ ├── convergence*.py # Termination criteria -│ │ ├── rl_*.py # DQN / PPO strategy backends -│ │ └── nexus_advisor.py # Causal inference integration -│ ├── hardware/ # Hardware adapters (OT-2, PLC, relay, sensors) -│ ├── adapters/ # Lab-mode adapters (simulated, battery lab) -│ ├── contracts/ # Pydantic data models (TaskContract, etc.) -│ ├── core/ # DB init, config, startup lifecycle -│ ├── static/ # Frontend (lab.html / lab.js / lab.css) -│ ├── main.py # FastAPI app entry point + lifespan -│ └── worker.py # Background async worker -├── recovery-agent/ # Standalone hardware bridge (port 8001) -├── tests/ # Pytest test suite -├── benchmarks/ # Performance tests and fault injection -├── examples/ # Demo scripts -├── models/ # Pre-trained RL model checkpoints (.pkl) -├── data/ # Runtime SQLite DB and object store (gitignored) -├── Dockerfile # Multi-variant build (simulated / hardware / ml / all) -├── docker-compose.yml # Two-service deployment +│ ├── contracts/ # Typed campaign contracts and query/task models +│ ├── optimization/ # Candidate pools, backend facades, arbitration, provenance +│ ├── services/ # Campaign policy, evidence, reward, replay, objective/failure logic +│ ├── api/v1/endpoints/ # Service API surfaces +│ └── core/ # Config, DB, startup lifecycle +├── docs/ +│ ├── HELIOS_ARCHITECTURE_VALIDATION.md +│ ├── adaptive_campaign_substrate.md +│ └── development_progress.md +├── tests/ # Pytest coverage for policy, replay, validation, and evidence layers +├── benchmarks/ # Offline method and policy evaluation harnesses +├── models/ # Learned-policy checkpoints and replay artifacts ├── pyproject.toml # Dependencies and tool config -└── .env.example # Environment variable template +└── README.md ``` --- -## Deployment - -### Docker Compose (recommended) - -```yaml -# docker-compose.yml provides: -# - helios : main service on :8000, with SQLite volume -# - recovery-agent : hardware bridge on :8001 (profile: hardware) -``` - -```bash -# Development -docker compose up - -# Production (with hardware) -docker compose --profile hardware up -d - -# View logs -docker compose logs -f helios -``` - -### Docker Build Variants - -```bash -# Simulated only (smallest image, default) -docker build -t helios . - -# With hardware serial drivers -docker build --build-arg EXTRAS=hardware -t helios:hw . - -# With ML strategy models -docker build --build-arg EXTRAS=ml -t helios:ml . - -# Full stack -docker build --build-arg EXTRAS=all -t helios:full . -``` - -### Health Checks - -```bash -curl http://localhost:8000/health # Liveness -curl http://localhost:8000/api/v1/health/ready # Readiness -curl http://localhost:8000/api/v1/health/detail # Full diagnostic -``` - ---- - -## Event-Driven Architecture - -All internal communication flows through an async event bus. Key event types: - -| Event | Trigger | Consumers | -|-------|---------|-----------| -| `CandidateExecuted` | Hardware run completes | Metrics, Analyzer, Memory | -| `MetricsUpdated` | KPI recomputed | Dashboard, Convergence | -| `ApprovalRequested` | Safety gate triggered | UI confirmation dialog | -| `KPIReached` | Objective met | Campaign termination | - -Campaign events are persisted to the `campaign_events` table so SSE streams replay them on reconnect — the UI receives the full history even if it connects after the campaign has completed. - ---- - -## External Integrations +## External Decision Inputs -| Integration | Purpose | -|-------------|---------| -| **Anthropic / OpenAI** | LLM backend for agent reasoning | -| **Opentrons OT-2 / Flex** | Liquid-handling robotics | -| **Ax (Meta)** | Bayesian Optimization service | -| **Nexus Advisor** | Causal inference for experimental design | -| **Potentiostats** | Electrochemical measurements (serial adapters) | -| **PLC / relay controllers** | Pump, stirrer, and process control | +| 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. Fork and create a feature branch -2. Code style: - - Type hints always; typed Pydantic models for all agent I/O - - `ruff check` + `ruff format` before committing - - Conventional commits (`feat/fix/refactor/chore/test/docs`) -3. Write tests alongside implementation, not after -4. Open a PR with type check, lint, and the full test suite passing +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. --- From e65e38a99e2a3d77edbebe2c19aa510ec940eaee Mon Sep 17 00:00:00 2001 From: Sissi Feng Date: Tue, 7 Jul 2026 14:38:57 -0400 Subject: [PATCH 6/9] docs: update Nexus install path --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2f01e17..4a24e08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ all = [ # HELIOS uses only Nexus's pure-Python core (backends/plugins/profiler/ # diagnostics/core/dsl) -- never its server/LLM stack. Nexus declares those # server deps as core, so install the core WITHOUT them: -# uv pip install -e /path/to/Nexus --no-deps +# uv pip install -e /path/to/HELIOS_Nexus --no-deps # When absent, HELIOS falls back to the built-in optimizer automatically. [tool.setuptools.packages.find] From cc808b4bc3309557a85f6ac32cb34b086a9bcdb2 Mon Sep 17 00:00:00 2001 From: Sissi Feng Date: Sat, 18 Jul 2026 10:43:01 -0400 Subject: [PATCH 7/9] feat: complete auditable campaign decision authority --- .gitignore | 2 + README.md | 76 + app/agents/orchestrator.py | 1383 ++++++++++++++++- app/agents/recovery_agent.py | 2 +- app/api/v1/endpoints/memory.py | 75 +- app/core/config.py | 53 + app/core/startup.py | 15 +- app/services/campaign_decision_authority.py | 260 ++++ app/services/campaign_mode.py | 5 + app/services/decision_markdown.py | 867 +++++++++++ app/services/decision_outcome.py | 17 + app/services/decision_replay.py | 12 + app/services/decision_trajectory.py | 76 +- app/services/dynamic_action_space.py | 68 + app/services/experimental_route_policy.py | 466 ++++++ app/services/nexus_early_stage.py | 588 +++++++ app/services/nexus_experimental_routes.py | 314 ++++ app/services/scientific_ledger.py | 702 +++++++++ app/services/scientific_ledger_git.py | 128 ++ app/services/scientific_ledger_runtime.py | 155 ++ docs/development_progress.md | 32 +- docs/scientific_decision_ledger.md | 180 +++ tests/fixtures/scientific_ledger.py | 123 ++ .../test_nexus_experimental_routes_joint.py | 134 ++ tests/test_campaign_decision_authority.py | 215 +++ tests/test_contextual_shadow_hook.py | 86 +- tests/test_decision_markdown.py | 137 ++ tests/test_decision_outcome.py | 13 + tests/test_decision_replay.py | 10 + tests/test_e2e_study.py | 19 + tests/test_experimental_route_policy.py | 437 ++++++ tests/test_nexus_early_stage.py | 265 ++++ tests/test_recovery_agent_episode.py | 52 + tests/test_reward_split.py | 2 +- tests/test_scientific_ledger.py | 161 ++ tests/test_scientific_ledger_config.py | 28 + tests/test_scientific_ledger_git.py | 75 + tests/test_scientific_ledger_runtime.py | 113 ++ tests/test_scientific_memory_api.py | 83 + 39 files changed, 7338 insertions(+), 91 deletions(-) create mode 100644 app/services/campaign_decision_authority.py create mode 100644 app/services/decision_markdown.py create mode 100644 app/services/experimental_route_policy.py create mode 100644 app/services/nexus_early_stage.py create mode 100644 app/services/nexus_experimental_routes.py create mode 100644 app/services/scientific_ledger.py create mode 100644 app/services/scientific_ledger_git.py create mode 100644 app/services/scientific_ledger_runtime.py create mode 100644 docs/scientific_decision_ledger.md create mode 100644 tests/fixtures/scientific_ledger.py create mode 100644 tests/integration/test_nexus_experimental_routes_joint.py create mode 100644 tests/test_campaign_decision_authority.py create mode 100644 tests/test_decision_markdown.py create mode 100644 tests/test_experimental_route_policy.py create mode 100644 tests/test_nexus_early_stage.py create mode 100644 tests/test_scientific_ledger.py create mode 100644 tests/test_scientific_ledger_config.py create mode 100644 tests/test_scientific_ledger_git.py create mode 100644 tests/test_scientific_ledger_runtime.py create mode 100644 tests/test_scientific_memory_api.py diff --git a/.gitignore b/.gitignore index 4e7a894..193a329 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,7 @@ docs/* !docs/adaptive_campaign_substrate.md !docs/development_progress.md !docs/agent_architecture.md +!docs/scientific_decision_ledger.md # Planning / design notes plan/ diff --git a/README.md b/README.md index e21ea70..30fe463 100644 --- a/README.md +++ b/README.md @@ -58,10 +58,52 @@ The live path is conservative by design: rule-based, auditable, and bounded by e - **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 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 +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. + +--- + ## Architecture | Surface | Responsibility | Representative modules | @@ -77,6 +119,27 @@ The live path is conservative by design: rule-based, auditable, and bounded by e 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 The optimization stack is split by authority boundary: @@ -141,8 +204,20 @@ 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 | --- @@ -188,6 +263,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 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/core/config.py b/app/core/config.py index 41b6e08..78c8d88 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,21 @@ 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. + 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 +139,15 @@ 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") + # ---- 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 +175,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/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/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/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/scientific_ledger.py b/app/services/scientific_ledger.py new file mode 100644 index 0000000..442e638 --- /dev/null +++ b/app/services/scientific_ledger.py @@ -0,0 +1,702 @@ +"""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.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 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("|", "\\|") + + +__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/docs/development_progress.md b/docs/development_progress.md index 77a03c8..816aab4 100644 --- a/docs/development_progress.md +++ b/docs/development_progress.md @@ -23,17 +23,32 @@ 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). - **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 +56,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. --- @@ -55,8 +73,8 @@ not change live candidate selection by default. | 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 +157,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/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/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_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_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_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_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" From 43fbdea4132f1ec1a217473646bfd31165064a54 Mon Sep 17 00:00:00 2001 From: Sissi Feng Date: Thu, 23 Jul 2026 09:56:38 -0400 Subject: [PATCH 8/9] feat(benchmarks): pathology evaluation layer with event-grounded ablation metrics Wrapper-injected experimental pathologies (noise drift, spatially correlated execution failure, censoring, proxy-gap shift, objective shift) over unmodified runner/problems; ground-truth event ledger; adaptation metrics (recovery efficiency, adaptation lag, constraint latency, decision quality) with family-scoped Holm-corrected paired bootstrap; dynamic + epoch-local regret rulers for objective shifts; backend-owned helios_full ablation variants; reproducible study artifacts (full config snapshot, dirty-tree + dependency provenance, overwrite protection); canonical preregistered study v1 and pathology-study CLI subcommand. --- .gitignore | 3 + benchmarks/methods/__main__.py | 262 ++++ benchmarks/methods/ablation.py | 1161 +++++++++++++++++ .../methods/configs/pathology_metrics.yaml | 47 + .../configs/studies/pathology_study_v1.yaml | 116 ++ benchmarks/methods/helios_full.py | 566 ++++++++ benchmarks/methods/pathologies.py | 446 +++++++ benchmarks/methods/problems.py | 544 +++++++- benchmarks/methods/recommend.py | 2 +- benchmarks/methods/report.py | 106 +- benchmarks/methods/runner.py | 44 +- tests/test_methods_ablation.py | 684 ++++++++++ tests/test_methods_helios_full.py | 211 +++ tests/test_methods_pathologies.py | 210 +++ tests/test_methods_problems.py | 51 + tests/test_methods_report.py | 79 ++ tests/test_methods_runner.py | 12 + 17 files changed, 4521 insertions(+), 23 deletions(-) create mode 100644 benchmarks/methods/__main__.py create mode 100644 benchmarks/methods/ablation.py create mode 100644 benchmarks/methods/configs/pathology_metrics.yaml create mode 100644 benchmarks/methods/configs/studies/pathology_study_v1.yaml create mode 100644 benchmarks/methods/helios_full.py create mode 100644 benchmarks/methods/pathologies.py create mode 100644 tests/test_methods_ablation.py create mode 100644 tests/test_methods_helios_full.py create mode 100644 tests/test_methods_pathologies.py create mode 100644 tests/test_methods_report.py diff --git a/.gitignore b/.gitignore index 193a329..a5367a0 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,9 @@ tests/**/__pycache__/ # Node modules node_modules/ +# Benchmark run artifacts (studies are reproducible from tracked configs) +/benchmark_results/ + # Runtime logs logs/ *.log 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/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) From 9d6bbf310e33135bd3ab3c5df4848378a4cd874a Mon Sep 17 00:00:00 2001 From: Sissi Feng Date: Thu, 13 Aug 2026 13:46:13 -0400 Subject: [PATCH 9/9] feat: continuous failure objectives + soft failure-region re-rank --- .codegraph/.gitignore | 5 + .github/workflows/ci.yml | 63 + .gitignore | 2 + README.md | 9 +- app/contracts/scientific_evidence.py | 468 ++++ app/core/config.py | 36 + app/optimization/pool_service.py | 31 +- app/services/closed_loop_drift.py | 969 +++++++ app/services/closed_loop_runtime.py | 383 +++ app/services/failure_region.py | 84 + app/services/hypothesis_experiment_planner.py | 240 ++ app/services/objective_state.py | 162 +- app/services/pas_scientific_evidence.py | 527 ++++ app/services/rl_strategy_selector.py | 175 ++ app/services/scientific_evidence.py | 449 +++ app/services/scientific_ledger.py | 272 ++ app/services/strategy_diagnostics.py | 22 + app/services/strategy_models.py | 4 + docs/development_progress.md | 7 +- .../objective_evolution_and_proxy_gap_math.md | 19 + docs/scientific_evidence_loop.md | 162 ++ tests/fixtures/scientific_evidence.py | 73 + tests/test_closed_loop_drift.py | 575 ++++ tests/test_hypothesis_experiment_planner.py | 125 + tests/test_objective_state.py | 101 + tests/test_pas_scientific_evidence.py | 408 +++ tests/test_scientific_evidence.py | 247 ++ tests/test_scientific_evidence_contract.py | 173 ++ tests/test_scientific_evidence_ledger.py | 156 ++ uv.lock | 2467 +++++++++++++++++ 30 files changed, 8405 insertions(+), 9 deletions(-) create mode 100644 .codegraph/.gitignore create mode 100644 .github/workflows/ci.yml create mode 100644 app/contracts/scientific_evidence.py create mode 100644 app/services/closed_loop_drift.py create mode 100644 app/services/closed_loop_runtime.py create mode 100644 app/services/hypothesis_experiment_planner.py create mode 100644 app/services/pas_scientific_evidence.py create mode 100644 app/services/scientific_evidence.py create mode 100644 docs/scientific_evidence_loop.md create mode 100644 tests/fixtures/scientific_evidence.py create mode 100644 tests/test_closed_loop_drift.py create mode 100644 tests/test_hypothesis_experiment_planner.py create mode 100644 tests/test_pas_scientific_evidence.py create mode 100644 tests/test_scientific_evidence.py create mode 100644 tests/test_scientific_evidence_contract.py create mode 100644 tests/test_scientific_evidence_ledger.py create mode 100644 uv.lock 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 fd5bcfe..e3089ec 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ docs/* !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 @@ -103,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 30fe463..394f696 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ The live path is conservative by design: rule-based, auditable, and bounded by e - **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. @@ -81,6 +82,10 @@ 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 @@ -102,6 +107,8 @@ The read-only API exposes: 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 @@ -110,7 +117,7 @@ RLVR JSONL is generated from the typed `decision_trajectories` store, not by scr |---------|----------------|------------------------| | **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` | 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 78c8d88..7563483 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -125,6 +125,34 @@ def __init__(self) -> None: # 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") @@ -148,6 +176,14 @@ def __init__(self) -> None: "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. 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/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/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/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 index 442e638..3bb69bc 100644 --- a/app/services/scientific_ledger.py +++ b/app/services/scientific_ledger.py @@ -35,6 +35,13 @@ ) 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. @@ -144,6 +151,84 @@ def record_completed( 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, @@ -692,6 +777,193 @@ def _table(value: Any) -> str: 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", 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/docs/development_progress.md b/docs/development_progress.md index 816aab4..e2c05dc 100644 --- a/docs/development_progress.md +++ b/docs/development_progress.md @@ -49,6 +49,11 @@ Legend: **not started** · **partial** (some infra exists, not wired/proven). 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 @@ -68,7 +73,7 @@ objective/space changes. | # | 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 | 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_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/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_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_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_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/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" }, +]