# Composing a model

Fifteen minutes, one terminal, no SDK. By the end you will have composed a
**parametric rainfall trigger model** out of platform primitives, watched Craton
backtest it over forty years of pinned public data, read its auto-generated
model card, and used it to produce a technical price for a structure.

No code is involved anywhere. A v1 model is four things and nothing else:

| Primitive | What it is | In this example |
|---|---|---|
| **feed** | a pinned snapshot of public data, named by id and vintage | `era5-rain-bangkok@2026-08-11` |
| **measurement** | the variable the model reads from that feed | `daily_precipitation_mm` |
| **transform** | how the daily measurement becomes one index level | `rolling_sum` over 3 days |
| **payout** | how that level becomes a share of the limit | a two-point `step` |

That is the whole vocabulary. It is deliberately small: every model composed
this way is deterministic against a pinned snapshot, so its result replays byte
for byte, and Craton can validate it automatically before it exists.

Read [the quickstart](quickstart.md) first if you have never called Craton — it
covers `CEDE_BASE_URL`, signing up, and the job pattern this page assumes.

---

## What you need

* `curl` 7.76 or newer and `python3`. Nothing else.
* `CEDE_BASE_URL` — the environment you are pointed at.
* An API key — and, as in the quickstart, you do not need one in advance: the
  block below signs you up if `CEDE_API_KEY` is unset.

```sh
export CEDE_BASE_URL="https://…"     # no trailing slash
export CEDE_API_KEY="…"              # optional: leave it unset and sign up below
```

---

## 1. See what you can build with: `GET /primitives`

The builder's whole vocabulary in one call — every pinned feed, the
measurements each one publishes, every transform and every payout function.
A feed has to be pinned before a model can name it, so this is the call that
tells you which `id` and `version` pairs exist;
[the pinned data feeds](feeds.md) is the same list written out, with each
feed's coverage and licence:

```sh
curl -sS --fail-with-body -H "Authorization: Bearer $CEDE_API_KEY" \
  "$CEDE_BASE_URL/primitives"
```

Abbreviated, the part this page uses:

```json
{
  "schema_version": "0.1.0",
  "feeds": [
    {
      "id": "era5-rain-bangkok",
      "version": "2026-08-11",
      "name": "ERA5 daily precipitation — Bangkok grid point",
      "kind": "rainfall_daily",
      "licence": { "name": "CC-BY-4.0" },
      "period_of_record": { "start": "1986-01-01T00:00:00Z", "years": 40 },
      "measurements": [{ "variable": "daily_precipitation_mm", "unit": "mm" }]
    }
  ],
  "transforms": [
    { "type": "rolling_sum", "index_unit": "the measurement's own unit" },
    { "type": "rolling_maximum", "index_unit": "the measurement's own unit" },
    { "type": "threshold_day_count", "index_unit": "days" }
  ],
  "payout_functions": [{ "type": "step" }, { "type": "linear" }]
}
```

A feed with an empty `measurements` list is a snapshot the platform holds but
has no reader for yet; the builder will refuse it by name rather than guess at
its columns.

---

## 2. Compose the model: `POST /models`

Send metadata and the four primitives. Nothing else — `declared_inputs`, the
validation report and the model card are produced by Craton, not supplied by you.

```sh
curl -sS --fail-with-body \
  -H "Authorization: Bearer $CEDE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "metadata": {
      "name": "Bangkok 3-day excess rainfall",
      "version": "1.0.0",
      "license": "CC-BY-4.0",
      "peril": "flood",
      "region": {"country": "TH", "description": "Bangkok"}
    },
    "logic": {
      "feed": {"id": "era5-rain-bangkok", "version": "2026-08-11"},
      "measurement": {"variable": "daily_precipitation_mm"},
      "transform": {"type": "rolling_sum", "window_days": 3},
      "payout": {"type": "step", "points": [
        {"level": 100, "payout_ratio": 0.5},
        {"level": 150, "payout_ratio": 1}
      ]}
    }
  }' \
  "$CEDE_BASE_URL/models"
```

`metadata.version` and `metadata.license` are mandatory. `author` is not yours
to send: Craton sets it from the account that composed the model.

The two payout functions differ in exactly one way, and it is the one worth
being sure of before you compose. **`step` is flat between its points and never
interpolates:** the ratio is the one attached to the highest point the index
level reaches, so the points above pay **0.5 at 130 mm** — the 100 mm point in
full — and nothing between 0.5 and 1 is ever produced. Below the first point the
ratio is zero. **`linear` is the ramp:** zero below `attachment`,
`maximum_payout_ratio` at `exhaustion` and above, straight-line between the two,
so the same two levels pay **0.6 at 130 mm**. Composed over one feed the two
publish columns that look alike and mean opposite things, so no result asks you
to remember which one you sent — see below.

