Normalise per group, and make the notification quota subject-based
Following Connor's point that corpus prevalence reflects how much an outlet publishes rather than what deserves a notification: allocation should track stated weights, not corpus composition in EITHER direction. Penalising a topic for being prevalent is a bug; rewarding it would just amplify whoever publishes most. This rules out the "reserve slots proportional to corpus share" model that the previous commit's findings were pointing toward. Two fixes: 1. Normalisation moves from per-block to per-GROUP. Taking the max across blocks gives a subject one draw per block, so foreign affairs -- six of 23 blocks -- beat everything else by having more chances. It held 70% of the digest while software-systems, a quarter of the corpus, held none. Collapsing to the group before normalising prices that in. Digest conflict share 13 -> 9, space 2 -> 4, and us-national appears at all. 2. The per-group quota now keys on the INTEREST group for both lanes. Importance blocks describe a kind of importance, not a subject, so two stories on one subject could each take a slot via different importance blocks -- which is what happened: both importance alerts were Middle East, one via war-escalation and one via economic-break. Interest alerts went from five-of-five Middle East to Ohio redistricting, political theory and tech policy. Also demoted prerelease/nightly build entries. The first alert software-systems ever won was "Nvim development (prerelease) build", which matches the block correctly and is worthless as a notification. Documented that switching normalizer requires re-tuning [routing] thresholds: they are in z-score units, and leaving them at 2.0 under "top-anchored" silences the interest lane entirely.
This commit is contained in:
1 parent
d4631ec13d
commit
b852840729
4 files changed
+70
-10
No files matched your search
+9
-2
@@ -98,8 +98,15 @@ def route(
|
|||||||
group_used: dict[str, int] = {}
|
group_used: dict[str, int] = {}
|
||||||
|
|
||||||
def group_of(s: ClusterScore, kind: str) -> str:
|
def group_of(s: ClusterScore, kind: str) -> str:
|
||||||
block = s.interest.block if kind == "interest" else s.importance.block
|
"""Quota is per SUBJECT, so both lanes share one counter.
|
||||||
return groups.get(block, block)
|
|
||||||
|
Importance blocks describe a KIND of importance (war escalation,
|
||||||
|
economic break), not a topic -- so without this, two stories about the
|
||||||
|
same subject could each take a slot by matching different importance
|
||||||
|
blocks, which is exactly what happened: both importance alerts were
|
||||||
|
Middle East, one via war-escalation and one via economic-break.
|
||||||
|
"""
|
||||||
|
return groups.get(s.interest.block, s.interest.block)
|
||||||
|
|
||||||
def group_full(s: ClusterScore, kind: str) -> bool:
|
def group_full(s: ClusterScore, kind: str) -> bool:
|
||||||
g = group_of(s, kind)
|
g = group_of(s, kind)
|
||||||
|
|||||||
+41
-8
@@ -95,6 +95,13 @@ class ProfileVectors:
|
|||||||
return float(block.get("weight", 1.0))
|
return float(block.get("weight", 1.0))
|
||||||
return 1.0
|
return 1.0
|
||||||
|
|
||||||
|
def group(self, kind: str, block_id: str) -> str:
|
||||||
|
"""Quota/normalisation group for a block, defaulting to its own id."""
|
||||||
|
for bid, _, block in self.blocks[kind]:
|
||||||
|
if bid == block_id:
|
||||||
|
return block.get("group", bid)
|
||||||
|
return block_id
|
||||||
|
|
||||||
def block(self, kind: str, block_id: str) -> dict:
|
def block(self, kind: str, block_id: str) -> dict:
|
||||||
for bid, _, b in self.blocks[kind]:
|
for bid, _, b in self.blocks[kind]:
|
||||||
if bid == block_id:
|
if bid == block_id:
|
||||||
@@ -166,12 +173,32 @@ def score_clusters(
|
|||||||
demote_raw = profile_vectors.raw_matrix("demote", mat)
|
demote_raw = profile_vectors.raw_matrix("demote", mat)
|
||||||
|
|
||||||
norm = NORMALIZERS.get(scoring_cfg.get("normalize", "zscore-per-block"), _z)
|
norm = NORMALIZERS.get(scoring_cfg.get("normalize", "zscore-per-block"), _z)
|
||||||
interest_z = {
|
|
||||||
b: norm(v) * profile_vectors.weight("interest", b) for b, v in interest_raw.items()
|
def normalise(kind: str, raw: dict[str, np.ndarray]) -> dict[str, np.ndarray]:
|
||||||
}
|
"""Normalise per GROUP, not per block.
|
||||||
importance_z = {
|
|
||||||
b: norm(v) * profile_vectors.weight("importance", b) for b, v in importance_raw.items()
|
Taking the max across blocks gives a subject one draw per block, so a
|
||||||
}
|
topic split into five blocks beats one covered by a single block purely
|
||||||
|
by having more chances. Measured: this reader has six foreign-affairs
|
||||||
|
blocks out of 23, and foreign affairs took 70% of the digest while
|
||||||
|
software-systems -- a quarter of the corpus -- took none.
|
||||||
|
|
||||||
|
Collapsing to the group first, THEN normalising, prices that in: the
|
||||||
|
group's distribution already reflects being a max over its members, so
|
||||||
|
normalisation removes the inflation instead of rewarding it.
|
||||||
|
"""
|
||||||
|
by_group: dict[str, np.ndarray] = {}
|
||||||
|
for block_id, values in raw.items():
|
||||||
|
g = profile_vectors.group(kind, block_id)
|
||||||
|
by_group[g] = values if g not in by_group else np.maximum(by_group[g], values)
|
||||||
|
weights = {}
|
||||||
|
for block_id in raw:
|
||||||
|
g = profile_vectors.group(kind, block_id)
|
||||||
|
weights[g] = max(weights.get(g, 0.0), profile_vectors.weight(kind, block_id))
|
||||||
|
return {g: norm(v) * weights[g] for g, v in by_group.items()}
|
||||||
|
|
||||||
|
interest_z = normalise("interest", interest_raw)
|
||||||
|
importance_z = normalise("importance", importance_raw)
|
||||||
# Veto stays on z-scores: it is a comparison against the interest signal,
|
# Veto stays on z-scores: it is a comparison against the interest signal,
|
||||||
# and both sides must be on the same scale.
|
# and both sides must be on the same scale.
|
||||||
veto_z = {b: norm(v) for b, v in veto_raw.items()}
|
veto_z = {b: norm(v) for b, v in veto_raw.items()}
|
||||||
@@ -180,9 +207,15 @@ def score_clusters(
|
|||||||
return max(((b, float(v[i])) for b, v in per_block.items()), key=lambda kv: kv[1])
|
return max(((b, float(v[i])) for b, v in per_block.items()), key=lambda kv: kv[1])
|
||||||
|
|
||||||
out: list[ClusterScore] = []
|
out: list[ClusterScore] = []
|
||||||
|
def best_block_in(kind: str, group: str, raw: dict[str, np.ndarray], i: int) -> str:
|
||||||
|
members = [b for b in raw if profile_vectors.group(kind, b) == group]
|
||||||
|
return max(members, key=lambda b: float(raw[b][i])) if members else group
|
||||||
|
|
||||||
for i, row in enumerate(rows):
|
for i, row in enumerate(rows):
|
||||||
i_block, i_z = argbest(interest_z, i)
|
i_group, i_z = argbest(interest_z, i)
|
||||||
m_block, m_z = argbest(importance_z, i)
|
m_group, m_z = argbest(importance_z, i)
|
||||||
|
i_block = best_block_in("interest", i_group, interest_raw, i)
|
||||||
|
m_block = best_block_in("importance", m_group, importance_raw, i)
|
||||||
|
|
||||||
# --- veto: absolute similarity OR a margin over the interest signal ---
|
# --- veto: absolute similarity OR a margin over the interest signal ---
|
||||||
veto_hit = None
|
veto_hit = None
|
||||||
|
|||||||
@@ -50,6 +50,15 @@ syndication_threshold = 0.97
|
|||||||
# Compare blocks by z-score WITHIN each block, never by raw cosine: cosine
|
# Compare blocks by z-score WITHIN each block, never by raw cosine: cosine
|
||||||
# magnitude is not comparable across blocks, and a plain max let one block win
|
# magnitude is not comparable across blocks, and a plain max let one block win
|
||||||
# 78% of all items. See CALIBRATION.md.
|
# 78% of all items. See CALIBRATION.md.
|
||||||
|
# "zscore-per-block" or "top-anchored". Both normalise per GROUP (see `group`
|
||||||
|
# in profile.toml), not per block: taking the max across blocks gives a subject
|
||||||
|
# one draw per block, so a topic split into five blocks beats one covered by a
|
||||||
|
# single block purely by having more chances.
|
||||||
|
#
|
||||||
|
# NOTE the thresholds in [routing] are in z-score units. "top-anchored" puts
|
||||||
|
# scores on a different scale (1.0 = that group's 99th percentile), so switching
|
||||||
|
# normalizer REQUIRES re-tuning them -- verified: leaving them at 2.0 silences
|
||||||
|
# the interest lane completely.
|
||||||
normalize = "zscore-per-block"
|
normalize = "zscore-per-block"
|
||||||
|
|
||||||
# Veto is not a plain similarity threshold. A story can be genuinely adjacent to
|
# Veto is not a plain similarity threshold. A story can be genuinely adjacent to
|
||||||
|
|||||||
@@ -369,6 +369,17 @@ probes = [
|
|||||||
"This week in review: the stories we covered, collected.",
|
"This week in review: the stories we covered, collected.",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Release feeds publish nightly and prerelease builds continuously. They match
|
||||||
|
# software-systems correctly and are worthless as notifications -- the first
|
||||||
|
# alert this block ever won was "Nvim development (prerelease) build".
|
||||||
|
[[demote]]
|
||||||
|
id = "prerelease-build"
|
||||||
|
penalty = 1.5
|
||||||
|
probes = [
|
||||||
|
"Nightly development prerelease build, automatically generated from the main branch.",
|
||||||
|
"Release candidate rc1: pre-release for testing, not recommended for production.",
|
||||||
|
]
|
||||||
|
|
||||||
# ============================================= the importance lane ===========
|
# ============================================= the importance lane ===========
|
||||||
# Independent of interest. Vetoes still apply here -- v0.2's bypass lane ignored
|
# Independent of interest. Vetoes still apply here -- v0.2's bypass lane ignored
|
||||||
# relevance entirely (gate.rs:302), which let blocklisted items fire at priority 5.
|
# relevance entirely (gate.rs:302), which let blocklisted items fire at priority 5.
|
||||||
|
|||||||
Reference in new issue
Block a user