Skip to content

[bugfix] fix feature configs fg can not honor - #673

Merged
tiankongdeguiji merged 1 commit into
alibaba:masterfrom
tiankongdeguiji:fix/fg-config-mismatch
Sep 17, 2026
Merged

tiankongdeguiji merged 1 commit into
alibaba:masterfrom
tiankongdeguiji:fix/fg-config-mismatch

Conversation

@tiankongdeguiji

@tiankongdeguiji tiankongdeguiji commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

An audit of the fg docs and the fg C++ against TorchEasyRec's feature layer turned up config that fg never honors. Everything below was confirmed against the fg source and re-measured with pyfg 1.0.6.

Fixes

  • KvDotProduct.kv_delimiter was typed float — the proto comment describes the default ":", but a float field cannot hold it, and fg reads the key as a one-character string, so a JSON number throws. The field was unusable in every direction, including the EasyRec config converter, which assigns the source json value straight into it. Now string, with the [default = ":"] the sibling separator field already has.
  • KvDotProduct.normalizer was declared but never emitted — fg parses it and applies it on every output path, but the feature's fg json never carried the key, so users got un-normalized dot products with no error.
  • ComboFeature emitted the raw proto value_dim instead of the property — a sequence_combo_feature with value_dim unset emitted value_dim: 0 while the inherited property returns 1, so fg produced multi-value sequence steps that the model never reduces (the embedding path gates the multi-value reduction on value_dim != 1).

Validation added

fg is lenient in ways that turn a typo into a silently wrong feature, so the python layer now rejects the values it cannot honor:

  • combiner (LookupFeature / CombineFeature): fg maps any unrecognized combiner to sum without warning, and gap_min / gap_max parse into enum values that are never accumulated — CombineFeature would train on a constant 0, while LookupFeature throws at construction. Allowed set is sum / mean / avg / min / max / count. The check runs on the combiner that is actually emitted: a discrete or multi-value lookup blanks it out first (fg never receives it), so those configs are left alone, and an empty combiner is accepted for both features since fg treats it as sum.
  • method (OverlapFeature): an unknown method logs an error but does not fail initialization, and every row then returns the default value, so the feature is a silent constant. Validated against the nine methods fg implements.
  • kv_delimiter: fg rejects a multi-character delimiter with RuntimeError: new KVDotProduct failed and a 26-frame stack dump; the python check names the feature and the field instead.

The proto comments and the two doc bullets that advertised gap_min / gap_max (and listed 4 of the 9 overlap methods) are corrected here rather than in the docs-only follow-up, so the enforced set and the documented set land together.

Upgrade note

Re-exporting an existing checkpoint now regenerates fg.json with the KvDotProduct.normalizer that older tzrec silently dropped, so online feature values change relative to what that model was trained with. Worth a line in the release notes.

