Skip to content

[feat] SID: add offline codebook collision-prevention tool#602

Open
WhiteSwan1 wants to merge 31 commits into
alibaba:masterfrom
WhiteSwan1:feat/sid_collision_prevention
Open

[feat] SID: add offline codebook collision-prevention tool#602
WhiteSwan1 wants to merge 31 commits into
alibaba:masterfrom
WhiteSwan1:feat/sid_collision_prevention

Conversation

@WhiteSwan1

@WhiteSwan1 WhiteSwan1 commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds tzrec/tools/sid/collision_prevention.py, an offline tool that bounds how many items may share the same Semantic ID (SID) and relocates the overflow — producing a capacity-safe SID map for downstream generative retrieval. It
complements the sid_quality_report tool (#596), which measures collisions: this one prevents them.

Given a SID table (the codes column from a SID model's tzrec.predict output), it caps each SID bucket at --max_items_per_codebook and reassigns over-capacity items to a free code in the last layer only, keeping every prefix layer — so an item is only ever nudged within its own SID neighborhood.

What it does

  • Two reassignment strategies
    • candidate (default): walk each item's model-provided top-k candidate SIDs (candidate_codes), best-first, and take the first with a free slot.
    • random: deterministic pseudo-random last-layer draws (SplitMix64).
  • Deterministic and order-independent: tie-breaks use a seeded hash of item_id, so the result is reproducible regardless of input row order or read batch size.
  • Vectorized: numpy/pyarrow throughout; the only Python loop is the greedy placement over the overflow items. Chunked writes respect Arrow's 2^31 list-offset limit. --rate_only computes and logs metrics without writing.
  • TorchEasyRec-native I/O: reads/writes via create_reader / create_writer (CSV / Parquet / ODPS). Single-process, CPU-only — launch with python -m (no torchrun / process group).

Inputs and outputs

Input — one table:

  • item_id
  • codeslist<int> of length n_layers
  • candidate_codeslist<list<int>> (uniform top-k), for the candidate strategy

Outputs:

  • Item map — item_id, origin_codebook, codebook, index — where index is the 1..cap slot within the item's final SID bucket (the de-duplication digit that makes each item's (codebook, index) unique for downstream generative retrieval).
  • Grouped SID -> item-ID tables, for both the original and the resolved SIDs.

Test plan

  • collision_resolution_test.py covers the pure NumPy core: golden resolution, empty / single-layer / no-overflow inputs, deterministic random draws, chunk-boundary bucket ranking, and range/shape validation.
  • collision_prevention_test.py covers the end-to-end runner over Parquet, CSV, and (mocked) ODPS writers: within-band reassignment, fallback behavior, cross-batch candidate alignment, group/slot ordering, determinism & order-independence, Arrow offset-limit chunking, item-id type preservation, and --rate_only.

All green; ruff (lint + format) and pyre clean.

WhiteSwan1 and others added 26 commits July 6, 2026 02:43
Deterministic offline post-process over predicted SID rows that caps items per
SID bucket at --max_items_per_codebook and reassigns the overflow. Strategies:
- candidate (default): reassign to explicit nearest-neighbor candidate rows.
- random: draw a uniform-random free within-band last-layer code, no candidate
  input required; a baseline that ignores semantic nearest-neighbor proximity
  and is still reproducible given --seed.

Local (CSV/Parquet) and ODPS-SQL backends. Capacity/strategy are CLI-only policy
(deliberately kept out of all protos). Output columns:
item_id, origin_codebook, codebook, index. Includes unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…p ODPS SQL

Follow hitrate.py -- route every backend through create_reader/create_writer
(which already speak CSV/Parquet/ODPS) instead of a parallel hand-written
ODPS-SQL path. Removes OdpsTableRef, OdpsSqlGenerator (~264 lines of SQL
generation), OdpsCollisionRunner, and the generate_odps_sql/run_odps entry
points. LocalCollisionRunner -> CollisionRunner (now serves ODPS too via
--reader_type/--writer_type OdpsReader/OdpsWriter); run_local -> run; drop the
--backend / --temp_prefix / --odps_lifecycle / --dry_run_sql CLI surface.

SidCollisionAssigner (the assignment algorithm) is unchanged. Tool 1231 -> 814
lines; tests 588 -> 441 (dropped the 4 ODPS-SQL tests). 14 tests pass; ruff and
format clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- extract CollisionRunner._write_table (create_writer/write/close) so the
  assignments and diagnostics writers share one path; plain dict instead of
  OrderedDict (dicts are ordered on the 3.10 target).
- drop the unused --candidate_origin_codebook_field CLI arg -- an orphan from
  the removed ODPS-SQL path, never read by the runner.

14 tests pass; ruff + format clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_candidate_sort_key's blake2b tie-breaker is pure but was recomputed on every
pass of the up-to-max_iters assignment loop -- in the per-codebook sorts and
the two-sided best-candidate comparisons. Cache it on the frozen
CandidateSidRow so each unique key is hashed exactly once, cutting the
assignment phase from O(max_iters x N) blake2b digests to O(N). Output is
identical (same keys, same order).

14 tests pass (incl. the determinism tests); ruff + format clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pass selected_cols=[item_id, *code_fields] into create_reader on the raw SID
read, so wide / ODPS source tables no longer decode unused columns
(create_reader already forwards selected_cols to the reader; hitrate.py does
the same). The candidate read still reads all columns because its
priority/score fields are optional and projecting them would fail when absent.

14 tests pass; ruff + format clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirror hitrate.py -- capture the main-input reader class during the raw read
and default --writer_type to it (CsvReader -> CsvWriter, etc.) instead of
hard-defaulting to ParquetWriter, so the output backend matches the input's
when unspecified. Explicit --writer_type still wins, and ODPS output still
resolves to OdpsWriter via create_writer's path detection.

Adds a test for the derivation path. 15 tests pass; ruff + format clean.

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

The SID model emits `codes` and the candidate SIDs in the same rows, so drop
--candidate_input_path (and the now-redundant --candidate_reader_type /
--candidate_item_id_field): raw SIDs and candidates both come from --input_path,
read in one pass. Candidates are loaded only for --strategy candidate (random
synthesizes its own) and only when the candidate column is present, so a plain
SID table still runs (overflow then follows --unassigned_policy).

Merges the two loaders into _load_rows with _origin_codes / _candidate_rows
helpers; tests use single-table fixtures. 15 tests pass; ruff + format clean.

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

The SID lands in one `codes` column (a scalar string or a list cell), so remove
the split-across-columns --code_fields path along with its _code_fields helper
and the now-trivial _origin_codes helper (folded into the read loop).
--code_field (default "codes") is the sole origin-SID source.

15 tests pass; ruff + format clean.

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

Byte-identical, deterministic-output-preserving cleanups from the perf review:
- slots=True on the four row dataclasses (~2x per-object memory); item_key
  becomes a derived @Property on RawSidRow/AssignedSidRow (was a duplicated
  str(item_id) field).
- _dedup_candidates filters to overflow_items (only their candidates can ever
  be used), capping the dedup map + sort-key memo to the overflow fraction.
- intern codebook strings (heavy repetition over a small distinct-SID space).
- fold the stats passes (raw_collision_buckets from by_origin; reassigned +
  final counts in one loop); drop the throwaway dup-detection set (build the
  dict first); in-place assigned.sort(); heapq.nsmallest for the top-k trims.
- _write_diagnostics via dataclasses.asdict; assign_sid_collisions collapsed to
  **kwargs (defaults live only on the class).

Verified byte-identical by a 6-scenario CLI harness (compact / 1:1+score /
score-order / drop / keep_original / random, with real multi-iteration
reassignment) that hashes identically before and after; 15 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rework SidCollisionAssigner's reassignment loop so candidates are grouped by
codebook and sorted ONCE (the sort key is a total order) instead of re-grouping
and re-sorting every codebook on each of up to max_iters passes. Each pass now
scans the pre-sorted lists filtered by a live `unassigned` set (discard on
assignment), rather than rebuilding `set(raw_by_item) - assigned_items` over all
items every pass; _available_candidates is deleted and _handle_unassigned reuses
the same set. The assignment phase drops from ~O(passes x N x K) to ~O(one sort
+ scans).

Output is byte-identical: best-per-item and the final accepted sort are
order-independent, and unique keys make filter-then-sort == sort-then-filter.
Verified by a 6-scenario CLI harness (identical output hash) and 15 unit tests.

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

Split _load_rows into two passes over the single input table: pass 1 reads just
[item_id, code] (projected) to build raw rows + per-origin counts; pass 2 reads
the candidate columns and materializes a CandidateSidRow only for items in
over-capacity buckets -- the only ones that can overflow. Candidate memory now
scales with the overflow fraction instead of one row per candidate per item
(the difference between OOM and feasible at 10M x 64). Also inlines the
single-call _read_batches into _read (single-table -> paths come from self.args)
and folds candidate-field resolution into _candidate_field.

Output byte-identical: over-capacity buckets are a deterministic superset of the
true overflow, which the assigner's dedup already narrows, so the assigner sees
the same candidates. Verified by the 6-scenario CLI harness (identical hash) and
15 unit tests.

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

Represent SID codebooks as tuple[int] (the codes are bigints) rather than a
delimited string. The tool uses a codebook purely as an opaque hashable, ordered
bucket identity, so a tuple is the faithful form. I/O:

- Parquet/ODPS (primary): read/write list<int64> columns directly -- codes,
  candidate_codebook, and the compact-candidate field (list<list<int64>>);
  origin_codebook/codebook are written as list<int64>.
- CSV (fallback): a compatibility layer parses delimited-int strings on read and
  joins codes with --code_delimiter on write (chosen by the writer type).

Drops the string join/parse/intern path, makes the tie-break numeric (natural
SID order), and hands downstream the integer codes with no re-parse; random
candidate generation is now pure tuple ops.

Determinism preserved. Verified that CSV and Parquet with the same logical data
normalize to identical tuples and produce identical assignment decisions (a unit
test plus a 3000-item cross-backend check); 16 tests pass; ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the per-row object-based iterative allocator with a numpy/Arrow
vectorized single greedy pass that reassigns only the last SID layer within an
item's band. The object path materialized a dataclass plus candidate objects per
row and drove an up-to-50-pass allocation, which does not scale to the
hundred-million-row maps this tool targets; the vectorized path packs band keys
to int64, ranks with lexsort/unique, and runs one first-fit pass, keeping the
multi-backend reader/writer and adding chunked writes plus --rate_only. The
greedy single pass replaces ALGR's iterative multi-pass allocation, trading a
small placement-quality loss in near-saturated bands for a ~15x faster,
vectorizable, contention-insensitive pass.

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

Declare the SID shape with a required --codebook (comma-separated per-layer
sizes) instead of inferring n_layers, band-packing radices, and the last-layer
code space from the data; this removes the "batch missing the max code" edge
case and subsumes --random_last_layer_size (now codebook[-1]). --seed becomes a
fixed module constant since the collision rate is seed-invariant. Config args
are parsed into private attributes and validated at construction; the vectorized
paths pick up review cleanups (in-place band packing, Python-native candidate
keys, a param-less _read).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rename the CollisionPlan/CollisionResolutionResult arrays for clarity (origin_bucket_indices, bucket_keys/bucket_counts, final_bucket_keys, overflow_bucket_key_prefixes), document CollisionPlan's fields, and remove test-only properties, single-use helpers, and redundant guards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the diagnostics-output feature (--diagnostics_output_path, the _write_diagnostics writer, and the CollisionResolutionStats.to_output_dict serializer), and with it the now-redundant reassigned_count/unassigned_count stat aliases whose only purpose was the ALGR-named diagnostics columns; the stats object now speaks one vocabulary (relocated_count/unresolved_count).

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

Resolve the tzrec/tools/sid/__init__.py add/add conflict by keeping the package docstring; upstream's sid_quality_report joins our collision tools in the same dir. No proto changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
These were a design proposal for an unimplemented append mode and referenced the local workspace; not part of the collision-prevention tool contribution.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the error and drop policies (and the --unassigned_policy config): an overflow item that cannot be relocated now always keeps its original SID over capacity, so every input item is preserved. Removes the retained_mask machinery that only existed for the drop policy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the keep-original assignment into the placement loop's else branch (the separate post-loop pass was residue of the removed multi-policy dispatch), fix stale "retained" wording in the resolved-groups CLI help, and bump the version to 1.3.5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The SID models emitted candidate_codes as nested list<list<int>> (topk x
n_layers), which pyarrow's CSV writer cannot serialize and which forced the
offline collision tool to carry two bespoke decoders plus dedicated "|"/";"
separators. Flatten it to a single list<int> of topk*n_layers codes in
_sid_predictions, so candidates share the same comma-separated wire format and
the same _codes_matrix decoder as the primary codes column; the tool recovers
per-candidate structure by splitting on the passthrough --codebook length.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@WhiteSwan1 WhiteSwan1 added the claude-review Let Claude Review label Jul 16, 2026
@github-actions github-actions Bot removed the claude-review Let Claude Review label Jul 16, 2026
Comment thread tzrec/tools/sid/collision_resolution.py
Comment thread tzrec/tools/sid/collision_resolution.py
Comment thread tzrec/tools/sid/collision_resolution.py Outdated
Comment thread tzrec/tools/sid/collision_prevention.py
Comment thread tzrec/models/sid_model.py
@github-actions

