Skip to content

[feat] prompt-native generative recommendation - #625

Open
WhiteSwan1 wants to merge 105 commits into
alibaba:masterfrom
WhiteSwan1:feat/prompt_genrec_qwen
Open

[feat] prompt-native generative recommendation#625
WhiteSwan1 wants to merge 105 commits into
alibaba:masterfrom
WhiteSwan1:feat/prompt_genrec_qwen

Conversation

@WhiteSwan1

@WhiteSwan1 WhiteSwan1 commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Ready for review. Multi-rank is still untested and two items from the
design remain unbuilt (see Not done below); both are called out rather than
silently pending.

Replaces the SID-feature-based generative stack with a prompt-native one: a new
prompt_config owns the template, slots, SID space and tokenizer, and
model_config keeps only what belongs to the LM.

Why

The per-level SID offset used to be applied twice — once in the SID tool's
sid_offset_codes, once again in SidFeature._parse — with no hash covering the
pair. The collision tool now emits an offset_codebook column, so the offset
arrives already applied and that second implementation can go.

What remains inside tzrec is a uniform + base_vocab, which is not
position-dependent and therefore needs no bespoke feature type. SidFeature is
deleted; an INLINE slot is an ordinary sequence_raw_feature and a PROJECTED one
is a sequence_id_feature with an injected num_buckets.

The offset form is also self-validating: a raw code 100 is legal under codebook
4096 and 8192 alike, so a tool/config mismatch passes a range check, whereas
offset codes carry cumsum(codebook[:l]) inside the value and throw every level

= 1 out of band.

What is here

area
tzrec/protos/prompt.proto PromptConfig, PromptSlot, PromptProjection, SidSpace
tzrec/prompt/compile.py compiles the config into a ResolvedSidSpace, a PromptPlan, a ProjectionPlan and an extended tokenizer
tzrec/prompt/assembler.py builds the packed varlen token stream in the dataloader worker
tzrec/prompt/persist.py writes the contract beside the weights, checks it on restore
tzrec/models/prompt_generative_qwen.py the model: inputs_embeds forward, index_copy scatter, band-constrained decode
tzrec/modules/prompt_projection.py slot width -> LM hidden size
docs/source/models/prompt_generative_qwen.md operator manual (Chinese)

Removed: sid_feature.py, generative_model.py, generative_qwen.py,
generative_model.proto and their tests. Nothing is kept for compatibility.

Two properties worth reviewing specifically:

  • The compiler resolves no dimension. No artifact stores in_dim/out_dim;
    the model resolves both at __init__ from group_total_dim and the backbone
    config, so one compiled plan is valid across model sizes.
  • No shape is derived from device tensors. The collator computes
    max_seqlen on the host, so the model never calls lengths.max(). It is
    carried in additional_infos, which Batch.to() migrates, so reading it back
    does cost one host sync per step — a known performance item, not a correctness
    one, tracked in the review triage.

Test Plan

Unit: 55 tests across compile, assemble, projection, persistence and the model.
pre-commit run -a and python scripts/pyre_check.py both clean.

End to end, on a two-layer Qwen saved locally (no download), both an INLINE-only
prompt and one with an added PROJECTED slot:

step result
tzrec.train_eval 3 steps, ce_loss 4.79 against ln(128) = 4.85 — near-random from a fresh init, as expected
eval ce_loss computed and written to train_eval_result_v2.txt
checkpoint model.ckpt-N/prompt/ carries sid_space.json, prompt_plan.json, prompt_hashes.json, tokenizer/
--continue_train restores and continues
hash guard changing the codebook to 8,8,8 is refused at restore
tzrec.predict 64 rows in, 64 out; generated_sids of shape (num_return, num_levels); every code inside [0, codebook), so the bands held and detokenize inverted both shifts
tzrec.export (HF) loads with AutoModelForCausalLM.from_pretrained; export dir carries the prompt contract

Eight defects were found by running the pipeline rather than by unit tests —
four missed _create_model call sites, a (total, value_dim) shape the
assembler mis-read, the absent loss/metric hooks, an FX Proxy in decode, and
the un-derived feature groups a projected slot needs. All are fixed and covered.

Review round

codex-review raised 12 comments; all are triaged. Fixed: plan_hash now
covers projection bodies and routing (slot_to_module), a checkpoint with no
recorded prompt assets is fatal rather than a warning, and predict builds
embeddings outside both FX leaves so the inference tracer sees the sharded
lookup. Earlier in the round: sid_space and response are required at compile,
a token_format with no {i} is rejected, spliced projections take the LM
dtype, and two unreachable validation branches were removed.

Deferred with reasons, not dismissed: the incomplete HF export of a
projected-slot model, SID placement relative to the backbone's vocabulary (a
migration — it changes vocab_hash), the per-step host sync above, and two
beam-search memory items. Three were declined as guards for states the pipeline
cannot produce. The full reasoning is in the PR comment.

