The AtmosphereConf talks your skyline missed
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: normalize attention intensity by engaged follows

Use the count of unique follows who discussed any talk as the
denominator instead of total follows. This spreads glow intensity
across the actual data range — previously all talks clustered near
1.0 because the raw denominator (total follows) dwarfed per-talk
mention counts.

Raw layer1 values are preserved; only intensity (used for glow +
sort) is recomputed via combineLayers with the normalized layer1.
Adds normalizedCoverage field to TalkScore for the detail strip,
decoupled from composite intensity. Detail strip reads "Covered by
X% of active follows."

Also adds branch workflow note to CLAUDE.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+113 -9
+1
CLAUDE.md
··· 57 57 - Lexicon NSID authority: `watch.understory` (reversed domain) 58 58 - GitHub org: `musicjunkieg` 59 59 - Deploy target: Railway 60 + - **Branch workflow:** Feature PRs target `staging` for Railway validation, then promote `staging → main` via separate PR. Never PR directly to `main`. 60 61 61 62 <!-- deciduous:start --> 62 63 ## Decision Graph Workflow
+5 -3
src/components/ui/lume-card.tsx
··· 29 29 ); 30 30 } 31 31 32 - // engaged — show percentage 33 - const pct = Math.min(99, Math.round(score.layer1.attentionInverse * 100)); 32 + // engaged — show coverage among conference-active follows 33 + const covered = score.normalizedCoverage != null 34 + ? Math.round(score.normalizedCoverage * 100) 35 + : 0; 34 36 return ( 35 37 <div className="text-label-sm text-on-surface-variant"> 36 - {pct}% of your network missed this 38 + Covered by {covered}% of active follows 37 39 </div> 38 40 ); 39 41 }
+47 -2
src/lib/scoring/rank.test.ts
··· 156 156 157 157 it("sorts missed first, then engaged (intensity desc), then unknown", () => { 158 158 const mentions: TalkMentions = { 159 - aaa: makeMention(1), // engaged, intensity 0.99 159 + aaa: makeMention(1), // engaged, normalized intensity 0.98 (1/50 engaged) 160 160 bbb: makeMention(0), // missed, intensity 1.0 161 - ccc: makeMention(50), // engaged, intensity 0.5 161 + ccc: makeMention(50), // engaged, normalized intensity 0.0 (50/50 engaged) 162 162 // D, E: no mentions → unknown 163 163 }; 164 164 const result = rankTalks({ ··· 199 199 }); 200 200 // L1 only contributes; L2 stub returns 0; rescale: 0.5/0.8 = 0.625 201 201 expect(result[0].intensity).toBeCloseTo(0.625, 6); 202 + }); 203 + }); 204 + 205 + describe("rankTalks — engaged-follow normalization", () => { 206 + it("normalizes intensity against engaged follows, not total follows", () => { 207 + // 200 total follows, but only 10 unique follows engaged with any talk. 208 + // Without normalization, all talks cluster near intensity 1.0. 209 + // With normalization, the spread covers the full 0–1 range. 210 + const mentions: TalkMentions = { 211 + aaa: makeMention(0), // missed: 0/10 → intensity 1.0 212 + bbb: makeMention(2), // engaged: 2/10 → intensity 0.8 213 + ccc: makeMention(10), // engaged: 10/10 → intensity 0.0 214 + }; 215 + const result = rankTalks({ 216 + talks: [makeTalk("aaa"), makeTalk("bbb"), makeTalk("ccc")], 217 + mentions, 218 + followCount: 200, 219 + }); 220 + 221 + const byRkey = Object.fromEntries(result.map((s) => [s.rkey, s])); 222 + expect(byRkey.aaa.intensity).toBeCloseTo(1.0, 6); 223 + expect(byRkey.bbb.intensity).toBeCloseTo(0.8, 6); 224 + expect(byRkey.ccc.intensity).toBeCloseTo(0.0, 6); 225 + // Raw layer1 values are preserved (totalFollows stays as original followCount) 226 + expect(byRkey.bbb.layer1.totalFollows).toBe(200); 227 + // normalizedCoverage set for non-unknown talks (fraction who discussed it) 228 + expect(byRkey.aaa.normalizedCoverage).toBe(0); // missed: 0/10 229 + expect(byRkey.bbb.normalizedCoverage).toBeCloseTo(0.2, 6); // 2/10 230 + expect(byRkey.ccc.normalizedCoverage).toBeCloseTo(1.0, 6); // 10/10 231 + }); 232 + 233 + it("skips normalization when no follows engaged (all missed)", () => { 234 + const mentions: TalkMentions = { 235 + aaa: makeMention(0), 236 + bbb: makeMention(0), 237 + }; 238 + const result = rankTalks({ 239 + talks: [makeTalk("aaa"), makeTalk("bbb")], 240 + mentions, 241 + followCount: 100, 242 + }); 243 + 244 + // No engaged follows → no normalization → original totalFollows preserved 245 + expect(result[0].layer1.totalFollows).toBe(100); 246 + expect(result[0].intensity).toBeCloseTo(1.0, 6); 202 247 }); 203 248 }); 204 249
+56 -4
src/lib/scoring/rank.ts
··· 27 27 rkey, 28 28 intensity: 0, 29 29 state: "unknown", 30 + normalizedCoverage: null, 30 31 layer1: { 31 32 uniqueFollows: 0, 32 33 totalFollows: safeTotalFollows, ··· 77 78 const state: TalkScoreState = 78 79 layer1.uniqueFollows === 0 ? "missed" : "engaged"; 79 80 80 - return { rkey: talk.rkey, intensity, state, layer1 }; 81 + return { rkey: talk.rkey, intensity, state, layer1, normalizedCoverage: null }; 81 82 } 82 83 83 84 const STATE_ORDER: Record<TalkScoreState, number> = { ··· 98 99 return a.rkey.localeCompare(b.rkey); 99 100 } 100 101 102 + /** 103 + * Count the unique follows who engaged with *any* talk. Used as the 104 + * denominator for normalized intensity so the glow spread reflects the 105 + * actual conference-engaged subset of the user's network, not the full 106 + * follow list (which dilutes differences to near-zero). 107 + */ 108 + function engagedFollowCount(mentions: TalkMentions | null): number { 109 + if (!mentions) return 0; 110 + const seen = new Set<string>(); 111 + for (const rkey in mentions) { 112 + for (const did of mentions[rkey].follows) { 113 + seen.add(did); 114 + } 115 + } 116 + return seen.size; 117 + } 118 + 101 119 export function rankTalks(inputs: ScoringInputs): TalkScore[] { 102 120 const { 103 121 talks, ··· 106 124 weights = DEFAULT_WEIGHTS, 107 125 active = DEFAULT_ACTIVE_LAYERS, 108 126 } = inputs; 109 - return talks 110 - .map((talk) => scoreTalk(talk, mentions, followCount, weights, active)) 111 - .sort(compareTalkScores); 127 + 128 + const scores = talks 129 + .map((talk) => scoreTalk(talk, mentions, followCount, weights, active)); 130 + 131 + // Normalize intensity: use "follows who discussed any talk" as the 132 + // denominator instead of total follows. This spreads glow across the 133 + // actual data range rather than clustering everything near 1.0. 134 + // Raw layer1 values are preserved for the UI detail strip; only 135 + // intensity (used for glow + sort) is recomputed via combineLayers. 136 + const engaged = engagedFollowCount(mentions); 137 + if (engaged > 0) { 138 + const talksByRkey = new Map(talks.map((t) => [t.rkey, t])); 139 + for (const score of scores) { 140 + if (score.state === "unknown") continue; 141 + const normalizedReach = Math.min( 142 + 1, 143 + score.layer1.uniqueFollows / engaged, 144 + ); 145 + const normalizedLayer1 = { 146 + ...score.layer1, 147 + reachRatio: normalizedReach, 148 + attentionInverse: 1 - normalizedReach, 149 + totalFollows: engaged, 150 + }; 151 + score.normalizedCoverage = normalizedReach; 152 + const talk = talksByRkey.get(score.rkey)!; 153 + score.intensity = combineLayers( 154 + normalizedLayer1, 155 + computeInterestStub(talk), 156 + computeFriendStub(talk), 157 + weights, 158 + active, 159 + ); 160 + } 161 + } 162 + 163 + return scores.sort(compareTalkScores); 112 164 }
+4
src/lib/scoring/types.ts
··· 23 23 intensity: number; // 0–1; UI uses for glow + ordering 24 24 state: TalkScoreState; 25 25 layer1: Layer1Result; 26 + /** Normalized L1 coverage: fraction of conference-active follows who 27 + * discussed this talk (0–1). Set by rankTalks; null before normalization 28 + * or for unknown-state talks. Use for detail strip display. */ 29 + normalizedCoverage: number | null; 26 30 layer2?: { interestScore: number }; 27 31 layer3?: { friendBoost: number; recommenders: string[] }; 28 32 }