`201`, and the body is the model — including everything the automatic
validation pass produced. **The pass is not optional and not sampled.** A model
that fails any part of it is not created, and you get the reason instead:

1. **schema conformance** — the composed model validates against the
   published model schema, `model-object.v0.schema.json`;
2. **determinism** — the backtest is run twice and the two results are compared
   as bytes. `validation.determinism.result_digest` is the sha256 of those
   bytes;
3. **a full backtest** — every whole year of the feed's record, year by year;
4. **degenerate-case flags** — minimally *always pays* and *never pays*;
5. **the model card** — assumptions, limitations, `license` and authorship,
   generated rather than written.

Abbreviated:

```json
{
  "id": "mdl_…",
  "status": "private",
  "validation": {
    "determinism": { "passed": true, "evaluations": 2, "result_digest": "…" },
    "backtest": {
      "window": { "start": "1986-01-01", "end": "2025-12-31", "years": 40 },
      "burn_rate": 0.1,
      "triggering_years": 6,
      "maximum_index_level": 198.7,
      "payout_function": { "type": "step", "semantics": "flat_steps", "…": "" },
      "years": [{ "year": 1986, "index_level": 44.7, "payout_ratio": 0 }],
      "data_coverage": { "years_with_missing_days": [] }
    },
    "degenerate_flags": { "flags": [], "triggering_years": 6, "years": 40 }
  },
  "model_card": {
    "title": "Bangkok 3-day excess rainfall",
    "version": "1.0.0",
    "license": "CC-BY-4.0",
    "licence": "CC-BY-4.0",
    "authorship": { "author": "acct_…", "note": "…" },
    "…": "peril, region, inputs, method, assumptions, limitations, measurements"
  }
}
```

The card restates `metadata.license` under **`model_card.license`**, the same
name you sent it under. `model_card.licence` is the same string under this
API's older spelling of the word; it is kept so that nothing already reading it
breaks, and the two can never disagree. Read `license`.

Models are **private by default**. `GET /models/{id}` returns yours; another
account's model is not found.

### `validation.backtest.payout_function`: the rule, not just the name

Every year row is a level and a ratio, and the ratio alone does not say which
function produced it. So the backtest carries the function it evaluated — the
same block a Backtest artifact carries ([backtesting a
structure](backtest.md)), from the same code — with every point, the
attachment, the ceiling, and the rule for reading them in two forms:

* `evaluation` — the rule in words, for you;
* `semantics` — the same fact as a constant, for a client that must branch on
  it: `flat_steps` or `linear_interpolation`. Nothing else can be there, and
  every ratio in the years above is reproducible from the points and this one
  field.

A `linear` model publishes the identical shape; its two `points` are exactly its
two ends, `(attachment, 0)` and `(exhaustion, maximum_payout_ratio)`.

```json
"payout_function": {
  "type": "linear",
  "semantics": "linear_interpolation",
  "unit": "mm",
  "points": [
    { "level": 100, "payout_ratio": 0 },
    { "level": 150, "payout_ratio": 1 }
  ],
  "maximum_payout_ratio": 1,
  "attachment_level": 100,
  "evaluation": "Straight-line interpolation between the two points, never flat. …"
}
```

The model card restates the rule in its assumptions, so a reader who has only
the card still knows whether a level between two points steps or ramps.

### Try composing a worthless one

Worth doing once, because it is the check most model builders do not have. Move
the first step down to `0` mm and the model pays every year of the record:

```json
"payout": {"type": "step", "points": [{"level": 0, "payout_ratio": 1}]}
```

The model is still created — it is a legitimate thing to compose — but
`validation.degenerate_flags` now carries `always_pays` at severity `high`, and
the flag is restated in the model card's limitations, where anyone reading the
card meets it. Push the level to `500` mm instead and you get `never_pays`.

---

## 3. Run it: `POST /models/{id}/runs`

A model measures over a **risk object's** cover period and applies its payout
ratio to that object's limit, so you need an object first. The model declares
which fields it reads (`declared_inputs.risk_object_fields`) and reads those
and nothing else.

```sh
curl -sS --fail-with-body \
  -H "Authorization: Bearer $CEDE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"object_id": "…"}' \
  "$CEDE_BASE_URL/models/$model_id/runs"
```

`202` and a job, like every other verb. Poll `GET /jobs/{id}`; the result
carries `model_run`:

```json
{
  "run_id": "run-model-…",
  "model": { "id": "mdl_…", "name": "Bangkok 3-day excess rainfall", "version": "1.0.0" },
  "index": {
    "level": 198.7,
    "unit": "mm",
    "payout_ratio": 1,
    "measured_from": "2012-09-04",
    "measured_to": "2012-09-06"
  },
  "technical_price": { "basis": "technical", "amount": 873836.05, "currency": "THB" },
  "expected_loss": { "amount": 501369.86, "currency": "THB" },
  "assumptions": { "…": "inputs read, feed vintage, burn, loadings, limitations" }
}
```

