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:
@@ -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",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user