Changelog#

All notable user-facing changes to the tauopt package. The format follows Keep a Changelog; versions are engine release stamps (SemVer). No real PyPI release has shipped yet (the name holds a placeholder until launch); the closed beta is hosted-only, and maintainers install from this repository.

[Unreleased]#

Added#

  • The list window and filters (the wire-polish lane, 2026-10-28): Client.suggestions(..., limit=, offset=) and Client.observations(..., limit=, offset=, sort=, dir=) return a Page – the list every caller already iterates, plus total, the service’s count under the call’s filters (named total so the list’s own count(value) method stays callable) – and Client.experiments(..., q=, sort=, dir=, include_deleted=) takes the service’s filters; a negative offset is the 400 validation_error on both planes (the local driver used to floor it) and the rows of experiments() carry progress. The local driver mirrors the whole grammar, the observation sort keys and refusal texts included. Only the given values ride the wire, so an older service sees the request it always saw.

  • The three queue provenance keys on Provenance – queue_wait_s, lane, attempt – served by the hosted plane since its queue (2026-10-24) and dropped by the tolerant read until now; the local driver serves 0.0, "local", 1.

  • The hosted transport retries a POST’s typed PRE-COMMIT 503s: experiment_busy, ask_slots_busy and queue_full (each raised before any suggestion or observation is committed, with Retry-After) now retry under the same bounded attempt cap as a 429; every other POST 5xx still raises at once (ask_deadline_exceeded, suggest_timeout and connection_pool_busy included: the pooled connection is released at commit, so a post-commit read can answer connection_pool_busy for a row already written, and a retry would duplicate it).

  • Docs: “Hosted beta: getting started” (docs/hosted-beta.md), the closed beta’s whole path once: sign in at app.tauopt.com by email link, mint a token on the tokens page, the five-call REST loop against api.tauopt.com with every answer pasted from a run, the experiment pages in the web app, the MCP connector for mcp.tauopt.com in Claude Code, Claude’s custom connectors, Cursor and the Python SDK, and every admission answer (429 too_many_inflight_asks, credential_rate_limited, 503 ask_slots_busy and queue_full, …) with its remedy. The hosted how-to, the MCP how-to, the quickstart, the install page, the examples README and the errors reference now name the hosts each page meets and the token self-service (a token is minted, rotated and revoked on the web app, never carried by an invite), and every “once the service’s matching change is deployed” clause is gone: the hosted service runs this engine version. The errors reference gains the admission and identity rows (too_many_inflight_asks, too_many_queued, suggest_rate_limited, credential_rate_limited, token_quota_exceeded, ask_slots_busy, queue_full, connection_pool_busy, token_expired, the session routes’ 403s), and the REST reference is regenerated from the service’s current OpenAPI document, which adds the session and token-management routes.

Changed#

  • The hosted service’s 500 for an engine terminal failure (all_rungs_failed, integrity_violation) is redacted on the wire: a constant message per type and details without the engine’s exception text (attempts[] keep rung, stage and exception_type); the local driver, your own process, keeps the text. The service names the join in the message: quote the response’s X-Request-Id.

  • The worker tag (the recovery kit’s label, 2026-10-23): Client.suggest(..., tag=) puts an optional label of 1 to 128 printable characters on the suggestion, served back as Suggestion.tag on every read (None when the ask carried none, and from a service built before the field), and Client.suggestions(..., state=, tag=) filters by it (an exact match, beside state), so a worker that dies and restarts can list its own open suggestions (“Recovering after a worker dies” in the parallel-workers how-to). Sent only when set, so an older service never sees the key; one that is sent it refuses the field as unknown (400 validation_error), and an older service ignores the list filter and lists every row – the hosted plane serves both once the service’s matching change is deployed. The local driver records, serves and filters the tag under the hosted field’s own rule and refuses a value outside it as the same 400 with the service’s texts; 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: httpx would send a number as its decimal string, which the service would accept as a label, and cannot encode the other two). A label, never a key for anything: no quota, no admission.

  • The local driver’s LIVENESS SUPERVISOR: a deadline-bearing ask (suggest(..., max_wait_s=W)) is answered within max(1.25 W, W + 3 s) whatever the engine’s cooperative clocks manage. Client(driver="local") runs such an ask on one warm worker process (supervisor="auto", the default; "process" runs every ask there, "off" inline as before; backstop_alpha and backstop_grace_s override the two constants for one driver; close() stops the worker). Past the backstop the ask raises tauopt.BackstopExceeded, the same 503 ask_deadline_exceeded ApiError with point backstop and a stage (ask, warmup, worker_died, reap, abandoned), backstop_s and backstop_source; the hung work is killed and the worker replaced BEHIND the response, the experiment’s state untouched and no suggestion opened. A delivered answer is byte-identical to the unsupervised answer. The worker is a fresh subprocess of the running interpreter (never a fork, never a multiprocessing.Process: an unguarded script is safe), warmed once with the engine and the math stack, resolving imports from the parent’s path plus its own stdlib and site directories, user-site off when the parent’s is (on 3.11+ by PYTHONSAFEPATH; on 3.10 the worker drops the working-directory entry -m put first before the engine imports – the package itself is resolved before that line) and leaving when its parent dies; an ask interrupted on the caller’s thread (a Ctrl-C inside the wait) retires its worker behind the raise, so no later ask shares a stream with the interrupted read; POSIX only in this version (on Windows "auto" runs inline and "process" is refused at construction). The protocol constants ALPHA (1.25), GRACE_S (3.0) and COLD_START_ALLOWANCE_S (30.0) have one home, tauopt._backstop, for both planes: a release that LENGTHENS the backstop ships clients first (an older client against a newer, longer server would stop listening first); shortening is safe in either order.

  • The wire mirrors name the two fields the hosted service has served since its suggestion-TTL change: Experiment.suggestion_ttl_s (the open-suggestion TTL in seconds; None = the service default of 86400 s applies, 0 = never expires) and Suggestion.expired_at (set when the TTL expiry closed the suggestion and reissued its slot). The tolerant-read mirrors dropped both silently before, so a hosted user could read neither through the Client. Client.create_experiment and Client.update_experiment take suggestion_ttl_s; the create sends the key only when it is set (a service built before the TTL change accepts the create and serves neither field); on update None means unchanged, as for every field, so reverting a hosted experiment to the service default has no Client form (send the REST PATCH with an explicit null). The local driver records and serves the value under the hosted field’s own rule (an integer in 0..2147483647, lax-coerced as the service coerces: 3600.0 and True are 3600 and 1; a fractional, negative, or past-int32 value is the same 400 validation_error with the service’s text), and never expires a suggestion (expired_at is always None there).

  • The Progress mirror names the two nested keys the hosted service has served since its web v1.1 step 2b: open_suggestions (the open-suggestion count, both drivers) and frontier_count (the frontier size of a multimetric experiment, the hosted plane’s bounded count; None on single-metric experiments, from the local driver, and from a service built before it served the key). The tolerant read dropped both before, so a hosted user could read neither through the Client. Client.experiment names them.

  • tauopt.AskDeadlineExceeded is exported at the top level (it was importable from tauopt.engine only).

  • The hosted flows with no package installed: python/examples/quickstart_rest.py, branin_rest.py, and parallel_workers_rest.py drive the hosted service over plain HTTP from the standard library with TAUOPT_BASE_URL and TAUOPT_TOKEN (a 429 that names a Retry-After and a 503 experiment_busy waited out on every route, at least 1 s, under a per-call budget a call may override with its own, a 409 space_exhausted read as completion, redirects refused, a connection that fails before the request is sent retried, a non-JSON answer raised as ApiError whatever its status), skip with exit 0 without credentials, and are pinned over a fake service by python/tests/test_rest_examples.py; CI runs the three (the skip path).

  • Ten more package-free twins, one per remaining example (exhaustion_rest.py, multimetric_rest.py, mixed_space_rest.py, experimental_design_rest.py, outcome_constraints_rest.py, sklearn_cross_validation_rest.py, xgboost_tuning_rest.py, pytorch_mlp_rest.py, ode_calibration_rest.py, convergence_plot_rest.py): the objective code of the local script over the REST routes, the seed as an explicit config with timebox_mode: "caps" (nine of the ten; exhaustion_rest.py enumerates a finite eight-point space to proof and sends no config); the shared helper takes an optional request body and keeps the last space_exhausted details on api.last_exhaustion; the fake-service test runs every twin and CI runs their skip path. replay_suggestion.py stays local-only during the beta (the replay runs the engine in-process).