Not done

  • Multi-rank is untested. Everything above is --nproc-per-node=1, so
    sharding, DMP and the planner have not been exercised. The projected config is
    the one with a sparse table to shard.
  • The scripted serving front end and cache_ids (§11 of the design). Without
    cache_ids, a stock radix prefix cache keyed on token ids will reuse KV across
    users at a sentinel position — a correctness bug, so it must land before any
    projected slot is served.
  • TorchScript export is refused with a message pointing at export_format: HF;
    the model's input is a stream the dataloader assembles, which an export-time
    dummy batch cannot supply.

Open design question

design_v2.md §8.1 has an "INLINE text" branch, but TokenizeFeature extends
IdFeature and is therefore always sparse, hence always PROJECTED. No existing
type can be an INLINE text slot. I implemented INLINE as unconditionally meaning
SID. Either the branch goes, or TokenizeFeature gains a no-embedding mode.

WhiteSwan1 and others added 30 commits June 8, 2026 09:04
- tzrec/models/generative_rec_lm.py, qwen2_rec_lm.py
- tzrec/protos/models/generative_model.proto + model.proto oneof entry
- tzrec/optim: lr_scheduler additions; optimizer.proto grad-accum/grad-clip
- tzrec/tools/export_genreclm_to_hf.py (DCP -> HF export)

Example scripts and design notes intentionally excluded (to be refactored).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- GenerativeRecLM (base): architecture-agnostic plumbing — vocab extension,
  _tokenize_sids (SID->token-id offset map), _sid_token_rows (jagged read +
  tokenize-once + split, with data-boundary answer-width validation), device
  property, loss/metrics; _build_prompt_tokens / predict are abstract hooks.
- Qwen2RecLM (subclass): ChatML template, causal-LM splice, decoder-only forward.
  _splice_input_ids builds input_ids/mask via pad_sequence and labels in one
  vectorized write (fixed answer width = len(codebook) levels).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- generative_rec_lm_test: registry dispatch, abstract-hook errors, device
  property, _tokenize_sids offset map, _sid_token_rows split/cast/(N,1)-squeeze
  and answer-width validation (ok + violation).
- qwen2_rec_lm_test: splice layout + label masking, left-padding/varied lengths,
  mask keeps trailing eos when pad==eos, _min_first_non_neg_index,
  _build_prompt_tokens buffer registration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rence beam search

predict() dispatches on the TER inference flag (BaseModule.is_inference, set by
main.py's set_is_inference before the predict/export wrappers):
- Branch 1 (not is_inference -> train/eval): existing teacher-forced forward +
  suffix-slice + CE loss (moved to _predict_train).
- Branch 2 (is_inference -> inference): _generate beam-searches the SID answer
  from an answer-less prompt (_splice_prompt_ids), emitting num_levels tokens/beam
  and mapping them back to raw SID indices -> {"generated_sids": (B, num_return, L)}.

Beam params (_num_beams/_num_return) default to algr's 50/50 (optional proto
fields). Tests cover prompt splice, is_inference routing, and token->SID map.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…name dispatch

Each LLM family is its own model_config oneof entry whose message-type name
resolves straight to the same-named class (qwen2_rec_lm -> Qwen2RecLM), so
GenerativeRecLM.__new__ and the class_name field are gone.

Proto split:
- GenerativeRecLMConfig = architecture-AGNOSTIC config (codebook, vocab pad,
  feature names, ignore_index, beam params), embedded as `common`.
- The backbone `hf_model_id` is OWNED by the family message (Qwen2RecLM,
  default "Qwen/Qwen2.5-0.5B"), NOT `common` — the registered family IS the
  architecture commitment, so a backbone in the shared block could contradict
  it. (common field 1 reserved.)
- Family-specific chat-template knobs also live on the family message.

Base __init__ reads cfg.common.* (shared) and cfg.hf_model_id (family-owned).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…k VRAM imbalance

One GPU reserved ~25GB more than the rest during 8-GPU training: the CUDA
caching allocator stranded a whole segment generation on whichever rank drew
its shortest batch first (variable seq-len + native allocator never shrinks
reserved). Pre-size the pool up front so it never has to grow mid-run.

- Qwen2RecLM warms the activation pool with a one-shot dummy fwd+bwd at the
  worst-case (batch_size, T_max) on the first training step (earliest the HF
  backbone is on-GPU); T_max = template frame + sequence_length + num_levels.
- Pool length reuses the user_sequence feature's sequence_length
  (GenerativeRecLM._input_sequence_length); _sid_token_rows enforces it with a
  recency-preserving, item-aligned tail clip (keep newest items, drop oldest)
  so it is a guaranteed bound under FG_NONE, which does not truncate.
- Thread data_config.batch_size into _create_model; move num_beams/num_return
  from base to subclass.

