The change pre-check over HTTP

POST the graph with what the job will do, and get back, as JSON and before the change takes a GPU or serves traffic, the same four answers the bot and the CLI give. No browser, no LLM, deterministic, derived from the graph and the config rather than measured.

  1. What changed. plan with a base: the structure diff, the parameter and memory delta, how far the change reaches.
  2. What it needs. plan or check: peak memory per card, the smallest card that holds it, what one run costs.
  3. Is the layout legal and balanced. check with layout: TP, PP and DP divisibility, work and memory on each pipeline stage, the bubble.
  4. Does the config match the reference. check with serve and data: the serving launch against the model it serves, and the data against what the model accepts.

Machine-readable: /openapi.json (OpenAPI 3.1). A test runs the hosted handler on real graphs and fails when a field it documents stops coming back.

EndpointAnswersUse it when
POST /api/v1/check The whole verdict on a graph, and with data, layout or serve in the body: does the data fit, is the parallel layout legal and balanced, is the serving launch legal for this model. An agent, a scheduler hook or a script needs the decision, not the rule list.
POST /api/v1/plan A plan for one graph: layers, params, will it forward-pass, which GPUs it fits, a cost estimate, and against a base what changed and how far it reaches, with your policy lines applied. With a key, your own outcome history is attached too. A bot comments on a pull request; neurarch-trace --plan prints a card.
GET /api/v1/history What this structure did the last time it trained under your key. The bot wants the one line a model cannot write.
POST /api/v1/train A real GPU run for a design, submitted on your account, and refused outright if the verifier says the design will not forward-pass. An agent, or a bot on a pull request, wants the measurement and not another estimate.
GET /api/v1/train?job= The curve, the final metrics, the wall clock and the cost of one of your runs. Polling the run you just submitted, so the bot can post it.
POST /api/v1/fix Unified diffs for a plan's blockers, built here from the code a model quotes back. Validated before they leave. Never applied here. The bot opens a fix pull request and lets CI re-verify it.
POST /api/v1/lint Just the structural rules that fired, with stable ids. CI wants an exit code and a diff-able list.
GET /api/v1/activity What was done under your keys: plans that name a repository, runs an agent submitted, the outcome ledger, 30-day totals, and what the record cannot say. Someone has to answer "what did the bot do last month".

lint is one stage of check. Both are the same verifier the editor runs on every edit, and neither calls a model.

If your graph cannot leave your network, the same answers are available from a container you run yourself. See Run it inside your own network. One older endpoint, rank, is kept for agents that already call it and is documented under Research endpoints.

Authentication

Mint a key in the app under Settings → Developer API. It's shown once; copy it then. Send it as a bearer token:

Authorization: Bearer nrk_your_key_here

Keys are hashed at rest (we store only a SHA-256); revoke any key from the same screen.

Use the www host. The apex answers every /api route with a 307 to www.neurarch.com, and both curl -L and fetch drop the Authorization header across that hop. A valid key sent to the apex comes back as a 401 that blames you.

What a keyed call records. Every endpoint writes one anonymous structural row for the graph you send: a fingerprint, the layer-type histogram, the edge count, and the {ruleId, severity} pairs that fired. Never the graph, never parameter values or layer names, and no caller identity, not even the account the key belongs to. It is the one thing we record that you cannot switch off, because it is the grade we just computed for you; if a graph cannot leave your network, run the verifier locally instead (neurarch-mcp or the GitHub Action compute the same verdict offline). What is stored and what never is: security and data handling.

The whole verdict

POST https://www.neurarch.com/api/v1/check

Five stages run in order (pre-flight, data, train, evaluate, deploy) and the response carries what each of them concluded. The walk stops at the first blocker, because every verdict after a graph that cannot forward-pass would be fiction.

Request

{
  "model": {
    "components": [
      { "id": "in",   "type": "input",     "name": "input",
        "params": { "shape": [3, 32, 32] } },
      { "id": "c1",   "type": "conv2d",    "name": "conv1",
        "params": { "inChannels": 3, "outChannels": 32, "kernelSize": 3, "padding": 1 } },
      { "id": "r1",   "type": "relu",      "name": "relu1",   "params": {} },
      { "id": "p1",   "type": "maxpool2d", "name": "pool1",   "params": { "kernelSize": 2 } },
      { "id": "fl",   "type": "flatten",   "name": "flatten", "params": {} },
      { "id": "fc",   "type": "linear",    "name": "fc",
        "params": { "inFeatures": 8192, "outFeatures": 10 } },
      { "id": "out",  "type": "output",    "name": "output",  "params": {} }
    ],
    "connections": [
      { "from": "in", "to": "c1" }, { "from": "c1", "to": "r1" },
      { "from": "r1", "to": "p1" }, { "from": "p1", "to": "fl" },
      { "from": "fl", "to": "fc" }, { "from": "fc", "to": "out" }
    ]
  }
}

Input shapes carry no batch dimension: [3, 32, 32] is C, H, W. A leading 1 is read as the channel axis and will quietly change every shape and cost below it.

Response

