feat(features): mirror invalidatedBy into Python ProfileFeature (#61)

Adds invalidated_by: tuple[str, ...] to ProfileFeature, mirroring the
invalidatedBy bus subjects from registry.ts. Adds a test that parses the
TS source and asserts Python stays in sync — same drift-detection pattern
used for names and ttlSec.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 07:10:36 +00:00
parent a75be0d832
commit 17b9516903
2 changed files with 48 additions and 4 deletions

View File

@@ -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. The accompanying test asserts the two stay in sync at the name level.
Feature-spec fields (issue #61): Feature-spec fields (issue #61):
freshness — "batched": value cached in profile store, recomputed on TTL/event. freshness "batched": value cached in profile store, recomputed on TTL/event.
ttl_sec — cache lifetime in seconds; mirrors ``ttlSec`` in registry.ts. ttl_sec — cache lifetime in seconds; mirrors ``ttlSec`` in registry.ts.
source — where the value originates. source — where the value originates.
fallback — raw value returned when the feature is unavailable (null stored). 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 from __future__ import annotations
@@ -37,6 +39,7 @@ class ProfileFeature:
ttl_sec: int ttl_sec: int
source: str source: str
fallback: str fallback: str
invalidated_by: tuple[str, ...] = ()
PROFILE_FEATURES: tuple[ProfileFeature, ...] = ( PROFILE_FEATURES: tuple[ProfileFeature, ...] = (
@@ -48,6 +51,7 @@ PROFILE_FEATURES: tuple[ProfileFeature, ...] = (
ttl_sec=6 * _HOUR, ttl_sec=6 * _HOUR,
source="profile_store", source="profile_store",
fallback="0.0", fallback="0.0",
invalidated_by=("signals.tip.feedback",),
), ),
ProfileFeature( ProfileFeature(
name="dismiss_rate_30d", name="dismiss_rate_30d",
@@ -57,6 +61,7 @@ PROFILE_FEATURES: tuple[ProfileFeature, ...] = (
ttl_sec=6 * _HOUR, ttl_sec=6 * _HOUR,
source="profile_store", source="profile_store",
fallback="0.0", fallback="0.0",
invalidated_by=("signals.tip.feedback",),
), ),
ProfileFeature( ProfileFeature(
name="mean_dwell_ms_30d", name="mean_dwell_ms_30d",
@@ -66,6 +71,7 @@ PROFILE_FEATURES: tuple[ProfileFeature, ...] = (
ttl_sec=6 * _HOUR, ttl_sec=6 * _HOUR,
source="profile_store", source="profile_store",
fallback="null — serving normalises to 0.0", fallback="null — serving normalises to 0.0",
invalidated_by=("signals.tip.feedback",),
), ),
ProfileFeature( ProfileFeature(
name="preferred_hour", name="preferred_hour",
@@ -75,6 +81,7 @@ PROFILE_FEATURES: tuple[ProfileFeature, ...] = (
ttl_sec=_DAY, ttl_sec=_DAY,
source="profile_store", source="profile_store",
fallback="null — serving normalises to 0.5 (neutral alignment)", fallback="null — serving normalises to 0.5 (neutral alignment)",
invalidated_by=("signals.tip.feedback",),
), ),
ProfileFeature( ProfileFeature(
name="tip_volume_30d", name="tip_volume_30d",
@@ -84,6 +91,7 @@ PROFILE_FEATURES: tuple[ProfileFeature, ...] = (
ttl_sec=_HOUR, ttl_sec=_HOUR,
source="profile_store", source="profile_store",
fallback="0", fallback="0",
invalidated_by=("signals.tip.served",),
), ),
) )

View File

@@ -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 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 file and grepping for `name: '...'`. Crude but cheap, and it catches the
common rename/add-without-mirror failure mode. common rename/add-without-mirror failure mode.
Also verifies invalidated_by subjects mirror the TS invalidatedBy arrays (#61).
""" """
from __future__ import annotations from __future__ import annotations
import re import re
@@ -111,3 +113,37 @@ def test_profile_feature_source_is_profile_store():
def test_profile_feature_fallback_set(): def test_profile_feature_fallback_set():
for f in PROFILE_FEATURES: for f in PROFILE_FEATURES:
assert f.fallback, f"{f.name}: fallback must not be empty" 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}"
)