Verified on 8xGPU: per-card spread 25GB -> 2.2GB, no OOM.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…=300

Under FG_NONE the reader does not truncate, so the model enforces this length
via the recency-preserving clip in _sid_token_rows and uses it to pre-size the
activation pool. 300 = AL-GR-Tiny's realistic max history (100 items x 3 codes);
the prior loose 1056 (algr max_length) would oversize the warm-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…fix export tool schema

Intermediate training checkpoints were only written in TER's DCP format, which
isn't directly from_pretrained-loadable and reduced confidence when validating
results. Now save an HF copy at each periodic checkpoint.

- GenerativeRecLM.export_hf(dir): save self.lm + the rebuilt extended tokenizer
  (base + C0..C{sum-1}) straight from the live model.
- main._train_and_evaluate: after each periodic ckpt_manager.save, rank-0 calls
  _model.export_hf(model_dir/hf_ckpt-{step}) when available (duck-typed; other
  models unaffected).
- export_genreclm_to_hf: fix three new-schema bugs (class_name -> which_msg
  resolution, abstract GenerativeRecLM -> resolved family class + register
  Qwen2RecLM, grl_cfg.codebook -> grl_cfg.common.codebook). The codebook bug
  had exported weights without the tokenizer, breaking predict.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…line tool

The standalone export tool duplicated the backbone+tokenizer save that
GenerativeRecLM.export_hf now owns. Make the tool do only the DCP overlay then
call model.export_hf, so offline and in-training HF exports are one code path
(byte-identical) and there is a single source of truth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… standalone tool

The standalone tzrec/tools/export_genreclm_to_hf.py re-implemented the model
build + checkpoint restore that TER's export() already does. Delete it and add
a GenerativeRecLM branch directly in main.export: after the existing
checkpoint resolution, overlay the DCP shards and call model.export_hf (the
same single save path used by the in-training checkpoint hook). No separate
tool, no duplicated save; `python -m tzrec.export` now produces the HF dir for
GenerativeRecLM models.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t-step pad pre-sizing

Import: __init__ builds the empty extended arch (AutoConfig + from_config, no
weight download); new init_from_pretrained() is the sole from_pretrained, invoked
by the pipeline on cold start via a no-op BaseModel hook (no hasattr duck-typing).
Restore/eval/export load weights from DCP, skipping the ~1GB download.

Export: training writes DCP only; each model.ckpt-N/ co-locates HF config+tokenizer
(no weights), gated by export_config.export_format == HF and owned by
CheckpointManager. tzrec.export converts via a standalone dcp_to_hf (recorded
backbone-prefix recorded as data + suffix-match self-heal + strict 1:1 validation,
never a silent partial load). Adds ExportFormat to export.proto; TORCHSCRIPT path
untouched.

Training: replace _warmup_alloc -- a separate unscaled forward+backward that
corrupted the first optimizer step (the lr5e-5 HR 2.6-2.9->1.14-flat regression) --
with first-step left-padding to the worst-case length in _predict_train /
_splice_input_ids / _left_pad. The pad rides the real step (positions masked +
labelled -100, so loss/grad are identical) while pre-sizing the activation pool to
(B, T_max), keeping per-rank reservations uniform.

Tests: rewrite the warmup unit tests to assert the first-step padding contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… comments

__init__ was ~105 lines of mixed concerns + heavy narration. Extract three
behavior-preserving private helpers and condense comments to the load-bearing
rationale only:
- _read_common_config(common) -> sid_atoms: proto knobs + codebook guard.
- _build_backbone() -> module: empty bf16 arch (no weight download) + empty-id guard.
- _build_extended_tokenizer(sid_atoms) -> (tokenizer, base): add C0.. atoms,
  resize self.lm, the added==sid_atoms and C0-at-base guards.

__init__ now reads as super().__init__ -> read config -> build backbone -> build
tokenizer -> pad/prompt/debug tail. All instance attributes, guard messages, and
effect order are unchanged; no behavior change. 22 model unit tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings in upstream's SidRqkmeans (FAISS residual K-Means SID) feature + the
maybe_save / event-time checkpoint refactor + on_train_end hook (3 commits,
ad083a7..3d4d5a8). Conflicts resolved:

- tzrec/protos/model.proto: both sides added a oneof field at 601. Kept
  upstream's `sid_rqkmeans = 601`; moved our `qwen2_rec_lm` to 700 (a clear
  100-block for the generative-LM family) and dropped our `reserved 600` in
  favor of upstream's planned SidRqvae=600. (TER configs are text-format / use
  field names, so the renumber is wire-irrelevant.) gen_proto verified: 27 oneof
  fields, all numbers unique.
- tzrec/main.py: adopted upstream's `ckpt_manager.maybe_save(...)` + `run_eval`
  dispatch, dropping our explicit step/epoch/final save blocks. Our HF-asset
  co-location rides along unchanged: maybe_save -> CheckpointManager.save ->
  the export_format==HF gate.

