Metadata-Version: 2.4
Name: radmah-sdk
Version: 1.4.0
Summary: RadMah AI Python SDK — typed client for the RadMah AI platform
Author-email: ITLOX <sales@radmah.ai>
License-Expression: LicenseRef-Proprietary
Project-URL: Homepage, https://radmah.ai
Project-URL: Documentation, https://docs.radmah.ai/sdk/installation
Project-URL: Issues, https://radmah.ai/contact
Keywords: synthetic-data,simulation,sdk,api-client,radmah
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.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx<0.29.0,>=0.25.0
Requires-Dist: pydantic<3.0.0,>=2.0.0
Requires-Dist: blake3<2.0.0,>=1.0.0
Requires-Dist: cryptography<51.0.0,>=50.0.1
Dynamic: license-file

# RadMah AI Python SDK

Typed client for the RadMah AI platform. Create jobs, upload datasets, stream
results, and retrieve cryptographic evidence bundles — all from Python.

> **Requires:** Python 3.10 or newer. `radmah_sdk.__version__` reports the installed version.

---

## Install

```bash
pip install radmah-sdk
```

From this repository during development:

```bash
pip install -e ./sdk
```

---

## Authentication

Get a scoped API key from **Settings → API Keys** in the RadMah AI dashboard
(the key is shown once, at creation). Keys are prefixed `sl_live_`.

```python
from radmah_sdk import RadMahClient

client = RadMahClient(api_key="sl_live_abcdef...")
```

Base URL defaults to `https://api.radmah.ai`. Enterprise customers running
their own deployment point the client at that deployment's API URL:

```python
client = RadMahClient(
    api_key="sl_live_...",
    base_url="https://radmah.example.com",  # your deployment's API URL
    timeout=60.0,      # per-request timeout seconds
    max_retries=3,     # transparent retry on 5xx / connection errors
)
```

Or via env var:

```bash
export RADMAH_API_URL=https://radmah.example.com
```

A deployment on your private network (an RFC 1918 address, an IPv6
unique-local address or a name under `.internal`) also needs an explicit
opt-in, so that a base URL reaching your program from somewhere else cannot
aim your API key at an internal service: pass `allow_private_base_url=True`,
or export `RADMAH_ALLOW_PRIVATE_BASE_URL=1` when the code does not pass that
argument (an explicit `allow_private_base_url=False` always refuses). Use
`https://` (set `RADMAH_CA_BUNDLE` or `ca_bundle_path=` to your CA's PEM
file when its certificate comes from your own CA). Plain `http://` is
accepted only for this machine (`localhost`); to any other host it would send
the key unencrypted, so it is refused (`BaseURLError`, reason `cleartext`)
unless you opt in by name with `allow_insecure_http=True` or
`RADMAH_ALLOW_INSECURE_HTTP=1`, and even then the SDK issues a
`radmah_sdk.InsecureBaseURLWarning`. (The refusal and the opt-in are new in
1.4.0; SDK 1.3.0 accepts such a URL with the warning alone.)
Cloud instance-metadata endpoints are always refused, in every spelling
the transport accepts (Unicode dots, numeric and IPv6 forms). A server's
region redirect never moves the key from a public host into your network or
from `https://` to `http://`. A refused base URL raises
`radmah_sdk.BaseURLError`, and an unusable CA bundle
`radmah_sdk.CABundleError` naming its setting and path; both are
`ValueError`s.

---

## Quickstart — reviewed Fabricate generation

```python
from radmah_sdk import RadMahClient

with RadMahClient(api_key="sl_live_...") as client:
    preview = client.create_fabricate_preview(
        "I need 10,000 account records with a unique account_id, "
        "a created_on date, an active flag, and a non-negative balance.",
        requested_records=10_000,
        seed=42,
    )
    preview_id = preview["preview_id"]
    state = client.wait_fabricate_preview(preview_id, timeout=300)

    # Review state["contract_versions"] before approval. Apply a refinement
    # with client.refine_fabricate_preview(...) when the contract needs one.
    approval = client.approve_fabricate_preview(preview_id)
    job = client.get_job(approval["job_id"])
    job = job.wait(timeout=300)

    if job.status == "succeeded":
        # The SDK requires exactly one server-declared primary artifact.
        df = job.to_dataframe()
        print(df.head())
        evidence = client.get_evidence(job.id)
        print("Evidence contract hash:", evidence.contract_hash)
```

