tauopt.client#

tauopt.Client: one surface, two drivers.

Client(base_url=..., token=...)          # hosted: talks to a TauOpt service
Client(driver="local")                   # local: full engine in-process, no server

A script written against one driver upgrades to the other by changing only the constructor. The hosted driver is featherweight (httpx + pydantic); the local driver needs tauopt[local] (and tauopt[botorch] for the GP strategies).

Module Contents#

Classes#

Client

The TauOpt client: one experiment/suggest/observe surface over two interchangeable drivers.

API#

class tauopt.client.Client(base_url: str | None = None, token: str | None = None, driver: Literal[hosted, local] = 'hosted', *, timeout_s: float = 60.0, supervisor: Literal[auto, process, off] = 'auto', backstop_alpha: float | None = None, backstop_grace_s: float | None = None)#

The TauOpt client: one experiment/suggest/observe surface over two interchangeable drivers.

Hosted (default): Client(base_url=..., token=...) talks to a TauOpt service over HTTP with Bearer auth (featherweight: httpx + pydantic only). Local: Client(driver="local") runs the full engine in-process with the same semantics, no server (needs tauopt[local]; the GP strategies need tauopt[botorch]).

A deadline-bearing ask (suggest(..., max_wait_s=W)) is BACKSTOPPED on the local driver: a typed answer within max(1.25 W, W + 3 s) whatever the engine’s cooperative clocks manage. supervisor picks the shape – "auto" (default) runs a deadline-bearing ask on a warm worker PROCESS, one per driver, started at the first such ask and killed and replaced behind a refusal; "process" runs every ask there (an ask with no deadline then gets a 375 s backstop, derived from the service’s 300 s substitute, and still carries no deadline of its own); "off" runs it inline, as before. backstop_alpha and backstop_grace_s override the protocol constants 1.25 and 3.0 for this driver (None = the constants). Local-driver options only: the hosted driver refuses them with ValueError. close() stops the worker. The worker hand-off is POSIX-only in this version: on Windows "auto" runs inline and "process" is refused at construction.

On the hosted driver the suggest POST listens for the backstop of its deadline plus a 40 s allowance whenever that exceeds timeout_s (415 s for an ask with no deadline), so the service’s typed refusal arrives before the client stops listening (the ask runs on its queue lane; provenance.lane worker); every other route keeps timeout_s.

Raises ValueError when the hosted driver is missing base_url/token, and ImportError from the local driver when math extras are absent (the message names the exact pip install target).

Initialization

close() → None#

Release the driver’s resources: the hosted driver’s connection pool, or the local driver’s worker process (the next supervised ask starts a fresh one). Idempotent.

create_experiment(name: str, parameters: list[dict[str, Any]], metric: dict[str, Any] | None = None, metrics: list[dict[str, Any]] | None = None, linear_constraints: list[dict[str, Any]] | None = None, num_solutions: int = 1, observation_budget: int | None = None, parallel_bandwidth: int = 1, metadata: dict[str, Any] | None = None, suggestion_ttl_s: int | None = None) → tauopt.models.Experiment#

metric (single-objective) XOR metrics (the metrics cluster: multimetric Pareto, thresholds, strategy=store|constraint). metric is a dict {"name": ..., "objective": "minimize" | "maximize"}; a metric without an objective maximizes. A bare string metric="name" is refused before any request leaves (400 validation_error naming the accepted form): the shorthand named no objective and silently maximized a loss. num_solutions asks best_assignments for k diverse top solutions (single-objective only). observation_budget is a planning hint rather than an enforced stop: the default routing never reads it; both drivers inject it as the queue_selection horizon when that config block names none; asks past the budget still answer, and your loop decides when to quit. suggestion_ttl_s is the hosted plane’s open-suggestion TTL in seconds: an open suggestion older than it is closed by the next ask on the experiment (expired_at set, its bandwidth slot reissued) and an observation against it answers 409 suggestion_expired; None leaves the service default (86400 s) in force and sends no key, 0 never expires. The local driver records and serves the value (the hosted field’s integer domain, 0..2147483647, lax-coerced as the service coerces, else 400 validation_error) and never expires a suggestion.

experiment(experiment_id: str) → tauopt.models.Experiment#