`run_id` is content-addressed: the same model over the same period against the
same snapshot produces the same id and the same bytes, every time.

---

## The whole thing, in one block

Run this in an empty directory. `CEDE_BASE_URL` is the only thing it needs from
you.

<!-- cede:runnable -->

```bash
set -euo pipefail

: "${CEDE_BASE_URL:?export CEDE_BASE_URL first — see 'What you need'}"

# Read one field out of a JSON document on stdin: `field a.b.0.c`.
field() {
  python3 -c 'import json, sys
document = json.load(sys.stdin)
for step in sys.argv[1].split("."):
    document = document[int(step)] if step.isdigit() else document[step]
print(document)' "$1"
}

# 1 — A key, if you do not have one.
if [ -z "${CEDE_API_KEY:-}" ]; then
  account=$(curl -sS --fail-with-body \
    -H "Content-Type: application/json" \
    -d '{"label": "model builder"}' \
    "$CEDE_BASE_URL/signup")
  CEDE_API_KEY=$(printf '%s' "$account" | field api_key.secret)
  export CEDE_API_KEY
  printf 'your key (shown once): %s\n' "$CEDE_API_KEY"
fi
auth="Authorization: Bearer $CEDE_API_KEY"

# 2 — What can this environment build with?
primitives=$(curl -sS --fail-with-body -H "$auth" "$CEDE_BASE_URL/primitives")
feed=$(printf '%s' "$primitives" | field feeds.0.id)
vintage=$(printf '%s' "$primitives" | field feeds.0.version)
printf 'feed: %s@%s\n' "$feed" "$vintage"

# 3 — Compose the model. Four primitives; no code anywhere.
model=$(curl -sS --fail-with-body -H "$auth" \
  -H "Content-Type: application/json" \
  -d "{
    \"metadata\": {
      \"name\": \"Bangkok 3-day excess rainfall\",
      \"version\": \"1.0.0\",
      \"license\": \"CC-BY-4.0\",
      \"peril\": \"flood\",
      \"region\": {\"country\": \"TH\"}
    },
    \"logic\": {
      \"feed\": {\"id\": \"$feed\", \"version\": \"$vintage\"},
      \"measurement\": {\"variable\": \"daily_precipitation_mm\"},
      \"transform\": {\"type\": \"rolling_sum\", \"window_days\": 3},
      \"payout\": {\"type\": \"step\", \"points\": [
        {\"level\": 100, \"payout_ratio\": 0.5},
        {\"level\": 150, \"payout_ratio\": 1}
      ]}
    }
  }" \
  "$CEDE_BASE_URL/models")
model_id=$(printf '%s' "$model" | field id)

# 4 — What the automatic validation pass found. This is the backtest: it ran
#     on creation, over every whole year the feed covers.
printf 'model %s composed and validated\n' "$model_id"
printf '  backtest: %s years, %s of them triggering, burn rate %s\n' \
  "$(printf '%s' "$model" | field validation.backtest.window.years)" \
  "$(printf '%s' "$model" | field validation.backtest.triggering_years)" \
  "$(printf '%s' "$model" | field validation.backtest.burn_rate)"
printf '  determinism: replayed byte for byte, digest %s\n' \
  "$(printf '%s' "$model" | field validation.determinism.result_digest)"
printf '  worst year on record: %s mm over 3 days\n' \
  "$(printf '%s' "$model" | field validation.backtest.maximum_index_level)"

# 5 — Read the model back. Yours, and only yours: another account gets a 404.
#     The card restates the metadata you sent, under the names you sent it
#     under: model_card.license is the metadata.license from step 3.
card=$(curl -sS --fail-with-body -H "$auth" "$CEDE_BASE_URL/models/$model_id")
printf 'model card: %s, licensed %s\n' \
  "$(printf '%s' "$card" | field model_card.title)" \
  "$(printf '%s' "$card" | field model_card.license)"

# 6 — A structure to measure over: a cover period and a limit.
object=$(curl -sS --fail-with-body -H "$auth" \
  -H "Content-Type: application/json" \
  -d '{
    "schema_version": "0.1.0",
    "status": "draft",
    "exposure": {"kind": "index", "currency": "THB",
      "source_fidelity": {"unmapped_columns": [], "guessed_units": [],
                          "ambiguous_rows": []}},
    "peril": {"code": "flood", "region": {"countries": ["TH"]}},
    "financial_structure": {
      "limit": {"amount": 5000000, "currency": "THB"},
      "attachment": {"value": 100, "unit": "mm",
                     "index_ref": "bangkok-3-day-rainfall"}
    },
    "period": {"inception": "2012-01-01T00:00:00Z",
               "expiry": "2012-12-31T00:00:00Z",
               "timezone": "Asia/Bangkok"}
  }' \
  "$CEDE_BASE_URL/objects")
object_id=$(printf '%s' "$object" | field id)

# 7 — Run the model against it. A job, like every verb.
job_id=$(curl -sS --fail-with-body -H "$auth" \
  -H "Content-Type: application/json" \
  -d "{\"object_id\": \"$object_id\"}" \
  "$CEDE_BASE_URL/models/$model_id/runs" | field id)

status=queued
for _ in $(seq 1 60); do
  job=$(curl -sS --fail-with-body -H "$auth" "$CEDE_BASE_URL/jobs/$job_id")
  status=$(printf '%s' "$job" | field status)
  case "$status" in succeeded|failed) break ;; esac
  sleep 1
done
if [ "$status" != succeeded ]; then
  printf 'model run %s\n' "$status" >&2
  printf '%s\n' "$job" >&2
  exit 1
fi

printf 'index level: %s %s -> payout ratio %s\n' \
  "$(printf '%s' "$job" | field result.model_run.index.level)" \
  "$(printf '%s' "$job" | field result.model_run.index.unit)" \
  "$(printf '%s' "$job" | field result.model_run.index.payout_ratio)"
printf 'technical price: %s %s (%s basis)\n' \
  "$(printf '%s' "$job" | field result.model_run.technical_price.amount)" \
  "$(printf '%s' "$job" | field result.model_run.technical_price.currency)" \
  "$(printf '%s' "$job" | field result.model_run.technical_price.basis)"
printf 'produced by model %s version %s\n' \
  "$(printf '%s' "$job" | field result.model_run.model.id)" \
  "$(printf '%s' "$job" | field result.model_run.model.version)"
```