Copy link
Copy Markdown
Contributor

Review summary

Reviewed statically across code quality, performance, tests, docs, and input-safety. Overall this is a well-engineered PR: clean separation between the I/O runner (collision_prevention.py) and the pure-NumPy core (collision_resolution.py), thorough Google-style docstrings that accurately match the code, deliberate memory management (chunked writes, del of large arrays, Arrow offset-limit guards), and a genuinely strong test suite (golden values, determinism/order-independence, CSV/Parquet/mocked-ODPS backends, writer-cleanup-on-failure, chunk boundaries). The sid_model.py flatten(1) change is consistent with the updated tests and no stale 3D consumers remain.

I left inline comments on the findings worth addressing. The theme running through the top ones is input validation — the core trusts that codes are in range, unique, and rectangular, and several realistic malformed inputs corrupt the output silently instead of erroring:

  • codes not range-validated against --codebook → a mismatched codebook silently merges buckets (highest priority; candidates are checked, base codes aren't).
  • int64 overflow of the prefix key for deep/large codebooks — the sibling sid_quality_report guards this and this tool dropped it.
  • slot_counts dict scales with total SIDs, not overflow count — the main performance concern at the stated millions-of-items scale.
  • Duplicate item_id not detected — breaks the advertised order-independence and is handled inconsistently.

Lower-priority follow-ups (not inlined)

  • Ragged codes can pass silently: _codes_matrix guards only divisibility, so a ragged-but-divisible list column (e.g. rows of width 2 and 4 with 3 rows → reshaped (3,2)) mis-groups with no error. Consider requiring a single list width / fixed_size_list.
  • Cryptic errors on bad field names / codebook: a typo in --item_id_field/--code_field surfaces as a bare KeyError deep in the stream (the sibling tool validates against reader.schema.names), and --codebook "256,,256" silently parses to (256, 256) (sibling rejects empty tokens).
  • Test gaps: (a) the --rate_only over-capacity stats branch (collect_grouping=False with unplaceable overflow) is never exercised — yet those are the exact numbers --rate_only exists to report; (b) no test uses ≥3 layers, even though the canonical config in the module docstring is 3-layer (256,256,256), so the middle-layer band fold is uncovered.
  • Docs: like the sibling sid_quality_report tool, this has no user-facing doc coverage; a short "SID collision post-processing" note in the SID model docs (which already discuss collision rate) would help discoverability. Consistent with existing precedent, so non-blocking.

WhiteSwan1 and others added 4 commits July 16, 2026 09:26
…ebook

The tool trusted the operator-supplied --codebook without checking the input
codes against it. Because a bucket key is band_id * last_size + last_code, an
out-of-range last_code aliases into the adjacent band and silently merges two
distinct SIDs; a too-small middle-layer size poisons the mixed-radix prefix
packing the same way. prepare_collision_plan now range-checks codes against
layer_sizes, CollisionResolutionConfig rejects a codebook whose product
overflows int64, _load_codes rejects duplicate item IDs, and --codebook parsing
rejects empty fields instead of silently dropping them -- each formerly-silent
corruption path is now a clear error.

Separately, resolve_sid_collisions seeded a Python slot map over every occupied
bucket (~one per item) whenever a single overflow row existed; it now scopes
that map to overflow-touched bands, keeping cost proportional to the overflow
set rather than the whole table.

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

Add tests for paths the suite left uncovered: a >=3-layer mixed-radix band
fold (a wrong middle radix or dropped middle layer now changes the asserted
bucket partition), a direct check that the flat candidate column splits at
stride n_layers, and a rate-only run with unplaceable overflow whose stats
must match the grouping branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collapse the vertical row-per-line fixtures into row-repetition form
(e.g. [[0, 0]] * 4 + [[0, 1]] + [[0, 2]] * 2 + [[1, 0]] * 3). Data is
unchanged; the multiplicities now read directly as the asserted bucket
counts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Name the representative-rows expression and return the 5-tuple on one line
instead of exploded one-per-line. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@WhiteSwan1 WhiteSwan1 added the codex-review Let Codex Review label Jul 17, 2026
@github-actions github-actions Bot removed the codex-review Let Codex Review label Jul 17, 2026
Comment thread tzrec/tools/sid/collision_prevention.py
Comment thread tzrec/tools/sid/collision_prevention.py
Comment thread tzrec/tools/sid/collision_prevention.py
Comment thread tzrec/tools/sid/collision_resolution.py
Comment thread tzrec/tools/sid/collision_prevention.py
@github-actions

Copy link
Copy Markdown
Contributor

Static review summary

Static review only; no tests or builds were run.

The I/O runner and NumPy core are cleanly separated, the deterministic grouping logic is well documented, and the existing tests cover the main resolution and output paths well.

I left five inline comments on the noteworthy remaining issues:

  • reject input/output overlap before a writer can overwrite source data;
  • enforce the documented single-process execution invariant;
  • reject floating-point SID lists instead of silently truncating them;
  • remove a redundant full-table sort from prefix-band construction;
  • clarify that unplaceable items can leave buckets over capacity.

I omitted lower-impact suggestions and findings already covered by the earlier review.

…ss launch

Two footguns silently corrupt data. (1) The output path was never checked against
the input, but TorchEasyRec writers overwrite in place (Parquet/Csv write part-N
into the dir, Odps uses overwrite=True), so pointing an output at the input
location destroys the source SID map after it is read; __post_init__ now rejects
an output whose write-directory matches the input's. (2) Under torchrun every rank
reprocessed the whole input, emitting duplicate local shards or racing ODPS
overwrite sessions; run() now raises when WORLD_SIZE != 1 or RANK != 0.

Co-Authored-By: Claude Opus 4.8 (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