Skip to content

feat(token-id-capture): external-sink token capture (worker-owned staging, ledger, terminal attribution) - #2872

Open
pthombre wants to merge 19 commits into
mainfrom
pthombre/tokcap/full-stack
Open

feat(token-id-capture): external-sink token capture (worker-owned staging, ledger, terminal attribution)#2872
pthombre wants to merge 19 commits into
mainfrom
pthombre/tokcap/full-stack

Conversation

@pthombre

@pthombre pthombre commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds external-sink token capture: a mode where a framework inference worker (e.g. NeMo RL with TransferQueue) stages each model call's token delta durably in its own storage and acknowledges Gym with token-light commit coordinates. Gym's data plane never carries raw training tokens end-to-end — it keeps only a token-free custody ledger of hashes and metadata, and the framework reconstructs and cryptographically verifies the full training sequence at rollout end.

This builds directly on the existing token capture stack (capture middleware, CaptureContext, fingerprint-based lineage resolution, LineageStore, and witness-based terminal attribution) rather than replacing it: the legacy complete-record path (TokenSink/TokenSource, TokenCaptureStore) is unchanged and remains the default. External staging is a per-server opt-in (token_id_capture.external_staging: true).

Why

For RL training at scale, shipping full token arrays through the agent/env data plane is wasteful and couples Gym's storage to training infrastructure. With external staging, tokens live only where training consumes them (e.g. TransferQueue), while Gym retains just enough — chained digests and custody metadata — to prove, at rollout end, exactly which tokens in which order earned the reward.

How it works

  1. Admission. Capture-prefixed requests are admitted by the existing capture middleware. resolve_parent performs strict tri-state admission against the rollout's ledger: a unique fingerprint match mints a token_in CaptureAdmission (parent id, prev_len, parent chain hash, staging chain); no model-authored context mints a text root; anything else writes a poison row and the call is unadmitted (fail closed).
  2. Worker-side staging. The admission rides the engine-bound request as ng_capture. The worker runs RolloutTokenCapture: verifies its prompt begins with the authorized prefix (fetched from the external sink via the staging chain — large token arrays never cross the Gym wire), builds the mask/logprob-aligned delta, computes chain_hash_N = H(chain_hash_{N-1} || delta_N) plus a whole-sequence cumulative_hash, stages the StagedCallRecord to the StagingSink, and only then acknowledges with CommitCoords on the response.
  3. Ledger commit. The vLLM server validates the coordinates against the active capture context, computes output/continuation fingerprints server-side, and appends a token-free custody row to the per-rollout ledger. All token/logprob transport fields are stripped before the response leaves the process. Any failure writes a poison row instead; an admitted call that returns without coordinates is poisoned by the middleware.
  4. Rollout end. The framework reads the token-free manifest over a bearer-protected control route, attributes the terminal call (declared response id > served response id > content fingerprints, with corroboration; parent-link heuristic as the no-witness fallback), seals a RolloutReceipt, fetches the staged deltas from the StagingSource, and runs verify_and_linearize: every digest and chain hash is re-derived link by link, with a single terminal cumulative-hash anchor, producing the masked LinearizedRow for training. Any tampering, loss, or reordering of a delta breaks a hash and fails closed.

What's added

nemo_gym/token_id_capture/staging/ — portable wire/integrity contracts. Dependency-free by design (no fastapi/ray/torch/transfer_queue imports, test-enforced) so the framework side can consume it as a spec:

  • digest.py — canonical encodings and the v2 staging digest: chain hashing, cumulative hashing, extras digests, delta construction with mask/logprob invariants.
  • records.pyCaptureAdmission, StagedCallRecord/StagedCallSnapshot, CommitCoords, token-free CallRecord, RolloutManifest, RolloutReceipt; plus the centralized machine-readable reason-code vocabulary (poison and terminal-selection reasons) the framework switches on.
  • capture.py / protocols.py — the worker-side lifecycle (RolloutTokenCapture: begin/complete/fail, single-shot claims, capture failures never fail the model completion) and the framework seams (StagingSink, StagingSource, CaptureAdapter, WeightVersionProvider, install_capture).
  • rebuild.pyverify_and_linearize: receipt/version checks, manifest-vs-snapshot comparison, full digest recomputation, parent-graph validation, chain-hash re-derivation, routed-experts splicing.
  • terminal.py — fail-closed heuristic terminal selection from parent links, for rollouts with no declared terminal and no witness.
  • routes.py — compact routed-experts envelope (decode + token-count without materializing the tensor).
  • conformance/ — installable golden vectors so an independent implementation can prove byte-identical digests at startup.

