[feat] SID: add offline codebook collision-prevention tool#602
[feat] SID: add offline codebook collision-prevention tool#602WhiteSwan1 wants to merge 31 commits into
Conversation
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>
Review summaryReviewed statically across code quality, performance, tests, docs, and input-safety. Overall this is a well-engineered PR: clean separation between the I/O runner ( I left inline comments on the findings worth addressing. The theme running through the top ones is input validation — the core trusts that
Lower-priority follow-ups (not inlined)
|
…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>
Static review summaryStatic 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:
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>
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. Itcomplements the
sid_quality_reporttool (#596), which measures collisions: this one prevents them.Given a SID table (the
codescolumn from a SID model'stzrec.predictoutput), it caps each SID bucket at--max_items_per_codebookand 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
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).item_id, so the result is reproducible regardless of input row order or read batch size.--rate_onlycomputes and logs metrics without writing.create_reader/create_writer(CSV / Parquet / ODPS). Single-process, CPU-only — launch withpython -m(no torchrun / process group).Inputs and outputs
Input — one table:
item_idcodes—list<int>of lengthn_layerscandidate_codes—list<list<int>>(uniform top-k), for the candidate strategyOutputs:
item_id, origin_codebook, codebook, index— whereindexis 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).SID -> item-IDtables, for both the original and the resolved SIDs.Test plan
collision_resolution_test.pycovers 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.pycovers 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) andpyreclean.