Every job produces a cryptographically sealed evidence bundle. Ask the
platform to re-verify it with `client.verify_job(job.id)`, or verify it
yourself offline — without trusting the API — by recomputing the hashes over
the downloaded bytes:

```python
from radmah_sdk.verify import verify_evidence_bundle

result = verify_evidence_bundle(client.get_evidence_data(job.id))
print(result.ok, result.kind, result.failures)
```

---

## Core operations

### Jobs

```python
from radmah_sdk import SynthesisReleasePolicy

release_policy = SynthesisReleasePolicy(
    minimum_overall_fidelity=0.75,
    minimum_column_fidelity=0.75,
    minimum_bivariate_fidelity=0.60,
    maximum_membership_advantage=0.20,
    maximum_linkage_risk=0.05,
    require_zero_exact_copies=True,
    block_live_identifiers=False,  # optional; off by default
)

# Fidelity thresholds use an equal-sized independent source baseline under the
# same untouched audit reference. Linkage limits apply to measured excess above
# the empirical source rate. Raw, baseline, and release values remain in the
# evidence bundle.
#
# block_live_identifiers=True refuses delivery when any delivered TEXT value is
# shaped like an email address, US SSN, NHS number, payment card number, IBAN or
# telephone number outside the formally reserved test ranges (example.com-style
# domains, 555-01xx numbers, SSN area 000). A refused job fails with
# SYNTHESIS_RELEASE_POLICY_FAILED, publishes nothing, and names each column,
# count and identifier format (never a value) in
# job.result_summary["synthesis_release_failure"]. Numeric columns are not
# checked. With the control off (the default) the data is delivered and any
# detection is reported in
# job.result_summary["release_policy_evaluation"]["value_safety"] and sealed as
# an observational record, which evaluate_value_safety_bundle() reports with
# enforcement="observational". It is a control you choose, not a guarantee that
# delivered data contains no identifiers.

client.jobs.create(
    kind="synthesize",
    dataset_id=dataset_id,
    rows=10_000,
    seed=42,
    release_policy=release_policy,
)
client.jobs.list(status="succeeded", offset=0, limit=50)
client.jobs.get(job_id)
client.jobs.cancel(job_id)
client.jobs.rerun(job_id)
```

