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