{
  "verdict": "ask",
  "designVerdict": "ask",
  "verdictSources": ["design"],
  "verdictParts": { "design": "ask" },
  "outcome": "needs_input",
  "summary": "No dataset is wired yet, so a run would train on random tensors.",
  "stoppedAt": null,
  "findings": [
    { "stage": "evaluate", "severity": "warn", "title": "No test cases",
      "detail": "Nothing would catch a regression between one run and the next.",
      "fix": "Starter cases can be derived from the input contract right now, for free." },
    { "stage": "deploy", "severity": "warn", "title": "Never trained",
      "detail": "This analysis is about the architecture. Nothing has been trained, so there are no weights to ship." }
  ],
  "stages": [
    { "stage": "preflight", "status": "ok",
      "headline": "Ready to train: 5 layers, 0.08M params, about $0.04 / 3m on an A10G (24GB).",
      "data": { "score": 98, "verdict": "ready", "blockers": [], "warnings": [],
                "layers": 5, "params": 82826, "flops": 1001472,
                "gpu": "A10G (24GB)", "estCostUsd": 0.0422, "estTrainSec": 138.3 } },
    { "stage": "data", "status": "needs_input", "headline": "No dataset is wired yet…",
      "data": { "wired": false, "modality": "vision",
                "contract": { "inputShape": [3, 32, 32], "inputDtype": "float32",
                              "outputDim": 10, "taskType": "classification" } } },
    { "stage": "train", "status": "needs_input", "headline": "Not trained yet…",
      "data": { "lastRun": null, "estCostUsd": 0.0422, "gpu": "A10G (24GB)" } },
    { "stage": "evaluate", "status": "attention",
      "headline": "Nothing measured yet: there is no training run to judge.", "data": { "run": null } },
    { "stage": "deploy", "status": "attention",
      "headline": "Best target is Cloud GPU T4 (100/100): ~1.0ms per inference, 0.3 MB.",
      "data": { "target": "cloud-t4", "targetChosenBy": "score", "score": 100, "latencyMs": 1.0,
                "sizeMB": 0.316, "sizeMBQuantized": 0.079, "alternatives": [ /* nine scored targets */ ] } }
  ],
  "decision": {
    "question": "Which data should this train on?",
    "because": "Everything else about the run is derived from the graph. This is not: the design says it wants vision input of shape (3, 32, 32), but not which corpus you have.",
    "options": [
      { "label": "CIFAR-10", "value": "hf:cifar10", "hint": "60k 32×32 colour images, 10 classes" },
      { "label": "No data yet, smoke-test it", "value": "synthetic", "hint": "Random tensors matching the input contract" }
    ]
  }
}
FieldMeaning
verdictblock · ask · warn · ok, worst first. The worst of the graph's verdict and of every data, layout, serve or spec block the request carried. A launch the serve check blocks is block here, whatever the graph says. This is the one field to gate on.
designVerdictThe graph's own verdict: the worst status any stage reported. Equal to verdict on a request with no blocks.
verdictPartsEach answered part's own verdict, keyed design, serve, layout, data, change. A block is block if any of its findings is a blocker, warn if any is a warning, ok otherwise; info findings never move it. A part the request did not carry is absent.
verdictSourcesThe parts whose verdict equals verdict, so ["serve"] reads "the graph is fine, the launch is not". ["design"] on a request with no blocks.
outcomecomplete, blocked, or needs_input.
summaryOne sentence, the same one the app leads with. When something is blocked it is priced, and the price says what it rests on: "1 blocker would have failed a run priced at $0.17 / 10m on an A10G (24GB), assuming 50,000 samples, 10 epochs, batch 32."
stoppedAtThe stage the walk stopped at, or null if all five ran.
provisionalStages[]When the walk stopped at a blocker, the stages after it whose answer reads only the graph: Data's contract check when a dataset is wired, and Deploy's target analysis. Same shape as stages[] plus their own findings. Never counted in verdict, outcome or findings; empty when nothing stopped.
findings[]Only block and warn, each tagged with the stage that raised it. fix is present when there is one specific thing to change.
findings[].ruleIdThe check that raised it, where the finding is a check: head-dim-divisibility, gqa-head-divisibility. Every id has a page at /r/<id>.html with the measurement behind it, so a blocker can be looked up rather than taken on our word. Absent on findings that are not checks, which is most of the later stages: "no test cases" and "never trained" are states of your workflow, not verdicts on the graph.
stages[].dataThe machine-readable payload per stage: params, FLOPs, the GPU the cost is priced on (gpu, with gpuFits and smallestFitGpu saying whether it actually fits), estimated cost and wall-clock, the derived input contract, the best deploy target with its latency and quantized size. Pre-flight also carries the memory a run would need (weightBytes, trainFootprintBytes) and samplesAssumed, which is true when the sample count behind the cost was a default rather than something you told us. A cost with no provenance is a claim, so the flag ships next to it.
decisionPresent when something is genuinely a human's call, with because stating why it could not be derived. There are exactly two in the whole flow: which dataset, and whether to spend the money. Surface it, do not answer it: a tool that picks a corpus is inventing a premise.

One difference from the app

In the app, a question pauses the walk. Over HTTP it does not: a caller posting a bare graph has no session, so "no dataset is wired" is true of every request and would otherwise hide the deploy analysis behind it. A blocker still stops. Questions come back in decision, and the stages after them still run.

What the job will do: data, layout, serve

Each is optional, and each adds one report beside the verdict: dataFit, layout, serve. Send any combination in one call. Keys are snake case or camel case; an unknown key is ignored, and a known key with an unreadable value is a 400 that names it, so a typo is refused rather than silently dropped. Post the model's Hugging Face config.json as config when you have it: the data and serving checks read the declared context, vocabulary and quantization from it.