The generic jobs endpoint accepts `synthesize`, `synthesis_probe` (see
[Quick probe](#quick-probe)), `simulate`, `analyze`, `verify`, `train`,
`fit`, `lift`, and `scenario_fabricate`. Prompt-to-data
Fabricate deliberately uses `create_fabricate_preview`, optional
`refine_fabricate_preview`, and `approve_fabricate_preview`; it cannot be
submitted through the generic jobs endpoint.

### Datasets

```python
# Upload from a path (auto-detects CSV / Parquet / JSON / JSONL).
# A display name without an extension inherits the real source extension.
dataset = client.upload_dataset("./data.csv", name="Transactions")

# Or from bytes.
dataset = client.upload_dataset_bytes(
    filename="data.csv",
    content=b"...",
)

# The server parses and stores the whole file before it answers, so a large
# upload waits up to an hour for its answer by default (the client's
# `timeout` still bounds connecting). Set `timeout=` in seconds, or
# RADMAH_UPLOAD_TIMEOUT; `rady datasets upload --timeout` does the same.
dataset = client.upload_dataset("./large.json", timeout=7200)

client.list_datasets()
client.get_dataset(dataset_id)
client.delete_dataset(dataset_id)
```

### Saved generators: export and import

A job that trained a Synthesis model can export it as a signed generator
package (`.rsg`). The file carries no executable content: it is a
pickle-free tensor payload in a header signed by your workspace's key, and
it can be imported only into the workspace that exported it, for a dataset
holding the data the model was trained on.

```python
exported = client.export_synthesis_model(job_id, "generator.rsg")
print(exported.sha256, exported.fitted_state_hash)

# Import it for a dataset uploaded from the same source file. The returned
# job verifies the package and the dataset; the generator is reusable once
# it has succeeded.
imported = client.import_synthesis_model("generator.rsg", dataset_id).wait()

client.list_synthesis_models(dataset_id, include_imported=True)
client.jobs.create(
    kind="synthesize",
    dataset_id=dataset_id,
    rows=10_000,
    seed=42,
    checkpoint_source_job_id=imported.id,
    release_policy=release_policy,
)
```

Generating from the imported generator with the same seed, rows and release
policy delivers byte-identical data to generating from the model it was
exported from.

### Quick probe

Before paying for a full Synthesize run, probe it. A quick probe trains on a
sample of your dataset with the exact settings of the run you plan (rows,
mode, training controls, time cap, release policy, numeric constraints,
compute) and reports estimates: what the probe itself took, the full run's
wall time (at the epoch ceiling, which is not an upper bound: the full run
can take longer; and at the point the sample converged, or `None` with the
reason), whether the run would hit the 4-hour job limit or your training
cap, its peak memory against the full run's worker memory and admission
floor, and each release bar's likely outcome
(`likely_pass`, `uncertain` or `likely_refuse`; predictive utility is
`advisory`). A figure carries a range only where calibration supports one;
otherwise its label is `uncalibrated` or `outside_calibrated_range` and it
has no range. Every figure is an estimate, never a release decision, and the
probe delivers no rows and no model. Its arguments and defaults are the ones
the planned run takes.

```python
quote = client.estimate_cost(
    "synthesis_probe",
    rows=10_000,
    dataset_id=dataset_id,
    epochs=400,
)
probe = client.probe_synthesis(
    dataset_id,
    10_000,
    epochs=400,
    seed=42,
    release_policy=release_policy,
    max_credits=quote["credits_required"],
).wait(timeout=3600)

report = client.get_synthesis_probe_report(probe.id)
print(report.deterministic_sha256)  # same image, data, settings, seed: same digest
ceiling = report.estimates.time_at_ceiling_seconds
print(ceiling.point, ceiling.label, ceiling.calibrated_interval)
for row in report.release_predictions:
    print(row.bar, row.sample_value, row.calibrated_interval, row.status)
for line in report.limitations:
    print(line)  # fixed statements; show them as given

# The report file the probe's evidence bundle hashes, digest-checked.
report_bytes = client.download_synthesis_probe_report(probe.id)

# Start the full run from the probe with the SAME settings; the run then
# records the probe's estimates against what it measured.
run = client.jobs.create(
    kind="synthesize",
    dataset_id=dataset_id,
    rows=10_000,
    seed=42,
    options={"epochs": 400},
    release_policy=release_policy,
    probe_job_id=probe.id,
).wait(timeout=3600)
print(run.probe_validation.comparable, run.probe_validation.quantities)
```

A probe refuses seed data and model reuse with
`SYNTHESIS_PROBE_UNSUPPORTED_CONTROL`, and a dataset below the Synthesis
row floor with `DATASET_TOO_SMALL_FOR_SYNTHESIS_TRAINING`, before any job
or charge exists. From the shell, `rady synthesize --probe` takes every
`rady synthesize` flag, and `--json` prints the same public report.

### Inference on a saved model

A saved generator (a succeeded `train` or `synthesize` job, or an imported
package's job) can answer questions about **your own rows**. Upload a table
with some or all of the model's source columns, then run one of five
operations:

| Operation | What each row gets |
|---|---|
| `impute` | Its missing cells filled; every present cell is delivered unchanged. By default (`statistic="most_likely"`) each categorical cell gets the most likely fitted category given the row's present categorical cells and the bands of its present numeric cells (flow-only cells, for example dates, and positions within a numeric band are not used) and each number or date the conditional median of `samples` model draws; `statistic="mean"` gives decimals the conditional mean; `statistic="draw"` fills each row with one joint model draw (keeps the data's spread and relationships — use it, with several seeds, for multiple imputation). `imputed_cells.csv` lists each filled cell and its method. |
| `predict` | The target's most likely category and its probability, or, for a numeric target, the median (default) or mean (decimal targets only) of its sampled values. |
| `predict_proba` | The probability of each fitted category of the target, or of each interval of the `bins` you supply for a numeric target. |
| `log_prob` | The row's **log-likelihood** under the model, in nats: the log-probability that the model delivers exactly this row as written (categories and nulls as delivered, each number at its delivered precision), with its Monte-Carlo standard error and which parts were exact or estimated. |
| `state_log_prob` | The log-probability, in nats, of the row's fitted **discrete state** only: its categories, missing flags, numeric range bands and character states as the model holds them. |

```python
model_job_id = trained_job.id           # or an imported generator's job id
profile = client.inference_profile(model_job_id)
print(profile.enabled, profile.default_samples, profile.probability_tolerance)
for entry in profile.columns:            # what each column supports
    print(entry.column, entry.representation, entry.operations)

target = profile.columns[0].column       # a column the profile allows as a target

# input is an uploaded dataset id, or a local file uploaded first.
job = client.predict_proba(model_job_id, "./my_rows.csv", target, seed=42).wait()
print(job.inference.results.rows_exact, job.inference.results.rows_estimated)
print(job.inference.privacy_statement)

result = client.inference_result(job.id)  # checked against seal.json first
print(result.sha256, result.columns)
for row in result.rows[:5]:
    print(row)                            # strings, None for an empty cell

filled = client.impute(model_job_id, dataset_id).wait()
print(client.inference_result(filled.id).imputed_cells[:5])
```

The same methods are on `client.jobs` and, as coroutines, on `AsyncRadMah`
(wait on a job there with `await client.wait_for_job(job.id)`). The rady CLI
runs them as `rady synthesis-model impute | predict | predict-proba |
log-prob | state-log-prob | profile`.

**Samples and precision.** `predict`, `predict_proba`, `log_prob` and
`state_log_prob` draw `samples` model states per row. The default is
`DEFAULT_INFERENCE_SAMPLES` = 400: the draws that resolve any probability to
within ±0.05 at 95% confidence even in the worst case (p = 0.5), since the
95% half-width is at most 1/√n. Input rows × samples (× the model's
`log_prob_draw_cost` for `log_prob`) may not exceed `INFERENCE_DRAW_BUDGET`
(10,000,000) per job; `impute` with `statistic="draw"` uses one draw per row.
That product is the quoted and charged quantity for every row: a row computed
exactly, or one that stops early, is not cheaper.

**Exact, estimated and sampled rows.** Every result row carries a `status`:

* `exact` — computed from the fitted state with no sampling, so it does not
  change with the seed;
* `estimated` — a Monte-Carlo estimate; the row reports its effective sample
  size and 95% half-width (1/√ESS), and the record gives the smallest ESS and
  the worst half-width in `job.inference.results`;
* `sampled` — reduced from `samples` seeded model draws of the row (a numeric
  target; the result reports the draws used), or, for `impute`, a row whose
  missing cells were filled by a model draw;
* `outside_fitted_support` — the row gets no score or prediction (never
  -inf or NaN), and `outside_support_reasons` says why. Most reasons mean the
  row's known values have no support in the fitted model (for
  `combination_never_delivered`, the model's exact discrete-state probability
  of the row is zero). For `log_prob`, `not_reproduced_in_sample_budget`
  means something weaker: the row's draws never reproduced its values after
  its full sample budget. That is not a proof of probability zero — the
  row's probability is below what that many draws can detect, and more
  `samples` may score it.

`job.inference.coverage` names every column and how it was used: as the
target, as evidence, only through its range band, or not at all.

**What `log_prob` computes.** The model draws a discrete state (categories,
missing flags, numeric range bands, character states) from an exact
chain-rule model, then a latent vector from its base prior, transports it
with a flow (the ODE integrated by the same Euler steps the generator
ships), and decodes it. `log_prob` evaluates that same process for your row:

* the **discrete part** is the chain's exact probability of the row's
  states (`discrete_part = exact`), or, when a chain column is unknown, an
  unbiased estimate over its possible states (`estimated`);
* the **continuous part** is the flow's density, by the change-of-variables
  formula along the ODE, integrated over every latent value that decodes to
  the row's delivered cells; latent coordinates the row does not pin
  (nuisance coordinates, and the missingness indicator of a nullable
  numeric) are marginalised by importance sampling, so this part is always
  `estimated` when the flow is read;
* the **divergence** in the change-of-variables formula is computed exactly
  from the full Jacobian for models whose latent has up to 64 coordinates
  (`divergence = exact_jacobian`) and by the Hutchinson/Skilling estimator
  beyond (`hutchinson_skilling_estimate`, with `divergence_variance`).

**Precision.** `log_prob` estimates rows together and stops each one at a
declared precision: `LOG_PROB_TARGET_STANDARD_ERROR` = 0.025 nats (a 95%
half-width of ±0.05 on the log-likelihood, so the likelihood itself is known
to about ±5%). Rows draw 32, then 64, 128, … up to `samples` (default 400);
`samples_used` and `target_met` say where each row stopped.
The quote and the charge are for the most draws a row may take, on every row:
input rows × `samples` × `profile.log_prob_draw_cost`, whether a row is
exact, stops early or is outside the fitted support. A `log_prob` draw counts
as `profile.log_prob_draw_cost` model draws — the priced cost for that model:
1 for a model with no flow (discrete state only), 2 when the model's fitted
transport is affine (its learned residual was not accepted at training), more
when every Euler step needs the residual's Jacobian. Current servers always
state it; if an older server leaves it `None`, do not assume 1 — quote the
job (`client.quote_inference(...)`) and use its `credits_required`.

Each row reports `log_prob`, `standard_error` (the Monte-Carlo standard
error of that log), `effective_sample_size`, `discrete_log_prob` /
`continuous_log_prob` where the split is exact, and `scored_columns` /
`marginalised_columns`. A number is scored at its **delivered precision** —
a date to the day, a decimal to the model's output decimals — so the score
is the probability of that cell, not a density of an unrounded value. A row
the model can never deliver is `outside_fitted_support` with no score, and
`outside_support_reasons` says why, per column
(`combination_never_delivered` only when the model's exact discrete-state
probability of the row is zero). A row whose draws never reproduced it after
its full sample budget is also `outside_fitted_support`, with the reason
`not_reproduced_in_sample_budget`: that is not a proof of probability zero —
its probability is below what that many draws can detect, and more `samples`
may score it. The common case of a proved zero is a date:
when a date column is mostly repeated dates, the model learns it as a
schedule (the dates seen at least twice) and only ever produces those dates,
so a row with any other date has likelihood zero (`date_not_in_fitted_schedule`).
A date column learned as continuous time scores any date within its fitted
range. Mark such a column unknown (`unknown_columns`) to score the rest of
the row. A row whose flow inversion or
divergence estimate cannot be trusted is `estimation_failed`. Each sample
inverts the flow step by step; when the model's learned residual transport
was not accepted at training the transport is affine and that inversion and
its determinant are closed-form, otherwise each step takes the residual's
Jacobian (exact up to 64 latent coordinates, estimated beyond), which costs
more per sample than the other operations.

**Refusals.** A request the API cannot run is refused before any job exists
with HTTP 422 and a `SYNTHESIS_INFERENCE_*` code (for example
`SYNTHESIS_INFERENCE_TARGET_REQUIRED`, `_BINS_INVALID`,
`_SAMPLES_OUT_OF_RANGE`, `_COLUMN_UNKNOWN`, `_NOT_ENABLED`), raised as
`ValidationError`. The SDK checks the parts that need no knowledge of the
model — fields that do not apply to the operation, the target, the bin edges
and the sample count — itself, with the same codes, before anything is
uploaded. For a local file it also reads the model's inference profile first
and refuses, before uploading anything, what the profile already decides:
inference not enabled on the plan (`_NOT_ENABLED`), a model inference cannot
use, or a column the model does not have (`_COLUMN_UNKNOWN`). If the API
refuses the job after the SDK uploaded your file for it, the SDK deletes that
uploaded dataset (best effort) and the error's message and
`detail["uploaded_input_deleted"]` say whether it was deleted; after a server
or network failure the upload is kept (the job may exist) and the error names
its dataset id. Details name columns and counts only, never a cell value.

**Quotes.** `client.quote_inference(model_job_id, dataset_id, operation, ...)`
(also on `client.jobs` and `AsyncRadMah`) prices a request before submitting
it and reserves nothing; it sends `POST /v1/client/jobs/quote` with the same
body the client area quotes (`kind`, `checkpoint_source_job_id`, `inference`)
and is equivalent to `client.estimate_cost(kind="synthesis_inference",
checkpoint_source_job_id=..., inference={...})`. A quote needs an uploaded
dataset id: it never uploads a file. Pass its `credits_required` as
`max_credits` to submit bounded by it. The rady inference commands show it
with `--quote`.

**Privacy.** These results are model inference on your own rows. They are
not a privacy-protected or differentially private release, the release
policy does not apply to them, and the job reads no source rows
(`job.inference.source_rows_read` is `False`). Treat the output with the same
care as the rows you uploaded.

### Account + auth helpers

```python
client.signup(email=..., password=..., org_name=...)
client.login(email=..., password=...)             # returns {access_token, refresh_token, mfa_pending?}
client.mfa_setup()                                 # TOTP provisioning URI
client.mfa_verify(code)                            # 6-digit TOTP OR 8-char backup code (xxxx-xxxx)

client.create_api_key(name="ci")                   # {raw_key, prefix, ...}
client.list_api_keys()
client.rotate_api_key(key_id)
client.revoke_api_key(key_id)
```

### Platform introspection

```python
client.health()            # service liveness
client.version()           # build + engine versions
client.get_openapi()       # full OpenAPI spec
client.get_error_catalog() # machine-readable error_code → message map
```

---

## Async client

For workloads that run many jobs in parallel or interleave with other
I/O (Jupyter, notebook pipelines, aiohttp servers), use
`AsyncRadMahClient`:

```python
import asyncio
from radmah_sdk import AsyncRadMah

async def main():
    async with AsyncRadMah(api_key="sl_live_...") as client:
        preview = await client.create_fabricate_preview(
            "I need 250 inventory records with a unique item_id and quantity "
            "between 0 and 500.",
            requested_records=250,
            seed=42,
        )
        preview_id = preview["preview_id"]
        await client.wait_fabricate_preview(preview_id)
        approval = await client.approve_fabricate_preview(preview_id)
        print("Final job:", approval["job_id"])

asyncio.run(main())
```

A quick probe from the async client:

```python
async def probe(dataset_id, release_policy):
    async with AsyncRadMah(api_key="sl_live_...") as client:
        job = await client.probe_synthesis(
            dataset_id, 10_000, seed=42, release_policy=release_policy,
        )
        await client.wait_for_job(job.id, timeout=3600)
        return await client.get_synthesis_probe_report(job.id)
```

The async surface mirrors the sync one method-for-method.

---

## Error handling

All API errors surface as `RadMahError` with a structured payload:

```python
from radmah_sdk import RadMahError

try:
    client.approve_fabricate_preview("unknown-preview-id")
except RadMahError as e:
    print(e.status_code)      # HTTP status
    print(e.error_code)       # machine-readable (CONTRACT_NOT_FOUND, etc.)
    print(e.message)          # human-readable
    print(e.detail)            # optional context dict
```

Common error codes: `INVALID_API_KEY`, `CONTRACT_NOT_FOUND`,
`CREDIT_LIMIT_EXCEEDED`, `CONCURRENT_JOB_LIMIT`,
`AGENT_SESSION_LIMIT`, `GOAL_OUT_OF_SCOPE` (ADS only).

Pull the full catalogue at runtime: `client.get_error_catalog()`.

---

## Agentic Data Scientist (ADS)

ADS turns a natural-language goal into a planned, human-approved,
self-healing multi-step run that seals a cryptographic evidence trail.
The SDK exposes the full lifecycle (same surface on `AsyncRadMah`,
awaited):

```python
# 1. Create + wait for the plan to reach the approval gate.
project = client.create_agent_project(
    goal="Profile the customers dataset, engineer tenure + spend "
         "features, train a churn classifier, evaluate on a 20% holdout.",
    title="Churn baseline — Q3",
)
project = client.wait_for_agent_project(project.id, timeout=120)

# 2. Approve the plan (HAGP gate) — execution begins.
if project.status == "awaiting_approval":
    quote = project.cost_summary or {}
    client.approve_agent_project(
        project.id,
        max_credits=quote["total_estimated_credits"],
        plan_sha256=quote["approval_plan_sha256"],
    )

# 3. Poll to a terminal state, routing any mid-run decision gate.
while True:
    project = client.wait_for_agent_project(project.id, timeout=600)
    s = (project.status or "").lower()
    if s in ("complete", "failed", "blocked"):
        break
    if s == "awaiting_patch_approval":
        client.submit_patch_decision(project.id, "accepted")
    elif s == "awaiting_replan_approval":
        client.submit_replan_decision(project.id, "accepted")
    elif s == "awaiting_step_approval":
        gate = project.pending_step_approval or {}
        consent = gate.get("consent") or {}
        if gate.get("trigger_reason") == "execution_consent":
            # Running the simulation: bind the exact cost you were shown.
            client.submit_step_approval(
                project.id, "approved",
                max_credits=consent["maximum_credits"],
                consent_fingerprint=consent["fingerprint"],
            )
        elif gate.get("trigger_reason") == "pack_preparation_consent":
            # Preparing a new reusable scenario pack.
            client.submit_step_approval(
                project.id, "approved", max_credits=consent["maximum_credits"],
            )
        else:
            client.submit_step_approval(project.id, "approved")
    elif s == "awaiting_escalation_review":
        client.submit_escalation_decision(project.id, "retry")

# 4. Read the sealed output + verify the replay bundle offline.
if project.status == "complete":
    output = client.get_agent_project_output(project.id)
    verdict = client.verify_replay_bundle(project.id)   # BLAKE3 + Ed25519, client-side
    assert verdict["ok"]
elif project.status == "blocked":
    client.resume_project(project.id, "retry_failed")    # or skip_failed / restart_step
```

**Simulation costs are approved in two parts.** A SCADA simulation's price
depends on its scenario pack, so it is never approved blind:

1. *Pack preparation.* If a compatible pack already exists it is reused at
   no cost. Otherwise the plan's `cost_summary["stages"]` shows the one-off
   cost of preparing a new, reusable pack, and approving the plan authorises
   only that — the plan's `total_estimated_credits` is the maximum you
   authorise now, and `total_expected_credits` is the expected figure.
2. *Execution.* Once the pack exists, the run is priced from it and the
   project pauses in `awaiting_step_approval` with
   `pending_step_approval["trigger_reason"] == "execution_consent"`. The
   `consent` block carries `expected_credits`, `maximum_credits`, the
   workload `basis` and a `fingerprint`; approve with `max_credits` equal to
   the maximum shown and `consent_fingerprint`. The run cannot be charged
   above the credits you approve: authorising less than its maximum stops it
   before it starts (nothing is charged) and the cost is asked for again. If
   the cost changed after it was shown the API answers 409 and nothing is
   approved. When the pack already exists
   at planning time, the execution is priced in the plan and approving the
   plan covers it.

`client.estimate_job(contract, prompt_text=...)` gives the same two-part
price before you create a project (`stages`, with `basis` equal to
`"bounded_estimate"`). `rady ads approve` and `rady ads decide <id> approve`
show both costs and bind them the same way.

Other lifecycle methods: `list_agent_projects`, `get_agent_project`,
`cancel_agent_project`, `delete_agent_project`, `stream_agent_project`
(SSE), `get_evidence_chain`, `verify_trail`, `get_evidence_entries`,
`get_replay_bundle`, `list_agent_memories` / `delete_agent_memory`
(CSSM), `list_agent_tools`, and `execute_agent_tool` (run one planner
tool directly). The `rady ads` CLI drives the same surface from a
shell. ADS-only error codes: `AGENT_SESSION_LIMIT`, `GOAL_OUT_OF_SCOPE`.

---

## Cryptographic evidence

Successful Fabricate, Synthesize, and simulation generation paths expose a
BLAKE3-sealed multi-file evidence bundle. The exact file count varies by
generation and output shape. Retrieve and verify programmatically:

```python
evidence = client.get_evidence(job.id)
print(evidence.seal_hash)          # binding hash over the bundle's artifacts
client.verify_job(job.id)          # re-computes every artifact hash server-side
```

Artifacts include the sealed contract JSON, the run manifest, the
generated data, the constraint / determinism / privacy / utility
reports, and the hash manifest. The full schema is in the documentation
(https://docs.radmah.ai/sdk).

---

## More

- Full API reference: https://docs.radmah.ai/sdk
- OpenAPI spec: `client.get_openapi()` at runtime

For issues, contact **support@radmah.ai**.

## Licence

Proprietary (`LicenseRef-Proprietary`); see the `LICENSE` file shipped with
the package. You may read the source and use the unmodified package to access
the RadMah AI platform under your account agreement; you may not copy, modify,
redistribute or reuse it.