Fetch an experiment by id, with live progress (observation count, best observation, the open-suggestion count, and – on the hosted plane – the next ask’s cost estimate expected_suggest_s and, for a multimetric experiment, the frontier size frontier_count).

update_experiment(experiment_id: str, name: str | None = None, observation_budget: int | None = None, parallel_bandwidth: int | None = None, metadata: dict[str, Any] | None = None, metric_thresholds: dict[str, float | None] | None = None, suggestion_ttl_s: int | None = None) → tauopt.models.Experiment#

PATCH semantics – None leaves a field unchanged. Space and metric structure are immutable; metric_thresholds moves thresholds on declared metrics (SigOpt allowed mid-experiment threshold updates). suggestion_ttl_s moves the hosted plane’s open-suggestion TTL (0 never expires; the new value applies from the next ask). Because None means unchanged here as for every field, reverting a hosted experiment to the service default TTL has no Client form: send the REST PATCH with an explicit "suggestion_ttl_s": null (the MCP server’s update_experiment drops null inputs the same way). The local driver records and serves the value (the hosted field’s integer domain, 0..2147483647, lax-coerced as the service coerces, else 400 validation_error) and never expires a suggestion.

archive_experiment(experiment_id: str) → None#

Archive (soft-delete) an experiment – the active-experiment-cap remedy on the hosted plane (DELETE /v1/experiments/{id}, 204).

An archived experiment stays READABLE: experiment() still returns it (state becomes "deleted"), and best() plus suggestion dismissals (close_suggestion/close_all_suggestions) keep working, so a finished study remains auditable after it frees its cap slot. suggest/observe/update_experiment refuse with a 404 :class:~tauopt.ApiError, and archiving twice 404s too – the hosted plane’s exact semantics; the local driver mirrors them. There is no unarchive.

Flaky-transport note: the hosted DELETE retries on 429 and on 500, 502, 503, and 504, so a success whose response was lost can surface the RETRY’s 404 even though the archive landed – treat not_found from this call as already-archived (the states are operationally equivalent: the cap slot is free either way).

suggest(experiment_id: str, config: dict[str, Any] | None = None, max_wait_s: float | None = None, on_timeout: str | None = None, intents: list[dict[str, Any]] | None = None, tag: str | None = None) → tauopt.models.Suggestion#

Ask for the next point. config pins an explicit optimizer config (the public config stratum); omitted -> the service/engine policy routes.

The wait budget is yours: max_wait_s bounds the whole ask (accepted domain: a number in (0, 300]); on_timeout="ladder" (default) degrades down the robustness ladder with the fallback recorded in provenance, "fail" surfaces an honest timeout (hosted: 503 + Retry-After) so you can run your own backoff. The experiment’s progress.expected_suggest_s estimates the next ask’s cost from history.

Raises :class:~tauopt.AskDeadlineExceeded (a 503 ask_deadline_exceeded :class:~tauopt.ApiError, no Retry-After: a caps re-ask repeats deterministic work) when config sets timebox_mode: "caps" and max_wait_s expires at a rung boundary or at delivery: no suggestion opens and no cheaper rung substitutes; the refusal receipt (point, max_wait_s, elapsed_s, rung) rides the exception’s attributes and its details. Both drivers raise the same class, so one except ApiError handler serves both. On the hosted plane, an ask that first waited for an ask slot reaches the engine with that wait already taken off its budget: the receipt’s max_wait_s is the budget the engine was handed after the wait (the figure you sent, less the queue wait) and elapsed_s counts from the engine’s start. A deadline-bearing ask is also BACKSTOPPED: a typed answer is promised within max(1.25 * max_wait_s, max_wait_s + 3 s) whatever the engine’s cooperative clocks manage (a native kernel that never returns). The local driver runs such an ask on a warm worker process (the constructor’s supervisor); past the backstop it raises :class:~tauopt.BackstopExceeded, the same 503 ask_deadline_exceeded with point "backstop" and a stage (ask, warmup, worker_died, reap, abandoned), the hung work killed behind the response and the state untouched. The hosted plane answers the same class from its own backstop on its queue lane (provenance.lane worker). The suggest POST listens for max(timeout_s, B(W) + 40 s) (415 s with no deadline), so the service’s backstop answers before the client stops listening; a stalled connection ends in ReadTimeout at the derived bound (115 s for a 60 s deadline), or at timeout_s where the derivation is under it. The engine’s own failures outside the never-fail net (:class:~tauopt.engine.AskFailed: every rung failed, or an integrity postcondition failed after a rung answered) surface on both drivers as a 500 :class:~tauopt.ApiError of the same error_type (all_rungs_failed with the attempts in details, integrity_violation with the failed check); the caller’s own argument errors (:class:~tauopt.AskValidationError) are the 400 validation_error class, and any other engine-side ValueError is a 500 internal_error.

