diff --git a/ml/features/profile_schema.py b/ml/features/profile_schema.py index 774f931..b2e93bf 100644 --- a/ml/features/profile_schema.py +++ b/ml/features/profile_schema.py @@ -10,10 +10,12 @@ Update this file whenever you add or rename a feature in the TS registry. The accompanying test asserts the two stay in sync at the name level. Feature-spec fields (issue #61): - freshness — "batched": value cached in profile store, recomputed on TTL/event. - ttl_sec — cache lifetime in seconds; mirrors ``ttlSec`` in registry.ts. - source — where the value originates. - fallback — raw value returned when the feature is unavailable (null stored). + freshness — "batched": value cached in profile store, recomputed on TTL/event. + ttl_sec — cache lifetime in seconds; mirrors ``ttlSec`` in registry.ts. + source — where the value originates. + fallback — raw value returned when the feature is unavailable (null stored). + invalidated_by — bus event subjects that trigger recompute for the affected user; + mirrors ``invalidatedBy`` in registry.ts. Empty = TTL-only refresh. """ from __future__ import annotations @@ -37,6 +39,7 @@ class ProfileFeature: ttl_sec: int source: str fallback: str + invalidated_by: tuple[str, ...] = () PROFILE_FEATURES: tuple[ProfileFeature, ...] = ( @@ -48,6 +51,7 @@ PROFILE_FEATURES: tuple[ProfileFeature, ...] = ( ttl_sec=6 * _HOUR, source="profile_store", fallback="0.0", + invalidated_by=("signals.tip.feedback",), ), ProfileFeature( name="dismiss_rate_30d", @@ -57,6 +61,7 @@ PROFILE_FEATURES: tuple[ProfileFeature, ...] = ( ttl_sec=6 * _HOUR, source="profile_store", fallback="0.0", + invalidated_by=("signals.tip.feedback",), ), ProfileFeature( name="mean_dwell_ms_30d", @@ -66,6 +71,7 @@ PROFILE_FEATURES: tuple[ProfileFeature, ...] = ( ttl_sec=6 * _HOUR, source="profile_store", fallback="null — serving normalises to 0.0", + invalidated_by=("signals.tip.feedback",), ), ProfileFeature( name="preferred_hour", @@ -75,6 +81,7 @@ PROFILE_FEATURES: tuple[ProfileFeature, ...] = ( ttl_sec=_DAY, source="profile_store", fallback="null — serving normalises to 0.5 (neutral alignment)", + invalidated_by=("signals.tip.feedback",), ), ProfileFeature( name="tip_volume_30d", @@ -84,6 +91,7 @@ PROFILE_FEATURES: tuple[ProfileFeature, ...] = ( ttl_sec=_HOUR, source="profile_store", fallback="0", + invalidated_by=("signals.tip.served",), ), ) diff --git a/ml/features/test_profile_schema.py b/ml/features/test_profile_schema.py index 06ff938..747f830 100644 --- a/ml/features/test_profile_schema.py +++ b/ml/features/test_profile_schema.py @@ -4,6 +4,8 @@ The TS registry in services/api/src/profile/registry.ts is the source of truth. This test checks the names listed here match the registry by reading the TS file and grepping for `name: '...'`. Crude but cheap, and it catches the common rename/add-without-mirror failure mode. + +Also verifies invalidated_by subjects mirror the TS invalidatedBy arrays (#61). """ from __future__ import annotations import re @@ -111,3 +113,37 @@ def test_profile_feature_source_is_profile_store(): def test_profile_feature_fallback_set(): for f in PROFILE_FEATURES: assert f.fallback, f"{f.name}: fallback must not be empty" + + +def _ts_registry_invalidated_by() -> dict[str, list[str]]: + """Parse invalidatedBy arrays from registry.ts. + + Extracts subjects from blocks like: + invalidatedBy: ['signals.tip.feedback'], + Returns {feature_name: [subject, ...]}; features with no invalidatedBy get []. + """ + text = REGISTRY_PATH.read_text(encoding="utf-8") + result: dict[str, list[str]] = {} + for block in re.split(r"\{", text): + name_m = re.search(r"name:\s*'([a-zA-Z0-9_]+)'", block) + if not name_m: + continue + name = name_m.group(1) + inv_m = re.search(r"invalidatedBy:\s*\[([^\]]*)\]", block) + if inv_m: + subjects = re.findall(r"'([^']+)'", inv_m.group(1)) + else: + subjects = [] + result[name] = subjects + return result + + +def test_invalidated_by_matches_ts_registry(): + ts_inv = _ts_registry_invalidated_by() + for f in PROFILE_FEATURES: + assert f.name in ts_inv, f"{f.name} not found in TS registry invalidatedBy parse" + expected = tuple(sorted(ts_inv[f.name])) + actual = tuple(sorted(f.invalidated_by)) + assert actual == expected, ( + f"{f.name}: Python invalidated_by={actual} != TS invalidatedBy={expected}" + )