---

## What just happened

**`GET /primitives`** enumerated the builder's vocabulary from the same tables
the composer enforces, so what it lists is exactly what `POST /models` accepts.

**`POST /models`** composed the model *and validated it*, synchronously. This
is the one place Craton does not answer with a job: a model does not exist until
the whole pass has accepted it, so there is no identifier to hand back early.

**The backtest** ran over the feed's whole forty-year record on creation. Years
where the feed has missing days are disclosed in `data_coverage`, never filled
in — a rainfall index computed from the days that happened to be present would
read lower than the weather that actually fell.

**`POST /models/{id}/runs`** measured the index over the object's cover period,
applied the payout function, and priced the structure off the model's own burn
rate. The run is appended to the object's `provenance.model_runs`, carrying the
model and version that produced it.

## What Craton does not do with any of this

Worth being explicit, because a trigger model invites the assumption:

* The price is a **technical price** — an actuarial estimate, carrying the
  assumption set that produced it. It is not a price at which any party stands
  ready to transact, and it carries no capacity and no validity period. Every
  price response says so in a fixed sentence pinned by the published schema.
* An index at or above its attachment level is a **measurement**, not a
  determination. `indicated_payout` is arithmetic — payout ratio times limit.
  Craton determines nothing, settles nothing, and moves no money.
* Craton **never ranks, recommends or endorses a model**, yours or anyone's. The
  numbers on a model card are measurements of that model over the record. There
  is no score, no ordering and no comparison to another model anywhere in the
  product.

## What is not here yet

Stated plainly so you do not go looking:

* **Publication and the public registry.** Models are private today. Publishing
  a version — making it immutable and public, with its performance metrics
  displayed as measurements — is the next piece of this surface.
* **More feeds.** One rainfall snapshot is composable today. The quake
  catalogue behind the Price verb is held but has no builder reader yet, so
  `GET /primitives` lists it with an empty `measurements` array rather than
  pretending.
* **More transforms and payout functions.** Two of each. Growing them is
  ordinary work; user-submitted *code* models are not — they are out of scope
  for v1 by specification and need an amendment before any work on them begins.

## Reference

| Route | What it does |
|---|---|
| `GET /primitives` | every feed, measurement, transform and payout function you can compose with |
| `POST /models` | compose a model; runs the automatic validation pass; `201` or the reason it was refused |
| `GET /models/{id}` | a model, its validation report and its card |
| `POST /models/{id}/runs` | run a model against a risk object; `202` and a job |

Every one of these bodies has a published JSON Schema —
`model-primitives.v0.schema.json`, `model-object.v0.schema.json` and
`model-run.v0.schema.json` — in Craton's open-source schema repository, which you
can consume without a Craton account and validate against without holding any
Craton code. `GET /health` names the surfaces this environment serves; the
published API contract maps each route and status to the schema that governs
it.