intents (dedup is the engine-wide default since 0.3.0 – explicit dedup: false disables and refuses intents): typed authorized-duplicate requests {kind, source_id, ordinal} – validated against the recomputed policy, never trusted; refusals surface in the suggestion’s provenance dedup record.

The ask-loop contract (0.3.0 completion ladder). Exhaustion is COMPLETION rather than failure. On a finite space the engine can prove exhausted (every finite space of at most 1,024 raw points; larger all-finite spaces without conditionals or linear constraints), once every input-feasible point is taken suggest raises :class:~tauopt.SpaceExhausted – PROVEN exhaustion (the proof receipt rides details.dedup; raised when no draft can be produced, while a batch of authorized replicas returns normally with uniqueness_status space_exhausted); outside those proofs a fully covered space surfaces as 409 unique_candidate_unavailable::

try:
    sug = client.suggest(exp_id)
except OptimizationComplete:   # SpaceExhausted narrows the WHICH
    ...                        # done -- read the best point

Old except ApiError handlers keep working (dual lineage). A 409 unique_candidate_unavailable (bounded repair ran dry, NO proof – continuous/large spaces) is NOT completion: check details.dedup.unfilled_count/uniqueness_status, then observe or close pending work first – a same-state re-ask is a DETERMINISTIC repeat (P4), never a retry strategy. Authorized replication intents remain the continuation path at proven exhaustion (noisy objectives).

tag (an optional worker label: 1 to 128 printable characters, no control or separator characters) is stored on the suggestion, served back as Suggestion.tag on every read, and is the exact-match filter of :meth:suggestions, so a worker that dies and restarts lists its own open suggestions by its tag (the parallel-workers how-to, “Recovering after a worker dies”); a label, never a key for anything (no quota, no admission). Sent only when set; the hosted plane serves the tag and refuses a value outside the rule as 400 validation_error. The local driver refuses a value outside the rule as the same 400 with the service’s texts, and a non-string, a lone-surrogate or a non-finite tag is refused by the Client itself before any request, on both drivers, with the service’s text (the wire cannot carry those faithfully).

observe(experiment_id: str, suggestion_id: str | None = None, assignments: tauopt.models.Assignments | None = None, value: float | None = None, values: dict[str, float] | None = None, value_stddev: float | None = None, values_stddev: dict[str, float] | None = None, failed: bool = False, metadata: dict[str, Any] | None = None) → tauopt.models.Observation#

value in single-metric mode, values (name -> value, every optimize/constraint metric required) in metrics mode. value_stddev / values_stddev report measurement noise – it enters the GP as fixed per-point variance.

close_suggestion(experiment_id: str, suggestion_id: str) → None#

Dismiss an open suggestion without observing it. Its bandwidth slot frees, and the dismissal advances the sequential streams. Note a dismissed-unobserved point leaves the dedup universe and may be re-suggested later. Unknown id: 404 ApiError; already closed: no-op.

close_all_suggestions(experiment_id: str) → int#

Bulk-dismiss every open suggestion; returns how many were closed.

best(experiment_id: str) → tauopt.models.BestAssignments#

Best result so far: the single best observation (single-objective), k diverse top solutions when the experiment declared num_solutions > 1, or the non-dominated threshold-respecting frontier in metrics mode.

suggestions(experiment_id: str, state: Literal[_io.open, closed] | None = None, tag: str | None = None, *, limit: int | None = None, offset: int = 0) → tauopt.models.Page[tauopt.models.Suggestion]#

List the experiment’s suggestions in id order, as a :class:~tauopt.Page (a list plus total, the service’s count under the filters): every row by default, or only the "open" ones (holding a bandwidth slot, awaiting an observation) or the "closed" ones. Works on archived experiments. This is the state check after a lost response: a POST that died with a 5xx may have committed server-side, so list before re-sending.