The top-level verdict is the worst of the graph and every report. These reports carry their own findings with their own severities, and the worst of them is folded into verdict (block, then ask, then warn, then ok). designVerdict keeps the graph's alone and verdictSources names what decided it. In the serve example below the graph only has the dataset question ("designVerdict": "ask") and the launch is blocked, so the response reads "verdict": "block", "verdictSources": ["serve"].

The examples are the real responses for Qwen/Qwen3-8B (the graph at /templates/qwen3-8b/model.json), trimmed where marked. Every number in them is derived from the graph, the config and the card, not measured, and each report says how in its basis field.

Will the data fit the model: data

{ "model": { ... },
  "data": { "seq_len": 65536, "tokenizer_size": 151669 } }
FieldMeaning
seq_lenTokens per sample. Checked against a learned position table (a hard error past it) or the declared context (a warning past it).
tokenizer_sizeIds the tokenizer can emit (alias vocab_size). More ids than embedding rows indexes outside the table.
num_classesLabels in the data (alias num_labels), against the classifier head.
image_sizePixels per side, against the vision model's patch grid.
channelsInput channels (alias num_channels).
"dataFit": {
  "checked": ["sequence length", "tokenizer size"],
  "skipped": [],
  "findings": [
    { "rule": "seq-over-context", "severity": "warn",
      "title": "Sequences of 65536 tokens are past the model's 40960-token context",
      "detail": "The config declares this as the longest input; past it, behaviour depends on the implementation.",
      "fix": "Truncate to 40960 tokens." }
  ],
  "contract": { "vocabSize": 151936, "maxPositions": 40960, "positionKind": "declared",
                "numLabels": null, "imageSize": null, "channels": null,
                "source": { "vocabSize": "the token embedding", "maxPositions": "the declared maximum length" } }
}

skipped names every check a field you sent could not be compared on, and why (a vision model has no token limit to compare seq_len with). A check that did not run is never reported as passed.

Is the parallel layout legal and balanced: layout

{ "model": { ... },
  "layout": { "tp": 4, "pp": 4, "dp": 8, "gpu": "h100",
              "micro_batch": 1, "global_batch": 512, "seq_len": 4096 } }
FieldMeaning
tp, pp, dp, cp, epTensor, pipeline, data, context and expert parallel sizes. Each defaults to 1.
micro_batch, seq_lenRequired. Samples per microbatch and tokens per sample.
global_batch | grad_accumEither one; with it the microbatch count per step is known, and the bubble and step time are computed at that count.
gpu, num_gpusA card id or an unambiguous prefix (h100, l4, a100-80g; default H100 SXM). num_gpus is checked against tp x pp x dp x cp.
schedule, virtual_stages"1f1b" (default) or "interleaved", and its virtual stage count.
layers_per_stageYour split, e.g. [9, 10, 10, 7]. Default: even.
zero_stage, fsdp, activation_checkpointingHow optimizer state and activations are held; each changes per-stage memory.
fused_cross_entropyWhen true, the last stage does not count the fp32 logits and their gradient.
kernel_stack"eager" or "fused": how many bytes the memory-bound ops move.
seq_len_histogram[{len, count}] or two-column CSV. Turns on the DP sequence-imbalance check.
"layout": {
  "checked": ["tp-heads", "tp-kv-heads", "tp-hidden", "tp-ffn", "tp-node", "cp-seq",
              "batch-arithmetic", "pp-over-blocks", "stage-balance", "pipeline-bubble", "stage-memory"],
  "skipped": [ { "check": "dp-seq-imbalance", "reason": "no sequence-length histogram given ..." }, ... ],
  "findings": [
    { "rule": "stage-imbalance", "severity": "warn",
      "title": "Stage 3 has 17.8% more predicted time than the mean stage (it also carries the LM head and the loss)",
      "detail": "Per-stage predicted ms per microbatch: 49.5 | 49.4 | 49.4 | 61.9. ...",
      "fix": "layersPerStage 9,10,10,7: predicted 12.3% faster steps at 64 microbatches (12.9% as microbatches grow)." }
  ],
  "resolved": { "gpu": "h100-sxm", "gpuMemoryGb": 80, "worldSize": 128, "microbatchesPerStep": 64,
                "globalBatch": 512, "blocks": 36, "layersPerStage": [9, 9, 9, 9],
                "schedule": "1f1b", "virtualStages": 1, "zeroStage": 0 },
  "stages": [ ...,
    { "index": 3, "layers": { "first": 28, "last": 36, "count": 9, "extras": ["head+loss"] },
      "params": 2359001472, "estMsPerMicrobatch": 61.9, "lossLogitsGb": 1.24,
      "peakGbPerGpu": 14.8, "peakGbUpperPerGpu": 21.2, "fitsGpu": "fits" } ],
  "imbalance": { "costBasis": "time", "maxOverMean": 1.178, "slowestStage": 3,
                 "estStepSlowdown": 0.168, "asymptoticSlowdown": 0.178,
                 "step": { "microbatches": 64, "currentMs": 4109.5, "balancedMs": 3519.0, "formula": "..." },
                 "rebalanced": { "layersPerStage": [9, 10, 10, 7], "maxOverMean": 1.044,
                                 "estStepSpeedup": 0.123, "asymptoticSpeedup": 0.129, "stepMs": 3661.0 } },
  "bubble": { "fraction": 0.045, "microbatches": 64, "microbatchesForTarget": null },
  "recommendations": [ "Rebalance: layersPerStage 9,10,10,7 (now 9,9,9,9); ..." ],
  "basis": "Derived from the graph, not measured. ..."
}

