Check a design over HTTP

Generating an architecture is cheap now. Knowing whether it will survive contact with a GPU is not. POST a model graph and get the verdict back as JSON: no browser, no LLM, deterministic, the same code path that answers the question inside the app.

EndpointAnswersUse it when
POST /api/v1/check The whole verdict: can it train, what would the run cost, where should it serve and how fast, and what is still a human's call. An agent or a script needs the decision, not the rule list.
POST /api/v1/rank Given k designs nobody has run yet: which of them will not run at all, and whether anything measured separates the rest. An agent proposed more candidates than it can afford to train.
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.

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 four answers are available from a container you run yourself. See Run it inside your own network.

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). Full terms in the data policy.

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",
  "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 Edge GPU (Jetson) (100/100): ~5.0ms per inference, 0.3 MB.",
      "data": { "target": "edge-gpu", "score": 100, "latencyMs": 5.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 · warn · ask · ok. The worst status any stage reported.
outcomeblocked, needs_input, attention, or ok.
summaryOne sentence, the same one the app leads with. When something is blocked it is priced: "1 blocker would have failed a $0.17 / 10m on an A10G run."
stoppedAtThe stage the walk stopped at, or null if all five ran.
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 it 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.

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.

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[]'

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
  },
  "share": false,
  "source": { "kind": "bot", "target": "models/gpt.py:GPT", "input": "1,128:long" }
}

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.

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. 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: docs/SELF_HOSTED.md.

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 }] }.
413More than 2000 components, or more than 20 candidates on rank.
422The graph couldn't be verified (malformed shapes).