Skip to main content
This is the complete path from a brand-new client to a live inference endpoint. Every call here is real — no mock steps. If you only want the operator-facing portal walkthrough, see Onboard a Client; this guide goes further and ends with a model actually serving traffic.

What you are building

client profile → data source → upload → validate → ingest issues
      → TRAIN  (clustering runs here)
      → EVALUATE (quality gates decide if the model is fit to serve)
      → PROMOTE (a passing model becomes the active artifact)
      → INFER  (POST /v1/inference/cluster assigns new issues to loops)
The clustering algorithm blends three signals — semantic embeddings, TF-IDF lexical overlap, and an 8-factor severity matrix — into one weighted graph, cuts it at an edge threshold, and runs greedy-modularity community detection to produce Tier 1 themes and Tier 2 loops nested inside them. Training freezes those loops as centroids; inference assigns new issues to the nearest loop, or honestly abstains.

Before you start: two settings that silently break everything

WM2_ARTIFACTS must be true. It defaults to false. With it off, training completes and looks successful, but never writes centroids.json, config_lock.json, or the model_artifacts row. Evaluation then 422s ("training run has no wm@2 artifact"), promotion has nothing to promote, and inference has nothing to load. This is the single most common way this runbook fails.
export WM2_ARTIFACTS=true          # REQUIRED — writes the artifacts eval/inference need
export MODEL_STORE_URI=file:///abs/path/to/model-store   # where artifacts land
export EVAL_REQUIRED=true          # keep true: refuses to serve an unevaluated model
export ENVIRONMENT=development
export AUTH_PROVIDER=none          # dev only
export PROVISIONING_PORTAL_ENABLED=true
Mint a staff token (dev):
python -m scripts.mint_dev_token ws_01 usr_01
export TOKEN=...        # staff role: ml_eng (training) / admin (overrides)

1. Create the client profile

curl -sX POST $API/v1/provisioning/client-profiles \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"display_name":"Meridian Home Health","tenant_slug":"meridian",
       "industry":"healthcare_home_health","primary_format":"xlsx_csv"}'
Returns {client: {id, workspace_id, status: "draft", ...}}. Keep id and workspace_id.

2. Register a data source, upload, validate

# register
curl -sX POST $API/v1/provisioning/client-profiles/$CLIENT/data-sources \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"kind":"upload"}'

# upload the client's issue export
curl -sX POST $API/v1/provisioning/client-profiles/$CLIENT/data-sources/$DS/upload \
  -H "Authorization: Bearer $TOKEN" -F "file=@issues.csv"

# validate: maps their columns onto our canonical fields, then INGESTS real issue rows
curl -sX POST $API/v1/provisioning/client-profiles/$CLIENT/data-sources/$DS/validate \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"schema_map":{"Issue ID":"external_id","Issue Description":"text",
                     "Identification Date":"observed_at"}}'
Validate is the step that actually creates issues rows and embeds them. The client status flips draft → data_validated. You need enough issues for clustering to mean anything — a handful of rows will produce a degenerate partition that the gates will (correctly) reject.
The clustering text is built from the issue’s body plus the canonical concern / cause / consequence fields, with title backfilling concern when it is absent. Issues with no text at all are rejected rather than silently clustered on an empty string.

3. Train

curl -sX POST $API/v1/provisioning/client-profiles/$CLIENT/training-runs \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"kind":"initial","params":{}}'
This launches a real engine run: feature_forge → graph_weave → partition → dsu_crosscheck → loop_anchor → term_namer → …. Clustering happens inside those stages, using the pinned cluster config (not whatever is “currently active” — the run freezes its config by content hash so the run is reproducible). Poll it, or stream the console:
curl -s $API/v1/provisioning/training-runs/$RUN -H "Authorization: Bearer $TOKEN"
curl -sN $API/v1/provisioning/training-runs/$RUN/events \
  -H "Authorization: Bearer $TOKEN" -H 'Accept: text/event-stream'
Wait for status: "succeeded". The run registers a candidate model version — candidates are never auto-activated.

4. Evaluate — the gates decide, not you

curl -sX POST $API/v1/provisioning/training-runs/$RUN/evaluate \
  -H "Authorization: Bearer $TOKEN"

curl -s $API/v1/provisioning/training-runs/$RUN/evaluation \
  -H "Authorization: Bearer $TOKEN"    # the scorecard
The scorecard reports every gate. Hard gates block promotion; soft ones only inform:
GateHard?Asks
E-DEThardIs the training run bit-reproducible?
E-COVhardDid enough issues actually get clustered?
E-DEGhardIs the partition non-degenerate (no one mega-loop, sane loop count)?
E-SILhardDo the clusters actually separate? (silhouette ≥ 0.20)
E-HOLDhardDo held-out issues land where they should?
E-PROBEhardIs assignment stable under small perturbations?
E-AGRsoftDoes an independent union-find crosscheck agree?
A failing hard gate is the system working. E-DEG and E-SIL exist to catch exactly the failure where clustering collapses everything into two enormous blobs — a result that looks like success (a run “succeeded”) but is useless. If E-SIL fails, do not lower the threshold. Fix the data or the config, and read the diagnosis in §8.
If the report is passed, or a human approves it:
curl -sX POST $API/v1/provisioning/training-runs/$RUN/approve -H "Authorization: Bearer $TOKEN"

5. Promote