Auto-merged cleanly: models/model.py (our init_from_pretrained hook + upstream's
on_train_end coexist), utils/checkpoint_util.py (our HF save-gate + upstream's
maybe_save coexist). Verified: hot files compile, protos regenerate, genrec unit
tests pass (22).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…1e-5 collapse

Root cause (overnight investigation): the LM was built in bf16 params with
mixed_precision unset, so the optimizer updated bf16 weights directly with no
fp32 master. Adam's small updates at lr=1e-5 (~1e-5) fall below the bf16 ULP of
the weights and round to zero -> weights freeze -> training collapses (eval ce
hard-plateaus ~5.76, HR ~0). lr=5e-5 masked it (5x larger updates clear the ULP).
ALGR's HF-Trainer bf16:true keeps an fp32 master, so it trains fine at lr1e-5.

Fix: build the LM in fp32 in BOTH paths (`_build_backbone` from_config and
`init_from_pretrained` from_pretrained) so the optimizer keeps fp32 master
weights. Set `mixed_precision:"BF16"` in the run config for bf16 *compute* speed
(autocast) on the fp32 master — the standard AMP pattern, mirroring ALGR.

Proven by a single-variable A/B (only precision changed):
  bf16-params:  lr1e-5 = 0.02 flat (collapse) | lr5e-5 = 2.71 rising
  fp32-master:  lr1e-5 = 1.17 rising (1ep)    | lr5e-5 = 2.68 rising
The fix recovers lr1e-5 ~60x without regressing lr5e-5 (2.68 ~= 2.71).

Tradeoff: fp32 master -> fp32 DCP checkpoint (~2x) + ~64GB/GPU (vs 54). Old bf16
checkpoints still restore (upcast to fp32). Details in
ai_report/MASTER_EXPERIMENT_REPORT.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Layer A — _generate now validates each beam against the per-level SID bands;
malformed candidates (early EOS / non-SID / wrong-level atom) collapse to a -1
sentinel that can never match a real item, and the fixed-width canvas removes
the reshape crash when beams stop early. The gate + token<->SID inversion live
in the base GenerativeRecLM (_validate_sid_candidates, _sid_level_bands) as one
source of truth, reused by every family.

Code-review findings:
- alibaba#2 (perf): cache self._suffix_keep; _forward_loss slices a constant suffix
  instead of recomputing it per step -> drops 2 GPU->CPU syncs/step.
- alibaba#5: isolate the rank-0 write_hf_assets call (try/except + log) so an asset
  write error can't abort before the next collective and hang other ranks.
- alibaba#7: make the dense-only contract explicit (embedding_group = None); remove the
  never-called init_input (calling it would wrongly build an unused sharded
  table for the SEQUENCE feature, which flows as raw token ids).
- alibaba#8: drop the dead batch_size plumbing from _create_model + call sites.
- alibaba#9: _resolve_pad_token_id asserts pad/eos present (clear error, not int(None)).
- dtype: single _PARAM_DTYPE source of truth (both builders fp32-master).

Also: streamline comments, pin transformers==4.51.2 (<5.0).
Tests: 25 genrec unit tests (Layer-A valid/malformed/narrow-tail, _suffix_keep
equivalence, pad resolution).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the explicit canvas alloc + min/slice-copy + boolean-index assignment
with F.pad (fixed-width -1 padding) and masked_fill (row invalidation): 6 logic
lines -> 4, no in-place indexing, clearer intent. Behavior is unchanged —
verified value-identical to the previous form on 4 edge cases (valid, narrow
tails w=1/2, all-invalid) + 2000 random fuzz batches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Condense the multi-line inline rationale comments (CE-suffix width, one-shot
pool pre-sizing, fp32-master build, SID-band gate, beam-order reshape) and the
two longest docstrings (init_from_pretrained, _validate_sid_candidates) to terse
1-3 liners, keeping the load-bearing why (fp32-master underflow, -1 can't match
a real item, suffix-slice OOM, pad==eos mask). Comments only — no behavior
change; ruff clean, 25 genrec tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The SID->token offset now has both directions as named helpers next to each
other: _tokenize_sids (sid -> token) and its inverse _detokenize_sids
(token -> sid), used by _validate_sid_candidates instead of the inline
`new_tokens - (base_vocab - 1)`. One owner for the offset constant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The data reader/DataParser does NOT truncate sequences under FG_NONE (only pyfg
does, in FG_NORMAL/FG_DAG; the native EmbeddingGroup does via to_padded_dense at
forward time). GenerativeRecLM uses FG_NONE and no EmbeddingGroup, so the history
cap is enforced model-side in _sid_token_rows (item-aligned to whole num_levels
items). Correct _read_common_config + _input_sequence_length to say so instead of
"the reader caps every row".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port ALGR's dynamic_beams schedule (beam width doubles per SID level
50->100->200->400, returns num_beams*2**num_levels candidates) as a
torch-only KV-cached kernel; faithful to ALGR's escalating beam and
exploits the fixed-length, EOS-free SID answer.