estStepSlowdown and rebalanced.estStepSpeedup are at this layout's own microbatch count; the asymptotic* fields are the limit as microbatches grow and overstate at small counts. Step times are compute only, per DP rank: no all-reduce, no optimizer step.

Does the serving launch match the model: serve

{ "model": { ... },
  "serve": { "command": "vllm serve Qwen/Qwen3-8B -tp 4 --max-model-len 65536",
             "gpu": "l4", "gpus": 4 } }
FieldMeaning
commandThe launch line as you would type it, for vLLM, SGLang or llama.cpp. Parsed for the flags the checks read.
enginevllm, sglang or llamacpp, when command does not say.
argsAlready-parsed flags; they win over command key by key.
gpu, gpusThe card (id or unambiguous prefix: h100, l40s, l4, a10g, ...) and how many.
model_idThe Hugging Face id, when the command does not name it. Used to find the engine's published recipe for that model and compare it with your flags.
tokenizer_config, chat_template, generation_configThe files themselves. Without them the template and sampler checks are skipped, and say so.
samplertemperature, top_p, top_k, repetition_penalty your clients send.
againstA reference: { model, config?, tokenizer_config?, chat_template?, generation_config?, model_id? }. The report then carries change: what differs between the two at the configuration level.
"serve": {
  "engine": "vllm",
  "resolved": { "gpu": "l4", "gpuMemoryGb": 24, "gpus": 4, "tp": 4, "pp": 1, "dp": 1,
                "dtype": "bf16", "quant": null, "kvCacheDtype": "auto",
                "servedLen": 65536, "derivedMaxLen": 40960,
                "engineVersion": "vLLM 0.32.0", "engineVersionAssumed": true,
                "defaultsAssumed": [ "gpu_memory_utilization 0.92 (vLLM >= 0.20.0 default)", ... ] },
  "checked": ["gpu-count", "tp-heads", "tp-kv-heads", "tp-ffn", "context",
              "quant-support", "memory", "memory-fit", "recipe"],
  "skipped": [ { "check": "chat-template", "reason": "no tokenizer_config.json or chat_template.jinja was given ..." },
               { "check": "sampler", "reason": "no generation_config.json ..." } ],
  "findings": [
    { "rule": "context-over-derived", "area": "context", "severity": "block",
      "title": "Served length 65536 is past the model's 40960-token context",
      "detail": "The engine refuses to start (\"User-specified max_model_len (65536) is greater than the derived max_model_len (40960)\") unless VLLM_ALLOW_LONG_MAX_MODEL_LEN=1 is set ...",
      "fix": "Serve at most 40960, or add rope scaling where the model card supports it ..." },
    { "rule": "kv-capacity", "area": "memory", "severity": "info",
      "title": "At most 7 concurrent 65536-token sequences per replica (474,841 KV tokens)", ... },
    { "rule": "recipe-reasoning-parser", "area": "template", "severity": "info",
      "title": "The vLLM recipe for Qwen/Qwen3-8B passes --reasoning-parser qwen3; this command does not", ... }
  ],
  "memory": { "weightsGbPerGpu": 4.10, "budgetGbPerGpu": 21.6, "kvBytesPerTokenPerGpu": 36864,
              "kvCapacityGbPerGpu": 17.5, "tokenCapacity": 474841, "maxConcurrentFullLength": 7.24,
              "method": "upper-bound", "notCounted": [ "the engine's activation reserve ...", ... ] },
  "change": null,
  "basis": "Predicted from config.json, the engine flags and the card, not measured. ..."
}

engineVersionAssumed: true means you did not name a release and the newest one the tables were read from was used; every default that filled in is listed in defaultsAssumed. Memory is an upper bound: what the engine reserves at startup comes out of the same budget and is listed under notCounted. This answers whether the launch is legal for the model at the configuration level; whether the served outputs match the reference is a different question, and this does not answer it.

Example: let an agent check its own design

curl -sS https://www.neurarch.com/api/v1/check \
  -H "Authorization: Bearer $NEURARCH_API_KEY" \
  -H "Content-Type: application/json" \
  --data @model.json \
  | jq '{verdict, summary, blockers: [.findings[] | select(.severity=="block") | .title]}'

Loop it: generate a graph, check it, feed findings[].fix back to the model, check again. Nothing in that loop costs GPU time, which is the point of running it before one exists.

A plan for a graph

POST https://www.neurarch.com/api/v1/plan

The front door. No key needed (60 plans an hour per address without one). A policy block needs no key either, because your own rules can only make the verdict stricter; with a key, one more thing happens, and it is the one that matters: your own history for that structure is looked up and attached, so the bot gets the whole comment from one call. share: true stores the graph at a public /p/<id> URL and is the only way anything here is kept.

Request

{
  "model":  { "components": [...], "connections": [...] },
  "base":   { "components": [...], "connections": [...] },   // optional: the version before
  "policy": {                                               // optional: your .neurarch.yml lines
    "max_params": "200M", "must_fit": "A100-40GB",
    "forbid_types": ["softmax"], "require_types": ["batchNorm"],
    "max_layers": 120, "max_memory_gb": 30, "max_cost_usd": 5
  },
  "attention": "sdpa",                                      // optional: "sdpa" (default) | "flash" | "eager"
  "seq_len": 2048,                                          // optional: tokens per sample (default 2048, or the graph's own length if shorter)
  "share": false,
  "source": { "kind": "bot", "target": "models/gpt.py:GPT", "input": "1,128:long" }
}