Promotion is phased — you do not have to flip straight to live:
# optional: shadow (score in parallel, serve nothing) then canary (a % of traffic)
curl -sX POST $API/v1/provisioning/models/$WS/$VERSION/shadow  -H "Authorization: Bearer $TOKEN"
curl -sX POST $API/v1/provisioning/models/$WS/$VERSION/canary  -H "Authorization: Bearer $TOKEN" \
     -H 'Content-Type: application/json' -d '{"traffic_pct":10}'

# make it the active artifact
curl -sX POST $API/v1/provisioning/models/$WS/$VERSION/promote -H "Authorization: Bearer $TOKEN"

# and if it misbehaves
curl -sX POST $API/v1/provisioning/models/$WS/$VERSION/rollback -H "Authorization: Bearer $TOKEN"
Only a promoted version serves inference. With EVAL_REQUIRED=true (the default and the right setting), an artifact that never passed its gates will not be loaded, even if you point inference at it — the model simply reports itself unavailable.

6. Infer

Check what is serving:
curl -s $API/v1/inference/model -H "Authorization: Bearer $USER_TOKEN"
{ "model": { "available": true, "version": 3, "content_hash": "9f2c…",
             "embed_manifest": "bootstrap@v0", "loop_count": 36 } }
available: false is an honest state, not an error — it means nothing has been trained, or what was trained did not pass its gates and so was never promoted. The reason field says which. Then score issues:
curl -sX POST $API/v1/inference/cluster \
  -H "Authorization: Bearer $USER_TOKEN" -H 'Content-Type: application/json' \
  -d '{"issues":[{"id":"iss_123","text":"Nightly ACH settlement batch deadlocks and misses cutoff."}]}'
{
  "model": { "available": true, "version": 3, "loop_count": 36 },
  "assignments": [
    { "issue_id": "iss_123", "loop_id": "loop_7", "score": 0.81, "margin": 0.14,
      "abstained": false, "artifact_version": 3, "content_hash": "9f2c…" }
  ]
}
Key properties:
  • Tenancy comes from your token, never the request body — you cannot score against another tenant’s model.
  • It is read-only. It scores and returns; it creates no patterns and appends no events, so it needs no Idempotency-Key. (The ingest path does the stamping.)
  • It abstains rather than guesses. An issue whose best match is below the model’s tau_assign, below that loop’s local threshold, or too close to a runner-up comes back abstained: true with a reason (below_tau_assign, margin_too_small, no_embedding, no_promoted_model). An abstention is a correct answer, not a failure.
  • Issues that already have a stored embedding reuse it; unseen text is embedded on the fly with the same provider the rest of the system uses.

7. Use it from the platform

The frontend talks to the same endpoint through the standard client:
// apps/platform/lib/api/inference.ts
import { apiClient } from './client'

export async function getInferenceModel() {
  return apiClient.get('inference/model').json<{ model: ModelCard }>()
}

export async function clusterIssues(issues: { id: string; text: string }[]) {
  return apiClient.post('inference/cluster', { json: { issues } })
    .json<{ model: ModelCard; assignments: Assignment[] }>()
}
Auth headers are attached automatically. Add matching handlers to lib/mock/router.ts so mock-mode dev keeps working. Render abstentions explicitly — a loop_id: null with a reason is information the user should see, not an empty cell.

8. When the gates fail: reading the diagnosis

E-DEG fails / E-SIL below 0.20 / everything lands in 2 huge clusters. This is a signal balance problem, and it has a known cause. The three clustering signals live on very different scales:
signalmean pairwise similaritystd — its actual discriminating power
embedding0.3270.111
lexical0.0550.050
severity0.8410.074
Severity has the highest mean and one of the smallest spreads — it is close to a constant offset rather than a signal. If it is weighted too heavily, it alone pushes every pair over the edge threshold: the graph becomes ~99.9% dense, the threshold stops doing anything, and community detection collapses the corpus into two blobs. There is a second, architectural reason the embedding must dominate: inference assigns by embedding cosine to loop centroids. A partition that is not embedding-coherent has centroids that mean nothing in the space where assignment actually happens — which is what E-SIL and E-PROBE detect. The shipped defaults account for both (w_embed 0.70 / w_tfidf 0.20 / w_severity 0.10, edge threshold 0.46). If you override them, keep severity as a tiebreaker.
Do not tune against E-SIL alone — it is gameable by fragmentation (a corpus split into singletons scores a perfect 1.0). E-COV and E-DEG are what stop that. Any retune has to clear all of them together.
You can tune this per workspace on the pinned cluster config:
curl -sX POST $API/v1/configs/cluster/versions \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"body":{"w_embed":0.7,"w_tfidf":0.2,"w_severity":0.1,
               "edge_threshold_large":0.46,"edge_threshold_small":0.36}}'
Then retrain. Because the run pins its config by content hash, the new run is reproducible and the old one is untouched. Everything abstains at inference. The model’s loops are tighter than your incoming issues. Check loop_count — if it is very high relative to your corpus, the partition is over-split. Evaluation 422s with “no wm@2 artifact”. WM2_ARTIFACTS is not true. See the warning at the top.

9. What is deliberately not here

  • There is no “just give me clusters without training” endpoint. Assignment requires a promoted artifact, on purpose: an assignment that cannot be traced to an evaluated, content-hashed model is not one you can defend to an auditor.
  • Inference never invents a loop. is_new_loop is always false; genuinely novel issues abstain and are picked up by the next training run.