Skip to content
This repository was archived by the owner on Aug 3, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions Dockerfile.sandbox
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Ralph validator EXECUTION SANDBOX image.
#
# This is NOT the proof-test/measurement image (that is ./Dockerfile, whose
# digest is the on-chain measurement). This image exists only so the validator
# can run UNTRUSTED miner model code in containment for op4 hidden-eval (and,
# later, the re-train audit). It is pinned by digest and verified by
# validator/sandbox.py:preflight().
#
# Hardening choices vs the proof image:
# - cuda RUNTIME, not devel: no nvcc/headers.
# - NO git, NO compilers: shrink the in-container attack surface.
# - eval/scoring CODE is baked (canonical); the rotating eval/private DATA is
# NEVER baked — it is a read-only mount at run time, so the moat is not in
# the image layers.
# - runs as a non-root user; the container is additionally launched with
# --network none --read-only --cap-drop ALL --user 65534 etc. by run_in_sandbox.
#
# Build (reproducible; pin the result by digest in SandboxConfig.image):
# DOCKER_BUILDKIT=1 docker build -f Dockerfile.sandbox -t ralph-eval-sandbox:$(git rev-parse --short HEAD) .
# docker inspect --format='{{index .RepoDigests 0}}' ralph-eval-sandbox:... # -> @sha256: for SandboxConfig
#
# Run contract (the host's run_in_sandbox supplies the hardening flags + mounts):
# /canon (ro) canonical recipe tree (model/, ...)
# /in/patch.diff (ro) miner patch
# /in/training/checkpoint.pt (ro) miner checkpoint
# /eval-private/... (ro) held-out shard + benchmark (never baked)
# /out (rw) nlls.npy + manifest.json
# Entrypoint: python -m validator.sandbox_eval /canon /in/patch.diff \
# /in/training/checkpoint.pt /eval-private /out
# It applies the patch in a tmpfs workdir, runs the (patched) model, and emits
# per-position NLLs + benchmark accuracy; the HOST reduces the crown-critical
# val_bpb from the NLLs and computes the eval-set hash itself.

FROM nvidia/cuda:12.4.1-runtime-ubuntu22.04 AS base

ENV DEBIAN_FRONTEND=noninteractive
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

