feat(token-id-capture): external-sink token capture (worker-owned staging, ledger, terminal attribution) - #2872
feat(token-id-capture): external-sink token capture (worker-owned staging, ledger, terminal attribution)#2872pthombre wants to merge 19 commits into
Conversation
|
/claude review |
|
/ok to test c5dccdb |
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>
c5dccdb to
7dc7ed4
Compare
|
/ok to test 7dc7ed4 |
|
SHIP WITH CARE Reviewed the full 40-file diff. This adds an opt-in "framework-owned token staging" path for training-time token capture ( The subsystem itself is gated behind config that defaults off and validates its prerequisites eagerly ( One finding worth confirming before merge (inline on
Non-blocking notes:
No async-hang, verifier-scoring, or broken-public-API blockers found. The public base-class surface is extended (new optional |
|
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:
RISK — test coverage of the model-server finalization seam. 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. |
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>
|
/ok to test 7f2f5ac |
| return self._failed_coords(call) | ||
| try: | ||
| with self._lock: | ||
| result = self._sink.stage(record) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
I think for the TQ write this lock should be safe to remove
… 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>
|
/ok to test b781987 |
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>
|
/ok to test b09d85f |
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
resolve_parentperforms strict tri-state admission against the rollout's ledger: a unique fingerprint match mints atoken_inCaptureAdmission(parent id,prev_len, parent chain hash, staging chain); no model-authored context mints atextroot; anything else writes a poison row and the call is unadmitted (fail closed).ng_capture. The worker runsRolloutTokenCapture: 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, computeschain_hash_N = H(chain_hash_{N-1} || delta_N)plus a whole-sequencecumulative_hash, stages theStagedCallRecordto theStagingSink, and only then acknowledges withCommitCoordson the response.RolloutReceipt, fetches the staged deltas from theStagingSource, and runsverify_and_linearize: every digest and chain hash is re-derived link by link, with a single terminal cumulative-hash anchor, producing the maskedLinearizedRowfor 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.py—CaptureAdmission,StagedCallRecord/StagedCallSnapshot,CommitCoords, token-freeCallRecord,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.py—verify_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 (
CaptureLedgerprotocol):FileLineageStoregains a durable per-rollout*.lineage.jsonlof 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 authoritativedeclared_response_idwitness 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 (requiresenabled, forbidsrebuild_response, completions API, responses-native mode,n != 1, and streaming).Agent plumbing (
swe_agents). Rollout-scoped model base URLs threaded into the sandbox, an OpenHandssitecustomizeoverlay that routes the pinned in-container client through the capture-prefixed URL, the declared-terminal response id surfaced onverify, and routed-experts attachment from harness completions.Control plane. A bearer-protected manifest route (
/training-token-capture/control/rollouts/{id}/manifest) plusRolloutControlClient; the token is read from$NEMO_GYM_TOKEN_CAPTURE_CONTROL_TOKEN(name configurable) and never serialized into run config.Configuration
No new runtime dependencies; TransferQueue is integrated purely by protocol (no
transfer_queueimport anywhere in Gym).🤖 Generated with Claude Code