Metadata-Version: 2.4
Name: simulacrum-sdk
Version: 2.0.0
Summary: Official Python SDK for accessing the Simulacrum API.
Author-email: "Simulacrum, Inc." <support@smlcrm.com>
License-Expression: MIT
Project-URL: Homepage, https://smlcrm-tempo-api.readme.io/reference
Project-URL: Repository, https://github.com/Smlcrm/simulacrum-sdk
Project-URL: Issues, https://github.com/Smlcrm/simulacrum-sdk/issues
Keywords: Simulacrum,time-series,forecasting,sdk
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx<1,>=0.27
Requires-Dist: pydantic<3,>=2.0
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: ruff==0.16.6; extra == "dev"
Requires-Dist: build>=1.2; extra == "dev"
Requires-Dist: twine>=5.0; extra == "dev"
Dynamic: license-file

![Simulacrum Logo](./simulacrum.png "Simulacrum SDK")

# Simulacrum SDK

A Python client for the Simulacrum time-series forecasting gateway. The SDK
covers every public `/v1` route of the gateway with type-safe Pydantic models
and a distinct typed exception per HTTP error code.

---

## Installation

### From PyPI (recommended)

Requires Python 3.8 or newer.

```bash
pip install simulacrum-sdk
```

### From source

```bash
git clone https://github.com/Smlcrm/simulacrum-sdk.git
cd simulacrum-sdk
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
```

---

## Quick start

### Creating a client

```python
from simulacrum import Simulacrum

client = Simulacrum(api_key="sp_your_api_key")
```

Override the base URL for staging or on-premise deployments:

```python
client = Simulacrum(
    api_key="sp_your_api_key", base_url="https://staging.api.smlcrm.com"
)
```

### Requesting a forecast

The SDK mirrors the gateway's Tempus/NamedTensor wire format. Build a
`ForecastPayload` with your history, then call `client.forecast()` with one
or more model ids.

```python
from simulacrum.models import (
    ForecastPayload,
    ModelErrorResult,
    ModelForecastResult,
    ModelIncompatibleResult,
    NamedTensor,
)

# NamedTensor encodes a block of series as a flat C-order list. The last
# dimension is time: shape=[1, T] is one series of T steps. None marks a
# missing value.
revenue = NamedTensor(shape=[1, 4], values=[102.4, 106.0, None, 111.9])

payload = ForecastPayload(
    forecast_horizon=3,
    past_timestamps=["2024-01-01", "2024-01-02", "2024-01-03", "2024-01-04"],
    future_timestamps=["2024-01-05", "2024-01-06", "2024-01-07"],
    past_targets={"revenue": revenue},
)

response = client.forecast(models=["chronos2_small", "lafn"], payload=payload)

for model_id, result in response.results.items():
    if isinstance(result, ModelForecastResult):
        tensor = result.forecast_targets["revenue"]
        print(model_id, "median:", tensor.median)
        print(model_id, "p10 / p90:", tensor.p10, tensor.p90)
        print(model_id, "cost (micro-cents):", result.cost.total_micro_cents)
    elif isinstance(result, ModelIncompatibleResult):
        print(model_id, "cannot serve this payload:", result.reason)
    elif isinstance(result, ModelErrorResult):
        print(model_id, "failed:", result.code, result.reason)
```

`response.results` holds one entry per requested model, and each entry is one
of three types, chosen by its `status`:

| Type | `status` | Fields |
|---|---|---|
| `ModelForecastResult` | `"success"` | `future_timestamps`, `forecast_targets`, `cost`, `transaction_id` |
| `ModelIncompatibleResult` | `"incompatible"` | `reason`: the model cannot serve this payload; nothing is charged |
| `ModelErrorResult` | `"error"` | `code` (e.g. `WALLET_INSUFFICIENT`, `MODEL_NOT_ENTITLED`), `reason`, `transaction_id` |

A model that fails does not fail the call; its result says why.