- tzrec/models/escalating_beam.py: escalating_beam_search kernel
- qwen2_rec_lm.py: _dynamic_beam_search delegates; _generate dispatch on
  the dynamic_beam flag
- generative_model.proto: dynamic_beam flag
- examples/generative_rec_lm_predict.py: --dynamic_beam / --codebook
- tests: exhaustive==brute-force top-k + validity/left-pad (19 pass)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ding

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t (JAGGED_SEQUENCE)

Route the genrec-LM's SID retrieval through the framework's standard
init_input/build_input + EmbeddingGroup path (the HSTU idiom) instead of
reaching into batch.sequence_dense_features directly.

- proto: add GenerativeRecLMConfig.history_group_name / label_group_name
  (group-name knobs, defaults "user_seq"/"answer"), keyed by GROUP name like
  HSTU, decoupled from feature names.
- generative_rec_lm: init_input builds the param-free raw-passthrough
  EmbeddingGroup; build_input reads "{group}.sequence"/".sequence_length" and
  tokenizes; _sid_token_rows now takes (values, lengths); group knobs read in
  _read_common_config.
- qwen2_rec_lm: _predict_train / _generate consume build_input.
- example config: one SEQUENCE group -> two single-feature JAGGED_SEQUENCE groups.
- tests updated (+ build_input coverage). Integration-verified that a top-level
  sequence_raw_feature passes raw through a JAGGED_SEQUENCE EmbeddingGroup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-configurable

Promote the two GenerativeRecLM class constants to GenerativeRecLMConfig knobs,
keeping their previous values as defaults:
- generated_sids_key (default "generated_sids") -> self._generated_sids_key,
  used by _generate's output dict.
- param_dtype (default "float32") -> self._param_dtype via _DTYPE_BY_NAME
  {float32, bfloat16, float16}; used by _build_backbone / init_from_pretrained.
  An unknown value raises a clear ValueError.
Both read in _read_common_config; tests cover defaults + dtype mapping + validation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…onstant

The value now lives in the GenerativeRecLMConfig.generated_sids_key proto default
("generated_sids"); _generate already emits self._generated_sids_key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ne .get()

/simplify cleanup: membership-check + dict-index -> single .get() + None-guard
(same ValueError). No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…(HSTU-style)

Promote the model's history budget to GenerativeRecLMConfig.max_sequence_length
(field 14), mirroring HSTU's DlrmHSTU.max_seq_len — a model knob distinct from the
user_sequence feature's sequence_length. _max_seq_length now reads
common.max_sequence_length, falling back to the feature's sequence_length when 0
(backward-compatible). It drives the recency-preserving truncation cap
(_sid_token_rows) + the activation-pool pre-size. Example config sets it in common;
tests cover the model-knob and the fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ce_length is the sole source

common.max_sequence_length is now the single source for the model's history
budget; remove the feature-derived fallback (_input_sequence_length) and its
test. 0 = off (no cap / no activation-pool pre-allocation). Proto + example-config
comments updated accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s2 example config

max_sequence_length is now a REQUIRED GenerativeRecLMConfig field (like HSTU's
DlrmHSTU.max_seq_len) — every genrec config must set it (0 = explicitly off).
Migrate examples/generative_rec_lm_s2pretrained.config to the new format to keep
it valid + consistent: SEQUENCE group -> two JAGGED_SEQUENCE groups (user_seq /
answer) + max_sequence_length: 1056. Both example configs verified to parse.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t a feature