tag narrows the list to the suggestions whose worker label is exactly tag (beside state): the restart step of the recovery how-to. The hosted query ?tag= filters on the service; the local driver filters by construction. A value outside the tag’s rule (1 to 128 printable characters) is the same 400 validation_error on both planes; a non-string or a lone-surrogate tag is refused by the Client before any request (httpx would send a number as its decimal string, which the service would accept as a label).

limit and offset page a long history (the hosted window, ?limit=&offset=): limit absent lists every row, present it is clamped to 1..200; offset skips that many rows and must be non-negative – a negative one is a 400 validation_error on both planes (query.offset: Input should be greater than or equal to 0), never floored, and so is a negative limit (only 0 clamps up). total (the service’s count) is the total under state and tag whatever the window (on the hosted plane a paged count and its page are two reads: a concurrent insert can make them differ by one row). A value left at its default stays off the wire. The local driver mirrors the window and the refusals, and a state outside open/closed is the same 400 on both planes.

observations(experiment_id: str, *, limit: int | None = None, offset: int = 0, sort: str = 'created', dir: Literal[asc, desc] = 'asc') → tauopt.models.Page[tauopt.models.Observation]#

List the experiment’s observations (archived experiments included) as a :class:~tauopt.Page (a list plus total, the service’s count): in report order by default, every row.

sort and dir order the rows as the hosted list does (?sort=&dir=): created is id order (report order); value the first optimized metric’s value, with failed rows (no value) last in either direction and the id as the secondary key; value-<metric> a declared metric by name (a metric absent from a row’s values sorts last); any parameter name its assignment (numeric parameters as numbers, categoricals as text; a conditional parameter’s absent assignment last). The two reserved words win over a parameter of the same name. An unknown key is a 400 validation_error naming the accepted keys, on both planes in the service’s text; so is a dir outside asc/desc. limit and offset page the result: limit absent lists every row, present it is clamped to 1..200; a negative offset (or limit) is a 400 on both planes, never floored; total is the service’s count whatever the window. A value left at its default stays off the wire. The local driver mirrors the whole grammar; its text keys sort by code point where the hosted plane sorts under its database collation.

experiments(limit: int = 50, offset: int = 0, *, q: str | None = None, sort: Literal[created, updated, name, observation_count] | None = None, dir: Literal[asc, desc] | None = None, include_deleted: bool = False) → tauopt.models.ExperimentList#

List your experiments as one page of the {count, data} envelope (hosted: GET /v1/experiments): by default the ACTIVE ones, newest first, with count the active total the hosted plane’s active-experiment cap counts, each row WITH its progress (observation count, best observation, open-suggestion count; the hosted plane adds a multimetric experiment’s frontier size; the hosted list leaves expected_suggest_s null – read one by id for the estimate). limit is clamped to 1..200 on both planes; a negative offset (or limit) is a 400 validation_error on both (query.offset: Input should be greater than or equal to 0), never floored. q narrows to names containing it, case-insensitively (the % and _ wildcards are literal); sort is created (the default), updated (the last change to the experiment, its history or its open set), name or observation_count, dir asc or desc (the default), ties broken by the newest id; include_deleted lists the archived ones too (state "deleted"); count follows every filter. A filter left unset stays off the wire. The local driver mirrors the grammar and the refusals; its name order is by code point where the hosted plane sorts under its database collation. This is the reconciliation step after a lost create_experiment response: a POST is retried only on a 429 and on the transport’s pre-commit set (experiment_busy, queue_full, and ask_slots_busy, retired at deploy 2 with the lane it guarded but kept in the set); a lost response or any other 5xx – connection_pool_busy included, which can follow a commit – is never retried, so list before creating again.

health() → tauopt.models.Health#

Liveness probe (hosted: GET /health, no token needed, so it says nothing about yours): status is "ok" after the service’s database round-trip. A hosted 5xx (the database round-trip failed) is retried by the transport like any GET – up to five attempts, about eight seconds of backoff – before the ApiError surfaces. The local driver answers "ok".

auth_check() → tauopt.models.AuthCheck#

Token probe (hosted: GET /v1/auth/check, not counted as usage): user_id is the account the token resolves to, or a 401 unauthorized :class:~tauopt.ApiError for a missing, malformed, unknown, revoked, or other-lane token. The local driver has no accounts and answers user_id=None.