Each `ForecastTargetTensor` in `forecast_targets` holds the dense quantile
grid plus five convenience knots, every list of length `forecast_horizon`:

- `quantile_levels`: the served quantile levels, strictly increasing
  (e.g. `[0.01, 0.05, 0.10, ..., 0.95, 0.99]`).
- `quantiles`: `[len(quantile_levels)][forecast_horizon]`; row `i` is the
  forecast at `quantile_levels[i]`, the source for adjustable confidence bands.
- `mean`: the model's point (mean) forecast.
- `median`, `p10`, `p25`, `p75`, `p90`: knots taken from the grid by the
  gateway (grid rows, or interpolated where the level is not served).

`cost` is a `CostBreakdown`: `model`, `horizon`, `num_targets`,
`num_samples`, `price_per_value_micro_cents` and `total_micro_cents`
(1 USD = 100 000 000 micro-cents).

Covariates go in `past_covariates` (over the history) and `future_covariates`
(over the horizon), both name-to-`NamedTensor` dicts. The SDK checks the
payload with the gateway's rules before sending it, so a bad payload raises
`pydantic.ValidationError` and nothing is sent.

### Backtesting with a batch forecast

`forecast_batch()` sends the whole series once with a list of rolling-origin
windows, and each model forecasts every window. A window's `origin_index` is
the last point of its context, and it needs `forecast_horizon` realised points
after it.

```python
from simulacrum.models import (
    BatchForecastPayload,
    BatchWindow,
    ModelBatchForecastResult,
)

history = [100.0, 101.5, 103.2, 104.0, 106.1, 107.3, 108.0]
batch = BatchForecastPayload(
    forecast_horizon=2,
    past_timestamps=[f"2024-01-0{day}" for day in range(1, 8)],
    past_targets={"revenue": NamedTensor(shape=[1, 7], values=history)},
    windows=[
        BatchWindow(origin_index=3, context_steps=4),
        BatchWindow(origin_index=4, context_steps=4),
    ],
)

backtest = client.forecast_batch(models=["chronos2_small"], payload=batch)
result = backtest.results["chronos2_small"]
if isinstance(result, ModelBatchForecastResult):
    for window in result.windows:
        median = window.forecast_targets["revenue"].median
        print("origin", window.origin_index, "median:", median)
```

Batch results use `ModelBatchForecastResult`, `ModelBatchIncompatibleResult`
and `ModelBatchErrorResult`, which work like their single-forecast
counterparts.

### Browsing models, families and licences

```python
catalog = client.models()
for entry in catalog.models:
    licence = entry.license
    commercial = licence.commercial_use if licence else None
    print(entry.id, entry.status, "commercial use:", commercial)

families = client.model_families()
for family in families.families:
    for member in family.models:
        print(family.id, member.id, "available:", member.available)
```

Route on each entry's `status`: `"serving"` accepts forecasts. Check
`license.commercial_use` before using a model's forecasts commercially. The
catalog has four views: `models()` (every published model),
`models_served()` and `models_blocked()` (models meant, or not meant, to be
deployed) and `models_online()` (models with a running instance, the only view
that fills `instance_count`).

### Attribution notices

Some models' licences require a notice wherever their forecasts are shown.
Display each one verbatim:

```python
for attribution in client.attributions().attributions:
    print(attribution.notice, "for", ", ".join(attribution.model_ids))
```

### Accessing the raw response

`forecast_raw()` returns the response as parsed JSON, without the typed
models:

```python
raw = client.forecast_raw(models=["chronos2_small", "lafn"], payload=payload)
for model_id, entry in raw["results"].items():
    print(model_id, entry["status"])
```

### Validating an API key

```python
validation = client.validate()
print("Valid:", validation.valid, "| key id:", validation.key_id)
```

An invalid key raises `UnauthorizedError` (401) or `ForbiddenError` (403).

---

## Route note

The gateway exposes a single canonical forecast route: `POST /v1/forecast`.
The portal and this SDK both use that path. An older per-model route pattern
`/{model}/v1/forecast` is not the canonical route and should not be relied on.