Move the answer/target SID stream out of feature_configs into
data_config.label_fields (a list<int64> column -> batch.jagged_labels[label]).
build_input now reads HISTORY from the user_seq JAGGED_SEQUENCE group (EmbeddingGroup)
and the ANSWER from batch.jagged_labels[self._label_name]. Drop the now-unused
label_group_name proto knob (reserved 11). This is semantically correct (the answer
is the target, not an input feature) and lets the label be absent at inference
without the EmbeddingGroup requiring it. Example configs (s1/s2) migrated; tests
updated. (Also wrapped the hf_backbone/hf_tokenizer export-only docstrings.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WhiteSwan1 and others added 9 commits August 13, 2026 05:59
Projected holes were emitted sample-major while projection outputs are occurrence-major, and prediction still parsed the supervised response. Align assembly with embedding order, keep response out of inference batches, and validate the effective decode contracts.
_build_backbone casts the LM to lm_parameter_dtype, but _build_projections
builds nn.Linear/MLP at the default fp32 and never casts them. index_copy
requires self and source to share a dtype, so build_input raised on the
first forward for any BF16 or FP16 config with a projected slot -- both
dtypes the proto advertises.

The spliced values now take embeds.dtype. Casting the projections
themselves would put their weights in bf16, which is the Adam underflow
the fp32 master-weight default exists to avoid; reading embeds.dtype also
adapts under autocast.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… token_format

Three prompt_configs compiled cleanly and then misbehaved.

No response left response_segments empty, so logits_suffix_len collapsed
to 0 + 1 and the loss ran over a window holding one ignored position:
nan, with no diagnostic. No sid_space produced a CompiledPrompt no model
can consume, since every prompt-native model needs one to extend its
embedding table and to decode. Both are now required at compile, each
reporting its own field rather than a downstream symptom.

A token_format with no {i} rendered every SID token as the same string,
so add_special_tokens deduplicated them and the tokenizer gained one row
where the bands assume sum(codebook) -- every SID above the first then
addressed a token id that was never assigned.

Requiring sid_space made two checks unreachable: the unbounded-response
guard, because an INLINE response slot now always derives STATIC from the
codebook, and the response-width check, which compared num_levels against
a value just set from num_levels. Both are removed, along with the cfg
and sid_space parameters they left unused on _validate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
plan_hash passed sorted(projection_plan.projections) to the digest, and
iterating a dict yields its keys, so only the projection module ids were
covered. Changing an MLP body while keeping its projection_name produced
an identical hash: a slot whose projection went from hidden_units [16] to
[256, 128] restored against an old checkpoint with no warning, though the
parameter shapes differ. sorted(...) is kept for a stable order across
runs.

The projection body is the one part of the plan that determines tensor
shapes, so it is exactly what the restore guard exists to catch, and the
docstring already claimed plan_hash covered it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@WhiteSwan1 WhiteSwan1 changed the title [WIP][feat] prompt-native generative recommendation [feat] prompt-native generative recommendation Aug 17, 2026
@WhiteSwan1
WhiteSwan1 marked this pull request as ready for review August 17, 2026 07:55
@WhiteSwan1 WhiteSwan1 added the codex-review Let Codex Review label Aug 17, 2026
@github-actions github-actions Bot removed the codex-review Let Codex Review label Aug 17, 2026
"""Strip the recorded prefix; None unless it yields an EXACT match."""
if not prefix:
return None
out = {k[len(prefix) :]: v for k, v in state.items() if k.startswith(prefix)}

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.

[P1] Preserve or reject projected-slot state during HF export. This prefix filter intentionally keeps only backbone keys, so learned embedding_group and projections parameters are silently dropped while projected prompts are still exportable. The result loads as an HF LM but cannot reproduce checkpoint inference; either serialize/load the non-backbone state or fail export when projected slots exist.

Comment thread tzrec/prompt/compile.py
)
break
variable_slot_seen = True
if not plan.response_segments:

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.

[P1] Make the accepted response plan match fixed banded decode. _generate() immediately forces one SID token per level, but this validator accepts static-only responses, prefixes such as Answer: {{answer}}, and multiple slots. Training then supervises a first token/sequence inference can never emit; restrict the response to the supported INLINE SID shape or make decode walk the compiled response plan.

Comment thread tzrec/prompt/assembler.py
projected_occurrence_index,
len(jagged_token_ids),
)
response_lengths.append(len(sample_token_ids) - prompt_len)

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.

[P1] Enforce the compiled response width per row. Response slots are planned as exactly num_levels, but _inline_tokens() accepts zero or any whole multiple; an empty answer can yield an all-ignored/NaN loss, while two items exceed logits_suffix_len and silently train only the tail. Validate the actual response length against the plan here.