Changed#

  • The hosted driver’s suggest POST listens for the backstop of its deadline: its READ timeout is max(1.25 W, W + 3 s) + 40 s (the service’s 300 s substitute when max_wait_s is unset, so 415 s) whenever that exceeds timeout_s; every other route and phase keeps timeout_s. A hosted deadline past about 48 s used to lose the service’s typed refusal to httpx.ReadTimeout at the 60 s default. Interim, until the service’s queue and worker supervisor are deployed: the suggest POST listens for max(timeout_s, B(W) + 40 s) (415 s with no deadline), so at the default timeout_s a hung hosted ask with no deadline, or one above 240 s, now ends in the platform’s 504 at 340 s (an ApiError) instead of ReadTimeout at 60 s; a smaller deadline still ends in ReadTimeout, at the derived bound (115 s for a 60 s deadline) or at timeout_s where the derivation is under it – no typed refusal either way.

  • AskDeadlineExceeded carries three more optional attributes and receipt keys – stage, backstop_s, backstop_source – None and absent on the engine’s own two refusal points; the hosted transport maps a 503 body whose point is backstop onto BackstopExceeded and requires stage (a string) and backstop_s (a number) beside the four keys for that point.

  • Client.close() on both drivers: the hosted connection pool, the local worker.

  • ExperimentState.pareto_frontier runs its dominance test on numpy when numpy is installed (the [local] extra, the prerequisite of every strategy beyond the stdlib random rung): the flipped rows are sorted so every dominator precedes what it dominates, then each row is compared against the frontier found so far and its own block, one metric at a time, with working memory bounded whatever n – output-sensitive, n^2 k element comparisons at worst when every point survives; without numpy, or for rows carrying anything but Python floats (an int past 2**53 planted through model_copy, which skips validation), the pure-Python pairwise loop runs as before. The frontier is the same – the same records in the same order, pinned against the pairwise loop on random states over every combination of ties, exact twins, thresholds, an outcome constraint, a store metric and failed rows, in single-metric mode, on rows one ulp apart, and on unvalidated ints. At the hosted plan cap, 4,000 observations with every point on the frontier (the shape a converged multi-objective run produces, where the pairwise loop pays every pair), the build reads 0.015 s at two metrics and 0.015 s at four on one desktop core where the loop read 3.1 s and 3.3 s; a noisy trade-off 0.74 -> 0.006 s; and where the loop was fastest – one early row dominating 50,000 others – the build matches the loop (0.08-0.09 s against 0.08-0.10 s across cores, parity within variance) (python/tests/test_frontier_vectorized.py pins the semantics against the loop, the build under one second at the cap, and the working set); the docstring’s envelope no longer calls O(n^2) fine at hundreds of observations, which the plan cap outgrew. import tauopt still loads no numpy (P3): the import happens inside the call.

  • AskDeadlineExceeded built from a hosted 503 carries the service’s details whole: a key a service build adds beside the four-key receipt reaches e.details on the typed path as it does on the plain ApiError path (it was dropped before; today’s service sends exactly the four keys and carries its admission-queue figures in the X-Tauopt-* response headers, already on e.headers); the four receipt keys are still the attributes’ values, and the local driver’s receipt is unchanged.

  • Every ApiError (the terminal classes and AskDeadlineExceeded included) pickles and deep-copies – the class, the message, status_code, error_type, details, headers, and the deadline receipt’s attributes; an exception chain (__cause__) is not carried, as for any Python exception: the default reduce re-called the class with the formatted string only and raised TypeError, so a process-pool worker could not hand the error back.

  • The benchmark suite: run prints its resumed: line BEFORE the remaining cells run (a resume interrupted a second time said nothing before); the quiet exit on a closed stdout reader leaves one line on stderr (tauopt-bench: stdout closed; stopping) instead of silence, since the exit is global across the subcommands and a fanout ... | head tore an orchestration down with no cause shown; the how-to’s refusal catalogue is pinned to the live last lines, its resume paragraph states the guard’s real identity (the committed HEAD and the installed versions), and the manifest’s wall_s is documented as the writing run’s.

  • Docs: the hosted how-to links the site’s downloads for the package-free scripts and runs them from a saved copy (no cd python); the quickstart’s inline loop strips a trailing slash from the base URL and the page names the http:// redirect and the certificate-check failure modes; the how-to’s timeout fence re-raises a caps-mode deadline instead of sleeping through it; suggestion_expired advice re-posts the result as a free-form point; TROUBLESHOOTING’s experiment_busy entry names which verbs the transport retries; the REST reference’s error-types intro points at the section that exists; the docs-site probe fetches every published example script and the deploy sets their content type.

  • AskDeadlineExceeded, the caps-mode request-deadline refusal, is an ApiError (503 ask_deadline_exceeded, deliberately no Retry-After: a caps re-ask repeats deterministic work) on both drivers. The local driver used to let the bare RuntimeError through unmapped, and the hosted driver surfaced the service’s 503 as a plain ApiError (a service built before its mapping: a 500 api_error with a plain-text body). One except ApiError handler now sees it on both planes, except AskDeadlineExceeded sees it on both planes, and the refusal receipt (point, max_wait_s, elapsed_s, rung) rides both the attributes and details. str(e) is the ApiError form, [503] ask_deadline_exceeded: <message>; the class is still a RuntimeError, so a direct tauopt.ask caller’s except RuntimeError keeps catching it.

  • A mistyped params VALUE is refused at the ask boundary as the caller’s 400 validation_error on both drivers, naming the strategy and the key – the value and the expected domain for a typed, range or enumerated refusal, the keys and the rule for a two-writer or posture conflict (invalid params value for strategy 'gp_ei': num_restarts must be an integer, got 'eight'). It used to raise inside the never-fail ladder, and the ask was served by the next rung with provenance.fallback_from as the only tell. The check runs the intended strategy’s own read_params(state, cfg) (new on the three GP strategies: the one place each reads its params dict) before any rung, so every reader refusal – the typed checks, the ranges, the enumerated values, and the two-writer and caps-posture conflicts – is now the boundary’s; the ladder descends on runtime failures only. Under strategy: "auto" the check runs the policy-routed strategy’s reader on the routed config that carries the top-level flags, so a flag the routed rung cannot run on this space is the same 400. A valid config executes byte-identically (no golden moved). A composition of valid values the strategy cannot run on this space (the mixed categorical mode on a conditional space; the fixed feasibility GP with the mixed mode on a space with categoricals) refuses at the boundary too. An integer past float range refuses at the boundary by range like any other reader refusal (the readers compare the raw number before any float conversion). The check runs on every install: the reader comes from an uncached instance, so a strategy whose extra is missing is still checked while the ladder still skips its rung for serving. On the hosted service a non-exempt user’s timebox-class refusal is answered by the service’s own config pre-check as config: <the same reader text> (see TROUBLESHOOTING). The unknown-key refusal runs on the policy-routed paths too: a custom policy routing a key its strategy does not read is refused like an explicit config (the default policy routes a bare config, so nothing changes for it).

  • The docs state the closed beta’s posture: the package is not distributed until the public release, beta participants use the hosted service with nothing installed (the REST scripts or the MCP server), and the install and quickstart pages address maintainers and collaborators with repository access; the quickstart and the hosted how-to carry the package-free loop (run against the beta lane) beside the client one.

  • Docstrings are plain ASCII: the 413 docstring lines in 26 package modules that carried em dashes, arrows, comparison and set symbols, Greek letters, and superscripts now read through the one glyph map the API reference already rendered them with (--, ->, <=, theta, sum of, ^2, …), so the module pages of the docs site, the reference page, and the source read one text; tools/ascii_docstrings.py (--check / --fix) and a pin (python/tests/test_docstrings_ascii.py) keep them ASCII, and the middle dot maps to a spaced asterisk so no swept asterisk opens markdown emphasis on a module page. Docs only: no package behavior, error string, or golden moved.

  • The benchmark suite (bench/python, tauopt-bench): list | head ends quietly (a BrokenPipeError on stdout’s own pipe exits 1 with no traceback; a broken pipe elsewhere still raises); list reads the metadata the bbob, bayesmark and lasso families register beside their factories, so an install without the battery extras lists its roster (the botorch and multi-objective families still need gp); the HEBO adapter’s default isolation venv is found beside the checkout’s bench/ from any working directory; run into a directory that already holds this protocol’s receipts.jsonl RESUMES it (present cells skipped under any status, the rest appended, resumed: N of M cell(s) ... printed, the manifest’s resumed_cells and file-wide n_errors), the exit code counts every error row in the file (a resume’s included), the run holds an exclusive lock on <out>/.run.lock so a second run into the directory refuses (another run holds ...), and another protocol’s receipts, a file whose last line has no trailing newline, a cell carried twice, another shard’s cells, or another environment’s rows refuse (refusing to resume ...); the FileExistsError refusal is gone; a generated tauopt-bench CLI reference page (docs/reference/bench-cli.md, tools/gen_cli_reference.py) with a currency test; the bench how-to’s pasted outputs are pinned to the checkout (bench/python/tests/test_howto_page_is_current.py). No receipt field, canonical form, protocol hash, or golden moved; the engine package is untouched.