---

## Handling errors

Each gateway HTTP status maps to a distinct typed exception. Catch the most
specific class you need; fall back to `SimulacrumError` for anything else.

```python
from simulacrum.exceptions import (
    UnauthorizedError,
    PaymentRequiredError,
    ForbiddenError,
    NotFoundError,
    UnsupportedMediaTypeError,
    ValidationError,
    RateLimitError,
    BadGatewayError,
    ServiceUnavailableError,
    GatewayTimeoutError,
    ServerError,
    UnexpectedResponseError,
    SimulacrumError,
)

try:
    response = client.forecast(models=["chronos2_small"], payload=payload)
except UnauthorizedError as exc:
    # HTTP 401 -- invalid or missing API key
    print("Auth failed:", exc.message, "| type:", exc.error_type)
except PaymentRequiredError as exc:
    # HTTP 402 -- insufficient wallet balance
    print("Wallet empty:", exc.message)
except ForbiddenError as exc:
    # HTTP 403 -- key recognised but access denied
    print("Forbidden:", exc.message)
except NotFoundError as exc:
    # HTTP 404 -- unknown model id
    print("Not found:", exc.message)
except UnsupportedMediaTypeError as exc:
    # HTTP 415 -- the request body's content type is not accepted
    print("Unsupported media type:", exc.message)
except ValidationError as exc:
    # HTTP 400 / 422 -- bad request payload
    print("Validation error:", exc.message, "| details:", exc.details)
except RateLimitError as exc:
    # HTTP 429 -- retry after exc.retry_after_seconds
    print("Rate limited; retry after", exc.retry_after_seconds, "s")
except BadGatewayError as exc:
    # HTTP 502 -- upstream model failure (retryable)
    print("Bad gateway:", exc.message)
except ServiceUnavailableError as exc:
    # HTTP 503 -- temporarily offline (retryable)
    print("Service unavailable:", exc.message)
except GatewayTimeoutError as exc:
    # HTTP 504 -- upstream timed out (retryable)
    print("Gateway timeout:", exc.message)
except ServerError as exc:
    # HTTP 5xx (other than 502/503/504)
    print("Server error:", exc.message, "| trace id:", exc.trace_id())
except UnexpectedResponseError as exc:
    # Unmapped status code -- inspect exc.status_code
    print("Unexpected response:", exc.status_code, exc.message)
except SimulacrumError as exc:
    print("Simulacrum error:", exc)
```

Billing and entitlement failures of a single model (for example an empty
wallet) arrive as that model's `ModelErrorResult` with a `code`, not as an
exception, so the other models' forecasts still come back.

Every exception exposes:

| Attribute | Description |
|---|---|
| `status_code` | HTTP status returned by the gateway |
| `error_type` | `type` field from the gateway ErrorEnvelope |
| `message` | Human-readable reason |
| `details` | Optional structured detail dict |
| `trace_id()` | Best available trace identifier (body or header) |
| `retry_after_seconds` | Seconds to wait before retry (`RateLimitError` only) |

---

## HTTP status to exception mapping

| HTTP status | Exception class |
|---|---|
| 401 | `UnauthorizedError` |
| 402 | `PaymentRequiredError` |
| 403 | `ForbiddenError` |
| 404 | `NotFoundError` |
| 409 | `ConflictError` |
| 415 | `UnsupportedMediaTypeError` |
| 400 / 422 | `ValidationError` |
| 429 | `RateLimitError` |
| 502 | `BadGatewayError` |
| 503 | `ServiceUnavailableError` |
| 504 | `GatewayTimeoutError` |
| other 5xx | `ServerError` |
| anything else | `UnexpectedResponseError` |

---

## API reference