Capture ledger. The lineage store doubles as the rollout's capture ledger (CaptureLedger protocol): FileLineageStore gains a durable per-rollout *.lineage.jsonl of token-free custody rows (fsync'd, incrementally cached, sharing the token store's per-rollout lock and rollout-id validation), with ledger-row fallback resolution so chains continue without tokens ever crossing the wire.

Terminal attribution. The existing witness-corroboration engine (token_id_capture/terminal.py) is extended to serve both record families: token-free custody rows identify by (cumulative_hash, chain_hash, cum_len) and match content through recorded fingerprints, and a new authoritative declared_response_id witness carries the harness's declaration (a declared id the ledger cannot confirm attributes nothing and never falls back).

Model server integration (vllm_model). External-capture request shaping (admission attached, prefix arrays omitted in favor of staging-chain fetch), response finalization (coordinate validation, fingerprint stamping, ledger commit, transport-field stripping), and hard config guards (requires enabled, forbids rebuild_response, completions API, responses-native mode, n != 1, and streaming).

Agent plumbing (swe_agents). Rollout-scoped model base URLs threaded into the sandbox, an OpenHands sitecustomize overlay that routes the pinned in-container client through the capture-prefixed URL, the declared-terminal response id surfaced on verify, and routed-experts attachment from harness completions.

Control plane. A bearer-protected manifest route (/training-token-capture/control/rollouts/{id}/manifest) plus RolloutControlClient; the token is read from $NEMO_GYM_TOKEN_CAPTURE_CONTROL_TOKEN (name configurable) and never serialized into run config.

Configuration

token_id_capture:
  enabled: true
  external_staging: true          # opt-in; forbids rebuild_response
  control_auth_token_env: NEMO_GYM_TOKEN_CAPTURE_CONTROL_TOKEN

No new runtime dependencies; TransferQueue is integrated purely by protocol (no transfer_queue import anywhere in Gym).

🤖 Generated with Claude Code

@copy-pr-bot

copy-pr-bot Bot commented Aug 29, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@pthombre
pthombre marked this pull request as ready for review August 30, 2026 17:50
@pthombre
pthombre requested a review from a team as a code owner August 30, 2026 17:50
@pthombre
pthombre requested a review from ananthsub August 30, 2026 21:52
@github-actions github-actions Bot added the sla:review-overdue Review response is over the one-business-day SLA label Aug 31, 2026
@pthombre

pthombre commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

/claude review

@pthombre

pthombre commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

/ok to test c5dccdb

pthombre and others added 12 commits August 31, 2026 17:12
Signed-off-by: Pranav Thombre <pthombre@nvidia.com>
Signed-off-by: Pranav Thombre <pthombre@nvidia.com>
Signed-off-by: Pranav Thombre <pthombre@nvidia.com>
A framework inference worker stages each call's token delta durably and
acknowledges with token-light commit coordinates; the process-shared
lineage store doubles as the per-rollout capture ledger, so serving
workers coordinate only through that store. Admission is the strict
tri-state of the lineage result and unresolved parents poison the call
(fail closed). Also preserves the capture route and capability in
external SWE agent clients.

Signed-off-by: Pranav Thombre <pthombre@nvidia.com>
The middleware stamps admitted_at once on the CaptureContext when it
admits the call; the commit hook threads it through the lineage stores'
custody columns into the token-free manifest as CallRecord.admitted_at.
Stamping at admission (not at record time) keeps retried commits
byte-identical, which the stores' idempotency-by-equality requires.
Rows written before the column carry None and still validate.

The column exists so heuristic terminal selection can order candidate
roots for harnesses that do not declare a terminal response id.

Signed-off-by: Pranav Thombre <pthombre@nvidia.com>
…llouts