attention names the kernel the run uses. The default is SDPA, Hugging Face's default, which keeps no attention score matrix for backward; so does flash attention. Only "eager" adds the stored scores to the training peak, and the plan says how many GB eager would add when it would matter.

seq_len is the tokens per sample a text model is sized at. The default is 2048, or the graph's own length when that is shorter, because a Hugging Face config declares its context window (Qwen3-0.6B: 40,960) and nobody trains at that. The plan prints the length it used, as in at batch 8, 2048 tokens. Image and other non-token graphs are not changed.

Response

{
  "plan": {
    "version": 1,
    "summary": "GPT: 62 layers, 124.4M params, will run, fits a T4, about $1.2 to train, vs GPT: params +12.6M",
    "model": { "name": "GPT", "layers": 62, "params": 124439808, "inputShape": [128], "outputShape": [128, 50257], "fingerprint": "3f1c9a02" },
    "run":   { "legal": true, "blockers": [], "warnings": [] },
    "cost":  { "estCostUsd": 1.21, "estTrainSec": 3820, "fitsGpu": "A10G (24GB)", "refusedRunUsd": null,
               "gpuFits": { "T4": true, "A100_40": true, "H100_80": true }, "assumptions": "..." },
    "diff":  { "base": { "name": "GPT", "layers": 62, "params": 111820800 },
               "added": 0, "removed": 0, "changed": 2, "paramsDelta": 12619008, "paramsDeltaPct": 11.3,
               "memoryDeltaGb": 0.2, "costDeltaUsd": 0.12, "blastRadius": 48,
               "lines": ["~ blocks.0.attn.embedDim 768 -> 832", "..."], "legalityChanged": null },
    "policy":  { "applied": true, "violations": 0 },
    "history": { "rows": [ { "at": "2026-08-21T10:02:11Z", "metric": "valAcc", "value": 0.72,
                             "epochs": 3, "wallSec": 612, "estCostUsd": 0.40, "source": "colab" } ] }
  },
  "text":     "Plan: GPT   62 layers · 124.4M params ...",     // fixed width, for a terminal
  "markdown": "**GPT: 62 layers, ...**\n\n| | |\n..."          // for a pull request comment
}

refusedRunUsd is what the run this plan refuses was quoted at, and it is null on any plan that did not refuse one, including a verdict we withheld. It is the same figure as estCostUsd, restated where a caller acting on legal: false will actually read it: the reason to ask for a plan before dispatch is the number attached to not dispatching. It is a price, not a prevented run. We count a run as prevented only when someone asked for it and we refused to start it, so summing this field across plans would count every graph anybody ever pasted and is not a measurement of anything.

A policy line that fails is a blocker with stage: "policy" and the line quoted in detail, so a red check says which rule of yours it broke. Budgets (max_memory_gb, max_cost_usd) are warnings. When history is found, markdown and text carry one extra line, and it is the only line in the comment a model reading the diff could not have written: Last time this structure trained here: valAcc 0.72, $0.40, 2026-08-21.

Your own history for a structure

GET https://www.neurarch.com/api/v1/history?fingerprint=3f1c9a02

Key required, and the key's owner is the organisation: the answer contains your rows and nobody else's, ever. The fingerprint is the eight hex characters a plan returns as plan.model.fingerprint, and the match is exact, so these are outcomes of this structure and not of one that merely resembles it. Newest first, at most 20. plan does this lookup for you when a key is present, which is why the bot needs one call and not two.

{ "fingerprint": "3f1c9a02",
  "rows": [ { "at": "2026-08-21T10:02:11Z", "metric": "valAcc", "value": 0.72,
              "epochs": 3, "wallSec": 612, "estCostUsd": 0.4, "source": "colab" } ] }

Two sources feed it, both owned by you: runs Neurarch orchestrated for your account (simulated runs are excluded, because a heuristic curve is not an outcome), and outcomes written under your key. Anonymous NEURARCH_REPORT rows have no owner and are never returned here. An empty rows means this structure has no history under your key, which is the answer for every structure until the first one trains.

What was done under your keys

GET https://www.neurarch.com/api/v1/activity?days=30

A key, or a signed-in app session (the person reading it is in the app; the bot writing it holds a key). days is 1 to 90, default 30. It returns your API keys and when each was last used (never the key values), the plans that name a repository or a pull request, the runs an agent submitted, the attributed outcome ledger, refused runs, and totals.

Each lane carries a status: unavailable (the table could not be read), absent (nothing was ever written), silent (rows exist, none in the window) or active. A zero under absent means the field was never written, not that nothing happened, and gaps says what the record cannot answer in a sentence instead of a zero. A real response for an account with one key and nothing else:

{
  "windowDays": 30,
  "generatedAt": "2026-10-18T12:00:00.000Z",
  "keys": { "status": "active", "rows": [
    { "name": "ci", "keyPrefix": "nrk_23ARTo", "createdAt": "2026-09-02T10:00:00Z",
      "lastUsedAt": "2026-10-17T08:12:00Z", "revoked": false } ] },
  "repositories": [],
  "plans":     { "status": "absent", "rows": [], "unattributedInWindow": 0 },
  "runs":      { "status": "absent", "rows": [], "jobsInWindow": 0, "sourceTagged": 0 },
  "ledger":    { "status": "absent", "rows": [] },
  "prevented": { "status": "absent", "rows": [] },
  "totals": { "plansWithRepository": 0, "pullRequestsPlanned": 0, "repositories": 0,
              "runsSubmitted": 0, "runsCompleted": 0, "outcomesRecorded": 0,
              "estCostUsd": null, "activeKeys": 1 },
  "gaps": [
    "No plan can be attributed to you: `shared_plans` has no owner column ...",
    "No agent has submitted a training run on this account. ...",
    ...
  ]
}

