diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index f4cccc8cd..a521f91f5 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -163,8 +163,11 @@ jobs: - name: Install Rust toolchain run: | set -euo pipefail - rustup toolchain install stable --profile minimal --component rustfmt,clippy - rustup default stable + # rust-toolchain.toml (repo root) pins 1.97.1 and overrides a floating + # stable default. Install rustfmt/clippy on that pin so cargo fmt and + # clippy do not fail with "cargo-fmt is not installed for 1.97.1". + rustup show + rustup component add rustfmt clippy rustc --version cargo --version diff --git a/contextual_orchestrator/batch_routing.py b/contextual_orchestrator/batch_routing.py index 45c12b3cf..7b21ee1af 100644 --- a/contextual_orchestrator/batch_routing.py +++ b/contextual_orchestrator/batch_routing.py @@ -1347,18 +1347,23 @@ def _publish_terminal( self._errors[job_id] = error self._states[job_id] = status - def wait(self, job: BatchJob, *, timeout: float) -> Dict[str, Any]: - """Wait within the caller's explicit deadline for a terminal state. - - ``timeout`` may be ``float("inf")`` when the caller has no wall-clock - deadline (contextual-orchestrator's no-implicit-deadline default); - ``threading.Event.wait`` raises ``OverflowError`` for a non-finite - timeout on CPython, so a non-finite value is translated to ``None`` - (block indefinitely) rather than passed through. + def wait(self, job: BatchJob, *, timeout: float | None) -> Dict[str, Any]: + """Wait for a terminal state, bounded only when the caller sets a deadline. + + ``timeout`` may be ``None`` or ``float("inf")`` when the caller has no + wall-clock deadline (contextual-orchestrator's no-implicit-deadline + default). ``threading.Event.wait`` raises ``OverflowError`` for a + non-finite timeout on CPython, so ``None`` and non-finite values are + translated to an unbounded wait rather than being passed through. """ event = self._terminal_events.get(job.job_id) if event is not None: - event.wait(timeout=timeout if math.isfinite(timeout) else None) + wait_timeout = ( + None + if timeout is None or not math.isfinite(timeout) + else timeout + ) + event.wait(timeout=wait_timeout) return self.poll(job) def poll(self, job: BatchJob) -> Dict[str, Any]: diff --git a/contextual_orchestrator/model_group.py b/contextual_orchestrator/model_group.py index 590a2e2fa..9ebe85e38 100644 --- a/contextual_orchestrator/model_group.py +++ b/contextual_orchestrator/model_group.py @@ -23,11 +23,19 @@ degenerates gracefully -- members without any observation share one identical neutral score, so ordering falls back to the caller's static ranking until real evidence exists. +- **Live selection** (:meth:`ModelGroupRouter.sampled_ranked_member_ids`) draws + one Thompson sample (Thompson, 1933) per member from its own Beta(alpha, + beta) posterior instead of comparing the posterior mean, so traffic keeps + probabilistically exploring every credible member in proportion to + remaining uncertainty rather than concentrating on whichever member + currently leads the point estimate (Chapelle & Li, 2011; Agrawal & Goyal, + 2012). Admin/report reads keep the deterministic mean-based order. """ from __future__ import annotations import math +import random import re import threading import time @@ -84,6 +92,7 @@ def __init__( min_latency_seconds: float = MIN_ROUTING_LATENCY_SECONDS, prior_resolver: Callable[[str], tuple[float, float]] | None = None, clock: Callable[[], float] = time.monotonic, + rng: random.Random | None = None, ) -> None: if not 0 < ewma_gain <= 1: raise ValueError("ewma_gain must be within (0, 1]") @@ -93,6 +102,11 @@ def __init__( self._min_latency_seconds = float(min_latency_seconds) self._prior_resolver = prior_resolver self._clock = clock + # A literal default (`rng: random.Random = random.Random()`) would be + # evaluated once at def time -- one shared, unsynchronized instance + # reused by every router built without an explicit rng=. The + # None-sentinel gives each router its own private generator instead. + self._rng = rng if rng is not None else random.Random() self._lock = threading.Lock() # member_id -> {"alpha", "beta", "ewma", "ewma_tps"}; ewma/ewma_tps are # None until the first observation of each kind arrives. @@ -274,18 +288,64 @@ def member_score(self, member_id: str) -> float: def member_observation_count(self, member_id: str) -> int: """Total completed attempts recorded for one member (success + failure).""" with self._lock: - state = self._members.get(member_id) - if state is None: - return 0 - alpha = float(state["alpha"]) - float(state.get("prior_alpha", BETA_PRIOR_SUCCESS_COUNT)) - beta = float(state["beta"]) - float(state.get("prior_beta", BETA_PRIOR_FAILURE_COUNT)) - return int(max(alpha, 0.0)) + int(max(beta, 0.0)) + return self._observation_count_locked(member_id) def ranked_member_ids(self, member_ids: list[str] | tuple[str, ...]) -> list[str]: """Order member ids best-first by measured score, preserving input ties.""" scored = {member_id: self.member_score(member_id) for member_id in member_ids} return sorted(member_ids, key=lambda member_id: -scored[member_id]) + def sampled_ranked_member_ids( + self, member_ids: list[str] | tuple[str, ...] + ) -> list[str]: + """Order member ids best-first by one live Thompson-sampled draw per member. + + Same physical quantity as :meth:`ranked_member_ids` -- P(success | data) + divided by EWMA latency -- except a member with at least one real + observation (:meth:`member_observation_count` > 0) contributes one draw + from its own Beta(alpha, beta) posterior (Thompson, 1933) instead of the + posterior mean, so a group's traffic keeps probabilistically exploring + every credible member in proportion to genuine remaining uncertainty + rather than letting whichever member currently leads the point estimate + absorb all subsequent traffic -- the correct, intended behavior at the + thin per-member observation counts this free-tier gateway actually runs + at (Chapelle & Li, 2011; Agrawal & Goyal, 2012). + + A member still at the shared prior (no real observation yet) keeps its + exact :data:`UNOBSERVED_MEMBER_SCORE`, unsampled -- the "no evidence -> + caller's static order survives untouched" contract holds identically to + :meth:`ranked_member_ids`. + + Uses this router's own injected ``rng`` -- a plain, non-cryptographic + :class:`random.Random` (a routing weight, not a security boundary). + """ + scored: dict[str, float] = {} + with self._lock: + for member_id in member_ids: + state = self._members.get(member_id) + if state is None or self._observation_count_locked(member_id) == 0: + scored[member_id] = UNOBSERVED_MEMBER_SCORE + continue + alpha = float(state["alpha"]) + beta = float(state["beta"]) + stability_sample = ( + self._rng.betavariate(alpha, beta) + if alpha > 0.0 and beta > 0.0 + # Degenerate Beta shape (an operator/prior_resolver + # pseudo-count of exactly 0.0 -- update_prior already + # permits this): the distribution collapses to a point + # mass, so fall back to the same closed-form mean + # _score_locked uses, instead of raising out of + # betavariate() (`ValueError: alpha and beta must be > 0`). + else alpha / (alpha + beta) + ) + ewma = state["ewma"] + latency = ( + 1.0 if ewma is None else max(float(ewma), self._min_latency_seconds) + ) + scored[member_id] = stability_sample / latency + return sorted(member_ids, key=lambda member_id: -scored[member_id]) + def member_report(self, member_id: str) -> dict[str, float | int | None]: """One member's measured evidence row for admin/analytics surfaces.""" with self._lock: @@ -301,6 +361,25 @@ def snapshot(self) -> dict[str, dict[str, float | int | None]]: def _ensure_locked(self, member_id: str) -> dict[str, float | None]: return self._members.setdefault(member_id, self._blank_state(member_id)) + @staticmethod + def _outcome_count(total: float, prior: float) -> int: + """Recover an integer observed-outcome count from floating prior mass.""" + return int(round(max(total - prior, 0.0))) + + def _observation_count_locked(self, member_id: str) -> int: + state = self._members.get(member_id) + if state is None: + return 0 + alpha = self._outcome_count( + float(state["alpha"]), + float(state.get("prior_alpha", BETA_PRIOR_SUCCESS_COUNT)), + ) + beta = self._outcome_count( + float(state["beta"]), + float(state.get("prior_beta", BETA_PRIOR_FAILURE_COUNT)), + ) + return alpha + beta + def _score_locked(self, member_id: str) -> float: state = self._members.get(member_id) if state is None: @@ -342,7 +421,13 @@ def _report_locked(self, member_id: str) -> dict[str, float | int | None]: "max_observed_rpm": self._max_observed_rpm.get(member_id, 0), "max_observed_tpm": self._max_observed_tpm.get(member_id, 0), "rate_observation_window_seconds": int(RATE_OBSERVATION_WINDOW_SECONDS), - "success_count": int(alpha - float(state.get("prior_alpha", BETA_PRIOR_SUCCESS_COUNT))), - "failure_count": int(beta - float(state.get("prior_beta", BETA_PRIOR_FAILURE_COUNT))), + "success_count": self._outcome_count( + alpha, + float(state.get("prior_alpha", BETA_PRIOR_SUCCESS_COUNT)), + ), + "failure_count": self._outcome_count( + beta, + float(state.get("prior_beta", BETA_PRIOR_FAILURE_COUNT)), + ), "score": round(self._score_locked(member_id), 9), } diff --git a/contextual_orchestrator/openrouter_uptime.py b/contextual_orchestrator/openrouter_uptime.py index 5cafd7abf..89bf11cac 100644 --- a/contextual_orchestrator/openrouter_uptime.py +++ b/contextual_orchestrator/openrouter_uptime.py @@ -21,8 +21,11 @@ import urllib.request from typing import TYPE_CHECKING -from .benchmark_priors import resolve_quality_prior -from .model_group import ModelGroupRouter +from .model_group import ( + BETA_PRIOR_FAILURE_COUNT, + BETA_PRIOR_SUCCESS_COUNT, + ModelGroupRouter, +) if TYPE_CHECKING: from .orchestrator import ModelAgent @@ -112,65 +115,71 @@ def _run_loop(self) -> None: break def _poll_agent(self, agent: ModelAgent) -> None: - """Fold one endpoint measurement into ledgers as window evidence.""" + """Fold one endpoint measurement into the transport ledger only. + + Availability is not answer quality: the quality ledger stays untouched + so judged accuracy evidence cannot be displaced by uptime priors. + """ if agent.provider_name != "openrouter": return uptime = self._fetch_uptime(agent.model) if uptime is None: return - successes = max(0.0, min(1.0, uptime / 100.0)) + successes = uptime / 100.0 failures = 1.0 - successes - base_alpha, base_beta = resolve_quality_prior(agent.id) prev_alpha, prev_beta = self._window_evidence.get(agent.id, (0.0, 0.0)) next_alpha = prev_alpha + successes next_beta = prev_beta + failures self._window_evidence[agent.id] = (next_alpha, next_beta) - self._apply_to_routers( + self._group_router.update_prior( agent.id, - base_alpha + next_alpha, - base_beta + next_beta, + BETA_PRIOR_SUCCESS_COUNT + next_alpha, + BETA_PRIOR_FAILURE_COUNT + next_beta, ) - def _apply_to_routers( - self, - member_id: str, - alpha: float, - beta: float, - ) -> None: - """Publish one member's blended prior into both ledgers.""" - self._group_router.update_prior(member_id, alpha, beta) - self._quality_router.update_prior(member_id, alpha, beta) - def _fetch_uptime(self, model_id: str) -> float | None: """Fetch best-endpoint 30-minute availability for one logical model. Args: - model_id: Discovery-sourced logical model identifier. + model_id: Discovery-sourced ``author/slug`` model identifier. Returns: - The highest reported endpoint uptime in ``[0, 100]``, or - ``None`` when the provider response cannot yield one. + The highest finite numeric endpoint uptime in ``[0, 100]``, or + ``None`` when any supplied percentage is invalid or none exists. + Null/missing measurements are absent, not observed failures. """ - segment = urllib.parse.quote(model_id, safe="") - url = f"{_OPENROUTER_UPTIME_ORIGIN}/models/{segment}/endpoints" + model_parts = model_id.split("/") + if len(model_parts) != 2 or any(part in {"", ".", ".."} for part in model_parts): + return None + model_path = "/".join(urllib.parse.quote(part, safe="") for part in model_parts) + url = f"{_OPENROUTER_UPTIME_ORIGIN}/models/{model_path}/endpoints" request = urllib.request.Request(url, method="GET") try: - # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected - scheme/host is the fixed constant origin; model_id is percent-encoded before interpolation and never reaches the scheme/authority. + # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected - scheme/host is the fixed constant origin; author and slug are separately percent-encoded and cannot reach the scheme/authority. with urllib.request.urlopen( request, timeout=_UPTIME_FETCH_TIMEOUT_SECONDS ) as response: payload = json.loads(response.read().decode("utf-8")) endpoints = payload.get("data", {}).get("endpoints", []) uptimes = [ - float(endpoint["uptime_last_30m"]) + endpoint["uptime_last_30m"] for endpoint in endpoints if isinstance(endpoint, dict) and endpoint.get("uptime_last_30m") is not None ] + # Validate before aggregation: coercion or clamping can turn + # booleans, NaN, or out-of-range values into availability mass. + if any( + type(value) not in (int, float) or not 0 <= value <= 100 + for value in uptimes + ): + raise ValueError( + "endpoint uptime must be a numeric percentage in [0, 100]" + ) if uptimes: # Provider routes to its strongest upstream, so the # observed maximum reflects delivered reliability. - return max(uptimes) + return float(max(uptimes)) except ( AttributeError, KeyError, diff --git a/contextual_orchestrator/orchestrator.py b/contextual_orchestrator/orchestrator.py index 6b7a69d80..2aa00208b 100644 --- a/contextual_orchestrator/orchestrator.py +++ b/contextual_orchestrator/orchestrator.py @@ -5269,15 +5269,17 @@ def load_decision_window(self, limit: int = 256) -> dict[str, Any]: phases = [] diagnostics = [] if request_ids: + # placeholders are only literal "?" markers; request_ids bind as + # parameters and never enter the SQL text (Semgrep IN-clause FP). placeholders = ",".join("?" for _ in request_ids) - phases = self._conn.execute( + phases = self._conn.execute( # nosemgrep: python.sqlalchemy.security.sqlalchemy-execute-raw-query.sqlalchemy-execute-raw-query "SELECT kind, key, payload FROM orchestration_records " "WHERE kind IN ('initial_decision', 'decision_receipt') " "AND key IN (" + placeholders + ") " "ORDER BY seq DESC LIMIT ?", (*request_ids, 2 * limit + 1), ).fetchall() - diagnostics = self._conn.execute( + diagnostics = self._conn.execute( # nosemgrep: python.sqlalchemy.security.sqlalchemy-execute-raw-query.sqlalchemy-execute-raw-query "SELECT kind, key, payload FROM orchestration_records " "WHERE kind IN ('provider_dispatch', 'auxiliary_dispatch') " "AND key IN (" + placeholders + ") ORDER BY seq DESC LIMIT ?", @@ -9969,17 +9971,27 @@ def _refine_partition(self, partition: list[ModelAgent], role: str) -> list[Mode for sub_partition in (eligible, excluded): by_id = {member.id: member for member in sub_partition} ordered.extend( - by_id[member_id] for member_id in self._measured_member_order(list(by_id)) + by_id[member_id] + for member_id in self._measured_member_order(list(by_id), sample=True) ) return ordered - def _measured_member_order(self, member_ids: list[str]) -> list[str]: + def _measured_member_order( + self, member_ids: list[str], *, sample: bool = False + ) -> list[str]: """Order same-declaration members by measured evidence, quality first. Evidence ladder: judged-answer observations (real-time fast-mlsirm verdicts) govern when any member has them; otherwise the transport throughput/stability ledger decides; with no evidence at all the caller's input order survives untouched. No synthetic scores. + + ``sample=True`` is for live serving selection only: it asks the chosen + router for one Thompson-sampled draw per member with real evidence + (:meth:`ModelGroupRouter.sampled_ranked_member_ids`) instead of + comparing posterior means. The default ``sample=False`` is the exact + prior deterministic behavior, kept for every admin/report reader + (:meth:`get_model_group`) unchanged. """ judged_quality = any( self._quality_router.member_observation_count(member_id) > 0 @@ -9994,6 +10006,8 @@ def _measured_member_order(self, member_ids: list[str]) -> list[str]: judged_quality, router.member_score(member_id), ) + if sample: + return router.sampled_ranked_member_ids(member_ids) return router.ranked_member_ids(member_ids) def _psychometric_order( diff --git a/docs/papers/README.md b/docs/papers/README.md index 8d49c0ea6..7f8450ee3 100644 --- a/docs/papers/README.md +++ b/docs/papers/README.md @@ -145,6 +145,18 @@ redistribution is unclear. (arXiv:2512.04388). https://arxiv.org/abs/2512.04388 Grounds workflow steps, recursion depth, decomposition, and access-list scope as first-class ablation factors. +- Zhang, A. L., Kraska, T., & Khattab, O. (2026). *Recursive language models* + (arXiv:2512.24601, Version 3). https://arxiv.org/abs/2512.24601 + Cited by the request-partitioning ADR and doctoring receipts as external + motivation for bounded ownership of long inputs. Citation only; this is not + a reproduction of RLM experiments. +- Thompson, W. R. (1933). On the likelihood that one unknown probability + exceeds another in view of the evidence of two samples. *Biometrika, + 25*(3–4), 285–294. https://doi.org/10.1093/biomet/25.3-4.285 + Grounds posterior probability-matching for live model-group member + selection. See + [Thompson-sampling routing traceability](thompson-sampling-model-group-routing.md). + Citation only; PDF not vendored. - Baker, F. B. (2001). *The basics of item response theory* (2nd ed.). ERIC Clearinghouse on Assessment and Evaluation. https://eric.ed.gov/?id=ED458219 @@ -286,6 +298,7 @@ that the papers were fully reviewed, their claims reproduced, or PDFs licensed. | 2506.22316 | [Polytomous judge benchmark](../benchmarks/2026-08-11-polytomous-llm-judge.md) and [judge calibration ADR](../planning/adrs/0006-polytomous-llm-judge-bias-calibration.md) | | 2110.15150 | [Purpose-limited protection ADR](../planning/adrs/0028-purpose-limited-pii-protection.md) | | 2601.17814 | [Model-group specification](../model-group-product-technical-spec.md) and [free-pool admission research](../research/review-gateway-free-pool-admission.md) | +| 2512.24601 | [Request partitioning ADR](../planning/adrs/2026-09-10-request-partitioning.md) and [learned-policy authority receipt](../doctoring/learned_policy_authority_20260910.md) | Scope: explicit arXiv URL, colon, and DOI-style identifiers in tracked Python, Rust, Markdown, and TOML files. This is a discovery census, not a complete @@ -406,6 +419,7 @@ Case and sentence-final punctuation are normalized by the inventory test. - [DOI 10.1007/s11336-021-09762-5](https://doi.org/10.1007/s11336-021-09762-5) - [DOI 10.1017/psy.2025.5](https://doi.org/10.1017/psy.2025.5) - [DOI 10.1037/0003-066X.50.9.741](https://doi.org/10.1037/0003-066X.50.9.741) +- [DOI 10.1093/biomet/25.3-4.285](https://doi.org/10.1093/biomet/25.3-4.285) - [DOI 10.1093/biomet/39.3-4.324](https://doi.org/10.1093/biomet/39.3-4.324) - [DOI 10.1097/01.yco.0000170421.57227.9b](https://doi.org/10.1097/01.yco.0000170421.57227.9b) - [DOI 10.1109/IAS.2007.29](https://doi.org/10.1109/IAS.2007.29) @@ -430,6 +444,7 @@ Case and sentence-final punctuation are normalized by the inventory test. - [arXiv 2406.18665 DOI](https://doi.org/10.48550/arXiv.2406.18665) - [arXiv 2512.04388 DOI](https://doi.org/10.48550/arXiv.2512.04388) - [arXiv 2512.04695 DOI](https://doi.org/10.48550/arXiv.2512.04695) +- [arXiv 2512.24601 DOI](https://doi.org/10.48550/arXiv.2512.24601) - [arXiv 2601.17814 DOI](https://doi.org/10.48550/arXiv.2601.17814) - [arXiv 2606.21228 DOI](https://doi.org/10.48550/arXiv.2606.21228) - [arXiv 2608.06867 DOI](https://doi.org/10.48550/arXiv.2608.06867) diff --git a/docs/papers/thompson-sampling-model-group-routing.md b/docs/papers/thompson-sampling-model-group-routing.md new file mode 100644 index 000000000..85eb56ace --- /dev/null +++ b/docs/papers/thompson-sampling-model-group-routing.md @@ -0,0 +1,36 @@ +# Thompson sampling for model-group routing + +Status: research traceability for `ModelGroupRouter.sampled_ranked_member_ids` on PR #1034. + +## Decision boundary + +Live serving uses posterior sampling to avoid deterministic winner-take-all selection after measured outcomes exist. Administrative and reporting reads remain deterministic. The implementation draws from each observed member's Beta posterior and combines that draw with the repository's measured latency term; it does not change the stored posterior or fabricate observations. + +The cited literature supports the probability-matching / exploration-exploitation mechanism, but its guarantees must not be overstated. Thompson (1933) supplies the original posterior probability-matching construction. Chapelle and Li (2011) provide empirical evidence that Thompson sampling is a competitive baseline on simulated and real bandit data. Agrawal and Goyal (2012) prove logarithmic expected regret for stochastic Bernoulli multi-armed bandits under their analyzed Thompson-sampling setting. + +`contextual-orchestrator` does **not** implement exactly the reward model in Agrawal and Goyal (2012): routing ranks a Beta stability sample divided by an EWMA latency estimate. Therefore the COLT regret bound is supporting evidence for posterior sampling of Bernoulli stability, not a proof of regret or optimality for the repository's composite successful-responses-per-second score. Any such claim requires a separate model and acceptance experiment. + +## Code traceability + +| Research concept | Repository implementation | Acceptance evidence | +|---|---|---| +| Posterior probability matching | `contextual_orchestrator/model_group.py::ModelGroupRouter.sampled_ranked_member_ids` | Seeded sampling tests in `tests/test_model_group.py` | +| Bernoulli success/failure posterior | `observe_success`, `observe_failure`, per-member `alpha` / `beta` | Existing model-group posterior/report tests | +| Deterministic reporting vs stochastic live selection | `ranked_member_ids` vs `sampled_ranked_member_ids`; `_measured_member_order(sample=...)` | Admin/report callers keep `sample=False`; serving refinement requests `sample=True` | +| Integer completed-outcome invariant under fractional prior refresh | `_outcome_count` shared by live observation-count and report paths | RED `c8223a3e4f10c2c48396359abdbad7520b84e920`; GREEN `40a5cc41e4e10525e6fdb73f4a7b19682a083e24` | + +## Risks and follow-up acceptance + +- The theory cited here does not validate the latency-normalized composite score. Measure routing regret / successful responses per second on right-cleared traffic or an approved replay before making an optimality claim. +- Members with no real outcomes currently retain the repository's neutral static score rather than receiving a sampled prior draw. This preserves the existing no-evidence ordering contract but is a repository-specific cold-start policy, not a consequence of the cited Thompson-sampling theory. A future change must test starvation and first-observation acquisition explicitly. +- Prior pseudo-counts may be fractional while completed outcomes are integral. Binary floating-point subtraction can drift below an integer; PR #1034 therefore recovers the domain-invariant completed-outcome count before deciding whether a member is observed. + +PDFs are not vendored solely for this change because redistribution permission is not assumed. The primary publication pages below are the traceable sources. + +## References + +Agrawal, S., & Goyal, N. (2012). Analysis of Thompson sampling for the multi-armed bandit problem. In S. Mannor, N. Srebro, & R. C. Williamson (Eds.), *Proceedings of the 25th Annual Conference on Learning Theory* (Vol. 23, pp. 39.1–39.26). Proceedings of Machine Learning Research. https://proceedings.mlr.press/v23/agrawal12.html + +Chapelle, O., & Li, L. (2011). An empirical evaluation of Thompson sampling. In J. Shawe-Taylor, R. Zemel, P. Bartlett, F. Pereira, & K. Q. Weinberger (Eds.), *Advances in Neural Information Processing Systems 24*. https://papers.nips.cc/paper/2011/hash/e53a0a2978c28872a4505bdb51db06dc-Abstract.html + +Thompson, W. R. (1933). On the likelihood that one unknown probability exceeds another in view of the evidence of two samples. *Biometrika, 25*(3–4), 285–294. https://doi.org/10.1093/biomet/25.3-4.285 diff --git a/pyproject.toml b/pyproject.toml index ac89e486b..76cd52fd2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ dependencies = [ "opentelemetry-exporter-otlp-proto-http>=1.30.0", "jsonschema>=4.26.0", "egressweave>=0.1.0,<0.2.0", - "fast-mlsirm @ git+https://github.com/ContextualWisdomLab/fast-mlsirm.git@09f762ded35786dd1078222a4577ff09d649816f ; python_full_version >= '3.12'", + "fast-mlsirm==0.11.3", ] [project.optional-dependencies] @@ -19,6 +19,7 @@ test = [ "hypothesis>=6.100", ] api = [ + "anyio==4.14.2", "fastapi>=0.128.0", "uvicorn>=0.38.0", ] diff --git a/requirements.lock b/requirements.lock index 5b66e52fa..6b2aa48fa 100644 --- a/requirements.lock +++ b/requirements.lock @@ -12,10 +12,11 @@ annotated-types==0.7.0 \ --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \ --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89 # via pydantic -anyio==4.14.1 \ - --hash=sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72 \ - --hash=sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e +anyio==4.14.2 \ + --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 \ + --hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f # via + # contextual-orchestrator (pyproject.toml) # httpx # starlette attrs==26.1.0 \ @@ -363,7 +364,21 @@ egressweave==0.1.0 \ --hash=sha256:6bcb07109bdee25a6d49e5516f4c99ecd172ffe8536455d2cac860c44a4492f6 \ --hash=sha256:e1e3a6dbabd4084fb03f19a95931ab96e4beeef96bc8fc7cf0d8e5b91e266057 # via contextual-orchestrator (pyproject.toml) -fast-mlsirm @ git+https://github.com/ContextualWisdomLab/fast-mlsirm.git@09f762ded35786dd1078222a4577ff09d649816f +fast-mlsirm==0.11.3 \ + --hash=sha256:1913579af80be7e6be735820bcde41c426e88e7769892e845e1e1584e60d957e \ + --hash=sha256:1fbc5483b44c5dc39b057f5a8db246eae1394dbebdc6b93eca8ca2f49a9b9ff8 \ + --hash=sha256:2cc2cc3508eef006d67d93e887183e91ec756e26fcf6b72c9c5a6247ba62e90a \ + --hash=sha256:353990c25673ff9f62e49ce03e1c16045c69ffbc06c27c4bd4c2c2a5f7ae344f \ + --hash=sha256:4a6e3aea03cfc93fb450869c8c156987cf6107ba68a72190ae6867aa46dfd8e0 \ + --hash=sha256:4dd7382d5f889c59451a5c12b39c7b8aebb87c91ef696f6cb2b050eb3753d867 \ + --hash=sha256:7017d98c6ee74a6b811e0fd00ef9e0b20faa39a1909ecf130ebec3fe7310501f \ + --hash=sha256:8c6bd1a229b5d06c05cc7ecf6689b255e6c1a9ae135ce38dc266d1a223b16912 \ + --hash=sha256:910ed13b562f81d5905081095be7faa5395ea9c033f988cfd170074a27dba738 \ + --hash=sha256:9470dd55acdca32d95113bdbfe322ac84b554176bd125bfb902c3960e88fd371 \ + --hash=sha256:980e1eb98edef0f0b33c2fbc631005c05fff0aa2892061506428c6abc0310e7e \ + --hash=sha256:9bbb9061aa08711a78898415617967052be041b08968ace6191322dc7fcfcf4f \ + --hash=sha256:dcc15298f404cfead24a3a6b1dd55ca4715fbd5594279566a1834a752322bf2e \ + --hash=sha256:eeb14887a4f20643ea0bf339a06df9ef8eefdbb88f9afdcd49a0792f531f6faa # via contextual-orchestrator (pyproject.toml) fastapi==0.138.2 \ --hash=sha256:6432359d067a432134620e7c5e4c6e5063e7f37815bbbbf20acef14b0d2e3fc8 \ diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 725551148..010f002cf 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,4 @@ [toolchain] channel = "1.97.1" profile = "minimal" +components = ["rustfmt", "clippy"] diff --git a/tests/test_fast_mlsirm_runtime_contract.py b/tests/test_fast_mlsirm_runtime_contract.py index dcf3b5fc6..a1b3439a2 100644 --- a/tests/test_fast_mlsirm_runtime_contract.py +++ b/tests/test_fast_mlsirm_runtime_contract.py @@ -14,8 +14,8 @@ def test_supported_python_floor_matches_fast_mlsirm_runtime() -> None: fast_mlsirm_dependencies = [ dependency for dependency in project_data["dependencies"] - if dependency.startswith("fast-mlsirm ") + if dependency.startswith("fast-mlsirm") ] assert fast_mlsirm_dependencies == [ - "fast-mlsirm @ git+https://github.com/ContextualWisdomLab/fast-mlsirm.git@09f762ded35786dd1078222a4577ff09d649816f ; python_full_version >= '3.12'" + "fast-mlsirm==0.11.3" ] diff --git a/tests/test_model_group.py b/tests/test_model_group.py index 4cd8c36d0..f65fbf7cf 100644 --- a/tests/test_model_group.py +++ b/tests/test_model_group.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import random import pytest @@ -139,6 +140,10 @@ def test_orchestrator_resolves_group_alias_and_reorders_only_its_members() -> No assert canonical_group_name("shared-reasoning-model") == "shared_reasoning_model" assert orchestrator._requested_agent("shared-reasoning-model") == high + # Live serving now samples from each measured member's posterior + # (sampled_ranked_member_ids); a fixed seed keeps this assertion + # deterministic while still exercising the sampled path. + orchestrator._group_router._rng = random.Random(0) orchestrator._group_router.observe_failure(high.id) orchestrator._group_router.observe_success(low.id, 0.1) assert orchestrator._requested_agent("shared_reasoning_model") == low @@ -271,6 +276,7 @@ def test_explicit_group_alias_routes_plain_completion_to_measured_member() -> No first = ModelAgent("provider_one_model", "vendor-one/model-a", group_name="shared_reasoning_model") second = ModelAgent("provider_two_model", "vendor-two/model-b", group_name="shared_reasoning_model") orchestrator = TaskOrchestrator([first, second]) + orchestrator._group_router._rng = random.Random(0) orchestrator._group_router.observe_failure(first.id) orchestrator._group_router.observe_success(second.id, 0.1) @@ -340,7 +346,64 @@ def test_group_selects_measured_member_for_every_model_capability(capability: st first = ModelAgent("first_member", "provider/first", tags=(tag,), group_name="shared_model") second = ModelAgent("second_member", "provider/second", tags=(tag,), group_name="shared_model") orchestrator = TaskOrchestrator([first, second]) + orchestrator._group_router._rng = random.Random(0) orchestrator._group_router.observe_failure(first.id) orchestrator._group_router.observe_success(second.id, 0.1) assert orchestrator.select_capability_agent(capability, "shared-model") == second + + +def test_sampled_ranking_leaves_unobserved_members_at_the_shared_prior_score() -> None: + """Neither member has a real observation, so both stay at the identical + ``UNOBSERVED_MEMBER_SCORE`` -- unsampled -- and the caller's input order + survives untouched, exactly like ``ranked_member_ids``. + """ + router = ModelGroupRouter(rng=random.Random(0)) + assert router.sampled_ranked_member_ids(["member_b", "member_a"]) == [ + "member_b", + "member_a", + ] + + +def test_sampled_ranking_falls_back_to_the_posterior_mean_for_a_degenerate_zero_prior() -> None: + """``update_prior`` permits ``prior_alpha``/``prior_beta`` == 0.0. A member + whose resulting ``alpha`` (or ``beta``) lands at exactly 0.0 with a real + observation recorded must not raise out of ``random.betavariate(0.0, x)`` + (``ValueError: alpha and beta must be > 0``); it falls back to the same + closed-form posterior mean ``_score_locked`` uses. + """ + router = ModelGroupRouter(rng=random.Random(0)) + router.register_member("degenerate_member") + router.update_prior("degenerate_member", 0.0, 0.0) + router.observe_failure("degenerate_member") # alpha stays 0.0, beta becomes 1.0 + + report = router.member_report("degenerate_member") + assert report["success_count"] == 0 and report["failure_count"] == 1 + assert report["success_posterior_mean"] == 0.0 + + # Posterior mean 0.0 ranks strictly below an unobserved sibling's neutral + # UNOBSERVED_MEMBER_SCORE (0.5) -- proof the fallback ran instead of + # raising or silently dropping the member. + ranked = router.sampled_ranked_member_ids(["degenerate_member", "unobserved_sibling"]) + assert ranked == ["unobserved_sibling", "degenerate_member"] + + +def test_sampled_ranking_prefers_but_does_not_monopolize_the_better_member() -> None: + """Authoritative statistical contract for :meth:`ModelGroupRouter.sampled_ranked_member_ids`. + + With one observation each (Beta(1, 2) for the loser, Beta(2, 1) for the + winner, equal latency), the true win probability for the better member is + exactly 5/6 ~= 0.833 (Thompson, 1933; the early-stage exploration behavior + Agrawal & Goyal 2012 prove drives Thompson Sampling's near-optimal regret). + 0.65 leaves a wide margin against a false failure while still catching a + broken (e.g. uniform-random or reverted-to-argmax) implementation. + """ + router = ModelGroupRouter(rng=random.Random(100)) + router.observe_failure("loser") # alpha=1, beta=2 + router.observe_success("winner", 1.0) # alpha=2, beta=1; same latency floor as loser's + wins = sum( + router.sampled_ranked_member_ids(["loser", "winner"])[0] == "winner" + for _ in range(200) + ) + assert wins / 200 >= 0.65 + assert wins < 200 # exploration, not permanent monopolization diff --git a/tests/test_multimodal_model_group_http.py b/tests/test_multimodal_model_group_http.py index 4474ce40f..3c9d20e07 100644 --- a/tests/test_multimodal_model_group_http.py +++ b/tests/test_multimodal_model_group_http.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import random import threading import time import urllib.error @@ -100,6 +101,7 @@ def test_json_capability_endpoints_use_measured_group_member( first = ModelAgent("first_member", "provider/first", tags=(capability,), group_name="media_group") second = ModelAgent("second_member", "provider/second", tags=(capability,), group_name="media_group") orchestrator = TaskOrchestrator([first, second]) + orchestrator._group_router._rng = random.Random(0) orchestrator._group_router.observe_failure(first.id) orchestrator._group_router.observe_success(second.id, 0.1) server = build_server(orchestrator, port=0, security=SecurityConfig(auth_token=TOKEN)) @@ -196,6 +198,7 @@ def test_video_poll_and_content_use_the_submission_provider() -> None: "second_video", "provider/second", tags=("video",), group_name="video_group" ) orchestrator = TaskOrchestrator([first, second]) + orchestrator._group_router._rng = random.Random(0) orchestrator._group_router.observe_failure(first.id) orchestrator._group_router.observe_success(second.id, 0.1) followups: list[tuple[str, str]] = [] diff --git a/tests/test_openrouter_uptime.py b/tests/test_openrouter_uptime.py index 0be4ef522..aeb8ef87b 100644 --- a/tests/test_openrouter_uptime.py +++ b/tests/test_openrouter_uptime.py @@ -42,6 +42,7 @@ def _collectors(uptime: float | None): collector = OpenRouterUptimeCollector( _agents(), group_router, + quality_router, interval_seconds=0.05, startup_delay_seconds=0.05, ) @@ -52,8 +53,9 @@ def _collectors(uptime: float | None): def test_start_without_openrouter_agents_is_inert() -> None: """No openrouter members means no thread and no evidence writes.""" group_router = ModelGroupRouter() + quality_router = ModelGroupRouter() plain = [ModelAgent("general_agent", "mock-planner", tags=("reasoning",))] - collector = OpenRouterUptimeCollector(plain, group_router) + collector = OpenRouterUptimeCollector(plain, group_router, quality_router) collector.start() assert collector.window_evidence("general_agent") == (0.0, 0.0) collector.stop() @@ -213,6 +215,22 @@ def test_update_prior_contract_preserves_observation_counts() -> None: assert after["failure_count"] == 1 +def test_fractional_prior_refresh_preserves_single_observation_exactly() -> None: + """Float drift in prior replacement must not erase one completed outcome.""" + router = ModelGroupRouter() + router.register_member("member_c") + router.observe_success("member_c", 0.2) + + # 2.0 + (0.4 - 1.0) - 0.4 is 0.9999999999999999 in binary float. + # The domain invariant is still exactly one completed Bernoulli outcome. + router.update_prior("member_c", 0.4, 1.0) + + report = router.member_report("member_c") + assert report["success_count"] == 1 + assert report["failure_count"] == 0 + assert router.member_observation_count("member_c") == 1 + + def test_update_prior_rejects_invalid_components() -> None: """Negative or non-finite prior components are rejected outright.""" import pytest @@ -282,7 +300,8 @@ def test_transport_refresh_does_not_import_answer_benchmark_prior(monkeypatch): for router in (group_router, reference): router.observe_success(agent.id, 1.0) router.observe_failure(agent.id) - collector = OpenRouterUptimeCollector([agent], group_router) + quality_router = ModelGroupRouter() + collector = OpenRouterUptimeCollector([agent], group_router, quality_router) collector._fetch_uptime = lambda _model: 100.0 collector._poll_agent(agent) reference.update_prior(agent.id, 2.0, 1.0) @@ -305,11 +324,12 @@ def test_invalid_endpoint_percentage_cannot_update_evidence(monkeypatch, raw_val http_response = BytesIO(response_body) monkeypatch.setattr(uptime_module.urllib.request, "urlopen", lambda *_args, **_kwargs: http_response) group_router = ModelGroupRouter() + quality_router = ModelGroupRouter() agent = _agents()[0] group_router.observe_success(agent.id, 0.2) group_router.observe_failure(agent.id) report_before = group_router.snapshot() - collector = OpenRouterUptimeCollector([agent], group_router) + collector = OpenRouterUptimeCollector([agent], group_router, quality_router) collector._poll_agent(agent) @@ -332,8 +352,9 @@ def test_endpoint_percentage_parsing_preserves_valid_and_absent_values(monkeypat http_response = BytesIO(('{"data":{"endpoints":' + raw_endpoints + '}}').encode()) monkeypatch.setattr(uptime_module.urllib.request, "urlopen", lambda *_args, **_kwargs: http_response) group_router = ModelGroupRouter() + quality_router = ModelGroupRouter() agent = _agents()[0] - collector = OpenRouterUptimeCollector([agent], group_router) + collector = OpenRouterUptimeCollector([agent], group_router, quality_router) collector._poll_agent(agent) @@ -362,7 +383,9 @@ def checked_open(request, *, timeout): return http_response monkeypatch.setattr(uptime_module.urllib.request, "urlopen", checked_open) - collector = OpenRouterUptimeCollector([], ModelGroupRouter()) + collector = OpenRouterUptimeCollector( + [], ModelGroupRouter(), ModelGroupRouter() + ) assert collector._fetch_uptime(model_id) == 99.5 assert http_response.closed @@ -379,7 +402,9 @@ def reject_open(*_args, **_kwargs): pytest.fail("malformed model ID reached transport") monkeypatch.setattr(uptime_module.urllib.request, "urlopen", reject_open) - collector = OpenRouterUptimeCollector([], ModelGroupRouter()) + collector = OpenRouterUptimeCollector( + [], ModelGroupRouter(), ModelGroupRouter() + ) assert collector._fetch_uptime(model_id) is None @@ -391,5 +416,6 @@ def reject_open(*_args, **_kwargs): test_unavailable_uptime_poll_is_a_no_op() test_background_loop_accumulates_and_stop_joins() test_update_prior_contract_preserves_observation_counts() + test_fractional_prior_refresh_preserves_single_observation_exactly() test_update_prior_rejects_invalid_components() print("ok") diff --git a/tests/test_provider_embedding_batch_backend.py b/tests/test_provider_embedding_batch_backend.py index 59a42959c..91439b0a6 100644 --- a/tests/test_provider_embedding_batch_backend.py +++ b/tests/test_provider_embedding_batch_backend.py @@ -20,7 +20,9 @@ ProviderEmbeddingBatchBackend, _DaemonWorkerPool, ) -from contextual_orchestrator.cost_router import _DEFAULT_EMBEDDING_CLAIM_LEASE_SECONDS +from contextual_orchestrator.cost_router import ( + _DEFAULT_PROVIDER_EMBEDDING_CLAIM_LEASE_SECONDS, +) from contextual_orchestrator.orchestrator import ModelClient from contextual_orchestrator.provider_errors import ProviderUpstreamError from contextual_orchestrator.server import SecurityConfig, build_server @@ -58,7 +60,10 @@ def test_default_client_keeps_batch_lifecycle_separate_from_model_timeout() -> N backend = coordinator._provider_embedding_backend() - assert backend._execution_timeout_seconds == 604_800 + # Non-durable registries keep claim lease unset; a null client timeout + # must stay a genuinely unbounded execution deadline (not the 7-day + # registry retention window). + assert backend._execution_timeout_seconds is None assert backend._claim_lease_seconds is None backend.close() @@ -593,7 +598,10 @@ def test_durable_provider_embedding_backend_survives_unbounded_client_timeout() coordinator = CostRoutingCoordinator(orchestrator, job_registry=registry) backend = coordinator._embedding_backends["provider"] - assert backend._claim_lease_seconds == _DEFAULT_EMBEDDING_CLAIM_LEASE_SECONDS + assert ( + backend._claim_lease_seconds + == _DEFAULT_PROVIDER_EMBEDDING_CLAIM_LEASE_SECONDS + ) backend.close() diff --git a/uv.lock b/uv.lock index a32c71f81..69460a029 100644 --- a/uv.lock +++ b/uv.lock @@ -328,6 +328,7 @@ dependencies = [ [package.optional-dependencies] api = [ + { name = "anyio" }, { name = "fastapi" }, { name = "uvicorn" }, ] @@ -360,10 +361,11 @@ native-build = [ [package.metadata] requires-dist = [ { name = "alembic", marker = "extra == 'db'", specifier = ">=1.17" }, + { name = "anyio", marker = "extra == 'api'", specifier = "==4.14.2" }, { name = "atheris", marker = "python_full_version >= '3.12' and extra == 'fuzz'", specifier = "==3.1.0" }, { name = "cryptography", specifier = ">=43.0" }, { name = "egressweave", specifier = ">=0.1.0,<0.2.0" }, - { name = "fast-mlsirm", marker = "python_full_version >= '3.12'", git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=09f762ded35786dd1078222a4577ff09d649816f" }, + { name = "fast-mlsirm", specifier = "==0.11.3" }, { name = "fastapi", marker = "extra == 'api'", specifier = ">=0.128.0" }, { name = "greenlet", marker = "extra == 'db'", specifier = ">=3.2" }, { name = "hypothesis", marker = "extra == 'test'", specifier = ">=6.100" }, @@ -460,11 +462,27 @@ wheels = [ [[package]] name = "fast-mlsirm" -version = "0.9.1" -source = { git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=09f762ded35786dd1078222a4577ff09d649816f#09f762ded35786dd1078222a4577ff09d649816f" } +version = "0.11.3" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/2b/89/04d9c55e5854afde1a20b2e3f907a3da4c50716255bc5e65118767fdd9be/fast_mlsirm-0.11.3.tar.gz", hash = "sha256:1fbc5483b44c5dc39b057f5a8db246eae1394dbebdc6b93eca8ca2f49a9b9ff8", size = 1538857, upload-time = "2026-09-17T22:20:11.395Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/ad/76451e1a629427d2a0d0451f08aedccd72ac4c451430d6372bcba788a73e/fast_mlsirm-0.11.3-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:1913579af80be7e6be735820bcde41c426e88e7769892e845e1e1584e60d957e", size = 9312053, upload-time = "2026-09-18T09:46:08.824Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8e/28d6fab4ca719049b8e00a892c3ab6d05b335c60e54c9ab17e70aaa55d4e/fast_mlsirm-0.11.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dcc15298f404cfead24a3a6b1dd55ca4715fbd5594279566a1834a752322bf2e", size = 4962980, upload-time = "2026-09-17T22:57:27.17Z" }, + { url = "https://files.pythonhosted.org/packages/42/d3/080417903271cce8237b8cebcd1e79247782d60be6a8c74b4714cab4fe07/fast_mlsirm-0.11.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7017d98c6ee74a6b811e0fd00ef9e0b20faa39a1909ecf130ebec3fe7310501f", size = 5726313, upload-time = "2026-09-18T09:46:10.535Z" }, + { url = "https://files.pythonhosted.org/packages/be/f5/e80f8cdd0f74abd2a86aaa8968914f4b4df72ddc4c030a9f0171bac873ba/fast_mlsirm-0.11.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:980e1eb98edef0f0b33c2fbc631005c05fff0aa2892061506428c6abc0310e7e", size = 5834699, upload-time = "2026-09-18T09:46:11.821Z" }, + { url = "https://files.pythonhosted.org/packages/64/97/3c5f21dc58ad260d63b92036c064ecd21d26d469c1123940daaaabdda027/fast_mlsirm-0.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:9bbb9061aa08711a78898415617967052be041b08968ace6191322dc7fcfcf4f", size = 5858112, upload-time = "2026-09-18T09:46:14.148Z" }, + { url = "https://files.pythonhosted.org/packages/c5/20/103ba525d4eb10fb6c5082174213221cb73292bd1f28bd20b3f3e57a00de/fast_mlsirm-0.11.3-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:353990c25673ff9f62e49ce03e1c16045c69ffbc06c27c4bd4c2c2a5f7ae344f", size = 9311763, upload-time = "2026-09-18T09:46:15.45Z" }, + { url = "https://files.pythonhosted.org/packages/83/08/6df915571cefeea908d7950b07a97bf3fb583de2207f9f9ae36e9d198cca/fast_mlsirm-0.11.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cc2cc3508eef006d67d93e887183e91ec756e26fcf6b72c9c5a6247ba62e90a", size = 5727481, upload-time = "2026-09-18T09:46:17.067Z" }, + { url = "https://files.pythonhosted.org/packages/1d/8f/5e175681f8d230485d83470143eb486fd885c84f76faf3302de772accaba/fast_mlsirm-0.11.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eeb14887a4f20643ea0bf339a06df9ef8eefdbb88f9afdcd49a0792f531f6faa", size = 5835009, upload-time = "2026-09-18T09:46:18.945Z" }, + { url = "https://files.pythonhosted.org/packages/4d/95/c53af89e3d07cb0418983e9347532fc360c5f1295f9e08ab18f01fc7a201/fast_mlsirm-0.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:8c6bd1a229b5d06c05cc7ecf6689b255e6c1a9ae135ce38dc266d1a223b16912", size = 5857788, upload-time = "2026-09-18T09:46:20.143Z" }, + { url = "https://files.pythonhosted.org/packages/c7/c4/8f1c7d030958db340709b7010891323df646c8b4bda553ae043197423b00/fast_mlsirm-0.11.3-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:4a6e3aea03cfc93fb450869c8c156987cf6107ba68a72190ae6867aa46dfd8e0", size = 9315138, upload-time = "2026-09-18T09:46:22.032Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e0/c32b713c061a277f65dd70c1ef087ab33c38d7944a878be6ca1e8b22d94a/fast_mlsirm-0.11.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9470dd55acdca32d95113bdbfe322ac84b554176bd125bfb902c3960e88fd371", size = 5732090, upload-time = "2026-09-18T09:46:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a4/ec1efdbf98e1333cc2d376060ce0415309a1530b0ce995295ba59f76aeef/fast_mlsirm-0.11.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4dd7382d5f889c59451a5c12b39c7b8aebb87c91ef696f6cb2b050eb3753d867", size = 5836653, upload-time = "2026-09-18T09:46:25.005Z" }, + { url = "https://files.pythonhosted.org/packages/a8/fe/c6f14fc5da70070d25350fbcbc9a3c011e5b8e8a2c5a0bf88e46cc14dd5e/fast_mlsirm-0.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:910ed13b562f81d5905081095be7faa5395ea9c033f988cfd170074a27dba738", size = 5859716, upload-time = "2026-09-18T09:46:26.246Z" }, +] [[package]] name = "fastapi"