Comment thread tzrec/prompt/compile.py
projection_plan=projection_plan,
tokenizer_dir=tokenizer_dir,
vocab_hash=_hash(sid_space, tok.to_str()),
plan_hash=_hash(

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.

[P1] Include projection routing in the persisted hash. slot_to_module is omitted, so swapping two slots' projection_name values when their bodies/shapes match leaves this hash unchanged; checkpoint keys still load, but each slot receives the other's learned weights. Hash the stable mapping and treat routing mismatches as incompatible.

Comment thread tzrec/prompt/assembler.py
out = self.assemble(
inline_values,
projected_lengths,
batch_size=batch_size if batch_size is not None else 0,

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.

[P1] Preserve batch size for static-only prediction prompts. When the body has no slot and the answer exists only in the omitted response, there is no included feature to infer from, so a nonempty input batch is assembled as zero rows (cu_seqlens == [0]). Pass the raw record-batch row count explicitly instead of defaulting to zero.

cfg.hf_model_name_or_path, cfg.common.lm_parameter_dtype
)
# Every run replaces this initialization from pretrained or DCP weights.
self.lm.resize_token_embeddings(

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.

[P1] Validate the base tokenizer against the backbone before resizing. base_vocab_size defines every SID/sentinel id, but nothing checks it equals the pretrained LM vocabulary size; with a mismatched tokenizer those ids can overlap existing text rows and silently use the wrong embeddings/bands. Fail before resize (and verify tokenizer identity where available).

materializing a second full-vocabulary log-probability tensor.
"""
band_lo, band_hi = bands[level]
log_z = torch.logsumexp(logits.float(), dim=-1, keepdim=True)

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.

[P1] Avoid materializing full-vocabulary FP32 logits for every live beam. At later levels there are batch * beam_width rows; for a 152K-token Qwen, batch 32/width 100 is about 486M logits, plus this FP32 copy, before slicing a small SID band. Preserve global-vocabulary scoring with chunked/fused lm_head + log-sum-exp, or enforce a memory-safe schedule.

seq = (band_idx + bands[0][0]).reshape(-1, 1)
beam_scores = beam_scores.reshape(-1)
row_starts = torch.arange(batch_size, device=device)
cache.reorder_cache(row_starts.repeat_interleave(capped_widths[0]))

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.

[P1] Do not physically duplicate the whole prompt KV cache for every first-level beam. With the documented 100+ widths, cache memory and copy bandwidth scale as batch * width * prompt_length * layers, which can OOM before subsequent levels run. Use prefix-sharing/paged cache, or a chunked and explicitly memory-bounded beam strategy.

"""
infos = batch.additional_infos
cu_seqlens = infos[PROMPT_CU_SEQLENS]
max_seqlen = int(infos[PROMPT_MAX_SEQLEN])

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.

[P2] Keep max_seqlen on the host. Batch.to() moves every additional_infos tensor to CUDA, so converting this scalar to int performs a device-to-host read and synchronizes every forward, defeating sparse-pipeline copy/compute overlap. Carry it as host/Python metadata that Batch.to() does not migrate.

Comment thread tzrec/models/prompt_generative_qwen.py Outdated
The loss when training, the decoded SIDs otherwise.
"""
if self.is_inference:
return {self._generated_sids_key: _fx_wrapped_generate(self, batch)}

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.

[P2] Keep projected embedding lookup visible to the inference tracer. Wrapping _generate(self, batch) also hides its build_input() call, so TorchRec sees no sharded-module node and projected-slot distribution/lookup runs synchronously inside the leaf. Mirror the loss path: build embeddings before the wrapper and wrap only padding/decode.

Comment thread tzrec/prompt/persist.py
"""
if compiled_prompt is None:
return
recorded = read_prompt_hashes(ckpt_dir)

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.

[P1] Do not fail open when a prompt checkpoint lacks hashes. CheckpointManager.save() swallows rank-0 asset-write failures, and this branch then restores with only a warning, disabling the vocabulary guard and allowing plausible but incorrect SID bands. Propagate asset-write status after required collectives and make missing prompt assets fatal, with any legacy bypass explicit.

Comment thread tzrec/prompt/assembler.py
f"number of {levels}-level items."
)
by_level = values.reshape(-1, levels)
if np.any(by_level < self._flat_lo) or np.any(by_level >= self._flat_hi):

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.

[P2] Validate finite integer-valued SID inputs before band checks. INLINE raw features are commonly floating-point; fractional values pass these comparisons and are truncated by astype(int64), while NaN makes both comparisons false and converts to an invalid id. Reject non-finite or non-integral values before conversion.

@github-actions

Copy link
Copy Markdown
Contributor

Static review complete. The compiler/assembler/model separation is clear, and I left inline comments only for issues that affect correctness, export fidelity, or realistic decode performance.

One general gap: the PR body references docs/source/models/prompt_generative_qwen.md, but that guide is absent and the new public model/HF export mode is not indexed or documented. Before merge, please add the Chinese operator guide and HF export documentation covering the required response/sid_space, offset-SID input contract, tokenizer/backbone pairing, projected-slot export limitations, and beam-memory constraints.

Per review request, I reviewed statically and did not run tests or builds.

WhiteSwan1 and others added 2 commits August 17, 2026 08:56
plan_hash passed sorted(projection_plan.projections) to the digest, which
iterates the dict and so covered only the module ids. Adding items() in
the previous commit covered the bodies but left slot_to_module out, so two
slots could swap projection_name with matching bodies and still hash the
same: keys load, and each slot receives the other's learned weights. Both
mappings are now hashed.

check_prompt_assets treated a checkpoint with no recorded hashes as a
warning and restored anyway. CheckpointManager.save swallows rank-0
asset-write failures, so that combination silently disables the vocabulary
guard in exactly the case it exists for -- decode bands addressing rows
the weights never learned. Absent assets are now fatal. No legacy bypass:
this stack has never been released, so no checkpoint predates the assets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
predict built the input embeddings outside the leaf on the training path
so TrainPipelineSparseDist could see the sharded embedding module and
prefetch it, but the inference path passed the whole batch into
_fx_wrapped_generate, which hid its build_input call. PredictPipelineSparseDist
therefore saw no sharded-module node and the projected-slot lookup ran
synchronously inside the leaf.

Both paths now build embeddings before the wrapper and pass them in, so
only the padding and the LM -- the parts that branch on host ints -- stay
hidden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@WhiteSwan1

WhiteSwan1 commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks — this was a useful pass. Triage of all 12 comments below: 3 fixed, 6 deferred, 3 not taken. Where the behaviour was checkable I verified it against the code rather than judging by reading; the evidence is in each item below.

Fixed

  • plan_hash omitted projection routing (compile.py) — sorted(projection_plan.projections) iterates a dict, so the digest covered only the module ids. plan_hash now hashes both projections.items() and slot_to_module.items(). Without the latter, two slots could swap projection_name with matching bodies and still hash the same, so the keys load and each slot receives the other's learned weights. Two regression tests, both of which fail if either mapping is dropped.
  • Missing prompt assets failed open (persist.py) — agreed, and it is worse in combination with CheckpointManager.save() swallowing rank-0 asset-write failures. An absent prompt_hashes.json is now fatal. No legacy bypass, since this stack has never been released.
  • The inference tracer could not see the projected lookup (prompt_generative_qwen.py) — correct, and the asymmetry was unintentional: the training path already built embeddings outside the leaf. predict now does so on both paths and passes them in, leaving only the padding and the LM hidden.

Deferred, with reasons

  • HF export drops projected-slot state — real, and tracked. It affects only a projected-slot model; the INLINE-only SID configuration this PR targets exports correctly, which is what the smoke runs and the checked-in mock config exercise. Taking the "fail export when projected slots exist" half separately is attractive, since dcp_to_hf currently writes a valid model.safetensors containing only the backbone, so the incompleteness is silent.
  • Response plan vs. banded decode — accepted as a real train/serve mismatch, but nothing is broken for the shape in use: a response holding one INLINE SID slot trains and decodes consistently. Restricting the plan now would forbid shapes nobody has requested; better resolved alongside the extension that wants them.
  • SID ids placed without knowing the backbone's table — the sharpest comment here, and it goes deeper than stated. Verified: SID ids land inside the backbone's existing table while the resize simultaneously adds rows above it that no token addresses, and a small sum(codebook) can make target_vocab_size round below config.vocab_size and shrink the table. A base_vocab_size == config.vocab_size check is not the fix — HF pads above the tokenizer (Qwen2.5 ships vocab_size 151936 against ~151.6k tokens), so that would reject every legitimate config. The correct invariant is max(tokenizer_vocab_size, lm.config.vocab_size), but compile_prompt has no access to the backbone config and the bands are already hashed into vocab_hash by the time the model could compare. That makes it a migration, not a patch, and it wants validating against a real backbone rather than a 64-vocab fixture.
  • max_seqlen read back off the device — confirmed: Batch.to() migrates every additional_infos tensor, so this is a device-to-host sync per step, and it contradicts a design property the surrounding comments asserted. Performance only, though — the width is the same number either way. A fix was written and reverted: it carried the width as a plain int field on Batch alongside tile_size, which worked, but adds a prompt-specific field to a dataclass every model touches. Deferred pending a GPU measurement to size the win. The stale docstring claiming the model never reads it off the device has been corrected regardless.
  • Full-vocabulary fp32 logits per beam and per-beam KV cache duplication — both real and both want a design rather than a patch (chunked or fused lm_head + log-sum-exp; prefix-sharing or a paged cache). Worth noting the current _band_logp is already a deliberate trade — normalize then slice, rather than a full-vocab log_softmax — so these ask for the next step rather than correct an oversight. The KV duplication is likely the largest single inference-memory item.

Not taken

These three each guard a state the pipeline does not produce:

  • Response width unchecked per row — every route was checked. A codebook mismatch is already caught by the offset validation (_inline_tokens rejects values that do not carry their level offset — the self-validating property the offset encoding exists for). An answer holding two items requires an upstream labeling bug. An empty answer cannot come from the SID pipeline: resolve_sid_collisions never drops an item — one it cannot relocate keeps its original SID and overflows the bucket.
  • Non-finite / fractional SID values — same guarantee. SID codes arrive from the collision tool's offset_codebook column as integers.
  • Static-only prediction prompt assembles zero rows — reproduced, but a prompt whose body carries no slot has no per-request content at all; every request would produce the same static prefix. Reaching that state means the prompt needs rethinking, not that the assembler needs a row count.

The first two rest on one upstream guarantee — the offset_codebook column supplies exactly one complete SID per row, as integers — which is real but stated nowhere in the prompt stack. Noted as an assumption to make explicit.

AGENTS.md asks for one short line of non-obvious "why"; several of the
comments added over the review round ran to two or three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant