[feat] export the front-end and serving contract for genrec - #662
Conversation
The HF export wrote the weights, a plain backbone config and a bare tokenizer, and refused any prompt with a projected slot, so a serving engine had to reimplement the assembler or not serve the model. It now writes the composite config a runtime resolves the backbone through, the extended tokenizer as a directory AutoTokenizer loads, prompt/prompt.json with the SID space, decode schedule and digests, and frontend/ as a standard tzrec model directory holding the scripted walk, the hole_keys fold, the trained projections and, by default, the slot tables. Under USE_DISTRIBUTED_EMBEDDING=1 the front-end becomes the processor's dense stage and the tables ship as the sparse npz files that stage already loads. The collator keeps its numpy walk; a parity test pins the two call sites of the one walk, and a multi-value SID history now assembles in both. ResolvedSidSpace gains bundle_uuid, so vocab_hash and plan_hash change once for checkpoints saved before this commit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MB8aCo5mgF5ohAcBpWYDaN
…l sites RFC 0001 fixes the assembler as one TorchScript module the collator calls on the host and export wraps as the serving front-end, but the serving work added a second walk beside the collator's numpy one and pinned the two with a parity test. The tensor-op walk now lives in assembler.py as the only PromptAssembler: the collator feeds it the parsed tensors directly and prefixes its streams into additional_infos, the exported front-end scripts the same module, and the numpy walk's validation moves into it so a bad row fails identically at both call sites. Holes are indexed by projected occurrence in emission order rather than by slot name, so a slot that appears twice in the template keeps both of its hole groups, and the plan drops the slot_index that collapsed them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MB8aCo5mgF5ohAcBpWYDaN
The serving front-end was assembled by hand: slot tables copied out of the restored EmbeddingGroup, a bespoke module, hand-written dense_meta.json and sparse npz writers, a frontend/ subdirectory and a class-specific block in export(), all re-implementing what export_model already does for every model. The served half of a genrec model is now GenrecFrontEnd, a TowerWrapper-style wrapper over the model's own embedding group and projections, handed to export_model beside MatchModel and TDM. ScriptWrapper assembles the prompt from the parsed dict as the collator does, the assembler is an FX leaf so tracing keeps it whole for TorchScript, and the HuggingFace weights, composite config and prompt/ are written by an export_assets hook the export discovers the way checkpointing discovers hf_backbone. Quantization, INPUT_TILE and the distributed-embedding split therefore apply unchanged, and the export is one flat directory. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MB8aCo5mgF5ohAcBpWYDaN
Nothing in the serving engine reads prompt.json; its one reader is the offline constraint-index builder, which needs the token base, the per-level bands and the bundle identity, all of which the resolved SID space carries. The decode schedule, the prefix bound, the length ceilings, the digests and the front-end description were written for readers that do not exist, so they go, together with the front-end's serving-contract helper. The export contract is still moving, so its user-facing documentation waits until it settles. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MB8aCo5mgF5ohAcBpWYDaN
The assembler is prompt structure only. The fold is a serving-only module composed by ScriptWrapper, so training no longer computes keys it discards. PromptPlan loses its fold constants and the assembler its host-ABI fallback. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MB8aCo5mgF5ohAcBpWYDaN
Nothing in the export or in serving reads vocab_hash or plan_hash. The restore guard refused routine warm starts such as a bundle refresh, and the plan salt covered only the holes of a cache the engine namespaces anyway. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MB8aCo5mgF5ohAcBpWYDaN
Each acronym keeps its own casing, as DeepFM and DlrmHSTU do, and the serving side already spells it PromptGenRecForCausalLM. Config field names and module file names are unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MB8aCo5mgF5ohAcBqWYDaN
The served module is a model, not an exporter: export() writes the HF weights, composite config, tokenizer and prompt.json after export_model, through export_hf_assets, and the duck-typed export_assets hook is gone. The bundle identity leaves ResolvedSidSpace for a top-level prompt.json key read from the manifest at export, and the assembler scatters every segment in one index_copy_. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MB8aCo5mgF5ohAcBqWYDaN
genrec_integration_test drives train_eval, eval and export through the same torchrun helpers as the rank tests and reads the export back the way the serving stack does, which retires the checkpoint-writing test fixture and the separate serving-contract test. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MB8aCo5mgF5ohAcBqWYDaN
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MB8aCo5mgF5ohAcBqWYDaN
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MB8aCo5mgF5ohAcBqWYDaN
The walk and the fold read the parsed dict the way every scripted module does: no schema, width, band, length or batch-size checks, each of which cost a device sync at serving, and no prompt_ prefix on the batch keys, since nothing else in additional_infos shares those names. The batch size comes from the first slot member, and a dense sequence member now folds per row instead of being refused. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MB8aCo5mgF5ohAcBqWYDaN
create_genrec_test_tokenizer and create_genrec_test_model sit beside create_tiny_causal_lm; the genrec tests are plain TestCases again and tzrec/tests/prompt_test_util.py is gone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MB8aCo5mgF5ohAcBqWYDaN
The export composes config.json once from the backbone config dcp_to_hf returns, under the names GenRecForCausalLM / genrec, and writes the tokenizer beside it. The server never reads the SID space and the offline index builder derives it from the tokenizer and the bundle manifest, so prompt/prompt.json and persist.py are gone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MB8aCo5mgF5ohAcBqWYDaN
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MB8aCo5mgF5ohAcBqWYDaN
…-export # Conflicts: # tzrec/version.py
Code review — PR #662 (static review; no tests/builds run)Five review passes ran over the change (code quality, performance, test coverage, documentation accuracy, security/multi-process). Overall this is a well-structured change: routing genrec through the standard Inline comments cover the noteworthy findings; the ones I'd weight highest:
Smaller notes, not worth separate threads:
Nice touches worth calling out: the FX-leaf list shared correctly across both tracers' matching semantics (class-name vs path), the |
The export built the LM on every rank and kept it through the shard, and dcp_to_hf read every checkpoint key -- all the sparse tables -- to keep the backbone's. The front-end holds no LM, so it is freed once built, and the DCP key mapping now runs on the metadata's names so the load asks for the backbone alone. The staging dir moves into a try/finally. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MB8aCo5mgF5ohAcBpWYDaN
An empty hole folded to zero in every slot, which the fold's own invariant says cannot happen: the slot must discriminate. The accumulator starts at the salt, and the salt is 1-based so slot 0 is salted too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MB8aCo5mgF5ohAcBpWYDaN
ScriptWrapper assembles the prompt for any module carrying compiled_prompt, so the base model advertising it made every wrapper build a serving assembler and fold it never uses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MB8aCo5mgF5ohAcBpWYDaN
max_length is a compile-time ceiling now, the sparse export filters projection heads rather than a backbone, and the tokenizer config names tokens, not ids. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MB8aCo5mgF5ohAcBpWYDaN
The scripted round trip compared int64 streams through float, no front-end case had more than one projected slot, and the integration test's projected parameter was never false. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MB8aCo5mgF5ohAcBpWYDaN
ScriptWrapper wraps every model, so it leads with parsing a request into a batch and treats the prompt as the conditional it is; the fold's rationale reads in one paragraph. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MB8aCo5mgF5ohAcBpWYDaN
Summary
The HF export of
genrec_causal_lm_modelwrote only the backbone weights, the plain backboneconfig.jsonand a baretokenizer.json, and refused any prompt with a PROJECTED slot. A serving engine therefore had to reimplement the prompt assembler or not serve the model.A genrec model now exports like every other tzrec model.
export()wraps the served half of the model,GenRecFrontEnd, and hands it to the sameexport_modelevery model goes through; the LM rides beside it as HuggingFace weights for the engine that decodes. One flat directory:tzrec/prompt/assembler.py: onePromptAssembler, a TorchScript-able tensor-op walk with two call sites (RFC 0001 §8.1): the collator andScriptWrapperboth run it on the parsed dict and prefix its streams intoadditional_infos. It is prompt structure only and trusts the parsed batch as every scripted module does: no schema, width, band, length or batch-size checks (each cost a device sync at serving; the data contract belongs to the parser and to the request), holes indexed by projected occurrence in emission order, the canonical parsed ABI (.values/.lengths/.key_lengths) and nothing else, oneindex_copy_for every segment. Its streams sit inadditional_infosunder their own names; nothing else there shares them, so there is noprompt_prefix.tzrec/prompt/hole_keys.py: the integerhole_keysfold of RFC 0002 §8 as its own module,HoleKeyBuilder, composed byScriptWrapperbeside the assembler at serving only; training never computes a key it discards. It iteratesprojected_slots, the same orderproject_slotsand the assembler's holes use, so the three hole-indexed streams stay row-aligned; a dense member folds its float32 bit pattern per row, for DEEP and sequence slots alike, and each hole's accumulator starts at its slot salt so a hole no value reaches is still that slot's hole.FoldConstantsis gone: the multipliers are class constants baked into the scripted front-end, nothing in sglang reads them, and no plan salt enters a key (a cache shared across model versions already matches static tokens alike, so only the engine can namespace it).ComputeJTDictToKJTalready is; TorchScript compiles them whole inside the traced graph.tzrec/models/genrec_model.py::GenRecFrontEnd: shares the model'sembedding_groupandprojections(checkpoint names unchanged underScriptWrapper, LM never loaded at export);predictreturns the streams plusslot_embeds. It owns no export logic:export()runsexport_modelon it -- freeing the LM as soon as the front-end is built, since only the HuggingFace weights serve it -- and then, on rank 0,hf_export_util.export_hf_assetswrites the HF weights, the extended tokenizer and one compositeconfig.json, written once from the backbone configdcp_to_hfreturns (written locally in a staging dir dropped however the export ends, and uploaded when the dir is remote).dcp_to_hfmaps the checkpoint onto the architecture on the DCP metadata's names, so it reads the backbone's tensors and not the sparse tables beside them. The composite config is what sglang keys on:model_type: genrecselectsGenRecConfig,GenRecForCausalLMresolves the backbone fromtext_config.architectures. Nothing tzrec-specific is in it: the server never reads the SID space (decode runs on the npz index, the processor needs no sentinel id, generation reads the standardeos_token_id/pad_token_id/vocab_size), and the offline index builder derives the token base from<|sid_0|>'s id intokenizer.jsonand the bands and bundle identity from the bundle manifest.QUANT_EMB,INPUT_TILEandUSE_DISTRIBUTED_EMBEDDINGapply unchanged: the distributed-embedding export splits the slot tables intosparse/*.npzand makes the front-end the processor's dense stage withdense_meta.json, with no genrec-specific writer.compile_promptchecks the codebook against the manifest at export, and the server checks the npz'sbundle_uuidagainst--sid-bundle-uuid._get_sparse_embedding_tensorskips.weightentries that are not sparse tables.vocab_hash,plan_hash, theprompt_digestsa checkpoint recorded and the restore guard (check_prompt_assets) are removed. Nothing in the export or in serving read them, and the guard refused routine warm starts: a SID bundle refresh with the same codebook, a renamed sentinel, any pre-existing checkpoint. Prompt-config compatibility with a checkpoint is the user's responsibility, as for every other tzrec config, recorded by thepipeline.configtraining saves.hf_export_meta.jsonkeeps only the backbone prefix.Paths considered
ConstraintCsr, so the builder sits beside the loader there and reads the export'stokenizer.json, the bundle'smanifest.jsonand itssid_to_itemsas data; tzrec's integration test asserts the token base the builder derives from.SlotTables copied out ofEmbeddingGroup, a hand-writtendense_meta.json/sparse/writer, afrontend/subdirectory, a class-specific block inexport()). That re-implemented whatexport_modelalready does; the third commit removes all of it in favour of aTowerWrapper-style wrapper and oneisinstancecase beside MatchModel/TDM.projected_slotskeeps one walk, stops the dataloader hashing what training discards, and takeshashlib, the fold constants and a second input ABI out of the assembler; the hole order it shares with the assembler is the contractproject_slotsalready relies on.repr(sid_space)and the tokenizer JSON catches a codebook re-split with the same total, which shapes would not, but it also fails every bundle refresh and cosmetic tokenizer change. Comparing the recorded SID space field by field would give an actionable error; nothing asked for it yet, so the guard goes rather than grows.Notes
Genrecis spelledGenRecthroughout (classes and proto messages; config field and file names unchanged), matchingDeepFM/DlrmHSTUand sglang'sGenRecForCausalLM.USE_DISTRIBUTED_EMBEDDING=1the processor runs input-tiled, so a request is one user andbatch_sizecounts candidates (RFC 0002 §12); the front-end also still needs every slot member's raw.values/.lengths/.key_lengthsbeside the looked-up embeddings, because the walk and the fold read the canonical parsed dict only: the processor-side pass-through RFC 0002 §5.7 lists.Out of scope, listed for follow-up:
drop_if_empty, the raw-id pass-through in TorchEasyRec Processor (RFC 0002 §5.7), text-slotvocab_fileinjection intofg.json. User-facing docs wait until the export contract settles.Test Plan
Env: torch 2.13.0 / torchrec 1.8.0 / transformers 5.16.1.
tzrec/prompt/assembler_test.py: every case of the numpy walk on tensor inputs, thekey_lengthslayout walking like the flat one, sentinel/hole/hole_slot_countsinvariants,max_seqlen, the batch sized from a dense or a jagged anchor, output-key constants, scripting round trip.tzrec/prompt/hole_keys_test.py: themix64host reference; the fold cases (equal content, changed content, same id in another slot, permuted item, swapped members, dense bit pattern, dense sequence per item, CPU/GPU bit-identity); keys follow the assembler's hole order across two slots; the builder scripts and is an FX leaf; two empty holes in different slots do not collide.tzrec/utils/test_util.pybesidecreate_tiny_causal_lm:create_genrec_test_tokenizerandcreate_genrec_test_model(tiny LM, tokenizer, compiled prompt and model in one call);tzrec/tests/prompt_test_util.pyand its base class are gone, each test is a plainTestCase.tzrec/models/genrec_model_test.py:ScriptWrapper(GenRecFrontEnd)returns the walk's streams,hole_keysfrom the builder andslot_embedsequal to the model's own projection on the same batch; it shares the checkpoint's parameter names and holds no LM; it FX-traces and scripts (integer streams compared exactly, floats with a tolerance); two projected slots concatenate occurrence by occurrence, split byhole_slot_counts; plus the model-level cases (vocab resize, finite loss into the backbone, FX trace of the training forward).tzrec/tests/genrec_integration_test.py, built likerank_integration_test.py:train_eval->eval->exportin torchrun subprocesses on mock parquet with a tiny LM, INLINE + PROJECTED slots and a SID manifest; then the export is read back the way the sglang stack does it: flat layout, the scripted front-end (called with and without a device) equalsPromptAssembler+HoleKeyBuilderon a parsed batch of the eval data,slot_embedsshape, composite config, tokenizer dir decoding a SID atom,config.jsonshape (no SID block) and<|sid_0|>at the token base. GPU-gated: theUSE_DISTRIBUTED_EMBEDDING=1export writessparse/,dense_meta.json,model_acc.json, and a processor simulator (numpy lookup in the exported npz persparse_features.json, fed perdense_meta.json, input-tiled) equals the default artifact.tzrec.predictis not run: the front-end emits token streams, not per-row columns.tzrec/utils/hf_export_util_test.pyalso pins thatdcp_to_hfasks the checkpoint for the backbone keys alone.prompt.compile_test,models.genrec_causal_lm_model_test,datasets.dataset_test,utils.hf_export_util_test,utils.export_util_test,main_test.prompt/persist_test.pytested only the removed guard and is deleted.--model-path,--manifest,--catalog; its unit test and an import smoke of the renamedGenRecForCausalLM/GenRecConfig/GenRecProcessormodules pass in the sglang test image.pre-commit run --files <changed>andpyrefly checkclean.🤖 Generated with Claude Code
https://claude.ai/code/session_01MB8aCo5mgF5ohAcBpWYDaN