totals.estCostUsd is only money somebody reported for a run, summed; it is never derived from a GPU price, and it is null when nothing was reported.

Train a design, if it will run

POST https://www.neurarch.com/api/v1/train

Key required. This is the only endpoint here that spends money, and the order it does things in is the point: it builds the plan first and refuses to book a GPU for a design the verifier blocks. The run is charged to the account behind your key, under that plan's quota, concurrency cap, runtime ceiling and daily dollar ceiling, exactly like a run started in the app.

Request

{
  "model":   { "components": [...], "connections": [...] },
  "dataset": "cifar10",                                    // mnist | cifar10 | tabular
  "config":  { "epochs": 3, "batchSize": 64, "lr": 0.001, "optimizer": "adamw" },
  "gpu":     "T4",                                         // optional; T4 L4 A10G L40S A100-40GB A100-80GB H100 H200
  "source":  { "repo": "acme/models", "pr": 12 }           // optional: where the run came from
}

Response

{
  "job_id": "job_1756900000000_a1b2c3",
  "mode":   "gpu",
  "backend": "modal",
  "config": { "epochs": 3, "batchSize": 64, "lr": 0.001, "optimizer": "adamw" },   // AFTER clamping
  "plan":   { "summary": "SmallCNN: 14 layers, 1.2M params, will run, fits a T4, about $0.30 to train", ... },
  "text":   "Plan: SmallCNN   14 layers · 1.2M params ..."
}

config is clamped, not validated: epochs to 1 to 20, batch size to 8 to 512, learning rate to 1e-5 to 1, optimizer to adam, adamw or sgd, and every other key you send is dropped rather than forwarded to the trainer. It is echoed back for that reason, so a caller that asked for 200 epochs can see that it got 20.

The three refusals

StatusBodyWhat happened
422 { "error": "blocked", "plan", "text" } The verifier found a blocker, so nothing was submitted. The plan comes back with it, because an agent needs to know which layer and which number to change. This is the endpoint's reason to exist: generating designs is cheap, and training the ones that cannot forward-pass is where the money goes.
503 { "error": "gpu_not_configured" } No managed GPU worker is available. It does not fall back to simulation. The app does, which is right for a person watching a canvas and wrong here: a simulated curve posted into a pull request as a training result is a fabricated measurement.
404 { "error": "No such job under this key." } On the poll below, for a job your key does not own. Another account's job and a job that never existed are the same answer.

403 and 429 come from the plan's GPU limits (big-GPU tier, jobs in flight, jobs today, dollars today) and carry the number you hit.

Poll a run

GET https://www.neurarch.com/api/v1/train?job=job_1756900000000_a1b2c3

Key required, and it returns the job only if your key's owner owns it. epochs is the metric rows the worker pushed, in order, which is what a curve is drawn from; metrics is the scalar summary.

{
  "job_id": "job_1756900000000_a1b2c3",
  "status": "completed",          // queued | running | completed | error | cancelled
  "mode":   "gpu",
  "dataset": "cifar10",
  "epochs": [ { "epoch": 1, "trainLoss": 1.82, "valLoss": 1.51, "valAcc": 0.46 }, ... ],
  "metrics": [ { "name": "valAcc", "value": 0.72 }, { "name": "valLoss", "value": 0.31 } ],
  "wall_sec": 612,
  "est_cost_usd": 0.4012,
  "created_at": "2026-09-03T10:00:00Z",
  "completed_at": "2026-09-03T10:10:12Z",
  "fingerprint": "3f1c9a02",
  "ledger": "written",
  "model_trained": true,
  "model_format": "onnx",
  "model_url": "https://...supabase.co/storage/v1/object/sign/...?token=..."   // 10 minutes
}

The model the run produced

A finished run exports its weights to ONNX inside the training container, while they are still the weights the last epoch left, and the worker uploads that file to your own storage prefix. model_url is a signed link to it, valid for ten minutes, scoped to your key's owner like the rest of this response. model_trained: true is a claim about the weights, not about the file: /api/train/convert-onnx also returns ONNX and it is random initialisation by construction, because it exports a freshly built graph. This one is the model you paid to train, and it will score what the run's valAcc says it scores.

When a finished run has no artifact you get model_trained: false and a model_reason saying which kind of no it is (the run was simulated, it was a Colab or Kaggle round trip whose weights are a checkpoint, or the export or its upload failed in the worker). While a run is still going, neither field appears at all: there is no news yet, and a false that turns true in forty seconds is worse than silence.

On the poll that first reads completed, one row is written to your own outcome ledger under that fingerprint, so GET /api/v1/history and the history line in a plan can quote it next time. ledger says what happened: written, already (you polled twice; it is written once), unavailable (this deployment cannot store it yet), or skipped:simulated / skipped:no_metrics. A simulated run is never written down, because a heuristic curve is not an outcome and would come back later as one.

A proposed fix for a blocked plan

POST https://www.neurarch.com/api/v1/fix

Key required; each call counts as one agent message on your plan. Send the blockers and the files that define the model.