A harness that reports the response id it kept gives receipt assembly an
authoritative terminal row; one that reports nothing previously masked
every rollout. select_terminal_call infers the terminal from the
manifest's explicit parent links alone: the earliest-admitted root (an
extended root beats an abandoned sibling), then a walk that eliminates
abandoned retries (childless child loses to an extended sibling).

Selection is token-free and fail-closed: a retry of the final call,
divergent extended branches, orphaned or cyclic rows all select nothing
and surface a reason the caller records as the failure reason. The
verifier still checks every digest on the chosen chain downstream.

RolloutReceipt.terminal_selection records which path chose the terminal
(declared vs heuristic) so consumers can meter heuristic reliance. The
README now documents the ledger (the gate it described was removed) and
the declared > heuristic > mask precedence.

Signed-off-by: Pranav Thombre <pthombre@nvidia.com>
Signed-off-by: Pranav Thombre <pthombre@nvidia.com>
Replace cumulative_token_ids in external (custody) lineage rows with a
pair of worker-computed hashes:

  chain_hash  = H(domain ‖ parent_chain_hash ‖ token_ids_delta)
  cumulative_hash = hash_token_ids(prompt + generated)

CommitCoords.token_ids_delta is removed (hard drop; gate and worker must
deploy together). The gate records token-free external rows and
verify_and_linearize checks both hashes during the terminal-chain walk.
Old rows without hashes skip verification; legacy external rows without
chain_hash are rejected at admission (fail closed).

Signed-off-by: Pranav Thombre <pthombre@nvidia.com>
Staged, fail-closed terminal selection: declared response id, scored
response envelope id, and content fingerprints each independently name a
manifest row; agreeing witnesses attribute, disagreement attributes
nothing, heuristic parent-link selection remains the no-witness fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranav Thombre <pthombre@nvidia.com>
…e primitives

Fold four parallel constructs the external-staging stack duplicated from
the legacy capture path into single implementations:

- Terminal attribution: token_id_capture/terminal.py is now the one
  witness-corroboration engine. Custody rows are duck-typed by their
  staging_key, identify by (cumulative_hash, chain_hash, cum_len), and
  match content through recorded fingerprints; a new keyword-only
  declared_response_id witness carries the manifest path's authoritative
  declaration semantics. staging/attribution.py is removed and the
  staging package re-exports resolve_terminal/TerminalAttribution.
- Fingerprint version: drop LINEAGE_FINGERPRINT_VERSION in favor of the
  existing fingerprint.FINGERPRINT_VERSION (same value; rows unaffected).
- Ledger file plumbing: the custody ledger reuses store.validate_rollout_id
  and the per-rollout .tokens.lock instead of a private regex and a second
  .lineage.lock file. Ledger read/append semantics are unchanged.
- Reason codes: all poison-row and terminal-selection reasons become
  documented constants in staging/records.py beside the wire schemas;
  writers import them (values unchanged).

NeMo RL's single import site must switch to
`from nemo_gym.token_id_capture.staging import resolve_terminal`; its
call already matches the unified signature.

358 token-capture unit tests pass (Slurm job 6721102).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranav Thombre <pthombre@nvidia.com>
Two completion-file parse sites referenced the stdlib json module without
importing it (ruff F821). The file is orjson-migrated everywhere else, so
parse with orjson and catch orjson.JSONDecodeError instead of adding a
second json dependency.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranav Thombre <pthombre@nvidia.com>
Signed-off-by: Pranav Thombre <pthombre@nvidia.com>
@pthombre
pthombre force-pushed the pthombre/tokcap/full-stack branch from c5dccdb to 7dc7ed4 Compare September 1, 2026 00:14
@pthombre

pthombre commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

/ok to test 7dc7ed4

@claude

claude Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

SHIP WITH CARE