[0.4.0] - 2026-10-21#

Added#

  • Client.suggestions(experiment_id, state=None) and Client.observations(experiment_id) on both drivers – the listing surface the hosted plane already served (GET .../suggestions?state=, GET .../observations) and the local driver already held, now reachable through the one Client; the state check TROUBLESHOOTING points at after a lost response.

  • Provenance.acq_init_filters on the wire mirror: the 0.3.2 acquisition-stage filtering receipts were advertised in provenance, but the tolerant-read mirror lacked the field, so every Client user silently lost them. A parity pin (python/tests/test_wire_provenance_parity.py) now requires the mirror to name every engine provenance field.

  • Client.experiments(limit=50, offset=0), Client.health(), and Client.auth_check() on both drivers: the three routes the REST surface served with no client method (GET /v1/experiments, GET /health, GET /v1/auth/check), typed as ExperimentList (count is the active total the cap counts; data the page, newest first, without progress), Health, and AuthCheck. A lost create_experiment (a POST, never retried on 5xx) is reconciled from the client now. The local driver mirrors the list route’s grammar (limit clamped to 1..200, offset floored at 0, archived experiments not listed) and answers the probes with status="ok" and user_id=None.

  • engine_sha is stamped into every built wheel and sdist: a hatchling build hook (python/hatch_build.py) writes the generated module tauopt._build_info (one constant, no imports) with the build commit, so a plain pip install of a built wheel carries the real sha instead of unknown and the identity triple the replay guide compares is no longer vacuous. Resolution is now TAUOPT_ENGINE_SHA -> the stamped module -> git HEAD of an enclosing checkout of this repository -> unknown; editable installs keep the live git fallback, and the git leg now checks the checkout’s layout, so a wheel installed into a venv under some other repository never borrows that repository’s HEAD; the build hook reuses an unpacked sdist’s own stamp before any git leg (its git leg checks the same layout), so an sdist unpacked and built beneath – or at the python/ of – some other repository stamps its own sha.

  • tauopt.engine.AskFailed (a RuntimeError): the engine’s own terminal failures, raised outside the never-fail net, now carry a machine-readable error_type and details – all_rungs_failed (every rung of the ladder raised, the random rung’s rejection cap on a thin constrained region being the reachable case; details.attempts holds one record per failed rung) and integrity_violation (a postcondition failed after a rung answered; details.check names it). The local driver answers a 500 ApiError of the same error_type and details, the shape the hosted service sends once its matching change lands. Before, these escaped the local driver as a bare RuntimeError and the hosted route answered a 500 with no error shape. (With dedup on, the all-rungs case read as a 409 instead; see Changed.)

  • tauopt.AskValidationError (a ValueError): the caller’s argument errors at the ask boundary (n < 1, a bad max_wait_s, auto with params, intents without dedup, an unread params key) are typed, and the local driver maps ONLY this class to 400 validation_error; any other ValueError out of the engine is the engine’s fault and surfaces as a 500 internal_error (before, every ValueError was blamed on the caller as a 400).