# `patch` is required by proof.runner.apply_patch; explicitly NO git / NO gcc.
RUN apt-get update && apt-get install -y --no-install-recommends \
python3.11 \
python3.11-venv \
python3-pip \
patch \
&& rm -rf /var/lib/apt/lists/*

RUN python3.11 -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

COPY pyproject.toml /app/pyproject.toml
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cu124 && \
pip install --no-cache-dir -e /app

# Canonical CODE only — model/recipe/eval/proof/validator. NO eval/private DATA
# (the .dockerignore excludes it; it is a runtime ro mount).
COPY model/ /app/model/
COPY recipe/ /app/recipe/
COPY data/ /app/data/
COPY eval/ /app/eval/
COPY calibration/ /app/calibration/
COPY proof/ /app/proof/
COPY validator/ /app/validator/
COPY configs/ /app/configs/
COPY restricted_files.yaml /app/restricted_files.yaml
COPY ralph_bootstrap.py /app/ralph_bootstrap.py

WORKDIR /app

# Verify the entrypoint + reducer import cleanly at build time.
RUN python -c "from validator.sandbox_eval import run_sandbox_eval, prepare_workdir; print('sandbox_eval import ok')"
RUN python -c "from eval.host_reduce import reduce_token_nlls; from eval.val_bpb import per_position_nlls; print('reducer import ok')"

# Non-root by default (run_in_sandbox also forces --user); 65534 = nobody.
USER 65534:65534

ENTRYPOINT ["python", "-m", "validator.sandbox_eval"]
CMD ["--help"]
118 changes: 118 additions & 0 deletions eval/host_reduce.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Host-side val_bpb reduction — make the crowning number validator-produced.

Today the crowning `val_bpb` for a STRUCTURAL patch is a float printed by the
miner's own code (`eval_in_workdir.py`), which the scorer trusts. This module
moves the reduction to the host: the sandboxed miner model emits the per-position
negative log-likelihood (nats) of each true target token, and the VALIDATOR
computes bpb from that array using the SAME formula as `eval.val_bpb`, holding
`bytes_per_token`, the expected token count, the tail mask, and the eval-set hash
itself.

Forgery scope (be honest): a miner controls the model code, so it can still emit
fake-low NLLs. Host-side reduction removes the formula / normalization / token-
count / hash forgery classes and centralizes the metric; the loss VALUES are made
trustworthy by the independent re-train audit (the paired BLOCKER — the audit
must recompute, not re-run miner code at a loose tolerance). For the CANONICAL-
architecture path the sandbox runs validator eval code, so the values are already
host-trusted; this primitive is the structural-patch counterpart.

Equivalence: `reduce_token_nlls` reproduces `eval.val_bpb.compute_val_bpb`'s
`val_bpb` / `tail_val_bpb` / `nll_per_token` exactly given the same per-position
cross-entropies, the same `seq_len`, and the same `bytes_per_token`.
"""
from __future__ import annotations

import hashlib
import math
from dataclasses import dataclass

import numpy as np

LN2 = math.log(2)


@dataclass(frozen=True)
class HostReduced:
val_bpb: float
tail_val_bpb: float | None
nll_per_token: float
tokens_evaluated: int
bytes_per_token: float
eval_set_hash: str # sha256 of the HOST's target stream (not miner-supplied)


def expected_token_count(stream_len: int, seq_len: int) -> int:
"""Number of target positions `compute_val_bpb` scores over a stream of
`stream_len` tokens: non-overlapping windows of (seq_len+1), each
contributing `seq_len` targets. Mirrors val_bpb.compute_val_bpb's packing."""
# Only full windows of (seq_len+1) contribute; the last partial window is
# skipped (`if len(ids) < seq_len + 1: break`).
full_windows = (stream_len - 1) // seq_len
return full_windows * seq_len


def hash_target_stream(target_tokens: np.ndarray) -> str:
"""sha256 over the FULL host-held target stream — replaces the miner-printed
100-token `eval_set_hash` that bound nothing."""
arr = np.ascontiguousarray(np.asarray(target_tokens, dtype=np.uint16))
return hashlib.sha256(arr.tobytes()).hexdigest()


def reduce_token_nlls(
nlls: np.ndarray,
*,
seq_len: int,
bytes_per_token: float,
expected_tokens: int,
eval_set_hash: str,
) -> HostReduced:
"""Reduce a per-position NLL array (nats, canonical window order) into bpb.

Validates the array against what the host expects before trusting it:
- length MUST equal `expected_tokens` (host-derived from its own stream);
- every value MUST be finite and >= 0 (cross-entropy is non-negative).
Raises ValueError on any violation — the caller treats that as a rejected
submission (never a silent pass).
"""
if bytes_per_token <= 0:
raise ValueError(f"bytes_per_token must be > 0; got {bytes_per_token}")
nlls = np.asarray(nlls, dtype=np.float64)
if nlls.ndim != 1:
raise ValueError(f"nlls must be 1-D; got shape {nlls.shape}")
if nlls.shape[0] != expected_tokens:
raise ValueError(
f"nll count {nlls.shape[0]} != expected {expected_tokens} "
f"(miner returned the wrong number of scored positions)"
)
if not np.all(np.isfinite(nlls)):
raise ValueError("nll array contains non-finite values")
if np.any(nlls < 0):
raise ValueError("nll array contains negative cross-entropy (impossible)")

total_nats = float(nlls.sum())
total_tokens = int(nlls.shape[0])
total_bytes = total_tokens * bytes_per_token
bpb = total_nats / (LN2 * total_bytes) if total_bytes > 0 else float("inf")
nll_per_token = total_nats / max(total_tokens, 1)

# Tail probe: positions [seq_len//2:] within each window. The host
# reconstructs the mask from the index — it does not trust a miner tail flag.
tail_start = seq_len // 2
within = np.arange(total_tokens) % seq_len
tail_mask = within >= tail_start
tail_tokens = int(tail_mask.sum())
if tail_tokens > 0:
tail_nats = float(nlls[tail_mask].sum())
tail_bytes = tail_tokens * bytes_per_token
tail_bpb: float | None = tail_nats / (LN2 * tail_bytes)
else:
tail_bpb = None

return HostReduced(
val_bpb=bpb,
tail_val_bpb=tail_bpb,
nll_per_token=nll_per_token,
tokens_evaluated=total_tokens,
bytes_per_token=bytes_per_token,
eval_set_hash=eval_set_hash,
)
51 changes: 51 additions & 0 deletions eval/val_bpb.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,57 @@ def compute_val_bpb(
}


def per_position_nlls(
model: torch.nn.Module,
eval_tokens: np.ndarray,
seq_len: int,
batch_size: int = 8,
device: torch.device | None = None,
) -> np.ndarray:
"""Per-position cross-entropy (nats) over the held-out stream, in the SAME
window-row-major order `compute_val_bpb` sums.

This is the producer side of HOST-side reduction: the sandboxed (untrusted)
miner model emits this array; the validator reduces it with
`eval.host_reduce.reduce_token_nlls` instead of trusting a miner-printed
bpb. `reduce_token_nlls(per_position_nlls(model, ...))` reproduces
`compute_val_bpb(model, ...)["val_bpb"]` exactly (tested).

Returns a float32 1-D array of length `n_full_windows * seq_len`.
"""
if device is None:
device = next(model.parameters()).device
model.eval()
n = len(eval_tokens)
n_windows = max(1, (n - 1) // seq_len)
chunks: list[np.ndarray] = []
with torch.no_grad():
batch_inp: list[torch.Tensor] = []
batch_tgt: list[torch.Tensor] = []
for w in range(n_windows):
start = w * seq_len
ids = eval_tokens[start : start + seq_len + 1]
if len(ids) < seq_len + 1:
break
batch_inp.append(torch.from_numpy(ids[:-1].astype(np.int64)))
batch_tgt.append(torch.from_numpy(ids[1:].astype(np.int64)))
if len(batch_inp) == batch_size or w == n_windows - 1:
inp = torch.stack(batch_inp).to(device)
tgt = torch.stack(batch_tgt).to(device)
logits, _ = model(inp)
nll = F.cross_entropy(
logits.view(-1, logits.size(-1)),
tgt.reshape(-1),
reduction="none",
)
chunks.append(nll.detach().float().cpu().numpy())
batch_inp.clear()
batch_tgt.clear()
if not chunks:
return np.zeros(0, dtype=np.float32)
return np.concatenate(chunks).astype(np.float32)


def compute_val_bpb_on_stream(
model: torch.nn.Module,
batch: SealedStreamBatch,
Expand Down
97 changes: 97 additions & 0 deletions tests/test_host_reduce.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Host-side val_bpb reduction must reproduce the in-process computation exactly,
and must reject malformed/forged NLL arrays."""
from __future__ import annotations

import math

import numpy as np
import pytest
import torch
import torch.nn.functional as F

from eval.host_reduce import (
expected_token_count,
hash_target_stream,
reduce_token_nlls,
)

LN2 = math.log(2)


def test_reduction_matches_in_process_cross_entropy():
"""reduce_token_nlls(per-position NLLs) == the bpb/tail compute_val_bpb gets
from the same logits — the equivalence the host-side move depends on."""
torch.manual_seed(0)
vocab, seq_len, n_windows, bpt = 11, 8, 5, 4.0
logits = torch.randn(n_windows, seq_len, vocab)
targets = torch.randint(0, vocab, (n_windows, seq_len))

# Reference: how compute_val_bpb reduces (sum over all positions).
total_nats = F.cross_entropy(
logits.view(-1, vocab), targets.reshape(-1), reduction="sum"
).item()
tokens = n_windows * seq_len
ref_bpb = total_nats / (LN2 * tokens * bpt)
tail_start = seq_len // 2
tail_nats = F.cross_entropy(
logits[:, tail_start:, :].reshape(-1, vocab),
targets[:, tail_start:].reshape(-1),
reduction="sum",
).item()
tail_tokens = n_windows * (seq_len - tail_start)
ref_tail = tail_nats / (LN2 * tail_tokens * bpt)

# The sandbox would emit these per-position NLLs (window-row-major order).
nlls = F.cross_entropy(
logits.view(-1, vocab), targets.reshape(-1), reduction="none"
).numpy()

out = reduce_token_nlls(
nlls, seq_len=seq_len, bytes_per_token=bpt,
expected_tokens=tokens, eval_set_hash="deadbeef",
)
assert out.val_bpb == pytest.approx(ref_bpb, rel=1e-6)
assert out.tail_val_bpb == pytest.approx(ref_tail, rel=1e-6)
assert out.tokens_evaluated == tokens


def test_expected_token_count_matches_packing():
# Full windows of (seq_len+1); partial last window skipped.
assert expected_token_count(41, 8) == 40 # 5 full windows
assert expected_token_count(40, 8) == 32 # 4 full windows
assert expected_token_count(9, 8) == 8
assert expected_token_count(5, 8) == 0 # no full window → 0 (bpb=inf upstream)


def test_rejects_wrong_length():
with pytest.raises(ValueError, match="wrong number"):
reduce_token_nlls(
np.ones(39), seq_len=8, bytes_per_token=4.0,
expected_tokens=40, eval_set_hash="x",
)


def test_rejects_non_finite_and_negative():
bad = np.ones(40)
bad[3] = np.inf
with pytest.raises(ValueError, match="non-finite"):
reduce_token_nlls(bad, seq_len=8, bytes_per_token=4.0, expected_tokens=40, eval_set_hash="x")
neg = np.ones(40)
neg[7] = -0.5
with pytest.raises(ValueError, match="negative"):
reduce_token_nlls(neg, seq_len=8, bytes_per_token=4.0, expected_tokens=40, eval_set_hash="x")


def test_rejects_bad_bytes_per_token():
with pytest.raises(ValueError, match="bytes_per_token"):
reduce_token_nlls(np.ones(40), seq_len=8, bytes_per_token=0.0, expected_tokens=40, eval_set_hash="x")


def test_hash_binds_host_stream_and_is_deterministic():
a = np.arange(100, dtype=np.uint16)
b = np.arange(100, dtype=np.uint16)
c = a.copy()
c[50] = 999
assert hash_target_stream(a) == hash_target_stream(b)
assert hash_target_stream(a) != hash_target_stream(c)
assert len(hash_target_stream(a)) == 64
Loading
Loading