Reviewed the full 40-file diff. This adds an opt-in "framework-owned token staging" path for training-time token capture (token_id_capture.external_staging), the worker/gate wire contracts, a capture-ledger surface on FileLineageStore/InMemoryLineageStore, a bearer-protected read-only manifest control route, and the SWE-agent plumbing to route sandboxed agent traffic through the capture-prefixed URL. The design is fail-closed throughout (unresolved parent → poison, not silent new-root; missing commit coords → poison; sink/adapter exceptions poison capture without failing the model completion), the digests are domain-separated and versioned, and test coverage is genuinely thorough (ledger, admission, attribution, rebuild, worker, staging core). Async hygiene is clean — no httpx.AsyncClient, no ray.get(), blocking ledger I/O is offloaded via asyncio.to_thread under flock, and the control client goes through Gym's global aiohttp request().

The subsystem itself is gated behind config that defaults off and validates its prerequisites eagerly (external_staging requires enabled, non-InMemoryLineageStore, CaptureLedger, rebuild_response=false, a control bearer, and non-streaming), so the risk to existing runs is low.

One finding worth confirming before merge (inline on responses_converter.py:790):

  • RISKchat_completion_to_response now reuses chat_completion.id as the Responses id on the default path (not just capture). This flips the public id from resp_* to the backend's chatcmpl-* for all chat-backed Responses traffic and makes uniqueness backend-dependent. If the terminal-attribution join only needs this under external_staging, gate it; otherwise call it out as a deliberate API contract change. Everything else in the capture path is correctly scoped behind the opt-in flag.

Non-blocking notes:

  • pyproject.toml package-data / exclude additions and the .secrets.baseline golden-vector entries look consistent with the new files.
  • The OpenHands sitecustomize.py overlay monkeypatches ServerClient.request in the pinned-Gym venv; it's idempotent (guard attr) and scoped to the capture URL, which is a reasonable approach given OpenHands pins its own nemo-gym.

No async-hang, verifier-scoring, or broken-public-API blockers found. The public base-class surface is extended (new optional BaseRunRequest.capture_rollout_id alias, new CaptureLedger protocol) without removing or renaming existing members.

Comment thread nemo_gym/responses_converter.py Outdated
@claude

claude Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

SHIP WITH CARE — no blockers found.

Reviewed the full diff (42 files) and the key correctness paths in the head tree. This is a large but carefully-engineered, fail-closed subsystem for framework-owned token staging.

What makes this low-risk to merge:

  • Fully gated behind the new token_id_capture.external_staging flag (default false). Config validators require enabled=true + rebuild_response=false, and VLLMModel.model_post_init rejects incompatible modes (use_completions_api, is_responses_native, return_token_id_information). Existing runs are untouched.
  • Async hygiene holds: no httpx/ray.get() in async paths; FileLineageStore file I/O is dispatched via asyncio.to_thread; RolloutControlClient routes through nemo_gym.server_utils.request(). New deps are stdlib only (hashlib, struct, base64).
  • Correctness is fail-closed end to end: unresolved/ambiguous parents, missing commit coords, admission/identity divergence, and custody rows without a served response_id all write a poison row (or abstain) rather than emitting a silently-wrong training row. Digest chaining (compute_chain_hash/cumulative_hash), verify_and_linearize, and multi-witness terminal attribution are invariant-checked with strong dedicated tests.
  • _strip_capture_transport_fields runs in a finally, so token IDs / logprobs / routed_experts never leak onto the agent hop.

RISK — test coverage of the model-server finalization seam. VLLMModel._finalize_external_capture / _apply_external_capture / _strip_capture_transport_fields (responses_api_models/vllm_model/app.py:1031-1150) are the glue that turns a worker CommitCoords ack into the custody ledger row that becomes a training row — and that strips custody fields before the response reaches the agent. This path has no direct unit test in the diff (the ledger/sink/attribution primitives it calls are well covered, but not the assembly: coords→record(...), the missing-response_id fail-closed branch, and the double-poison-vs-mark_external_staging_committed ordering). Given the >=96% coverage bar and that this seam manufactures training-row data, a focused test (happy commit, capture_failed, missing coords, missing envelope id, coords/admission divergence) would close the gap. Author's call whether to defer to integration coverage.

Per repo policy: this needs a real rollout with a model inspected end-to-end before relying on it — green unit tests alone aren't sufficient for a capture/training-signal path.

