The AtmosphereConf talks your skyline missed
0
fork

Configure Feed

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

Merge pull request #16 from musicjunkieg/feat/network-attention-display

feat: normalize attention scoring by engaged follows (#36)

authored by

chaos gremlin and committed by
GitHub
59ecd4b0 cada7e73

+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 }