Test Plan

  • New cases: kv_dot_product_test.py (string kv_delimiter end to end plus a log10 normalizer whose output is asserted, and a multi-char delimiter rejection), combo_feature_test.py (sequence combo with value_dim unset asserts 1 in the fg json), and negative cases in lookup_feature_test.py, combine_feature_test.py, overlap_feature_test.py using assertRaisesRegex on the raise site.
  • Whitelists are pinned against real fg behavior rather than against themselves: overlap_feature_test.py runs all nine methods through fg and asserts their distinct outputs, and lookup_feature_test.py gains min / max / count rows (1 / 3 / 2 on a map fg would otherwise sum to 4), so a wrong entry can no longer hide behind fg's silent sum fallback.
  • raw_feature_test.py pins that separator stays out of a scalar feature's fg json — an earlier revision of this PR emitted it, and measurement showed fg then abandons the row on a dim mismatch without filling it, returning uninitialized memory (different values every run) where master returns the default. That change is reverted; the doc rule ("separator only applies when value_dim > 1") is in the docs PR.
  • All of tzrec/features/*_test.py pass, plus tzrec/datasets/data_parser_test.py.
  • tzrec/tools/convert_easyrec_config_to_tzrec_config_test.py fails identically on untouched master in this environment (TypeError: Descriptors cannot be created directly from the vendored EasyRec protos), so it is unrelated to this change.
  • pre-commit run --files <changed> and pyrefly check clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VkWBjeAQP8rcsutwL6rkfV

@tiankongdeguiji tiankongdeguiji added the claude-review Let Claude Review label Sep 17, 2026
@github-actions github-actions Bot removed the claude-review Let Claude Review label Sep 17, 2026
Comment thread tzrec/features/lookup_feature.py Outdated
"""Get fg json config impl."""
combiner = self.config.combiner.lower()
# an empty combiner outputs the matched values without aggregation
if combiner and combiner not in FG_COMBINERS:

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.

The combiner is validated unconditionally here, but on two paths below it is discarded before fg ever sees it: value_dim > 1 overrides it to "" (line 140), and every discrete/sparse lookup (needDiscrete — hash_bucket_size / vocab_* / num_buckets / boundaries / zch / dynamicemb) overrides it to "" (lines 190–191), matching the proto note "when need_discrete is true, combiner will be empty string".

A previously-working sparse lookup config carrying a stale gap_min/gap_max (values the docs advertised until now, and which fg never received because the field was zeroed) will now hard-fail on a field that is ignored. Consider validating only where the combiner is actually emitted, or moving the check after the overrides.

Comment thread tzrec/features/feature.py
MAX_HASH_BUCKET_SIZE = 2**63 - 1
# combiners fg implements. gap_min/gap_max parse but are never accumulated,
# and fg falls back to sum for any other value without warning.
FG_COMBINERS = frozenset({"sum", "mean", "avg", "min", "max", "count"})

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.

FG_COMBINERS (and FG_OVERLAP_METHODS) are now the enforced source of truth, but the proto comments in feature.proto — a file this PR already modifies — contradict them: line 341 lists CombineFeature combiners as {sum | mean | min | max} (missing avg/count), line 396 lists LookupFeature's without avg, and line 632 lists only 4 of the 9 overlap methods. Deferring the docs/*.md updates to the doc-only PR makes sense, but these are comment-only edits with no wire impact, and a user hitting the new ValueError will read a proto comment that lists a different set than the error message. Worth syncing them here.

Comment thread tzrec/protos/feature.proto Outdated
optional string separator = 7 [default = "\x1d"];
// fg kv separator, default is :.
optional float kv_delimiter = 8;
// fg kv separator, default is :. only one char is allowed

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.

Nit: "default is :" reads like a proto-level default, but the field has no [default = ...] annotation (proto2 default is ""); : is fg's own fallback when the key is absent, which works because kv_dot_product.py guards emission with HasField. Since the line is being rewritten anyway, consider adding [default = ":"] to match the sibling separator = 7 [default = "\x1d"] (behavior is unchanged — emission is still HasField-gated), or rewording to "fg defaults to : when unset".

Comment thread tzrec/features/raw_feature.py Outdated
"expression": self.config.expression,
"value_type": "float",
}
if self.config.separator != "\x1d":

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.

Question: with separator now emitted for scalar features (value_dim == 1), if the input field actually contains the separator, fg will split it and can produce multiple values per row while the parser still assumes width 1 (data_parser.py records feature.value_dim in dense_length_per_key and pads to a fixed width). The old behavior for such configs was "separator silently ignored → unparseable value falls back to default", so this only affects previously-mishandled configs — but the new failure mode can be a shape mismatch downstream rather than a clear error. Does pyfg clamp the output width for value_dim == 1 here, or would rejecting separator != default with value_dim == 1 (consistent with this PR's validation theme) be safer?

np.testing.assert_allclose(parsed_feat.values, np.array(expected_values))
np.testing.assert_allclose(parsed_feat.lengths, np.array(expected_lengths))

def test_overlap_feature_with_invalid_method(self):

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.

The negative case is good, but 5 of the 9 whitelisted methods (index_of and the four proximity_*) have no coverage anywhere in this file. A typo in FG_OVERLAP_METHODS for one of them would hard-reject configs fg actually honors, with no test failing. Consider at least one parse case for a method with distinctive output (e.g. index_of, whose -1.0 miss value is easy to assert) to pin the whitelist to real fg behavior.

np.allclose(parsed_feat.seq_lengths, np.array(expected_seq_lengths))
)

def test_lookup_feature_with_invalid_combiner(self):

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.

Two suggestions for the combiner validation tests (applies to combine_feature_test.py:360 too):

  1. The whitelist itself is untested: existing parameterized tables only exercise sum/mean (and "" for lookup), so nothing verifies that min/max/count/avg are actually honored by fg. Since fg silently falls back to sum for values it doesn't recognize, a wrong FG_COMBINERS entry would pass the suite while misbehaving in production. One count or max parse case with sum-differing expected output would validate it cheaply.
  2. The repo's established pattern for validation errors is assertRaisesRegex (e.g. feature_test.py:748); matching "invalid combiner" would pin these tests to the new raise site rather than any unrelated ValueError escaping fg_json().

@github-actions

Copy link
Copy Markdown
Contributor

Code review — PR #673

Carefully-researched set of fixes. I independently verified several of the PR's claims statically: the repo docs' overlap-method table matches FG_OVERLAP_METHODS exactly; the kv_delimiter float→string retype actually fixes the EasyRec converter path (setattr of a string into the old float field would have thrown); configs are persisted as text pbtxt, so the wire-type change on field 8 can only surface as a loud parse error on configs that could never have worked (a float field cannot hold ":"); and the new validation fires early — at feature construction for FG_NORMAL, at DataParser init for the fg-handler modes, and at export (create_fg_json) for FG_NONE. The combo value_dim fix also aligns the training fg json with the scalar-export materialization in project_grouped_sequence_feature_to_scalar, which previously could emit 1 while training emitted 0. The kv_dot_product normalizer test is genuinely sensitive to the fix (log10 of the dot product 10 → 1.0), and the combo test follows the neighboring sequence-test construction pattern.

Findings posted inline (6)

  1. lookup_feature.py:115 (medium) — combiner is validated even on the value_dim > 1 and needDiscrete/sparse paths where it is overridden to "" before fg sees it; previously-working sparse lookup configs carrying a stale gap_min/gap_max (documented until now) would hard-fail on a field that was always ignored.
  2. feature.py:74 (medium) — the proto comments in feature.proto (lines 341, 396, 632), a file this PR touches, still list combiner/method sets that contradict the newly enforced FG_COMBINERS / FG_OVERLAP_METHODS and the new error messages. Comment-only edits; worth syncing in this PR rather than the docs-only follow-up.
  3. feature.proto:785 (nit) — "default is :" describes fg's fallback, not a proto default; consider [default = ":"] (matching sibling separator) or rewording.
  4. raw_feature.py:86 (question) — scalar features now emit separator; if the input actually contains it, fg output width can diverge from the value_dim == 1 assumptions in data_parser.py. Does pyfg clamp, or should separator-with-scalar-value_dim be rejected too?
  5. overlap_feature_test.py:181 (low) — 5 of 9 whitelisted methods (index_of, proximity_*) have zero coverage; a typo in the whitelist would reject configs fg honors with no test failing.
  6. lookup_feature_test.py:586 (low) — no positive parse case for min/max/count/avg (where fg's silent sum-fallback would be exposed), and the repo pattern for these errors is assertRaisesRegex.

Minor notes (no inline)

  • version.py jumps 1.4.8 → 1.4.10 while master is still at 1.4.8 — please confirm skipping 1.4.9 is intentional.
  • CombineFeature rejects an empty combiner while LookupFeature explicitly allows it. That asymmetry looks deliberate (only lookup documents un-aggregated multi-value output), but note it is a behavior change for any config that set combiner: "" on a combine feature and relied on fg's silent sum fallback.

Review coverage note

This review ran five parallel areas: code quality, performance, test coverage, documentation accuracy, and security/compatibility. The first four completed and their noteworthy findings are the inline comments above; the dedicated security reviewer had not finished by the time this summary was posted, so the compatibility analysis in the first paragraph is my own (config persistence format, converter path, validation timing, and a repo-wide survey confirming no example/test config uses a combiner or method the new validation would reject).

🤖 Generated with Claude Code

@@ -74,6 +74,8 @@ def _fg_json(self) -> List[Dict[str, Any]]:
fg_cfg["separator"] = self.config.separator
if self.config.HasField("kv_delimiter"):
fg_cfg["kv_delimiter"] = self.config.kv_delimiter

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.

The new proto comment adds a single-char contract ("only one char is allowed", matching docs feature.md:653), but the emission here is unchecked — a multi-char kv_delimiter flows into the fg json and gets silently mis-honored by fg, which is exactly the failure class this PR sets out to eliminate. Since combiner/method are already validated in _fg_json, a len(self.config.kv_delimiter) != 1 check here would be consistent.

@github-actions

Copy link
Copy Markdown
Contributor

Review follow-up: security / compatibility pass completed

The dedicated security/compatibility review (still running when my summary above was posted) has now finished; this updates the coverage note there. It confirmed my compatibility analysis (no binary proto serialization anywhere in tzrec/ — configs are text pbtxt only — so the float→string retype of field 8 fails loudly at parse time at worst, and no working config could ever have set the old float field). It also independently corroborated the whitelists against the shipped pyfg 1.0.6 binary: all 9 FG_OVERLAP_METHODS strings exist in libfg.so, the EasyRec-only common_word*/diff_word* methods do not, and the gap_min/gap_max tokens are present — consistent with "parse into enums that are never accumulated".

