A validator leases miner submissions, scores them in an isolated local sandbox, publishes signed results, and sets weights on Finney. Production runs the validator from an immutable GHCR digest with the repository's cooperative updater; building from source is a fallback when the release channel is unavailable.
- What runs
- Requirements
- First deployment
- Verify health
- Upgrade and operate
- Automatic full-stack updates (recommended)
- How scoring and weights work
- Optional observability
- Development
The root Docker Compose stack starts four services:
| Service | Purpose |
|---|---|
ditto-subnet |
Leases work, signs scores, and computes weights. |
dittobench-api |
Scores submissions. |
sandbox-docker |
Isolated nested Docker daemon that runs miner containers. |
pylon |
Submits weights with the validator wallet. |
For v7 and v8, each ticket routes chat and the locked 768-dimensional embedding
operation through dittobench-api to the platform-owned inference profile. No
provider credential is installed on the validator or exposed to a miner
container.
The validator is stateless: the queue and score ledger live on the platform, and Pylon keeps in-flight weight state in a named volume. The platform screens every submission before it reaches validators and ships a verified pre-built Docker image with it, so your host normally does not compile miner code.
Every miner container runs with strict CPU, memory, PID, capability, seccomp,
and egress limits. The stack advertises eight full-run slots — the protocol
maximum — and the platform decides how many of them actually receive tickets, so
the concurrency your host runs is an operator setting on the platform, not a
value you edit here. Advertising the maximum is what lets the platform's cap be
the single fleet-wide lever: it can narrow an advertised eight down to any value
in [1, 8] within seconds and with no release, but it can never widen past what
you advertise.
Disk is the binding constraint, not memory. Each concurrent run adds a miner sandbox carrying its own image and writable layer, and those accumulate; the 3 GiB per-sandbox figure is a memory cap, not steady-state usage. Production validators run 3-4 concurrent benchmarks at 15-20% memory while sitting at 80-90% disk, so size the disk first:
VALIDATOR_BENCHMARK_CAPACITY |
Free disk | Host RAM |
|---|---|---|
| 8 (default) | 160 GB | 32 GB |
| 6 | 120 GB | 24 GB |
| 4 | 80 GB | 16 GB |
| 2 | 40 GB | 8 GB |
The RAM column is headroom for the worst case where every sandbox reaches its cap at once; a host that is comfortably under it in practice is normal and not a sign you can skip the disk.
A full disk costs far more than advertising fewer slots. A validator whose
heartbeat reports disk at or above the platform's disk_percent_ceiling is held
to a single slot until it recovers — so a host that advertises eight and then
fills its disk ends up serving one, which is strictly worse than having
advertised four. If you cannot give the stack the disk in the table above, set
VALIDATOR_BENCHMARK_CAPACITY to match what you can.
If you need more throughput but are near a memory or disk ceiling, raise the
within-run parallelism instead (VALIDATOR_V7_CASE_CONCURRENCY,
VALIDATOR_V7_EMBEDDING_CONCURRENCY) — those add no sandbox.
The validator also gates itself. At or above VALIDATOR_DISK_PERCENT_CEILING or
VALIDATOR_MEMORY_PERCENT_CEILING (95% each by default) it stops claiming new
tickets and reports admission=resource_constrained in its heartbeat, so the
fleet view shows a validator that is healthy and deliberately idle rather than
one that has quietly gone missing. Benchmarks already running are unaffected --
they finish and report -- and claiming resumes on its own as soon as a later
sample reports headroom. The platform gates the same readings independently from
its side, so an overloaded host is refused work even if it asks.
- Linux x86-64 with at least 4 vCPU, 160 GB free disk, and 32 GB RAM to serve the default eight slots. Disk is the constraint that binds in practice; see the sizing table above to run a smaller host.
- Docker Engine, Buildx, and the Docker Compose plugin v2 or newer, including v5. Docker must start at boot.
- Git and
flockfrom util-linux. - A local Bittensor wallet whose hotkey is registered on Finney SN118 and has a validator permit.
- Outbound access to Finney, the Ditto platform, and GHCR (anonymous pull of the public
ghcr.io/ditto-assistant/ditto-subnet-validatorpackage).
Python and uv are only required for development.
Clone the repository and create the one environment file used by Compose:
git clone https://github.com/ditto-assistant/ditto-subnet
cd ditto-subnet
cp .env.example .env
openssl rand -base64 32Put the generated value in PYLON_TOKEN, then fill these values in .env:
| Env | Value |
|---|---|
VALIDATOR_HOTKEY |
Public SS58 address of the permitted validator hotkey. |
VALIDATOR_WALLET_NAME |
Coldkey directory under ~/.bittensor/wallets. |
VALIDATOR_WALLET_HOTKEY |
Hotkey file inside that wallet. |
PYLON_TOKEN |
Random token generated above. |
VALIDATOR_BENCHMARK_CAPACITY |
Full-run slots this host advertises, 1-8. Leave unset to take the compose default of 8 (the protocol maximum) so the platform's cap is the only lever; the platform decides how many are actually used. Set 4 on a 16 GB host — see the sizing table. |
VALIDATOR_DISK_PERCENT_CEILING |
Stop claiming tickets at or above this disk usage (default 95). 0 disables; otherwise a multiple of 5 in [50, 100]. |
VALIDATOR_MEMORY_PERCENT_CEILING |
Same, for memory (default 95). |
VALIDATOR_CPU_PERCENT_CEILING |
Same, for CPU. Defaults to 0 (disabled) -- a pinned CPU is a working benchmark host, not a failing one. |
DITTOBENCH_REQUIRE_TICKET_INFERENCE |
Keep true; v7/v8 scoring fails closed without a ticket session. |
VALIDATOR_INFERENCE_PROXY_REQUIRED |
Keep true; the validator must not claim work without the platform proxy. |
The example selects Finney, SN118, and the production platform. For local
testing, change both the platform and chain settings in a separate .env.
The wallet remains on the host and is mounted read-only. The loaded hotkey must
exactly match VALIDATOR_HOTKEY. Never put a mnemonic in .env, never commit
.env, and never run two validator stacks with the same hotkey.
Resolve the release channel to the exact digest that will run:
IMAGE=ghcr.io/ditto-assistant/ditto-subnet-validator
docker pull "$IMAGE:compat-2"
DIGEST="$(
docker image inspect \
--format '{{ range .RepoDigests }}{{ println . }}{{ end }}' \
"$IMAGE:compat-2" |
awk -v prefix="$IMAGE@" 'index($0, prefix) == 1 { print; exit }'
)"
test -n "$DIGEST"
printf '%s\n' "$DIGEST"Stop if the pull or digest check fails. Do not substitute a mutable tag or an unpromoted image; use the source-build fallback until the channel is available.
Start the three sidecars, then start only the digest-pinned validator:
./scripts/validator-compose.sh config --quiet
./scripts/validator-compose.sh up -d --build --wait --wait-timeout 180 \
pylon sandbox-docker dittobench-api
DITTO_SUBNET_IMAGE="$DIGEST" \
./scripts/validator-compose.sh up -d --no-deps --no-build --pull never \
ditto-subnet
./scripts/validator-compose.sh logs --since 10m ditto-subnetAfter the validator reports a fresh platform-accepted heartbeat, enter managed mode with the supervised stack migration in Automatic full-stack updates (recommended). Perform it during a window with no live ticket; never interrupt a running benchmark to enter managed mode.
./scripts/validator-compose.sh ps
./scripts/validator-compose.sh logs --since 10m ditto-subnet
curl -fsS https://platform-api.heyditto.ai/health
./scripts/validator-stack-auto-update.sh statusAll four services should be Up; sandbox-docker and dittobench-api should
be healthy. An idle validator may log
scoring sweep complete: 0 agent(s). That is normal when the queue is empty.
Production acceptance also requires:
- platform health reports
db: okandchain: ok; - the hotkey has a validator permit on SN118;
- sweeps complete without recurring platform, scorer, or Pylon errors;
- the on-chain last-update block advances after a weight submission; and
- the public validators endpoint lists the hotkey online.
In managed mode, the stack updater performs every upgrade — the validator and
all three sidecars are replaced together as one transaction. Do not use direct
docker compose, a second supervisor, or manual restarts; those paths can
replace a reviewed digest or interrupt leased work. The host launcher scripts
are outside the signed bundle, so keep the repository checkout on the reviewed
release with git pull --ff-only.
Useful commands:
./scripts/validator-compose.sh ps
./scripts/validator-compose.sh logs -f ditto-subnet
./scripts/validator-compose.sh logs --since 10m sandbox-docker
./scripts/validator-compose.sh logs --since 10m dittobench-api
./scripts/validator-compose.sh logs --since 10m pylon
./scripts/validator-stack-auto-update.sh statusdocker compose up reuses an image whenever one already exists. When the
reviewed dittobench-api pin in docker-compose.yml moves, a plain up
therefore recreates the scorer container with the new pin's environment on
top of the old binary. The scorer then reports a source revision it was
never built from, the validator's identity check passes, and the validator
silently serves an outdated benchmark set — it stops advertising the active
benchmark version, so submissions needing k=3 quorum hang instead of failing.
Two things prevent that now.
scripts/validator-compose.shrecords the revision it last built. When a command that can start containers runs against a different pin, it forcesdocker compose build --pull dittobench-apiand replaces that container before running your command. If the source-built stack is live, the wrapper first asks the validator to drain and waits for every active benchmark and weight update to finish; it resumes only after the rebuilt scorer is serving. An unchanged pin does nothing, so restarts stay fast and work without network access. A failed rebuild stops the command instead of starting the previous image.- The validator refuses to call a scorer verified unless its identity is
proven by the running binary.
dittobench-apilinks its revision in at build time and reports where the value came from (source_revision_origin), plus whether the binary and the environment disagreed (source_revision_mismatch). WithVALIDATOR_SCORER_REQUIRE_BINARY_PROVENANCE=1— committed next to the pin, because only a pin whose scorer stamps itself can satisfy it — a revision that is merely asserted by the environment is treated as an unrebuilt image. That is exactly the evidence that lied during the incident.
A validator in that state logs scorer_image_stale, reports
capabilities.scorer_benchmarks.status = identity_mismatch and
stack_health.dittobench_api.health = identity_mismatch, and advertises
no benchmark versions. It keeps heartbeating and never crash-loops, so the fault is
visible on the dashboard rather than hidden behind a green validator.
On a managed stack the next stack update replaces the scorer from the signed descriptor, so no manual rebuild is needed. On a source-built install, rebuild the scorer from the pin during a supervised window with no live lease. The Compose wrapper detects the moved pin and rebuilds before it runs your command:
git pull --ff-only
./scripts/validator-compose.sh config --quiet
./scripts/validator-compose.sh up -d --build --no-deps --wait \
--wait-timeout 180 dittobench-api
./scripts/validator-compose.sh logs --since 10m ditto-subnetThe validator logs scorer identity verified once the rebuilt scorer answers.
A running scorer container is not a serving scorer. From heartbeat protocol 15
the validator reports what its /v1/capabilities probe actually did, alongside
the conclusion it drew, under capabilities.scorer_benchmarks.probe:
| Field | Meaning |
|---|---|
outcome |
served (a document read in full), served_degraded (readable, part rejected), http_error, unreadable (200 with an unusable body), timeout, connect_error, not_probed |
observed_at |
when this probe ran |
http_status |
the status the scorer answered with, when it answered |
reason |
why a readable reply was still not fully usable |
last_served_at |
when the scorer last answered with a fully readable document |
consecutive_failures |
probes since the last fully readable document |
last_served_at and consecutive_failures are counted by the running validator
process and reset when it restarts, so a fresh process reports no history rather
than a history it cannot support.
This exists because the status alone cannot distinguish two very different
scorers. A sidecar that 404s /v1/capabilities and a genuine pre-capabilities
scorer both produce status: legacy_v2 with every identity field null; the
probe shows http_error / 404 for the first. A scorer whose capability
document is only partly readable still produces status: fresh_verified; the
probe shows served_degraded and names the field that was rejected.
On the platform's fleet view a validator whose probe reports no usable answer
reads health: critical with scorer_liveness: not_serving. Its admission
is unchanged: the validator keeps taking work, which is why the condition needs
to be visible.
- GHCR pull fails: confirm outbound access to
ghcr.ioand thatcompat-2exists. Do not guess a digest or fall back to a mutable tag. - No work is scored: zero queued agents is normal. Otherwise inspect the validator, sandbox, scorer, relay, and Ollama health before restarting anything.
- Runs fail with
tool_endpoint advertised but unreachable: one failure can be a non-compliant miner harness; recurring failures across different agents mean your sandbox networking (host.docker.internalrouting) is broken — fix it before the reopened tickets expire. No zeroed score is signed either way. - Logs show
transcript publication failed: the accepted score already stands. Checkdittobench-apihealth and platform reachability so future runs publish their transcripts. - The fleet view shows
Scorer down/scorer_liveness: not_serving: the validator's/v1/capabilitiesprobe got no usable answer. Readcapabilities.scorer_benchmarks.probe:connect_errormeans nothing is listening,timeoutmeans the scorer is wedged, and anhttp_errorwith404means the container is up but never mounted the route (check that thedittobench-apiimage matches the pin rather than restarting the validator). See Scorer liveness. - Logs show
scorer_image_staleorscorer_revision_mismatch: the running scorer is not the pinneddittobench-apirevision. See Stale scorer image. Restarting the validator does not help — the scorer image itself must be rebuilt from the pin. - v7 tickets fail immediately with
reason=infrastructure, scorer logs showinference control plane unavailable(401): the scorer and the validator disagree about the inference control token. The scorer joinssandbox-docker's network namespace while the validator stays on the Compose bridge, so the validator is never a loopback peer and must presentDITTOBENCH_BROKER_CONTROL_TOKENas a bearer. Both values come from one Compose anchor and have a working default, so this only appears when the two services run from different Compose revisions — recreatedittobench-apiandditto-subnetfrom the same checkout/release. If you setDITTOBENCH_BROKER_CONTROL_TOKENyourself, both services must be recreated after changing it. - Updater reports a transaction: keep the timer disabled, verify the
validator and all sidecars, then use
recover. - Host rebooted: verify Docker is enabled and active, then check Compose and updater status. Do not add PM2 or another systemd service for the stack.
- Disk use grows: inspect
sandbox-docker. Its nested daemon prunes unused benchmark data; do not run broad cleanup against the host Docker daemon. - Sandbox resource failure (
sandbox_oom,sandbox_tmpfs_exhausted): these are validator-infrastructure classifications, not miner verdicts; the worker stops claiming work and the ticket expires safely. Fix the resource issue, then ask the Ditto team for the audited single-agent retry; never bulk retry or alter accepted scores.
Enable the managed stack updater unless you run your own update automation.
Patch releases ship often, and the platform routes work by advertised
compatibility, so a validator that lags the release channel falls out of
ticket routing; the updater keeps the complete immutable Compose stack current
as one transaction with automatic rollback. If
you maintain your own updater instead, that is fine — it must track the
compat-2 channel promptly, pin exact digests, and drain the validator before
replacing services.
The migration preflight, transaction boundaries, rollback guarantees, and trust policy are documented in FULL-STACK-UPDATES.md; read it before the first cutover.
Update this checkout to the exact reviewed release, install Cosign from its
verified upstream release, and migrate. If this host ever ran the retired
validator-only updater, disable its timer first. migrate waits for the
validator to drain, installs all four exact services, and verifies a fresh
accepted heartbeat before recording the stack:
sudo systemctl disable --now ditto-validator-auto-update.timer 2>/dev/null || true
STACK=ghcr.io/ditto-assistant/ditto-subnet-stack
docker pull "$STACK:compat-2"
DIGEST="$(
docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' \
"$STACK:compat-2" |
awk -v prefix="$STACK@" 'index($0, prefix) == 1 { print; exit }'
)"
test -n "$DIGEST"
./scripts/validator-stack-auto-update.sh migrate "$DIGEST"
./scripts/validator-stack-auto-update.sh statusIf all four services already match the descriptor, adopt "$DIGEST" records
them without replacement. Both commands fail closed if the descriptor's
signature is not from this repository's release workflow.
Enable the timer only after migration or adoption succeeds:
if grep -q '^VALIDATOR_STACK_AUTO_UPDATE=' .env; then
sed -i 's/^VALIDATOR_STACK_AUTO_UPDATE=.*/VALIDATOR_STACK_AUTO_UPDATE=true/' .env
else
printf '\nVALIDATOR_STACK_AUTO_UPDATE=true\n' >>.env
fi
sudo DITTO_VALIDATOR_UPDATE_USER="$USER" \
./scripts/install-validator-stack-auto-update.sh
systemctl list-timers ditto-validator-stack-auto-update.timer
./scripts/validator-stack-auto-update.sh statusThe current compatibility stack does not require a host AppArmor profile or unprivileged-user-namespace exception. Keep the host dedicated to validation; the nested executor remains privileged while stronger rootless/AppArmor isolation is redesigned as a separately migrated security boundary.
Compatible patch and minor releases then install automatically: the updater drains the validator (an active benchmark finishes first), replaces all four services as one transaction, and rolls the complete previous stack back if the new one fails verification. Major or schema changes require supervised migration.
To disable updates, inspect an interrupted run, or roll back manually:
sed -i 's/^VALIDATOR_STACK_AUTO_UPDATE=.*/VALIDATOR_STACK_AUTO_UPDATE=false/' .env
sudo systemctl disable --now ditto-validator-stack-auto-update.timer
sudo systemctl stop ditto-validator-stack-auto-update.service
./scripts/validator-stack-auto-update.sh status
./scripts/validator-stack-auto-update.sh rollback # manual rollback onlyIf everything is healthy but status shows transaction_phase, run
./scripts/validator-stack-auto-update.sh recover only after verifying that
resuming lease intake is safe.
The platform leases each submission to independent validators and finalizes the median signed score. Each ticket pins the workload and deadline; expired work reopens automatically, and every benchmark run must originate from a live platform ticket. Each scored run starts with a reachability preflight that requires the miner harness to call the mock tool endpoint; if the probe is never observed the run fails and the ticket reopens — a zeroed report is never signed. After the platform accepts a score, the worker publishes the run's graded transcript for public verification.
The validator computes the deterministic weight vector from the public finalized ledger, and Pylon handles UID resolution, commit-reveal, retries, and the on-chain extrinsic on an independent cadence that honors the chain rate limit and subnet tempo.
During a benchmark-version rollout, the platform selects one authoritative row
per agent: the desired-version median after 3/3, otherwise its active-version
fallback. The ledger can therefore intentionally contain both v2 and v3 rows.
Validators fold that full platform-selected pool; they must not apply a global
"maximum benchmark version" filter, because older validators ignore the
additive bench_version field and already fold the full pool. This preserves
identical weights across asynchronous validator upgrades.
Add the shared WANDB_API_KEY supplied by Ditto to .env, or set
WANDB_MODE=disabled. Never commit the key.
The validator also sends a signed public heartbeat with its version, source digest, phase, and coarse health; the platform uses it to route compatible work. It does not send secrets, prompts, expected answers, model output, or host identity.
Heartbeat protocol 10 adds authoritative bounded capacity: configured and healthy slot ids, admission state, and privacy-safe progress for every active benchmark. Active heartbeats refresh every 30 seconds, with changed aggregate question counts eligible every 15 seconds. The stack advertises eight slots by default — the protocol maximum — and the platform's operator cap decides how many receive tickets; draining or paused validators advertise no healthy slots and receive no new work.
Heartbeat protocol 18 adds a signed health entry for each of the four Compose components. Three ideas are reported separately and must not be conflated:
- Configured identity — what Compose intends to run (the pinned image
digest / source revision already reported under
stack). It proves intent, not the running artifact. - Observed identity — what a live probe could independently verify (for
example the scorer's
/v1/capabilitiesrevision). When nothing can be observed safely the field stays unset; the validator never copies the configured pin into an observed field. - Functional readiness — whether the component answered a real request
just now (
ready, plusmodel_readyfor the embedding model / model route), with its ownobserved_at, so a stale probe is distinguishable from a stale heartbeat.
Each component reports healthy, degraded, unreachable,
identity_mismatch, or unknown. Probes run from the validator over the
private Compose network — no Docker socket is mounted for telemetry — and are
bounded so a wedged sidecar never stalls the heartbeat. Optional env:
VALIDATOR_SANDBOX_DOCKER_PROBE_URL/VALIDATOR_MODEL_RELAY_PROBE_URL— internal readiness endpoints. The relay probe is removed with the v6 cleanup.VALIDATOR_PYLON_PROBE_URL— defaults toPYLON_URL.VALIDATOR_STACK_PROBE_TIMEOUT_SECONDS(default 2) andVALIDATOR_STACK_HEALTH_CACHE_SECONDS(default 60).
Probe URLs are configuration only; the public payload carries health states, timestamps, booleans, and verified identities — never URLs, hostnames, container ids, or paths.
The source-build path is a fallback when the reviewed GHCR compatibility channel is unavailable. It does not enter managed updater mode:
./scripts/validator-compose.sh config --quiet
./scripts/validator-compose.sh up -d --build
./scripts/validator-compose.sh psUpgrade a source-built validator only during a supervised window with no live
lease: git pull --ff-only, then the same three commands.
The wrapper builds services/dittobench-api from the clean checkout and stamps
the latest monorepo commit that changed that service. When that component
revision has moved since the last build it rebuilds and replaces the scorer
first, so up can never start a cached image while claiming a newer revision —
see Stale scorer image.
For local code work outside Compose:
uv sync
make lint typecheck testThe worker entry point is uv run python -m ditto.validator.
The platform ledger contains at most one generation for each coldkey captured at upload-payment time. Different agent names and hotkeys owned by that coldkey compete for the same position; the best fully eligible canonical score wins, with first submission time and agent UUID as deterministic ties. The winning row's hotkey remains the chain weight destination.
Validators do not query current coldkey ownership or perform a second collapse. They fold the platform's identical, payment-time snapshot so hotkey rotation or a later ownership change cannot make validators disagree. A held, banned, or otherwise ineligible newer generation cannot shadow an older eligible winner.