The model is never asked for a diff. It answers in search-and-replace blocks over the text you sent, and the server computes the hunks, because counting context lines in an @@ -a,b +c,d @@ header is a thing language models get wrong reliably. Every block must name a path you sent and quote text that appears exactly once in it, whitespace included; the diff is generated from the character range that matched, and re-validated before it leaves. Paths come back bare, so each patch applies with patch -p0. The server applies nothing, runs nothing, and keeps none of the files. The bot applies the diff, re-traces, re-plans, and pushes only if the new plan is legal.

Request

{
  "model": { "components": [...], "connections": [...] },
  "findings": [ { "title": "MultiheadAttention: embedDim 384 not divisible by numHeads 5", "detail": "head_dim would be 76.80", "stage": "preflight" } ],
  "files": [ { "path": "models/tiny_gpt.py", "text": "import torch.nn as nn\n..." } ],
  "policy": { "max_params": "200M" },
  "maxPatches": 3
}

Response

{
  "patches": [ { "path": "models/tiny_gpt.py",
                 "diff": "--- models/tiny_gpt.py\n+++ models/tiny_gpt.py\n@@ -6,1 +6,1 @@\n-        num_heads=5\n+        num_heads=6\n",
                 "findingTitles": ["MultiheadAttention: embedDim 384 not divisible by numHeads 5"] } ],
  "rationale": "Six heads divide 384 into 64-wide heads; nothing else changes.",
  "model": "deepseek/deepseek-chat"
}

422 means the model answered but its blocks did not pass validation, and the message says which block and which of the failures it was: text that is in no file (a quote that was invented), text that appears more than once (a quote too short to be unambiguous), a path you never sent, an empty SEARCH section, more edits than maxPatches, or two edits on the same lines. 500 is ours and never yours: the blocks were legal and the diff we built from them failed our own validator. 429 is your agent quota; 503 is the provider.

Just the structural rules

POST https://www.neurarch.com/api/v1/lint

Request

{
  "model": {
    "components": [
      { "id": "in",   "type": "input",  "name": "input",
        "params": { "shape": [1, 128] } },
      { "id": "attn", "type": "groupedQueryAttention", "name": "attn",
        "params": { "embedDim": 512, "numHeads": 8, "numKVHeads": 3 } },
      { "id": "out",  "type": "output", "name": "output", "params": {} }
    ],
    "connections": [
      { "from": "in",   "to": "attn" },
      { "from": "attn", "to": "out" }
    ]
  }
}

The graph format is the same JSON the app exports (File → Export → JSON), so the easiest way to get a valid payload is to design once in the app and export.

Response