Three additional points worth noting, one posted inline:

  1. Docs-ordering risk (medium) — docs/source/feature/feature.md:287,314 currently tell users combiner supports gap_min/gap_max. If this PR lands before the deferred doc-only PR, any config written by following the current docs hard-fails with ValueError. Consider landing the doc correction together with (or before) this change, or at least calling the narrowed combiner set out in the release notes.
  2. kv_delimiter single-char contract is documented but not enforced — posted inline at kv_dot_product.py:76; a len() != 1 check would match the validation theme of this PR.
  3. Upgrade-path behavior change on re-export (low, release-notes worthy) — normalizer (KvDotProduct) and separator (scalar RawFeature) were previously dropped from the fg json, so models trained on older tzrec never saw them applied. After upgrade, re-exporting the same checkpoint regenerates fg.json with these keys, silently changing online feature values versus what the model was trained on. This is the correct honoring of the config, but together with the kv_delimiter retype it deserves a line in the release notes.

The reviewer also suggested hoisting the combiner/method validation to feature-construction/config-check time so FG_NONE jobs fail at job start instead of at export after a full training run — related to the inline comment on lookup_feature.py:115, offered as an optional improvement rather than a blocker.

🤖 Generated with Claude Code

kv_delimiter was declared as a float, so the ":" the docs describe could
not be written at all and fg, which reads it as a one char string, would
throw on the number. KvDotProduct declared a normalizer that the fg json
never carried, so it was silently ignored. ComboFeature emitted the raw
proto value_dim instead of the property, so a sequence combo feature with
value_dim unset told fg to emit multi-value steps while the model assumed
single values.

combiner, overlap method and kv_delimiter are now rejected when fg can not
honor them: fg silently falls back to sum for an unknown combiner, never
accumulates gap_min/gap_max, keeps running with an unknown overlap method
while every row returns the default value, and dies with a stack dump on a
multi char kv_delimiter. The combiner is validated after the discrete and
multi-value paths blank it out, because fg never receives it there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VkWBjeAQP8rcsutwL6rkfV
@tiankongdeguiji
tiankongdeguiji merged commit ef119e9 into alibaba:master Sep 17, 2026
7 checks passed
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.

2 participants