| Client method | Route | Returns |
|---|---|---|
| `forecast(models=, payload=)` | `POST /v1/forecast` | `ForecastResponse` |
| `forecast_raw(models=, payload=)` | `POST /v1/forecast` | `dict` (parsed JSON) |
| `forecast_batch(models=, payload=)` | `POST /v1/forecast/batch` | `BatchForecastResponse` |
| `models()` | `GET /v1/models` | `ModelCatalogResponse` |
| `models_online()` | `GET /v1/models_online` | `ModelCatalogResponse` |
| `models_served()` | `GET /v1/models_served` | `ModelCatalogResponse` |
| `models_blocked()` | `GET /v1/models_blocked` | `ModelCatalogResponse` |
| `model_families()` | `GET /v1/model_families` | `ModelFamiliesResponse` |
| `attributions()` | `GET /v1/attributions` | `AttributionsResponse` |
| `validate()` | `GET /v1/validate` | `ValidateAPIKeyResponse` |

Every model lives in `simulacrum.models` and is named after the gateway's
OpenAPI schema it mirrors:

| Model | Description |
|---|---|
| `NamedTensor` | A block of series in flat C-order form; time is the last dimension |
| `ForecastPayload`, `ForecastRequest` | Single-forecast request |
| `BatchWindow`, `BatchForecastPayload`, `BatchForecastRequest` | Batch (backtest) request |
| `ForecastResponse` | `results`: model id to `ModelForecastResult`, `ModelIncompatibleResult` or `ModelErrorResult` |
| `ForecastTargetTensor` | Dense quantile grid (`quantile_levels`, `quantiles`, `mean`) and knots (`median`, `p10`, `p25`, `p75`, `p90`) |
| `CostBreakdown`, `BatchCostBreakdown` | What a forecast cost, in micro-cents |
| `BatchForecastResponse`, `WindowForecast` | Batch results, one forecast per window |
| `ModelCatalogResponse`, `ModelCatalogEntry`, `ModelCapabilities` | The model catalog |
| `ModelLicense`, `ModelFamilyInfo` | A model's licence and family lineage |
| `ModelFamiliesResponse`, `ModelFamily`, `FamilyModelEntry` | The family catalog |
| `AttributionsResponse`, `AttributionEntry` | Notices to display |
| `ValidateAPIKeyResponse` | API key metadata |

`simulacrum.exceptions` holds the typed exception hierarchy (one class per
HTTP status).

---

## Upgrading to 2.0

2.0 matches the gateway's public `/v1` API. Releases on PyPI before 2.0
(0.x) used a different client: rewrite that code against the examples above.
Code written against 1.1 from source, whose requests the gateway rejected
with 422, needs these changes:

- `forecast()` returns a `ForecastResponse`. Read `response.results[model_id]`
  and branch on its type; `incompatible` and `error` results are no longer
  dropped.
- `NamedTensor` has no `names` field, and its `shape` needs at least two
  dimensions (`[1, T]` for one series). `values` may contain `None`.
- `ForecastTensor` is now `ForecastTargetTensor`, and `ForecastCost` is now
  `CostBreakdown`, with all six cost fields.
- `model_used` is gone: the key of `results` is the model id, and
  `cost.model` repeats it.
- `ValidateAPIKeyResponse` carries the gateway's fields (`valid`, `key_id`,
  `name`, `user_external_id`, `meta`, `permissions`, `roles`); `client` and
  `expires_at` are gone.
- HTTP 415 raises `UnsupportedMediaTypeError` instead of
  `UnexpectedResponseError`.
- New: `forecast_batch()`, `models()`, `models_online()`, `models_served()`,
  `models_blocked()`, `model_families()` and `attributions()`.

---

## Development

```bash
pip install -e ".[dev]"
pytest          # run all tests (no live gateway required)
ruff check .    # lint
ruff format .   # format
python -m build # build sdist + wheel (publish is human-only via twine)
```

`tests/fixtures/gateway_openapi.json` is the gateway's committed OpenAPI
schema, and `tests/test_contract.py` holds the SDK to it. To adopt a newer
gateway, copy its `gateway/openapi.json` over the fixture and update the
digest in that test.

---

## License

MIT (c) Simulacrum, Inc.