Changed#

  • OptimizerConfig.strategy defaults to "auto", so config={"dedup": False} (the dedup escape hatch) is valid as typed on both drivers (on the hosted plane once the service runs this engine) instead of answering 400 strategy Field required; the explicit {"strategy": "auto", ...} form stays valid. The canonical form is unchanged: strategy is a v0 field and serializes at its default like seed and params, so no config_hash moved.

  • Unknown params keys are refused: config={"strategy": "gp_ei", "params": {"num_restrats": 8}} used to ride into the canonical config and move config_hash with no signal. Every built-in strategy now declares the keys it reads (accepted_params on the class, mirrored in the registry), and the ask refuses any other key before a rung runs – AskValidationError, a 400 validation_error on either driver, naming the key (every unknown key when there are several), the accepted set, and a close match when one exists. Custom strategies that declare nothing are not checked.

  • The bare-string metric shorthand is refused: metric="loss" used to become {"name": "loss"} and MAXIMIZE the loss with no signal. Both drivers now answer a 400 validation_error that quotes the accepted form (metric={"name": "loss", "objective": "minimize"}); the Client refuses it before any request leaves, and the local driver’s own door refuses it too (a direct LocalDriver.create_experiment(metric="loss") used to die in an untyped TypeError). The dict form without an objective keeps its maximize default.

  • With dedup on (the default), an ask whose every rung raised is the typed all_rungs_failed failure above: it used to fall through to a draftless 409 unique_candidate_unavailable (the repair-ran-dry status, telling the caller to observe or close pending work) with the rung evidence dropped from the wire.

  • Error messages are plain ASCII: the em dashes and arrows in 27 message strings at raise sites read -- and ->, the glyphs the docs quote them with (users and agents grep these strings); a pin (python/tests/test_error_strings_ascii.py) keeps every raise-message literal, module-level string constant, and warn-call argument ASCII, and every fenced line of TROUBLESHOOTING.md (the quoted messages) ASCII.

  • The docs landing (docs/index.md) is card-based (sphinx-design on the shibuya theme): a button row, a two-door Start here grid with a one-line route, and the How-to, Reference, Explanation, and Examples sections as link cards; the markdown twins render every card as a list item, so index.md at the site and llms.txt read as before.

  • Strategy modules name their optimizer defaults (DEFAULT_NUM_RESTARTS / DEFAULT_RAW_SAMPLES: gp_ei 8/128, gp_nehvi 8/128, ucb_cl 4/64) and use them at the int_param sites – a behavior-neutral extraction (goldens unmoved) so consumers read the defaults off the module instead of restating literals. Both test suites honor TAUOPT_CPU_CAP=<n> (alias CI_CPU_CAP; 0 disables; a malformed, empty or whitespace-only value refuses loudly) for local runs: CPU affinity shrinks to n cores and the BLAS/OpenMP pools default to one thread (an exported pool variable wins).