pthombre added a commit to NVIDIA-NeMo/RL that referenced this pull request Sep 1, 2026
The previous pin d8a11dba existed only in the local submodule clone (never
pushed to NVIDIA-NeMo/Gym), which broke every CI job at submodule checkout
('not our ref') and the submodule fast-forward check. Re-pin to 7dea0e786,
the live head of NVIDIA-NeMo/Gym#2872, and relock for its pyproject delta
(uv 0.11.28, lockfile revision 3).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranav Thombre <pthombre@nvidia.com>
@pthombre

pthombre commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

/ok to test 7f2f5ac

Comment thread nemo_gym/token_id_capture/staging/rebuild.py
return self._failed_coords(call)
try:
with self._lock:
result = self._sink.stage(record)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this lock covers the external sink.stage() call, not only the single-completion state transition.

this means concurrent requests serialize on the capture host. for an out-of-tree sink implementation, can the protocol require thread-safe sinks and move stage() outside of this global lock? or could we use a per-rollout/keyed synchronization instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think for the TQ write this lock should be safe to remove

@github-actions github-actions Bot removed the sla:review-overdue Review response is over the one-business-day SLA label Sep 1, 2026
@ananthsub ananthsub linked an issue Sep 1, 2026 that may be closed by this pull request
… table

Promote encode_routed_experts (reference pure-Python inverse of the nrlre1
decoder) and classify_route_span (the full/tail/sentinel route-linearization
decision table as a pure-metadata function) to public staging APIs. The
envelope wire format lives with Gym because extras_digest is computed over
the encoded payload; frameworks apply the decision table instead of
mirroring it. Adds codec round-trip, malformed-envelope, and
classification-boundary tests.

Signed-off-by: Pranav Thombre <pthombre@nvidia.com>
…-only verifier

Add StagedCallBaseSnapshot — the extras-free part of a staged call whose
validator enforces every base invariant and recomputes the call digest from
the committed extras_digest, so it verifies without materializing extras
bytes. StagedCallRecord now derives from it and layers the extras-digest
check on top.

verify_and_linearize() keeps its name but changes contract: it consumes base
snapshots, performs all receipt/graph/chain-hash/terminal-selection and
linearization checks, and returns per-call ExtrasCommitment values instead of
decoded routes. LinearizedRow drops routed_experts/routed_experts_dtype;
_decode_selected_routes is deleted. Consumers fetch extras (immediately or
deferred), verify them against the returned commitments with
compute_extras_digest, and decode with the public codec.

BREAKING CHANGE: verify_and_linearize no longer accepts extras-bearing-only
semantics nor decodes routes; StagingSource.fetch returns base snapshots.
A one-time mutation-matrix parity check (37 base-record cases) confirmed
identical success/failure codes and row payloads against the old verifier
before deletion.

Signed-off-by: Pranav Thombre <pthombre@nvidia.com>
@pthombre

pthombre commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

/ok to test b781987

pthombre added a commit to NVIDIA-NeMo/RL that referenced this pull request Sep 2, 2026
Pin 7b842eab: verify_and_linearize replaced with the metadata-only
implementation this branch's finalizer now calls (StagedCallBaseSnapshot,
ExtrasCommitment, public route codec + span decision table). The SHA is
pushed to NVIDIA-NeMo/Gym#2872, so CI submodule checkout resolves it.

Signed-off-by: Pranav Thombre <pthombre@nvidia.com>
The capture host's instance-wide lock covered the external
StagingSink.stage() call, so every concurrent completion on a worker
serialized behind one blocking sink round trip - and because
_claim_completion shared the same lock, even unrelated claims and
fail_call poison paths waited on in-flight network I/O.

The lock's only real content is the per-call single-completion claim:
by the time stage() runs, the claim already made the caller the sole
stager, and cross-call ordering comes from stage-before-ack (a child is
only admitted after its parent's coords produced a ledger row). Narrow
the lock to the claim, run stage() unlocked, and promote thread safety
into the StagingSink protocol contract so implementations that need
serialization synchronize internally.

Adds a regression test that completes and poisons unrelated calls while
another call is blocked mid-stage; it fails against the previous code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranav Thombre <pthombre@nvidia.com>
@pthombre

pthombre commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

/ok to test b09d85f

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Gym data plane integration

2 participants