{
  "verdict": "fail",
  "counts": { "block": 1, "warn": 0, "info": 0 },
  "findings": [
    {
      "rule": "gqa-head-divisibility",
      "severity": "block",
      "message": "numHeads (8) must be divisible by numKVHeads (3)…",
      "componentName": "attn",
      "componentType": "groupedQueryAttention"
    }
  ]
}
FieldMeaning
verdictfail if any blocker, else warn if any warning, else pass. Wire it to your CI exit code.
countsFindings by severity.
findings[].ruleStable rule id (e.g. gqa-head-divisibility, full-mha-serving-cost). Catalogue at /rules.html.
findings[].severityblock (won't run) · warn · info.
inferredPresent only when the graph you sent carries edges our PyTorch importer inferred rather than read out of forward() — two statements that happen to be adjacent, or an add the graph has no node for. Every check about layer adjacency held back on those edges, so a clean verdict on a graph with a non-zero inferred is a smaller claim than a clean verdict without one. Carries total, inferred, constructionOrder, unmodelledMerge. Absent for a graph authored by hand or drawn in the app, and absent when the importer read every edge.

Example: fail CI on a blocker

curl -sS https://www.neurarch.com/api/v1/lint \
  -H "Authorization: Bearer $NEURARCH_API_KEY" \
  -H "Content-Type: application/json" \
  --data @model.json \
  | tee result.json \
  | grep -q '"verdict":"fail"' && { echo "Structural blockers found"; exit 1; } || true

Run it inside your own network

A bank, a pharma company or an autonomous-driving team often cannot POST a model structure to an outside service at all, and that includes the structure inside a pull request an outside research agent just opened against their repository. Not because they doubt us: the graph is the design, and the review process has no box for sending it to a startup's API. So the verifier also ships as one container you run yourself.

docker run --rm --network none -p 8080:8080 neurarch/verifier:local

curl -sS http://localhost:8080/v1/check \
  -H "Content-Type: application/json" \
  --data @model.json

It answers POST /v1/plan, /v1/check, /v1/lint and /v1/rank with request and response bodies field for field identical to the hosted endpoints above, so a client written against one works against the other by changing the base URL. No API key: there is no account behind it. A parity test runs all 36 shipped templates and a seeded split of generated graphs through both the container and the hosted code path and deep-compares every response, because a container that could disagree with us would make an audit trail built on it worse than no audit trail.

What leaves your network: nothing. In its default configuration the container makes no outbound connection of any kind. A graph you submit is parsed in memory, verified, and returned in the response; it is never written to disk, never logged, never retained after the response, and never transmitted. It runs as a non-root user, holds no credentials, opens no database connection, and --network none is the documented way to run it rather than a hardened mode. Optional aggregate telemetry exists, is off unless two environment variables are both set, and can carry counts only: requests per action, verdict counts, how many times each structural rule fired, and the container version. GET /metrics/preview shows you exactly what a send would contain, in either mode.

EndpointAnswers
GET /versionThe container version, the sha256 of every verifier bundle it loaded, and the rule-set id. Quote this pair alongside any verdict you archive: it is what makes the verdict reproducible later.
GET /healthzLiveness, and which telemetry mode the process is in.

What it cannot do, because it has no database: mint a shared /p/<id> plan URL, attach your organisation's prior outcome history, record a corpus row, or create a citable verification badge. Every response says so in a hosted_only array rather than quietly returning less. Full setup, the security-review paragraph, how to verify an image and how to pin a digest ship with the image, which is not on a public registry yet: ask for it.

Research endpoints

Kept for agents that already call it, and not part of the pre-check. rank answers one thing well (which of several unexecuted designs cannot forward-pass at all) and one thing weakly (which of the rest to prefer), and every response carries the in-sample numbers that say how weakly. If what you need is "will this change run on the cards we reserved", that is check.

Ranking candidates you haven't run

POST https://www.neurarch.com/api/v1/rank

An agent can propose far more designs than it can afford to train, so the lever is which ones get the budget. POST up to 20 graphs and this returns an ordering, the ids that should not be given execution budget at all, and, in the same response, how much the ordering is actually worth.

It abstains, on purpose. Two published 2026 papers measure this task with frozen frontier models reading candidate source code, and reach 61.5% (arXiv:2601.05930) and 69.35% (arXiv:2608.13940) pairwise accuracy. Those models are forced to answer. A structural verifier is not: it separates designs that cannot run from designs that can, which our 264-graph study measured at 96/96 and 80/80, and it has no measured basis for separating two legal designs. So recommended comes back null whenever the top rank is shared, and the calibration block says what our ordering scored on the same axis. Reading a tie as a failure of the endpoint is reading it correctly: that is what the measurement says today.

Request

{
  "candidates": [
    { "id": "wider",  "model": { "components": [...], "connections": [...] } },
    { "id": "deeper", "model": { "components": [...], "connections": [...] } }
  ],
  "tieBreak": "cost"
}

id is optional; candidates without one come back as candidate-0, candidate-1, and so on.

tieBreak is optional: "cost" or "params". Where the measured ordering is silent, it breaks the tie on your stated rule, cheapest or smallest first. It never reorders across measured ranks and never lifts a blocked design. The rule is echoed back with how many positions it decided, and every reason it decided says so, because "cheaper" is your budget's preference and not the verifier's.

Response

{
  "ranked": [
    {
      "id": "deeper", "rank": 1, "tier": "legal", "tiedWith": 2,
      "blocking": 0, "warnings": 0, "outcomeFlags": [],
      "params": 31120896, "estCostUsd": 0.197, "fitsGpu": "A10G (24GB)",
      "otherStageBlockers": [],
      "reasons": ["Runs, and trips no rule with a trained-outcome measurement behind it."]
    },
    {
      "id": "unnormalized", "rank": 3, "tier": "legal", "tiedWith": 1,
      "outcomeFlags": ["deep-no-norm"],
      "reasons": ["Carries 1 finding measured against trained outcomes (deep-no-norm), …"]
    },
    {
      "id": "broken", "rank": 4, "tier": "blocked", "blocking": 2,
      "reasons": ["2 blocking findings: this graph does not forward-pass, so executing it spends budget on a crash."]
    }
  ],
  "recommended": null,
  "recommendation": "2 of 3 legal candidates are tied at the top and nothing measured separates them…",
  "budget": { "candidates": 3, "blocked": 1, "legal": 2, "wouldNotRun": ["broken"], "reclaimed": 0.333 },
  "calibration": { "exclusion": {…}, "ordering": { "pairwiseAccuracy": 0.514, "coverage": 0.083, … } }
}
FieldMeaning
budget.wouldNotRunThe reason to call this. These graphs crash on the forward pass; every GPU-second spent on them is spent on a stack trace.
recommendedThe single candidate at the top rank, or null when the top rank is shared. Never a tie broken silently.
rank1-based, and tied candidates share a rank (so a shared first place reads 1, 1, 3, never 1, 2, 3).
measuredRankThe rank on measured evidence alone. Equal to rank unless a tieBreak moved the candidate inside a tie.
tieBreakYour rule, echoed: { rule, decided, note }. decided is how many positions came from your rule rather than from a measurement.
outcomeFlagsRules that fired and have a measured link to a trained outcome. The only thing used to order two runnable designs.
otherStageBlockersBlockers past pre-flight, e.g. "Model too large" at deploy. Reported, never ranked on: a design can be undeployable and still be the one worth training.
params · estCostUsd · fitsGpuDerived, returned per candidate, and deliberately not used in the measured ordering. Pass tieBreak to have a tie broken on one of them; we won't pretend cheaper means better, so the response says which positions were yours.
calibrationWhat the ordering scored on the pairwise axis the papers report, with its coverage, its sample size and the published comparators. Shipped in every response so the number is never read alone.

Example: don't pay to train a graph that crashes

curl -sS https://www.neurarch.com/api/v1/rank \
  -H "Authorization: Bearer $NEURARCH_API_KEY" \
  -H "Content-Type: application/json" \
  --data @candidates.json \
  | jq -r '.budget.wouldNotRun[]'

Errors

Every endpoint answers the same way.

StatusWhen
401Missing or invalid / revoked API key.
400Body isn't { model: { components: [...] } }, or, for rank, isn't { candidates: [{ model }] }. Also a field of data, layout or serve with an unreadable value; the error names the field, e.g. `layout.micro_batch` is required.
413More than 2000 components, or more than 20 candidates on rank.
422The graph couldn't be verified (malformed shapes).