Fixed#

  • The request-access link on the quickstart page, the install guide, and the quickstart notebook rendered as [tauopt.com followed by a NUL-wrapped placeholder index after the 2026-10-17 brand-case sweep: the tool masked a link destination and then let its URL pattern swallow the placeholder. The three lines are restored, the tool protects every non-prose span in one pass, and --check and --fix refuse a NUL-carrying file instead of passing or rewriting it.

  • The executed notebook page’s cells follow the theme’s light/dark mode: myst-nb colored them from the OS color scheme while shibuya marks its mode with a class on <html>, so a dark OS saw dark cells on the light page (docs/_static/custom.css binds the two).

  • TROUBLESHOOTING contradicted itself on ApiError headers (the 503 section said none are exposed; e.headers/e.retry_after have shipped since the client-surface wave) and told users to “list suggestions/observations” before the Client had list methods. Both corrected.

  • tauopt.__version__ lagged the 0.3.2 release (the literal stayed 0.3.1 while the distribution and the provenance identity triple said 0.3.2; bench native receipts stamped adapter_version 0.3.1). Fixed, with a pin that the two legs move together.

[0.3.2] - 2026-08-31#

Added#

  • gp_nehvi acquisition-stage robustness: when the acquisition itself produces non-finite values at its raw samples (a class a variance-floor escalation cannot cure), the caught retry re-runs with an armed finite-filtering initializer at the UNCHANGED floor – whole q-batch rows with non-finite raw acquisition values are excluded from initialization, a raw-base postcondition refuses any non-finite answer, and every armed attempt emits an AcqInitFilterRecord receipt (acq_init_filters in suggestion provenance, absent when nothing armed). Clean asks are byte-identical (the generator does not exist on unarmed attempts); when nothing finite remains, the never-fail ladder descends with the full receipt trail instead of retrying blind.

  • Client.archive_experiment(experiment_id): archive (soft-delete) an experiment on both drivers – the active-experiment-cap remedy (hosted: DELETE /v1/experiments/{id}). Archived experiments stay readable (experiment(), best(), listings, dismissals); suggest/observe/update_experiment on them refuse with a 404, as does a second archive.

  • ApiError.headers and the parsed ApiError.retry_after: the final HTTP response’s headers now ride every transport-raised error, so a caller catching a final 429/503 can honor Retry-After in its own backoff loop (numeric form only – an HTTP-date, negative, or non-finite value yields None; fall back to your own backoff).

  • Documentation pass: TROUBLESHOOTING.md (every client-raised error: symptom, cause, fix), AGENTS.md, CONTRIBUTING.md, this changelog; README quickstarts are runnable verbatim with verified output; a vendored docs gate (tools/docslint.py) runs in CI; a new exhaustion example (python/examples/exhaustion_local.py) exercises the completion contract live.

  • py.typed marker: the public API’s type annotations now reach type checkers and IDEs (PEP 561).

Fixed#

  • The retry loop crashed on a negative Retry-After header (float("-5") passed the old parse and reached time.sleep(-5), raising ValueError); non-finite values could poison the delay arithmetic. Both now fall back to exponential backoff.

Changed#

  • No behavioral golden change: fixtures regenerated with receipts (stamps-only: engine version / sha / environment fingerprints), machine-classified zero trajectory or value deltas.

  • Two error-message parentheticals reworded to plain prose (the UniqueRepairExhausted message on the gp_nehvi repair path; the caps-mode field refusal in work-cap validation). No error types, codes, or wire shapes changed.

  • The hosted transport now refuses HTTP redirects loudly with ApiError("unexpected_redirect") instead of misparsing the redirect body as JSON; a 3xx from the API means a misconfigured base_url (http vs https, a proxy, a moved host). Redirects were never followed; now the refusal is explicit.

  • Examples: the --seed flag in the local examples now seeds the optimizer (it rides each ask as config={"strategy": "auto", "seed": ...}); previously it only labeled the experiment name/metadata, so different seeds produced identical trajectories. The hosted examples accept TAUOPT_BASE_URL/TAUOPT_TOKEN environment variables (preferred on shared machines).

[0.3.1] - 2026-08-23#

Fixed#

  • gp_nehvi (multimetric) acquisition: a guard controller catches the degenerate-probability-tensor class (NaN acquisition values) during acquisition optimization, removing a spurious source of ladder fallbacks on multimetric asks.

[0.3.0] - 2026-08-23#

Changed#

  • Duplicate suppression (dedup) is now the engine-wide default. The engine never re-suggests an observed or still-open point; per ask, config={"strategy": "auto", "dedup": False} restores the old behavior (and refuses intents; strategy is required on an explicit config). A suggestion closed without an observation leaves the dedup universe and may be re-suggested. Migration note: a 0.3.0 client against a pre-0.3.0 server still gets that server’s dedup-off default – the default is engine-side.

Added#

  • The completion ladder: on a finite space, exhaustion is completion, not failure. suggest raises SpaceExhausted (HTTP 409, space_exhausted) carrying a machine-readable proof under details.dedup; catch OptimizationComplete to handle every terminal state with one clause. Wire-mapped terminal errors are dual-lineage (ApiError subclasses), so existing except ApiError handlers keep working unchanged.

  • Two exhaustion proofs: enumeration on finite spaces up to 1024 raw points (conditional parameters included), and a no-enumeration cardinality certificate for larger all-finite spaces that are unconditional and unconstrained. Spaces outside both (continuous axes; large conditional/constrained spaces) surface unique_candidate_unavailable (bounded repair, no proof) instead.

  • Work-capped timeboxing: timebox_mode="caps" bounds suggest-time work in deterministic work units instead of wall-clock. Explicit per-strategy work knobs refuse to combine with caps mode rather than silently interact.

  • Fallback evidence: when the strategy ladder degrades, the suggestion’s provenance now records the attempted rung’s fit/acquisition events and the verbatim triggering exception (fallback_attempts), never just a bare fallback_from tag.

[0.2.0] - 2026-08-21#

Changed#

  • No behavioral change: internal golden fixtures were regenerated with receipts proving identical output. The v0 config-compatibility freeze ended – new optional config fields can now be added without moving existing states’ bytes.

Added#

  • Non-finite parameter values (NaN/inf) are refused at config validation; previously a pathological non-finite value could alias distinct configs.

[0.1.0] - 2026-07-06 (development era, through 2026-08-21)#

Initial engine and client, spec-first:

  • Typed parameter space: double / integer / categorical, log scale, integer grids, conditional parameters, linear constraints (suggestions feasible by construction).

  • Strategies behind one interface: random, sobol, gp_ei (SingleTaskGP + LogEI), ucb_cl (GP-UCB + constant liar), gp_nehvi (qLogNEHVI multimetric); policy routing with the never-fail ladder (gp_ei -> ucb_cl -> sobol -> random; multimetric gp_nehvi -> sobol -> random), fallbacks recorded in provenance.

  • Determinism receipts on every suggestion: engine version/sha, dependency fingerprint, strategy, config hash, seed; suggest is a pure function of (state, config) per fixed environment (see the Reproducibility section of TROUBLESHOOTING.md for the timebox fine print).

  • Featherweight client: import tauopt never imports the math stack; local and hosted drivers behind one Client surface.

  • Typed duplicate/replication intents; queue-selection lookahead; wall-clock timeboxing plus timebox_mode="off" for algorithm-natural termination.

  • GP conditioning: never-zero base noise default, duplicate-x aggregation, deterministic jitter escalation before ladder fallback.

  • Multimetric: Pareto frontier via best_assignments, metric thresholds as the reference point, strategy: "store" | "constraint" metric roles.