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 #8 from musicjunkieg/docs/scoring-algorithm-spec

docs: scoring algorithm spec and implementation plan (#19)

authored by

chaos gremlin and committed by
GitHub
fdfea2bf 1fb66b91

+1897
+1188
docs/superpowers/plans/2026-04-09-scoring-algorithm.md
··· 1 + # Layer 1 Scoring Algorithm Implementation Plan 2 + 3 + > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. 4 + 5 + **Goal:** Implement the Layer 1 (network attention, inverted) scoring layer of Understory's three-layer scoring engine, with Vitest unit tests, stub interfaces for Layers 2 and 3, and a deployment-level `ActiveLayers` flag that rescales weights for partial layer rollouts. 6 + 7 + **Architecture:** Pure-function TypeScript module under `src/lib/scoring/`. No React, no fetch, no globals. Consumes the existing `TalkMentions` shape from `src/lib/crawl/types.ts` and `TalkEntry` from `src/lib/types.ts`. Test-driven throughout — every function is written test-first with the precise numeric assertions from the spec. 8 + 9 + **Tech Stack:** TypeScript 5, Vitest, vite-tsconfig-paths. Project is Next.js 16 / React 19 but the scoring module itself is framework-agnostic. 10 + 11 + **Spec:** `docs/superpowers/specs/2026-04-09-scoring-algorithm.md` 12 + 13 + **Chainlink Issue:** #19 14 + 15 + **Branch convention:** Work on `feat/scoring-algorithm` branched from `main`. 16 + 17 + --- 18 + 19 + ## File Structure 20 + 21 + | File | Action | Responsibility | 22 + |------|--------|----------------| 23 + | `package.json` | Modify | Add `vitest`, `vite-tsconfig-paths` devDeps; add `test` and `test:watch` scripts | 24 + | `vitest.config.ts` | Create | Vitest config with path-alias plugin | 25 + | `src/lib/scoring/types.ts` | Create | `TalkScore`, `TalkScoreState`, `Layer1Result`, `ScoringWeights`, `ScoringInputs`, `DEFAULT_WEIGHTS`, re-export `ActiveLayers` | 26 + | `src/lib/scoring/networkAttention.ts` | Create | Layer 1 — `computeLayer1` | 27 + | `src/lib/scoring/networkAttention.test.ts` | Create | Unit tests for `computeLayer1` | 28 + | `src/lib/scoring/interestStub.ts` | Create | Layer 2 stub — `computeInterestStub` | 29 + | `src/lib/scoring/friendStub.ts` | Create | Layer 3 stub — `computeFriendStub` | 30 + | `src/lib/scoring/combine.ts` | Create | `combineLayers`, `ActiveLayers`, `DEFAULT_ACTIVE_LAYERS`, `safe`, `clamp` | 31 + | `src/lib/scoring/combine.test.ts` | Create | Unit tests for `combineLayers` including the per-talk discontinuity regression test | 32 + | `src/lib/scoring/rank.ts` | Create | `scoreTalk`, `rankTalks`, `unknownScore`, `compareTalkScores` | 33 + | `src/lib/scoring/rank.test.ts` | Create | Unit tests for `scoreTalk` and `rankTalks` | 34 + | `src/lib/scoring/index.ts` | Create | Re-exports for the public surface | 35 + 36 + --- 37 + 38 + ## Chunk 1: Test infrastructure + types 39 + 40 + ### Task 1: Add Vitest and vite-tsconfig-paths to the project 41 + 42 + **Files:** 43 + - Modify: `package.json` 44 + - Create: `vitest.config.ts` 45 + 46 + - [ ] **Step 1: Create the feature branch** 47 + 48 + ```bash 49 + git checkout main 50 + git pull --ff-only 51 + git checkout -b feat/scoring-algorithm 52 + ``` 53 + 54 + - [ ] **Step 2: Install dev dependencies** 55 + 56 + Run: `npm install --save-dev vitest vite-tsconfig-paths` 57 + 58 + Expected: `package.json` and `package-lock.json` updated. New `node_modules/vitest` and `node_modules/vite-tsconfig-paths` directories. 59 + 60 + - [ ] **Step 3: Add test scripts to package.json** 61 + 62 + Use npm's tooling to insert the scripts non-destructively (preserves any other scripts that might have been added since this plan was written): 63 + 64 + ```bash 65 + npm pkg set scripts.test="vitest run" 66 + npm pkg set scripts.test:watch="vitest" 67 + ``` 68 + 69 + Verify with: `cat package.json | grep -A 10 '"scripts"'` — should show `test` and `test:watch` alongside the existing `dev`, `build`, `start`, `lint`, `transcribe`, `build-talk-index` scripts. 70 + 71 + - [ ] **Step 4: Create `vitest.config.ts` at the repo root** 72 + 73 + ```ts 74 + import { defineConfig } from "vitest/config"; 75 + import tsconfigPaths from "vite-tsconfig-paths"; 76 + 77 + export default defineConfig({ 78 + plugins: [tsconfigPaths()], 79 + test: { 80 + environment: "node", 81 + }, 82 + }); 83 + ``` 84 + 85 + - [ ] **Step 5: Verify Vitest finds zero tests cleanly** 86 + 87 + Run: `npm test` 88 + Expected: Exits 0 with output like "No test files found" — proves Vitest is wired up correctly even though we have nothing to test yet. 89 + 90 + - [ ] **Step 6: Verify tsc and eslint still pass** 91 + 92 + Run in parallel: 93 + - `npx tsc --noEmit` — Expected: clean 94 + - `npx eslint src/` — Expected: clean 95 + 96 + - [ ] **Step 7: Commit** 97 + 98 + ```bash 99 + git add package.json package-lock.json vitest.config.ts 100 + git commit -m "chore: add vitest + vite-tsconfig-paths for unit testing" 101 + ``` 102 + 103 + --- 104 + 105 + ### Task 2: Create the types module 106 + 107 + **Files:** 108 + - Create: `src/lib/scoring/types.ts` 109 + 110 + - [ ] **Step 1: Create the directory** 111 + 112 + Run: `mkdir -p src/lib/scoring` 113 + 114 + - [ ] **Step 2: Write the types module** 115 + 116 + Create `src/lib/scoring/types.ts`: 117 + 118 + ```ts 119 + import type { TalkEntry } from "@/lib/types"; 120 + import type { TalkMention, TalkMentions } from "@/lib/crawl/types"; 121 + // Local-import-then-re-export so we get a usable local binding for ScoringInputs 122 + // AND re-export the type so callers can `import { ActiveLayers } from "@/lib/scoring/types"` 123 + // without needing to know combine.ts owns it. combine.ts doesn't exist yet 124 + // — this import will resolve once Task 6 lands. 125 + import type { ActiveLayers } from "./combine"; 126 + export type { ActiveLayers }; 127 + 128 + export type TalkScoreState = "engaged" | "missed" | "unknown"; 129 + 130 + export interface Layer1Result { 131 + uniqueFollows: number; 132 + totalFollows: number; 133 + reachRatio: number; // uniqueFollows / totalFollows, clamped to [0, 1] 134 + attentionInverse: number; // 1 - reachRatio, clamped to [0, 1] 135 + } 136 + 137 + export interface TalkScore { 138 + rkey: string; 139 + intensity: number; // 0–1; UI uses for glow + ordering 140 + state: TalkScoreState; 141 + layer1: Layer1Result; 142 + layer2?: { interestScore: number }; 143 + layer3?: { friendBoost: number; recommenders: string[] }; 144 + } 145 + 146 + export interface ScoringWeights { 147 + surpriseSlider: number; // 0–1; controls Layer 2 contribution (high = serendipity) 148 + friendsSlider: number; // 0–1; controls Layer 3 contribution (high = friends override) 149 + } 150 + 151 + export const DEFAULT_WEIGHTS: ScoringWeights = { 152 + surpriseSlider: 0.5, 153 + friendsSlider: 0.5, 154 + }; 155 + 156 + export interface ScoringInputs { 157 + talks: TalkEntry[]; 158 + mentions: TalkMentions | null; // null = crawl not yet completed 159 + followCount: number; // from CrawlResult.followCount 160 + weights?: ScoringWeights; 161 + active?: ActiveLayers; // omitted = layer 1 only (today's deployment) 162 + } 163 + 164 + // Re-export TalkMention for downstream consumers that import only from 165 + // scoring/types — saves them having to know about the crawl module. 166 + export type { TalkMention, TalkMentions }; 167 + ``` 168 + 169 + - [ ] **Step 3: Confirm it does NOT compile yet** 170 + 171 + Run: `npx tsc --noEmit` 172 + Expected: TypeScript errors about `./combine` not existing. This is correct — we'll fix it in Task 7. Note the exact error so you can verify it disappears later. 173 + 174 + > **Note:** We are intentionally writing types.ts before combine.ts. The forward reference is normal for TypeScript projects with cyclic type-only dependencies. Do NOT commit this file standalone — it will land together with combine.ts in a single commit at the end of Chunk 2. 175 + 176 + --- 177 + 178 + ## Chunk 2: Layer 1 + stubs + combine (the math) 179 + 180 + ### Task 3: Layer 1 — `computeLayer1` (test first) 181 + 182 + **Files:** 183 + - Create: `src/lib/scoring/networkAttention.test.ts` 184 + - Create: `src/lib/scoring/networkAttention.ts` 185 + 186 + - [ ] **Step 1: Write the failing test** 187 + 188 + Create `src/lib/scoring/networkAttention.test.ts`: 189 + 190 + ```ts 191 + import { describe, it, expect } from "vitest"; 192 + import { computeLayer1 } from "./networkAttention"; 193 + import type { TalkMention } from "@/lib/crawl/types"; 194 + 195 + // Build a TalkMention with `n` distinct follow DIDs. 196 + // Note: this is "follows engaged with this talk", NOT the user's total 197 + // follow count — that's passed separately as the second arg to computeLayer1. 198 + function makeMention(n: number): TalkMention { 199 + const follows = Array.from({ length: n }, (_, i) => `did:plc:f${i}`); 200 + return { 201 + count: n, 202 + follows, 203 + posts: [], 204 + rsvps: [], 205 + }; 206 + } 207 + 208 + describe("computeLayer1", () => { 209 + it("returns attentionInverse 1.0 when zero follows engaged", () => { 210 + const result = computeLayer1(makeMention(0), 100); 211 + expect(result.uniqueFollows).toBe(0); 212 + expect(result.totalFollows).toBe(100); 213 + expect(result.reachRatio).toBeCloseTo(0, 6); 214 + expect(result.attentionInverse).toBeCloseTo(1.0, 6); 215 + }); 216 + 217 + it("returns attentionInverse 0.5 when half engaged", () => { 218 + const result = computeLayer1(makeMention(50), 100); 219 + expect(result.reachRatio).toBeCloseTo(0.5, 6); 220 + expect(result.attentionInverse).toBeCloseTo(0.5, 6); 221 + }); 222 + 223 + it("returns attentionInverse 0.0 when fully engaged", () => { 224 + const result = computeLayer1(makeMention(100), 100); 225 + expect(result.reachRatio).toBeCloseTo(1.0, 6); 226 + expect(result.attentionInverse).toBeCloseTo(0.0, 6); 227 + }); 228 + 229 + it("returns attentionInverse 1.0 when followCount is 0 (divide-by-zero guard)", () => { 230 + const result = computeLayer1(makeMention(3), 0); 231 + expect(result.reachRatio).toBeCloseTo(0, 6); 232 + expect(result.attentionInverse).toBeCloseTo(1.0, 6); 233 + }); 234 + 235 + it("clamps reachRatio to 1 when stale data has more follows than followCount", () => { 236 + const result = computeLayer1(makeMention(110), 100); 237 + expect(result.reachRatio).toBeCloseTo(1.0, 6); 238 + expect(result.attentionInverse).toBeCloseTo(0.0, 6); 239 + }); 240 + 241 + it("treats undefined mention as zero engagement", () => { 242 + const result = computeLayer1(undefined, 100); 243 + expect(result.uniqueFollows).toBe(0); 244 + expect(result.attentionInverse).toBeCloseTo(1.0, 6); 245 + }); 246 + }); 247 + ``` 248 + 249 + - [ ] **Step 2: Run the test to verify it fails** 250 + 251 + Run: `npm test -- networkAttention` 252 + Expected: FAIL — "Cannot find module './networkAttention'" or similar import error. 253 + 254 + - [ ] **Step 3: Implement `computeLayer1`** 255 + 256 + Create `src/lib/scoring/networkAttention.ts`: 257 + 258 + ```ts 259 + import type { TalkMention } from "@/lib/crawl/types"; 260 + import type { Layer1Result } from "./types"; 261 + 262 + /** 263 + * Compute the Layer 1 (network attention, inverted) score for a single talk. 264 + * 265 + * Returns the fraction of the user's follows who engaged with the talk 266 + * (`reachRatio`) and its inverse (`attentionInverse`), where 1.0 means 267 + * "nobody in your network engaged" and 0.0 means "every single one of your 268 + * follows engaged." 269 + * 270 + * We use `mention.follows.length` rather than `mention.count` so the algorithm 271 + * is robust to a future crawler change that decouples the two. Today the 272 + * crawler enforces `count === follows.length`. 273 + */ 274 + export function computeLayer1( 275 + mention: TalkMention | undefined, 276 + followCount: number, 277 + ): Layer1Result { 278 + const uniqueFollows = mention?.follows.length ?? 0; 279 + // Clamp to [0, 1]: uniqueFollows can theoretically exceed followCount if a 280 + // CrawlResult is reused after the user's follow list changes (someone 281 + // unfollowed but still appears in cached mentions). The clamp prevents 282 + // attentionInverse from going negative in that edge case. 283 + const reachRatio = 284 + followCount > 0 ? Math.min(1, uniqueFollows / followCount) : 0; 285 + const attentionInverse = 1 - reachRatio; 286 + return { 287 + uniqueFollows, 288 + totalFollows: followCount, 289 + reachRatio, 290 + attentionInverse, 291 + }; 292 + } 293 + ``` 294 + 295 + - [ ] **Step 4: Run the test to verify it passes** 296 + 297 + Run: `npm test -- networkAttention` 298 + Expected: PASS — 6 tests passing in `networkAttention.test.ts`. 299 + 300 + > **Note:** This works because Vitest/esbuild elides the `import type ... from "./combine"` and `export type { ActiveLayers }` lines in `types.ts` at transpile time (they're type-only constructs, erased before runtime resolution). If the test run instead fails with `Cannot find module './combine'`, that means esbuild is *not* erasing the type re-export. Workaround: temporarily comment out both the `import type { ActiveLayers } from "./combine";` and `export type { ActiveLayers };` lines in `types.ts`, plus change `active?: ActiveLayers;` to `active?: unknown;` in `ScoringInputs`. Re-run the test, then restore all three before Task 6 commits. 301 + 302 + > **Do NOT commit yet.** This file imports from `./types`, which itself imports from `./combine`. Both will land together at the end of Chunk 2 once `combine.ts` exists. 303 + 304 + --- 305 + 306 + ### Task 4: Layer 2 stub — `computeInterestStub` 307 + 308 + **Files:** 309 + - Create: `src/lib/scoring/interestStub.ts` 310 + 311 + - [ ] **Step 1: Write the stub** 312 + 313 + Create `src/lib/scoring/interestStub.ts`: 314 + 315 + ```ts 316 + import type { TalkEntry } from "@/lib/types"; 317 + 318 + export interface InterestStubResult { 319 + interestScore: number; 320 + } 321 + 322 + /** 323 + * Layer 2 stub. Returns 0 until the following issues land: 324 + * - #21: generate transcript embeddings 325 + * - #22: publish topicIndex records 326 + * - #23: user interest profiling 327 + * - #24: cosine similarity matching 328 + * 329 + * When implemented, this should return cosine similarity in [0, 1] between 330 + * the user's recent-post embedding and the talk's topicIndex embedding. 331 + * 332 + * The leading underscore on `_talk` follows the `@typescript-eslint/no-unused-vars` 333 + * `argsIgnorePattern: "^_"` convention configured by `eslint-config-next/typescript`. 334 + */ 335 + export function computeInterestStub(_talk: TalkEntry): InterestStubResult { 336 + return { interestScore: 0 }; 337 + } 338 + ``` 339 + 340 + - [ ] **Step 2: Verify ESLint accepts the `_talk` parameter convention** 341 + 342 + Run: `npx eslint src/lib/scoring/interestStub.ts` 343 + Expected: clean. If ESLint emits `@typescript-eslint/no-unused-vars` for `_talk`, the project's Next.js preset isn't configuring `argsIgnorePattern: "^_"`. Two recovery options: 344 + 1. Rewrite the body to consume the param: change to `export function computeInterestStub(talk: TalkEntry): InterestStubResult { void talk; return { interestScore: 0 }; }` 345 + 2. Add the rule override to `eslint.config.mjs`. 346 + 347 + > **No test file.** A stub that returns a literal constant doesn't need its own test — its behavior is exercised end-to-end by the `combineLayers` and `scoreTalk` tests in later tasks. 348 + 349 + --- 350 + 351 + ### Task 5: Layer 3 stub — `computeFriendStub` 352 + 353 + **Files:** 354 + - Create: `src/lib/scoring/friendStub.ts` 355 + 356 + - [ ] **Step 1: Write the stub** 357 + 358 + Create `src/lib/scoring/friendStub.ts`: 359 + 360 + ```ts 361 + import type { TalkEntry } from "@/lib/types"; 362 + 363 + export interface FriendStubResult { 364 + friendBoost: number; 365 + recommenders: string[]; 366 + } 367 + 368 + /** 369 + * Layer 3 stub. Returns 0 until the following issues land: 370 + * - #18: friend recommendation reader 371 + * - #5: publish lexicons 372 + * 373 + * When implemented, this should return the normalized sum of friend 374 + * recommendation intensities (1–3 each, capped at a sensible max) and the 375 + * DIDs of the recommending follows. 376 + */ 377 + export function computeFriendStub(_talk: TalkEntry): FriendStubResult { 378 + return { friendBoost: 0, recommenders: [] }; 379 + } 380 + ``` 381 + 382 + --- 383 + 384 + ### Task 6: Combine logic — `combineLayers` (test first) 385 + 386 + **Files:** 387 + - Create: `src/lib/scoring/combine.test.ts` 388 + - Create: `src/lib/scoring/combine.ts` 389 + 390 + - [ ] **Step 1: Write the failing tests** 391 + 392 + Create `src/lib/scoring/combine.test.ts`: 393 + 394 + ```ts 395 + import { describe, it, expect } from "vitest"; 396 + import { 397 + combineLayers, 398 + DEFAULT_ACTIVE_LAYERS, 399 + type ActiveLayers, 400 + } from "./combine"; 401 + import type { Layer1Result, ScoringWeights } from "./types"; 402 + 403 + const DEFAULT_WEIGHTS: ScoringWeights = { 404 + surpriseSlider: 0.5, 405 + friendsSlider: 0.5, 406 + }; 407 + 408 + function l1(attentionInverse: number): Layer1Result { 409 + return { 410 + uniqueFollows: 0, 411 + totalFollows: 0, 412 + reachRatio: 1 - attentionInverse, 413 + attentionInverse, 414 + }; 415 + } 416 + 417 + describe("combineLayers — DEFAULT_ACTIVE_LAYERS sentinel", () => { 418 + it("has both layers off by default", () => { 419 + expect(DEFAULT_ACTIVE_LAYERS).toEqual({ layer2: false, layer3: false }); 420 + }); 421 + }); 422 + 423 + describe("combineLayers — Layer 1 only (today's deployment)", () => { 424 + const active: ActiveLayers = { layer2: false, layer3: false }; 425 + 426 + it("returns layer1.attentionInverse for fully missed talk", () => { 427 + const result = combineLayers( 428 + l1(0.95), 429 + { interestScore: 0 }, 430 + { friendBoost: 0, recommenders: [] }, 431 + DEFAULT_WEIGHTS, 432 + active, 433 + ); 434 + expect(result).toBeCloseTo(0.95, 6); 435 + }); 436 + 437 + it("returns layer1.attentionInverse for partially engaged talk", () => { 438 + const result = combineLayers( 439 + l1(0.4), 440 + { interestScore: 0 }, 441 + { friendBoost: 0, recommenders: [] }, 442 + DEFAULT_WEIGHTS, 443 + active, 444 + ); 445 + expect(result).toBeCloseTo(0.4, 6); 446 + }); 447 + 448 + it("uses DEFAULT_ACTIVE_LAYERS when active arg omitted", () => { 449 + const result = combineLayers( 450 + l1(0.7), 451 + { interestScore: 1.0 }, // ignored — layer 2 is inactive 452 + { friendBoost: 1.0, recommenders: ["did:plc:x"] }, // ignored 453 + DEFAULT_WEIGHTS, 454 + ); 455 + expect(result).toBeCloseTo(0.7, 6); 456 + }); 457 + }); 458 + 459 + describe("combineLayers — Layer 1 + Layer 2 (future stage)", () => { 460 + const active: ActiveLayers = { layer2: true, layer3: false }; 461 + 462 + it("rescales weights to [0.5/0.8, 0.3/0.8] and reaches 1.0 at maximum", () => { 463 + // (1.0 * 0.5 + 1.0 * (1 - 0) * 0.3) / 0.8 = 0.8 / 0.8 = 1.0 464 + const result = combineLayers( 465 + l1(1.0), 466 + { interestScore: 1.0 }, 467 + { friendBoost: 0, recommenders: [] }, 468 + { surpriseSlider: 0, friendsSlider: 0.5 }, 469 + active, 470 + ); 471 + expect(result).toBeCloseTo(1.0, 6); 472 + }); 473 + 474 + it("returns 0.625 for fully missed talk with no interest score", () => { 475 + // (1.0 * 0.5 + 0 * 0.5 * 0.3) / 0.8 = 0.5 / 0.8 = 0.625 476 + const result = combineLayers( 477 + l1(1.0), 478 + { interestScore: 0 }, 479 + { friendBoost: 0, recommenders: [] }, 480 + DEFAULT_WEIGHTS, 481 + active, 482 + ); 483 + expect(result).toBeCloseTo(0.625, 6); 484 + }); 485 + }); 486 + 487 + describe("combineLayers — Layer 1 + Layer 3 (future stage)", () => { 488 + const active: ActiveLayers = { layer2: false, layer3: true }; 489 + 490 + it("rescales weights to [0.5/0.7, 0.2/0.7]", () => { 491 + // (0 * 0.5 + 1.0 * 1 * 0.2) / 0.7 = 0.2 / 0.7 ≈ 0.2857 492 + const result = combineLayers( 493 + l1(0.0), 494 + { interestScore: 0 }, 495 + { friendBoost: 1.0, recommenders: ["did:plc:a"] }, 496 + { surpriseSlider: 0.5, friendsSlider: 1 }, 497 + active, 498 + ); 499 + expect(result).toBeCloseTo(0.2 / 0.7, 6); 500 + }); 501 + }); 502 + 503 + describe("combineLayers — all three layers active (final stage)", () => { 504 + const active: ActiveLayers = { layer2: true, layer3: true }; 505 + 506 + it("matches the design-doc formula exactly at maximum", () => { 507 + // 1.0 * 0.5 + 1.0 * 1 * 0.3 + 1.0 * 1 * 0.2 = 1.0 508 + const result = combineLayers( 509 + l1(1.0), 510 + { interestScore: 1.0 }, 511 + { friendBoost: 1.0, recommenders: ["did:plc:a"] }, 512 + { surpriseSlider: 0, friendsSlider: 1 }, 513 + active, 514 + ); 515 + expect(result).toBeCloseTo(1.0, 6); 516 + }); 517 + }); 518 + 519 + describe("combineLayers — clamping and NaN guards", () => { 520 + it("clamps result to [0, 1] when slider drives raw above 1", () => { 521 + // surprise = -2 → (1 - (-2)) = 3 multiplier on l2 522 + // (1.0 * 0.5 + 1.0 * 3 * 0.3) / 0.8 = 1.4 / 0.8 = 1.75 → clamped to 1.0 523 + const result = combineLayers( 524 + l1(1.0), 525 + { interestScore: 1.0 }, 526 + { friendBoost: 0, recommenders: [] }, 527 + { surpriseSlider: -2, friendsSlider: 0.5 }, 528 + { layer2: true, layer3: false }, 529 + ); 530 + expect(result).toBeCloseTo(1.0, 6); 531 + }); 532 + 533 + it("coerces NaN slider to 0 (equivalent to surprise=0)", () => { 534 + // surprise=NaN → safe(NaN)=0 → (1.0 * 0.5 + 1.0 * 1 * 0.3) / 0.8 = 1.0 535 + const result = combineLayers( 536 + l1(1.0), 537 + { interestScore: 1.0 }, 538 + { friendBoost: 0, recommenders: [] }, 539 + { surpriseSlider: Number.NaN, friendsSlider: 0.5 }, 540 + { layer2: true, layer3: false }, 541 + ); 542 + expect(result).toBeCloseTo(1.0, 6); 543 + }); 544 + 545 + it("coerces ±Infinity to 0", () => { 546 + const result = combineLayers( 547 + l1(1.0), 548 + { interestScore: Number.POSITIVE_INFINITY }, 549 + { friendBoost: Number.NEGATIVE_INFINITY, recommenders: [] }, 550 + DEFAULT_WEIGHTS, 551 + { layer2: true, layer3: true }, 552 + ); 553 + // All non-finite stub values become 0; only L1 contributes. 554 + // (1.0 * 0.5 + 0 + 0) / 1.0 = 0.5 555 + expect(result).toBeCloseTo(0.5, 6); 556 + }); 557 + }); 558 + 559 + describe("combineLayers — REGRESSION: per-talk discontinuity bug", () => { 560 + // This test specifically locks in the correct behavior the renormalization 561 + // fix introduced. If a future refactor reintroduces a per-talk `> 0` branch 562 + // on stub outputs, this assertion will fail loudly. 563 + // 564 + // The bug: with a per-talk `> 0` check, two talks with identical Layer 1 565 + // (0.95) but different Layer 2 (0 vs 0.4) would rank as: 566 + // Talk A (interest=0): takes "stub-only" branch → 0.95 567 + // Talk B (interest=0.4): takes "weighted" branch → 0.535 568 + // Talk B (the one user cares about per L2) ranks BELOW Talk A. 569 + it("ranks talk with positive interest score above identical-L1 talk with zero interest", () => { 570 + const active: ActiveLayers = { layer2: true, layer3: false }; 571 + 572 + const intensityA = combineLayers( 573 + l1(0.95), 574 + { interestScore: 0.0 }, 575 + { friendBoost: 0, recommenders: [] }, 576 + DEFAULT_WEIGHTS, 577 + active, 578 + ); 579 + 580 + const intensityB = combineLayers( 581 + l1(0.95), 582 + { interestScore: 0.4 }, 583 + { friendBoost: 0, recommenders: [] }, 584 + DEFAULT_WEIGHTS, 585 + active, 586 + ); 587 + 588 + // Pre-computed expected values from spec §11.2: 589 + // intensityA = (0.95*0.5 + 0.0*0.5*0.3) / 0.8 = 0.59375 590 + // intensityB = (0.95*0.5 + 0.4*0.5*0.3) / 0.8 = 0.66875 591 + expect(intensityA).toBeCloseTo(0.59375, 6); 592 + expect(intensityB).toBeCloseTo(0.66875, 6); 593 + expect(intensityB).toBeGreaterThan(intensityA); 594 + }); 595 + }); 596 + ``` 597 + 598 + - [ ] **Step 2: Run the tests to verify they fail** 599 + 600 + Run: `npm test -- combine` 601 + Expected: FAIL — module not found. 602 + 603 + - [ ] **Step 3: Implement `combineLayers`** 604 + 605 + Create `src/lib/scoring/combine.ts`: 606 + 607 + ```ts 608 + import type { Layer1Result, ScoringWeights } from "./types"; 609 + import type { InterestStubResult } from "./interestStub"; 610 + import type { FriendStubResult } from "./friendStub"; 611 + 612 + function clamp(n: number, min: number, max: number): number { 613 + return Math.max(min, Math.min(max, n)); 614 + } 615 + 616 + /** 617 + * Coerce non-finite numeric inputs (NaN, ±Infinity) to 0. Defense in depth 618 + * against uninitialized React state or JSON-parsed nulls slipping past 619 + * TypeScript types and propagating into the sort key. 620 + */ 621 + function safe(n: number): number { 622 + return Number.isFinite(n) ? n : 0; 623 + } 624 + 625 + /** 626 + * Which scoring layers have a live data source. Layer 1 is always live; 627 + * Layers 2 and 3 flip to true when their respective implementations land 628 + * (#21–24 for Layer 2, #18 for Layer 3). Today both are false. 629 + */ 630 + export interface ActiveLayers { 631 + layer2: boolean; 632 + layer3: boolean; 633 + } 634 + 635 + export const DEFAULT_ACTIVE_LAYERS: ActiveLayers = { 636 + layer2: false, 637 + layer3: false, 638 + }; 639 + 640 + /** 641 + * Design-doc weights from `docs/understory-design.md` §"The Scoring Algorithm". 642 + * These values are the canonical contribution shares when all three layers 643 + * are live; they are rescaled in `combineLayers` for partial deployments. 644 + */ 645 + const DESIGN_WEIGHTS = { 646 + layer1: 0.5, 647 + layer2: 0.3, 648 + layer3: 0.2, 649 + } as const; 650 + 651 + /** 652 + * Combine the three layers into a 0–1 intensity score. 653 + * 654 + * Per the design doc: 655 + * final = (attention_inverse * 0.5) 656 + * + (interest_score * (1 - surprise_slider) * 0.3) 657 + * + (friend_boost * friends_slider * 0.2) 658 + * 659 + * Weights are rescaled over the active layer set so the maximum achievable 660 + * intensity is always 1.0: 661 + * - Today (layer 1 only): w1 = 0.5/0.5 = 1.0 → intensity == attentionInverse 662 + * - Layer 1 + 2: w1 = 0.5/0.8, w2 = 0.3/0.8 (sum = 1.0) 663 + * - Layer 1 + 3: w1 = 0.5/0.7, w3 = 0.2/0.7 (sum = 1.0) 664 + * - All three: w1 = 0.5, w2 = 0.3, w3 = 0.2 (already sum to 1.0) 665 + * 666 + * Stubs are still consulted when their layer is inactive, but their values 667 + * are multiplied by a zero weight — so swapping a stub for a real 668 + * implementation is purely a data change once the active flag flips. 669 + */ 670 + export function combineLayers( 671 + layer1: Layer1Result, 672 + layer2: InterestStubResult, 673 + layer3: FriendStubResult, 674 + weights: ScoringWeights, 675 + active: ActiveLayers = DEFAULT_ACTIVE_LAYERS, 676 + ): number { 677 + const w1 = DESIGN_WEIGHTS.layer1; 678 + const w2 = active.layer2 ? DESIGN_WEIGHTS.layer2 : 0; 679 + const w3 = active.layer3 ? DESIGN_WEIGHTS.layer3 : 0; 680 + const total = w1 + w2 + w3; // always > 0 because layer 1 is always live 681 + 682 + const l1 = safe(layer1.attentionInverse); 683 + const l2 = active.layer2 ? safe(layer2.interestScore) : 0; 684 + const l3 = active.layer3 ? safe(layer3.friendBoost) : 0; 685 + const surprise = safe(weights.surpriseSlider); 686 + const friends = safe(weights.friendsSlider); 687 + 688 + const raw = 689 + l1 * w1 + 690 + l2 * (1 - surprise) * w2 + 691 + l3 * friends * w3; 692 + 693 + return clamp(raw / total, 0, 1); 694 + } 695 + ``` 696 + 697 + - [ ] **Step 4: Run the tests to verify they pass** 698 + 699 + Run: `npm test -- combine` 700 + Expected: PASS — all combine tests green, including the regression test. 701 + 702 + - [ ] **Step 5: Run the Layer 1 tests too — the type chain now resolves** 703 + 704 + Run: `npm test -- networkAttention` 705 + Expected: PASS — 6 tests in `networkAttention.test.ts`. 706 + 707 + - [ ] **Step 6: Verify tsc and eslint are clean** 708 + 709 + Run in parallel: 710 + - `npx tsc --noEmit` — Expected: clean (the forward `./combine` reference from `types.ts` now resolves) 711 + - `npx eslint src/` — Expected: clean 712 + 713 + - [ ] **Step 7: Commit Chunk 2** 714 + 715 + ```bash 716 + git add src/lib/scoring/types.ts \ 717 + src/lib/scoring/networkAttention.ts \ 718 + src/lib/scoring/networkAttention.test.ts \ 719 + src/lib/scoring/interestStub.ts \ 720 + src/lib/scoring/friendStub.ts \ 721 + src/lib/scoring/combine.ts \ 722 + src/lib/scoring/combine.test.ts 723 + git commit -m "feat(scoring): add Layer 1, layer 2/3 stubs, combine logic with ActiveLayers 724 + 725 + Includes the regression test for the per-talk discontinuity bug — see 726 + docs/superpowers/specs/2026-04-09-scoring-algorithm.md §8.1." 727 + ``` 728 + 729 + --- 730 + 731 + ## Chunk 3: Public API + index 732 + 733 + ### Task 7: `scoreTalk` and `rankTalks` (test first) 734 + 735 + **Files:** 736 + - Create: `src/lib/scoring/rank.test.ts` 737 + - Create: `src/lib/scoring/rank.ts` 738 + 739 + - [ ] **Step 1: Write the failing tests** 740 + 741 + Create `src/lib/scoring/rank.test.ts`: 742 + 743 + ```ts 744 + import { describe, it, expect } from "vitest"; 745 + import { scoreTalk, rankTalks } from "./rank"; 746 + import type { TalkEntry } from "@/lib/types"; 747 + import type { TalkMention, TalkMentions } from "@/lib/crawl/types"; 748 + import type { ActiveLayers } from "./combine"; 749 + 750 + function makeTalk(rkey: string, overrides: Partial<TalkEntry> = {}): TalkEntry { 751 + return { 752 + rkey, 753 + title: `Talk ${rkey}`, 754 + vodUri: `at://example/${rkey}`, 755 + vodCid: "bafy", 756 + hlsUrl: "", 757 + durationMs: 0, 758 + createdAt: "", 759 + eventUri: `at://event/${rkey}`, 760 + description: null, 761 + speakers: [], 762 + room: null, 763 + talkType: null, 764 + category: null, 765 + startsAt: null, 766 + endsAt: null, 767 + transcriptFile: null, 768 + ...overrides, 769 + }; 770 + } 771 + 772 + // Build a TalkMention with `n` distinct follow DIDs (engaged follows for 773 + // this talk, NOT the user's total follow count — that's a separate arg). 774 + function makeMention(n: number): TalkMention { 775 + const follows = Array.from({ length: n }, (_, i) => `did:plc:f${i}`); 776 + return { 777 + count: n, 778 + follows, 779 + posts: [], 780 + rsvps: [], 781 + }; 782 + } 783 + 784 + describe("scoreTalk — state derivation", () => { 785 + const talk = makeTalk("a"); 786 + 787 + it("returns unknown when mentions is null", () => { 788 + const score = scoreTalk(talk, null, 100); 789 + expect(score.state).toBe("unknown"); 790 + expect(score.intensity).toBe(0); 791 + }); 792 + 793 + it("returns unknown when followCount is 0", () => { 794 + const score = scoreTalk(talk, { a: makeMention(5) }, 0); 795 + expect(score.state).toBe("unknown"); 796 + }); 797 + 798 + it("returns unknown when the talk has no mention entry (out of crawl scope)", () => { 799 + const score = scoreTalk(talk, {}, 100); 800 + expect(score.state).toBe("unknown"); 801 + }); 802 + 803 + it("returns missed when uniqueFollows is 0 but talk is in scope", () => { 804 + const score = scoreTalk(talk, { a: makeMention(0) }, 100); 805 + expect(score.state).toBe("missed"); 806 + expect(score.intensity).toBeCloseTo(1.0, 6); 807 + }); 808 + 809 + it("returns engaged when at least one follow engaged", () => { 810 + const score = scoreTalk(talk, { a: makeMention(3) }, 100); 811 + expect(score.state).toBe("engaged"); 812 + expect(score.intensity).toBeCloseTo(0.97, 6); 813 + }); 814 + }); 815 + 816 + describe("scoreTalk — defaults", () => { 817 + const talk = makeTalk("a"); 818 + const mentions: TalkMentions = { a: makeMention(0) }; 819 + 820 + it("uses both DEFAULT_WEIGHTS and DEFAULT_ACTIVE_LAYERS when both omitted", () => { 821 + const score = scoreTalk(talk, mentions, 100); 822 + expect(score.intensity).toBeCloseTo(1.0, 6); 823 + }); 824 + 825 + it("uses DEFAULT_ACTIVE_LAYERS when active omitted but explicit weights supplied", () => { 826 + const score = scoreTalk(talk, mentions, 100, { 827 + surpriseSlider: 0.25, 828 + friendsSlider: 0.75, 829 + }); 830 + // active defaults to both-off → L1-only branch → weights don't enter 831 + // the math at all → intensity == layer1.attentionInverse == 1.0 832 + expect(score.intensity).toBeCloseTo(1.0, 6); 833 + }); 834 + 835 + it("uses DEFAULT_WEIGHTS when weights omitted but explicit active supplied", () => { 836 + const score = scoreTalk(talk, mentions, 100, undefined, { 837 + layer2: true, 838 + layer3: false, 839 + }); 840 + // L1+L2 active, L2 stub returns 0, default surprise=0.5 841 + // (1.0*0.5 + 0*0.5*0.3) / 0.8 = 0.625 842 + expect(score.intensity).toBeCloseTo(0.625, 6); 843 + }); 844 + }); 845 + 846 + describe("rankTalks — sort order", () => { 847 + const A = makeTalk("aaa"); 848 + const B = makeTalk("bbb"); 849 + const C = makeTalk("ccc"); 850 + const D = makeTalk("ddd"); 851 + const E = makeTalk("eee"); 852 + 853 + it("sorts missed first, then engaged (intensity desc), then unknown", () => { 854 + const mentions: TalkMentions = { 855 + aaa: makeMention(1), // engaged, intensity 0.99 856 + bbb: makeMention(0), // missed, intensity 1.0 857 + ccc: makeMention(50), // engaged, intensity 0.5 858 + // D, E: no mentions → unknown 859 + }; 860 + const result = rankTalks({ 861 + talks: [A, B, C, D, E], 862 + mentions, 863 + followCount: 100, 864 + }); 865 + 866 + expect(result.map((s) => s.rkey)).toEqual(["bbb", "aaa", "ccc", "ddd", "eee"]); 867 + }); 868 + 869 + it("uses rkey ascending as a deterministic tiebreak", () => { 870 + const Z = makeTalk("zzz"); 871 + const A = makeTalk("aaa"); 872 + const mentions: TalkMentions = { 873 + zzz: makeMention(0), 874 + aaa: makeMention(0), 875 + }; 876 + const result = rankTalks({ 877 + talks: [Z, A], // intentionally not in rkey order 878 + mentions, 879 + followCount: 100, 880 + }); 881 + 882 + // Both missed with intensity 1.0; tiebreak puts "aaa" before "zzz" 883 + expect(result[0].rkey).toBe("aaa"); 884 + expect(result[1].rkey).toBe("zzz"); 885 + }); 886 + 887 + it("threads weights and active flags through to combineLayers", () => { 888 + const active: ActiveLayers = { layer2: true, layer3: false }; 889 + const mentions: TalkMentions = { aaa: makeMention(0) }; 890 + const result = rankTalks({ 891 + talks: [A], 892 + mentions, 893 + followCount: 100, 894 + active, 895 + }); 896 + // L1 only contributes; L2 stub returns 0; rescale: 0.5/0.8 = 0.625 897 + expect(result[0].intensity).toBeCloseTo(0.625, 6); 898 + }); 899 + }); 900 + 901 + describe("rankTalks — empty / degenerate inputs", () => { 902 + it("returns [] for empty talks array", () => { 903 + const result = rankTalks({ 904 + talks: [], 905 + mentions: {}, 906 + followCount: 100, 907 + }); 908 + expect(result).toEqual([]); 909 + }); 910 + 911 + it("returns all unknown sorted by rkey when mentions is null", () => { 912 + const result = rankTalks({ 913 + talks: [makeTalk("ccc"), makeTalk("aaa"), makeTalk("bbb")], 914 + mentions: null, 915 + followCount: 100, 916 + }); 917 + expect(result.map((s) => s.state)).toEqual(["unknown", "unknown", "unknown"]); 918 + expect(result.map((s) => s.rkey)).toEqual(["aaa", "bbb", "ccc"]); 919 + }); 920 + 921 + it("returns all unknown when followCount is 0", () => { 922 + const result = rankTalks({ 923 + talks: [makeTalk("aaa"), makeTalk("bbb"), makeTalk("ccc")], 924 + mentions: { 925 + aaa: makeMention(5), 926 + bbb: makeMention(10), 927 + ccc: makeMention(0), 928 + }, 929 + followCount: 0, 930 + }); 931 + expect(result.map((s) => s.state)).toEqual(["unknown", "unknown", "unknown"]); 932 + }); 933 + }); 934 + ``` 935 + 936 + - [ ] **Step 2: Run the tests to verify they fail** 937 + 938 + Run: `npm test -- rank` 939 + Expected: FAIL — module not found. 940 + 941 + - [ ] **Step 3: Implement `scoreTalk` and `rankTalks`** 942 + 943 + Create `src/lib/scoring/rank.ts`: 944 + 945 + ```ts 946 + import type { TalkEntry } from "@/lib/types"; 947 + import type { TalkMentions } from "@/lib/crawl/types"; 948 + import { 949 + type TalkScore, 950 + type TalkScoreState, 951 + type ScoringInputs, 952 + type ScoringWeights, 953 + DEFAULT_WEIGHTS, 954 + } from "./types"; 955 + import { computeLayer1 } from "./networkAttention"; 956 + import { computeInterestStub } from "./interestStub"; 957 + import { computeFriendStub } from "./friendStub"; 958 + import { 959 + type ActiveLayers, 960 + DEFAULT_ACTIVE_LAYERS, 961 + combineLayers, 962 + } from "./combine"; 963 + 964 + function unknownScore(rkey: string, followCount: number): TalkScore { 965 + return { 966 + rkey, 967 + intensity: 0, 968 + state: "unknown", 969 + layer1: { 970 + uniqueFollows: 0, 971 + totalFollows: followCount, 972 + reachRatio: 0, 973 + attentionInverse: 0, 974 + }, 975 + }; 976 + } 977 + 978 + /** 979 + * Score a single talk. Pass the full `mentions` map (or null if no crawl 980 + * has run yet) — the function looks up the talk's mention internally so 981 + * callers don't have to encode "do we have crawl data" as a separate flag. 982 + * 983 + * Returns `unknown` state when: 984 + * - mentions is null (crawl hasn't run) 985 + * - followCount is 0 (user has no follows; reach is undefined) 986 + * - mention is absent (talk is out of crawl scope, e.g. no eventUri) 987 + * 988 + * Otherwise runs Layer 1 + the two stubs through `combineLayers` with the 989 + * given weights and active layer flags. 990 + */ 991 + export function scoreTalk( 992 + talk: TalkEntry, 993 + mentions: TalkMentions | null, 994 + followCount: number, 995 + weights: ScoringWeights = DEFAULT_WEIGHTS, 996 + active: ActiveLayers = DEFAULT_ACTIVE_LAYERS, 997 + ): TalkScore { 998 + if (mentions === null || followCount === 0) { 999 + return unknownScore(talk.rkey, followCount); 1000 + } 1001 + const mention = mentions[talk.rkey]; 1002 + if (!mention) { 1003 + // Talk is not in crawl scope (e.g. no eventUri so the crawler skipped it). 1004 + return unknownScore(talk.rkey, followCount); 1005 + } 1006 + 1007 + const layer1 = computeLayer1(mention, followCount); 1008 + const layer2 = computeInterestStub(talk); 1009 + const layer3 = computeFriendStub(talk); 1010 + const intensity = combineLayers(layer1, layer2, layer3, weights, active); 1011 + 1012 + const state: TalkScoreState = 1013 + layer1.uniqueFollows === 0 ? "missed" : "engaged"; 1014 + 1015 + return { rkey: talk.rkey, intensity, state, layer1 }; 1016 + } 1017 + 1018 + const STATE_ORDER: Record<TalkScoreState, number> = { 1019 + missed: 0, 1020 + engaged: 1, 1021 + unknown: 2, 1022 + }; 1023 + 1024 + function compareTalkScores(a: TalkScore, b: TalkScore): number { 1025 + // Primary: state group (missed first, then engaged, then unknown) 1026 + const stateDelta = STATE_ORDER[a.state] - STATE_ORDER[b.state]; 1027 + if (stateDelta !== 0) return stateDelta; 1028 + // Secondary: intensity descending (highest glow first within each state) 1029 + const intensityDelta = b.intensity - a.intensity; 1030 + if (intensityDelta !== 0) return intensityDelta; 1031 + // Tertiary: rkey ascending — deterministic tiebreak so the order is stable 1032 + // across renders (matters for React reconciliation). 1033 + return a.rkey.localeCompare(b.rkey); 1034 + } 1035 + 1036 + export function rankTalks(inputs: ScoringInputs): TalkScore[] { 1037 + const { 1038 + talks, 1039 + mentions, 1040 + followCount, 1041 + weights = DEFAULT_WEIGHTS, 1042 + active = DEFAULT_ACTIVE_LAYERS, 1043 + } = inputs; 1044 + return talks 1045 + .map((talk) => scoreTalk(talk, mentions, followCount, weights, active)) 1046 + .sort(compareTalkScores); 1047 + } 1048 + ``` 1049 + 1050 + - [ ] **Step 4: Run the tests to verify they pass** 1051 + 1052 + Run: `npm test -- rank` 1053 + Expected: PASS — all rank tests green. 1054 + 1055 + - [ ] **Step 5: Run the full suite** 1056 + 1057 + Run: `npm test` 1058 + Expected: PASS — all scoring tests across networkAttention, combine, and rank. 1059 + 1060 + - [ ] **Step 6: Commit** 1061 + 1062 + ```bash 1063 + git add src/lib/scoring/rank.ts src/lib/scoring/rank.test.ts 1064 + git commit -m "feat(scoring): add scoreTalk + rankTalks public API" 1065 + ``` 1066 + 1067 + --- 1068 + 1069 + ### Task 8: Index re-exports 1070 + 1071 + **Files:** 1072 + - Create: `src/lib/scoring/index.ts` 1073 + 1074 + - [ ] **Step 1: Write the index file** 1075 + 1076 + Create `src/lib/scoring/index.ts`: 1077 + 1078 + ```ts 1079 + // Public surface for the scoring module. Consumers should import from 1080 + // `@/lib/scoring`, not from individual files, so refactors inside the module 1081 + // don't break call sites. 1082 + 1083 + export type { 1084 + TalkScore, 1085 + TalkScoreState, 1086 + Layer1Result, 1087 + ScoringWeights, 1088 + ScoringInputs, 1089 + TalkMention, 1090 + TalkMentions, 1091 + } from "./types"; 1092 + 1093 + export { DEFAULT_WEIGHTS } from "./types"; 1094 + 1095 + export type { ActiveLayers } from "./combine"; 1096 + export { DEFAULT_ACTIVE_LAYERS, combineLayers } from "./combine"; 1097 + 1098 + export { computeLayer1 } from "./networkAttention"; 1099 + export { scoreTalk, rankTalks } from "./rank"; 1100 + ``` 1101 + 1102 + - [ ] **Step 2: Verify tsc and eslint are clean** 1103 + 1104 + Run in parallel: 1105 + - `npx tsc --noEmit` — Expected: clean. This compiles the whole project including `index.ts`, which type-checks every public re-export and would catch any name typos or missing exports. 1106 + - `npx eslint src/` — Expected: clean. 1107 + 1108 + - [ ] **Step 3: Commit** 1109 + 1110 + ```bash 1111 + git add src/lib/scoring/index.ts 1112 + git commit -m "feat(scoring): add public index re-exports" 1113 + ``` 1114 + 1115 + Note: the renumbering above is intentional — Step 3 is the commit because the smoke check that was previously here turned out to be unworkable in a sandboxed environment AND would have disabled `tsconfig.json` path-alias resolution if it had run. The Task 9 full-project `npx tsc --noEmit` already exercises every re-export through `index.ts`. 1116 + 1117 + --- 1118 + 1119 + ## Chunk 4: Final verification 1120 + 1121 + ### Task 9: Lint, type check, build, full test pass 1122 + 1123 + - [ ] **Step 1: Run the full test suite** 1124 + 1125 + Run: `npm test` 1126 + Expected: All scoring tests pass. Note the test count and timing for the PR description. 1127 + 1128 + - [ ] **Step 2: Run the linter** 1129 + 1130 + Run: `npx eslint src/` 1131 + Expected: clean. Fix any issues found. 1132 + 1133 + - [ ] **Step 3: Run the type checker** 1134 + 1135 + Run: `npx tsc --noEmit` 1136 + Expected: clean. Fix any errors. 1137 + 1138 + - [ ] **Step 4: Run the production build** 1139 + 1140 + Run: `npm run build` 1141 + Expected: build succeeds with no new bundle warnings. Confirm in the route listing that `/api/crawl` still appears with the dynamic route indicator (`ƒ`), not the static one (`○`). The set of listed routes should be unchanged from before this PR — if a previously dynamic route flipped to static (or vice versa), investigate before proceeding. 1142 + 1143 + - [ ] **Step 5: Smoke check that scoring isn't accidentally imported by a server route** 1144 + 1145 + The scoring module is intended for client-side use. Use the Grep tool (NOT raw `grep` and NOT `npx grep`, neither of which match this project's tooling conventions) to search for any imports of `@/lib/scoring` under `src/app/api/`: 1146 + 1147 + - Pattern: `from ["']@/lib/scoring` 1148 + - Path: `src/app/api/` 1149 + - Expected: zero matches. If you find any, double-check whether the API route actually needs scoring (it shouldn't for this issue) — scoring is pure functions so it would technically work server-side, but no consumer in scope for #19 should import it. 1150 + 1151 + - [ ] **Step 6: Commit any fixes from this task** 1152 + 1153 + If any of the verification steps surfaced issues that needed fixing: 1154 + 1155 + ```bash 1156 + git add src/lib/scoring/... 1157 + git commit -m "fix(scoring): resolve issues from final verification" 1158 + ``` 1159 + 1160 + If everything was already clean, no commit is needed for this task. 1161 + 1162 + --- 1163 + 1164 + ### Task 10: Wrap up 1165 + 1166 + - [ ] **Step 1: Summary check** 1167 + 1168 + Confirm all tasks 1–9 are checked off above. The final state of `src/lib/scoring/` should contain: 1169 + 1170 + ``` 1171 + src/lib/scoring/ 1172 + ├── combine.ts 1173 + ├── combine.test.ts 1174 + ├── friendStub.ts 1175 + ├── index.ts 1176 + ├── interestStub.ts 1177 + ├── networkAttention.ts 1178 + ├── networkAttention.test.ts 1179 + ├── rank.ts 1180 + ├── rank.test.ts 1181 + └── types.ts 1182 + ``` 1183 + 1184 + 10 files total. 3 test files + 6 source files + 1 index. 1185 + 1186 + - [ ] **Step 2: Use the finishing-a-development-branch skill** 1187 + 1188 + Invoke `superpowers:finishing-a-development-branch` to verify tests, present merge options, and execute the chosen workflow.
+709
docs/superpowers/specs/2026-04-09-scoring-algorithm.md
··· 1 + # Layer 1 Scoring Algorithm Spec 2 + 3 + **Date:** 2026-04-09 4 + **Issue:** Chainlink #19 5 + **Status:** Approved (pending review) 6 + **Depends on:** PR #5 (social graph crawler — merged) 7 + **Unblocks:** #10 (network attention display), #12 (personalized feed page), #20 (slider UI) 8 + 9 + --- 10 + 11 + ## 1. Goal 12 + 13 + Implement the Layer 1 (network attention, inverted) scoring layer of Understory's three-layer scoring engine, with stub interfaces for Layers 2 (interest similarity) and 3 (friend recommendations) so they can plug in later without touching the combine logic. 14 + 15 + The output is a `TalkScore` per talk: a 0–1 intensity value that the UI uses for ranking and bioluminescent glow, plus a three-state classifier (`engaged | missed | unknown`) so the UI can distinguish "your network missed this" from "we don't know." 16 + 17 + This is the keystone that turns the crawler's `TalkMentions` into something the UI can render. It is purely client-side TypeScript — no API routes, no fetches, no React coupling. 18 + 19 + --- 20 + 21 + ## 2. Background 22 + 23 + The crawler in `src/lib/crawl/` returns a `TalkMentions` map per authenticated user, keyed by talk rkey: 24 + 25 + ```ts 26 + interface TalkMention { 27 + count: number; // unique follows engaged (RSVPs ∪ posters) 28 + follows: string[]; // DIDs of those follows 29 + posts: string[]; // URIs of every matching post (a single follow may post multiple) 30 + rsvps: string[]; // DIDs of follows who RSVPed (subset of follows) 31 + } 32 + ``` 33 + 34 + The full scoring algorithm in `docs/understory-design.md` §"The Scoring Algorithm" describes a three-layer engine combining network attention (inverted), interest similarity (cosine of user/talk embeddings), and friend recommendation overrides: 35 + 36 + ``` 37 + final_score = (attention_inverse * 0.5) 38 + + (effective_interest * 0.3) 39 + + (friend_boost * friends_slider * 0.2) 40 + ``` 41 + 42 + Layers 2 and 3 require data from issues that don't exist yet: 43 + 44 + - **Layer 2** needs `topicIndex` records: blocked on #21 (transcript embeddings), #22 (publish topicIndex), #23 (user interest profiling), #24 (cosine similarity matching). 45 + - **Layer 3** needs friend recommendation reading: blocked on #18 (friend rec reader) and #5 (publish lexicons). 46 + 47 + This spec implements Layer 1 fully and stubs the other two, with a renormalization rule so the score stays interpretable while only Layer 1 has real data. 48 + 49 + --- 50 + 51 + ## 3. Architecture 52 + 53 + ### 3.1 File layout 54 + 55 + ``` 56 + src/lib/scoring/ 57 + ├── types.ts Shared types (TalkScore, ScoringWeights, etc.) 58 + ├── networkAttention.ts Layer 1 — pure functions 59 + ├── interestStub.ts Layer 2 stub (returns 0) 60 + ├── friendStub.ts Layer 3 stub (returns 0) 61 + ├── combine.ts Weighted combine + renormalization 62 + ├── rank.ts Public API: scoreTalk() and rankTalks() 63 + └── index.ts Re-exports 64 + ``` 65 + 66 + Pure functions throughout. No React, no fetch, no globals. The module is downstream of the crawler — it operates only on data already passed in. This makes it trivially testable, memo-friendly across slider changes, and SSR-safe. 67 + 68 + A future React hook (`useTalkScores`, built in #12 or #20) will wrap these functions, fetch `/api/crawl`, and feed the result into `rankTalks`. That hook is **not** part of this spec. 69 + 70 + ### 3.2 Data flow 71 + 72 + ``` 73 + Server-side Client-side 74 + ───────────── ───────────── 75 + data/talks.json ──┐ 76 + ├──► /api/crawl ──► TalkMentions ──┐ 77 + session.agent ──┘ │ 78 + 79 + talks (fetched alongside) + mentions + weights 80 + 81 + 82 + rankTalks() 83 + 84 + 85 + TalkScore[] (sorted) 86 + 87 + 88 + Feed page / glow / sliders 89 + ``` 90 + 91 + --- 92 + 93 + ## 4. Types 94 + 95 + ```ts 96 + // src/lib/scoring/types.ts 97 + import type { TalkEntry } from "@/lib/types"; 98 + import type { TalkMention, TalkMentions } from "@/lib/crawl/types"; 99 + // `ActiveLayers` is declared in combine.ts (see §8) and re-exported here so 100 + // call sites don't have to know its origin module. 101 + export type { ActiveLayers } from "./combine"; 102 + 103 + export type TalkScoreState = "engaged" | "missed" | "unknown"; 104 + 105 + export interface Layer1Result { 106 + uniqueFollows: number; 107 + totalFollows: number; 108 + reachRatio: number; // uniqueFollows / totalFollows, clamped to [0, 1] 109 + attentionInverse: number; // 1 - reachRatio, clamped to [0, 1] 110 + } 111 + 112 + export interface TalkScore { 113 + rkey: string; 114 + intensity: number; // 0–1; UI uses for glow + ordering 115 + state: TalkScoreState; 116 + layer1: Layer1Result; 117 + layer2?: { interestScore: number }; 118 + layer3?: { friendBoost: number; recommenders: string[] }; 119 + } 120 + 121 + export interface ScoringWeights { 122 + surpriseSlider: number; // 0–1; controls Layer 2 contribution (high = serendipity) 123 + friendsSlider: number; // 0–1; controls Layer 3 contribution (high = friends override) 124 + } 125 + 126 + export interface ScoringInputs { 127 + talks: TalkEntry[]; 128 + mentions: TalkMentions | null; // null = crawl not yet completed 129 + followCount: number; // from CrawlResult.followCount 130 + weights?: ScoringWeights; 131 + active?: ActiveLayers; // omitted = layer 1 only (today's deployment) 132 + } 133 + 134 + export const DEFAULT_WEIGHTS: ScoringWeights = { 135 + surpriseSlider: 0.5, 136 + friendsSlider: 0.5, 137 + }; 138 + ``` 139 + 140 + `mentions` may be `null` so the talk page can render before a crawl has completed (everything will be `unknown`). `weights` is optional with sensible defaults. 141 + 142 + --- 143 + 144 + ## 5. Layer 1 — Network Attention (inverted) 145 + 146 + ### 5.1 Raw signal 147 + 148 + We count **unique follows** who engaged with a talk. A follow may have RSVPed via Constellation, posted about it via Bluesky search, or both — they still count as one. Multiple posts from the same follow do not stack. 149 + 150 + > **Design principle:** Unique voices over engagement volume. One person posting the same thing repeatedly doesn't tell you anything new — it's noise, not signal. This is the inverse of algorithmic feed dynamics that reward the loudest voice in a room, which is exactly what Understory is built to invert. 151 + 152 + The crawler already de-duplicates by follow in `TalkMention.follows`, so the raw signal is: 153 + 154 + ```ts 155 + const uniqueFollows = mention.follows.length; 156 + ``` 157 + 158 + ### 5.2 Normalization 159 + 160 + The score is normalized as a **reach ratio** — the fraction of the user's follows who engaged with the talk. This is interpretable, comparable across users, and stable: 161 + 162 + ```ts 163 + const reachRatio = followCount > 0 ? uniqueFollows / followCount : 0; 164 + const attentionInverse = 1 - reachRatio; 165 + ``` 166 + 167 + > "0.95" literally means "95% of the people you follow didn't talk about this." 168 + 169 + ### 5.3 The function 170 + 171 + ```ts 172 + // src/lib/scoring/networkAttention.ts 173 + import type { TalkMention } from "@/lib/crawl/types"; 174 + import type { Layer1Result } from "./types"; 175 + 176 + export function computeLayer1( 177 + mention: TalkMention | undefined, 178 + followCount: number, 179 + ): Layer1Result { 180 + const uniqueFollows = mention?.follows.length ?? 0; 181 + // Clamp to [0, 1]: uniqueFollows can theoretically exceed followCount if a 182 + // CrawlResult is reused after the user's follow list changes (someone 183 + // unfollowed but still appears in the cached mentions). The clamp prevents 184 + // attentionInverse from going negative in that edge case. 185 + const reachRatio = 186 + followCount > 0 ? Math.min(1, uniqueFollows / followCount) : 0; 187 + const attentionInverse = 1 - reachRatio; 188 + return { 189 + uniqueFollows, 190 + totalFollows: followCount, 191 + reachRatio, 192 + attentionInverse, 193 + }; 194 + } 195 + ``` 196 + 197 + > **Implementation note:** We use `mention.follows.length` rather than `mention.count` so the algorithm is robust to a future crawler change that decouples the two (e.g., if `count` ever starts including non-follow signals). Today the crawler enforces `count === follows.length`. 198 + 199 + --- 200 + 201 + ## 6. State derivation 202 + 203 + The three-state classifier separates "missed" from "unknown" so the UI can render them distinctly. The mapping is **evaluated in order; first match wins**: 204 + 205 + | Order | Condition | State | Notes | 206 + |-------|--------------------------|-----------|--------------------------------------------| 207 + | 1 | `mentions` is `null` | `unknown` | Crawl has not yet run | 208 + | 2 | `followCount === 0` | `unknown` | User has zero follows; reach is undefined | 209 + | 3 | `mention === undefined` | `unknown` | Talk not in crawl scope (e.g. no eventUri) | 210 + | 4 | `uniqueFollows === 0` | `missed` | Network missed it — full glow | 211 + | 5 | `uniqueFollows > 0` | `engaged` | Network engaged with it — fade | 212 + 213 + Order matters: rows 1 and 2 must come before row 4, otherwise a user with zero follows would have every in-scope talk classified as `missed` (since `uniqueFollows === 0` is trivially true), polluting the feed with bogus full-glow entries. Conflating "unknown" with "missed" would either light up false positives (a talk the crawler couldn't see glows as if missed) or hide talks entirely (filtering unknowns drops talks the user might still want). Three-state preserves honesty. 214 + 215 + --- 216 + 217 + ## 7. Layer 2 + Layer 3 stubs 218 + 219 + Both stubs return zero contributions so they don't affect Layer 1's intensity. They exist to lock the public API shape and the combine math so layers 2 and 3 can be filled in later by their respective issues without changing call sites. 220 + 221 + ```ts 222 + // src/lib/scoring/interestStub.ts 223 + import type { TalkEntry } from "@/lib/types"; 224 + 225 + export interface InterestStubResult { 226 + interestScore: number; 227 + } 228 + 229 + /** 230 + * Layer 2 stub. Returns 0 until issues #21–24 land: 231 + * - #21: generate transcript embeddings 232 + * - #22: publish topicIndex records 233 + * - #23: user interest profiling 234 + * - #24: cosine similarity matching 235 + * 236 + * When implemented, this should return cosine similarity in [0, 1] between 237 + * the user's recent-post embedding and the talk's topicIndex embedding. 238 + */ 239 + export function computeInterestStub(_talk: TalkEntry): InterestStubResult { 240 + return { interestScore: 0 }; 241 + } 242 + ``` 243 + 244 + ```ts 245 + // src/lib/scoring/friendStub.ts 246 + import type { TalkEntry } from "@/lib/types"; 247 + 248 + export interface FriendStubResult { 249 + friendBoost: number; 250 + recommenders: string[]; 251 + } 252 + 253 + /** 254 + * Layer 3 stub. Returns 0 until issue #18 (friend recommendation reader) 255 + * and #5 (publish lexicons) land. When implemented, this should return the 256 + * normalized sum of friend recommendation intensities (1–3 each, capped at 257 + * a sensible max) and the DIDs of the recommending follows. 258 + */ 259 + export function computeFriendStub(_talk: TalkEntry): FriendStubResult { 260 + return { friendBoost: 0, recommenders: [] }; 261 + } 262 + ``` 263 + 264 + The leading underscore on `_talk` is the project convention for unused parameters; ESLint allows it. 265 + 266 + --- 267 + 268 + ## 8. Combine + intensity 269 + 270 + The combine logic must handle three deployment stages: today (only Layer 1 has real data), the intermediate stage where one of Layers 2/3 has shipped but the other hasn't, and the final stage with all three layers live. We need two invariants across every stage: 271 + 272 + 1. **Maximum achievable intensity is always 1.0.** A "fully missed by everyone in your network" talk should glow at full strength regardless of which layers are deployed. Otherwise the bioluminescent UI visibly dims sitewide every time we ship a new layer. 273 + 2. **Within a single deployment stage, no per-talk discontinuities.** Two talks with identical Layer 1 scores must rank in the same order they would under any other interpretation of Layers 2/3 — e.g., a talk the user is interested in must never rank below an identical-Layer-1 talk the user *isn't* interested in just because Layer 2 happens to return 0 for one of them. 274 + 275 + A naive runtime `> 0` check on stub outputs violates both invariants: per-talk branching corrupts ordering at the activation boundary, and the design-doc weights `(0.5, 0.3, 0.2)` only sum to 1.0 when all three layers contribute, so partial deployments cap intensity below 1.0. 276 + 277 + The correct abstraction is a **deployment-level `ActiveLayers` flag** that selects which layers are live, with weights rescaled to sum to 1.0 over the active set. Stub outputs are still ignored (multiplied by zero weight) when their layer is inactive, but they no longer drive control flow. 278 + 279 + ```ts 280 + // src/lib/scoring/combine.ts 281 + import type { Layer1Result, ScoringWeights } from "./types"; 282 + import type { InterestStubResult } from "./interestStub"; 283 + import type { FriendStubResult } from "./friendStub"; 284 + 285 + function clamp(n: number, min: number, max: number): number { 286 + return Math.max(min, Math.min(max, n)); 287 + } 288 + 289 + /** 290 + * Coerce non-finite numeric inputs (NaN, ±Infinity) to 0. Defense in depth 291 + * against uninitialized React state or JSON-parsed nulls slipping past 292 + * TypeScript types and propagating into the sort key. 293 + */ 294 + function safe(n: number): number { 295 + return Number.isFinite(n) ? n : 0; 296 + } 297 + 298 + /** 299 + * Which scoring layers have a live data source. Layer 1 is always live; 300 + * Layers 2 and 3 flip to true when their respective implementations land 301 + * (#21–24 for Layer 2, #18 for Layer 3). Today both are false. 302 + */ 303 + export interface ActiveLayers { 304 + layer2: boolean; 305 + layer3: boolean; 306 + } 307 + 308 + export const DEFAULT_ACTIVE_LAYERS: ActiveLayers = { 309 + layer2: false, 310 + layer3: false, 311 + }; 312 + 313 + /** 314 + * Design-doc weights from `docs/understory-design.md` §"The Scoring Algorithm". 315 + * These values are the canonical contribution shares when all three layers 316 + * are live; they are rescaled in `combineLayers` for partial deployments. 317 + */ 318 + const DESIGN_WEIGHTS = { 319 + layer1: 0.5, 320 + layer2: 0.3, 321 + layer3: 0.2, 322 + } as const; 323 + 324 + /** 325 + * Combine the three layers into a 0–1 intensity score. 326 + * 327 + * Per the design doc: 328 + * final = (attention_inverse * 0.5) 329 + * + (interest_score * (1 - surprise_slider) * 0.3) 330 + * + (friend_boost * friends_slider * 0.2) 331 + * 332 + * Weights are rescaled over the active layer set so the maximum achievable 333 + * intensity is always 1.0: 334 + * - Today (layer 1 only): w1 = 0.5/0.5 = 1.0 → intensity == attentionInverse 335 + * - Layer 1 + 2: w1 = 0.5/0.8, w2 = 0.3/0.8 (sum = 1.0) 336 + * - Layer 1 + 3: w1 = 0.5/0.7, w3 = 0.2/0.7 (sum = 1.0) 337 + * - All three: w1 = 0.5, w2 = 0.3, w3 = 0.2 (already sum to 1.0) 338 + * 339 + * Stubs are still consulted when their layer is inactive, but their values 340 + * are multiplied by a zero weight — so swapping a stub for a real 341 + * implementation is purely a data change once the active flag flips. 342 + */ 343 + export function combineLayers( 344 + layer1: Layer1Result, 345 + layer2: InterestStubResult, 346 + layer3: FriendStubResult, 347 + weights: ScoringWeights, 348 + active: ActiveLayers = DEFAULT_ACTIVE_LAYERS, 349 + ): number { 350 + const w1 = DESIGN_WEIGHTS.layer1; 351 + const w2 = active.layer2 ? DESIGN_WEIGHTS.layer2 : 0; 352 + const w3 = active.layer3 ? DESIGN_WEIGHTS.layer3 : 0; 353 + const total = w1 + w2 + w3; // always > 0 because layer 1 is always live 354 + 355 + // Defensive: coerce non-finite slider/score values to 0 so a stray NaN 356 + // can't propagate into the sort key. Caller is still responsible for 357 + // sane slider input; this is a last-resort guard. 358 + const l1 = safe(layer1.attentionInverse); 359 + const l2 = active.layer2 ? safe(layer2.interestScore) : 0; 360 + const l3 = active.layer3 ? safe(layer3.friendBoost) : 0; 361 + const surprise = safe(weights.surpriseSlider); 362 + const friends = safe(weights.friendsSlider); 363 + 364 + const raw = 365 + l1 * w1 + 366 + l2 * (1 - surprise) * w2 + 367 + l3 * friends * w3; 368 + 369 + return clamp(raw / total, 0, 1); 370 + } 371 + ``` 372 + 373 + ### 8.1 Why per-talk discontinuities are catastrophic 374 + 375 + To make the trap concrete: with the per-talk `> 0` check, after Layer 2 ships, two talks with identical Layer 1 scores would rank as: 376 + 377 + | Talk | layer1.attentionInverse | layer2.interestScore | branch | intensity | 378 + |---|---|---|---|---| 379 + | A | 0.95 | 0.0 | "stub-only" → returns layer 1 | **0.95** | 380 + | B | 0.95 | 0.4 | "weighted" → applies design weights | 0.95·0.5 + 0.4·0.5·0.3 ≈ **0.535** | 381 + 382 + Talk B (the user *does* care about it per Layer 2) ranks below Talk A (the user doesn't), even though both have identical network coverage. The whole "missed by network *and* matches my interests" thesis inverts at the activation boundary. 383 + 384 + The `ActiveLayers` flag fixes this because *every* talk in the same deployment stage runs through the same formula. Within a stage, ordering is consistent; across stages, the rescaling preserves the "fully missed = 1.0" invariant. 385 + 386 + ### 8.2 When do the active flags flip? 387 + 388 + The `active` parameter is plumbed through `scoreTalk` and `rankTalks` with `DEFAULT_ACTIVE_LAYERS` (both false) so today's call sites need not change. The PR that ships the real Layer 2 implementation flips `layer2: true` either at the call site (e.g., the `useTalkScores` hook) or via a constant the hook reads. Same for Layer 3. The flags are deployment configuration, not per-request state. 389 + 390 + --- 391 + 392 + ## 9. Public API 393 + 394 + ```ts 395 + // src/lib/scoring/rank.ts 396 + import type { TalkEntry } from "@/lib/types"; 397 + import type { TalkMentions } from "@/lib/crawl/types"; 398 + import { 399 + type TalkScore, 400 + type TalkScoreState, 401 + type ScoringInputs, 402 + type ScoringWeights, 403 + DEFAULT_WEIGHTS, 404 + } from "./types"; 405 + import { computeLayer1 } from "./networkAttention"; 406 + import { computeInterestStub } from "./interestStub"; 407 + import { computeFriendStub } from "./friendStub"; 408 + import { 409 + type ActiveLayers, 410 + DEFAULT_ACTIVE_LAYERS, 411 + combineLayers, 412 + } from "./combine"; 413 + 414 + function unknownScore(rkey: string, followCount: number): TalkScore { 415 + return { 416 + rkey, 417 + intensity: 0, 418 + state: "unknown", 419 + layer1: { 420 + uniqueFollows: 0, 421 + totalFollows: followCount, 422 + reachRatio: 0, 423 + attentionInverse: 0, 424 + }, 425 + }; 426 + } 427 + 428 + /** 429 + * Score a single talk. Pass the full `mentions` map (or null if no crawl 430 + * has run yet) — the function looks up the talk's mention internally so 431 + * callers don't have to encode "do we have crawl data" as a separate flag. 432 + * 433 + * Returns `unknown` state when: 434 + * - mentions is null (crawl hasn't run) 435 + * - followCount is 0 (user has no follows; reach is undefined) 436 + * - mention is absent (talk is out of crawl scope, e.g. no eventUri) 437 + * 438 + * Otherwise, runs Layer 1 + the two stubs through `combineLayers` with the 439 + * given weights and active layer flags. 440 + */ 441 + export function scoreTalk( 442 + talk: TalkEntry, 443 + mentions: TalkMentions | null, 444 + followCount: number, 445 + weights: ScoringWeights = DEFAULT_WEIGHTS, 446 + active: ActiveLayers = DEFAULT_ACTIVE_LAYERS, 447 + ): TalkScore { 448 + if (mentions === null || followCount === 0) { 449 + return unknownScore(talk.rkey, followCount); 450 + } 451 + const mention = mentions[talk.rkey]; 452 + if (!mention) { 453 + // Talk is not in crawl scope (e.g. no eventUri so the crawler skipped it). 454 + return unknownScore(talk.rkey, followCount); 455 + } 456 + 457 + const layer1 = computeLayer1(mention, followCount); 458 + const layer2 = computeInterestStub(talk); 459 + const layer3 = computeFriendStub(talk); 460 + const intensity = combineLayers(layer1, layer2, layer3, weights, active); 461 + 462 + const state: TalkScoreState = 463 + layer1.uniqueFollows === 0 ? "missed" : "engaged"; 464 + 465 + return { rkey: talk.rkey, intensity, state, layer1 }; 466 + } 467 + 468 + const STATE_ORDER: Record<TalkScoreState, number> = { 469 + missed: 0, 470 + engaged: 1, 471 + unknown: 2, 472 + }; 473 + 474 + function compareTalkScores(a: TalkScore, b: TalkScore): number { 475 + // Primary: state group (missed first, then engaged, then unknown) 476 + const stateDelta = STATE_ORDER[a.state] - STATE_ORDER[b.state]; 477 + if (stateDelta !== 0) return stateDelta; 478 + // Secondary: intensity descending (highest glow first within each state) 479 + const intensityDelta = b.intensity - a.intensity; 480 + if (intensityDelta !== 0) return intensityDelta; 481 + // Tertiary: rkey ascending — deterministic tiebreak so the order is stable 482 + // across renders (matters for React reconciliation and predictable UX). 483 + return a.rkey.localeCompare(b.rkey); 484 + } 485 + 486 + export function rankTalks(inputs: ScoringInputs): TalkScore[] { 487 + const { 488 + talks, 489 + mentions, 490 + followCount, 491 + weights = DEFAULT_WEIGHTS, 492 + active = DEFAULT_ACTIVE_LAYERS, 493 + } = inputs; 494 + return talks 495 + .map((talk) => scoreTalk(talk, mentions, followCount, weights, active)) 496 + .sort(compareTalkScores); 497 + } 498 + ``` 499 + 500 + Note: `ScoringInputs` (in §4) gains an optional `active?: ActiveLayers` field. See the updated type sketch in §4. 501 + 502 + ### 9.1 Sort order rationale 503 + 504 + `missed` first encodes Understory's whole thesis: talks the user's network missed are the ones they came here to find. Within `missed`, higher intensity means a lower reach ratio — i.e., even fewer follows engaged — so those bubble to the top. `engaged` follows in descending intensity (least-engaged first, since high intensity within this group means closest-to-missed). `unknown` is last so it doesn't pollute the top of the list with talks the system can't actually rank. 505 + 506 + The deterministic `rkey` tiebreak ensures stable order across renders, which matters for React reconciliation and predictable UX when sliders move. 507 + 508 + --- 509 + 510 + ## 10. UI requirements (handed off to #20 / #12) 511 + 512 + This spec only ships the math; the slider UI lives in #20 and the feed page in #12. Those issues must honor the following requirements so the slider explanation isn't forgotten: 513 + 514 + - Both sliders render fully live and accept user input (no disabled state). 515 + - Slider state is passed into `rankTalks` so the API contract is exercised end-to-end, even though Layer 1 doesn't read it. 516 + - Each slider has a label or `ⓘ` affordance communicating its inactive state, e.g.: 517 + - **"Surprise Me ↔ For Me"**: "Coming soon — interest matching unlocks once talk embeddings are live." 518 + - **"Algorithm ↔ Friends"**: "Coming soon — friend recommendations unlock once friend rec records are published." 519 + - The exact wording is at the discretion of #20; this spec only requires the existence and intent of the affordance. 520 + 521 + The point is that the UI we ship now is the UI we ship later. No throwaway "disabled" state to design twice; the data sources flip on later and the existing UI just starts moving. 522 + 523 + --- 524 + 525 + ## 11. Testing 526 + 527 + The project does not currently have a test runner installed. Vitest will be added as part of this work because: 528 + 529 + - Pure scoring functions with clear inputs/outputs are exactly the kind of code that benefits most from unit tests 530 + - Setting up Vitest (~15 min of config) pays for itself the first time anyone touches the math 531 + - Future scoring layers (#21–24, #18) will land safer with a test foundation already in place 532 + 533 + ### 11.1 Vitest setup 534 + 535 + Add as devDependencies: 536 + 537 + - `vitest` — the test runner 538 + - `vite-tsconfig-paths` — so Vitest resolves the `@/*` alias from `tsconfig.json` without duplicating config 539 + 540 + Add to `package.json` scripts: 541 + 542 + ```json 543 + "test": "vitest run", 544 + "test:watch": "vitest" 545 + ``` 546 + 547 + Add `vitest.config.ts` at the repo root: 548 + 549 + ```ts 550 + import { defineConfig } from "vitest/config"; 551 + import tsconfigPaths from "vite-tsconfig-paths"; 552 + 553 + export default defineConfig({ 554 + plugins: [tsconfigPaths()], 555 + test: { 556 + environment: "node", 557 + }, 558 + }); 559 + ``` 560 + 561 + Tests live next to source files: `src/lib/scoring/*.test.ts`. 562 + 563 + ### 11.2 Test cases 564 + 565 + All tests are pure: no mocks, no fixtures from disk, no network. Inputs are constructed inline. Numeric expected values below are pre-computed against the formulas in §5 and §8 — use `.toBeCloseTo(value, 6)` for floating-point assertions. 566 + 567 + #### `computeLayer1` 568 + 569 + | Case | Inputs | Expected `attentionInverse` | 570 + |---|---|---| 571 + | Zero follows engaged | `mention.follows = []`, `followCount = 100` | `1.0` | 572 + | Half engaged | `mention.follows = [50 dids]`, `followCount = 100` | `0.5` | 573 + | Fully engaged | `mention.follows = [100 dids]`, `followCount = 100` | `0.0` | 574 + | Divide-by-zero | `mention.follows = [3 dids]`, `followCount = 0` | `1.0` (reachRatio is 0) | 575 + | Stale data overflow | `mention.follows = [110 dids]`, `followCount = 100` | `0.0` (reachRatio clamped to 1) | 576 + | Mention undefined | `mention = undefined`, `followCount = 100` | `1.0` (uniqueFollows defaults to 0) | 577 + 578 + #### `combineLayers` 579 + 580 + `weights = { surpriseSlider: 0.5, friendsSlider: 0.5 }` unless otherwise stated. Numbers chosen so the expected values are easy to verify by hand. 581 + 582 + | Case | active | layer1 | layer2.interestScore | layer3.friendBoost | weights | Expected intensity | 583 + |---|---|---|---|---|---|---| 584 + | Layer 1 only — fully missed | `{l2:false, l3:false}` | `0.95` | `0` | `0` | default | `0.95` | 585 + | Layer 1 only — partially engaged | `{l2:false, l3:false}` | `0.4` | `0` | `0` | default | `0.4` | 586 + | L2 active, fully missed + perfect interest | `{l2:true, l3:false}` | `1.0` | `1.0` | `0` | `surprise=0` | `(1.0·0.5 + 1.0·1·0.3) / 0.8 = 1.0` | 587 + | L2 active, no interest score | `{l2:true, l3:false}` | `1.0` | `0` | `0` | default | `(1.0·0.5 + 0·0.5·0.3) / 0.8 = 0.625` | 588 + | L3 active, friend boost only | `{l2:false, l3:true }` | `0.0` | `0` | `1.0` | `friends=1` | `(0·0.5 + 1·1·0.2) / 0.7 ≈ 0.2857` | 589 + | All three active, design-doc maximum | `{l2:true, l3:true }` | `1.0` | `1.0` | `1.0` | `surprise=0, friends=1` | `0.5 + 0.3 + 0.2 = 1.0` | 590 + | Slider drives raw above 1 → clamps | `{l2:true, l3:false}` | `1.0` | `1.0` | `0` | `surprise=-2` | raw = `(1·0.5 + 1·3·0.3)/0.8 = 1.75`, clamped to `1.0` | 591 + | NaN slider | `{l2:true, l3:false}` | `1.0` | `1.0` | `0` | `surprise=NaN` | `safe(NaN)=0` → equivalent to `surprise=0` case = `1.0` | 592 + 593 + **Regression test — the per-talk discontinuity bug.** This case exists specifically to lock in the correct behavior the renormalization fix introduced. If a future refactor reintroduces a per-talk `> 0` branch, this assertion will fail loudly: 594 + 595 + ``` 596 + active = { l2: true, l3: false }, weights = default 597 + 598 + Talk A: layer1.attentionInverse = 0.95, layer2.interestScore = 0.0 599 + Talk B: layer1.attentionInverse = 0.95, layer2.interestScore = 0.4 600 + 601 + intensityA = (0.95·0.5 + 0.0·0.5·0.3) / 0.8 = 0.59375 602 + intensityB = (0.95·0.5 + 0.4·0.5·0.3) / 0.8 = 0.66875 603 + 604 + Assert: intensityB > intensityA (talk the user actually cares about ranks higher) 605 + ``` 606 + 607 + #### `scoreTalk` state derivation 608 + 609 + | Case | Expected `state` | 610 + |---|---| 611 + | `mentions = null`, `followCount = 100` | `unknown` | 612 + | `mentions = {}`, `followCount = 0` | `unknown` | 613 + | `mentions = {}` (talk has no entry), `followCount = 100` | `unknown` | 614 + | `mentions = { rkey: { follows: [], ... } }`, `followCount = 100` | `missed` | 615 + | `mentions = { rkey: { follows: [3 dids], ... } }`, `followCount = 100` | `engaged` | 616 + 617 + #### `scoreTalk` defaults 618 + 619 + | Case | Behavior | 620 + |---|---| 621 + | Omit `weights` | Uses `DEFAULT_WEIGHTS` (surprise=0.5, friends=0.5) | 622 + | Omit `active` | Uses `DEFAULT_ACTIVE_LAYERS` (both false) | 623 + | Omit both | Equivalent to today's deployment: Layer 1 only | 624 + 625 + #### `rankTalks` sort order 626 + 627 + Construct fixtures like: 628 + 629 + ``` 630 + talks = [A, B, C, D, E] // each with distinct rkeys 631 + mentions = { 632 + A: { follows: [1 did], count: 1, posts: [], rsvps: [] }, // engaged, intensity = 0.99 633 + B: { follows: [], count: 0, posts: [], rsvps: [] }, // missed, intensity = 1.0 634 + C: { follows: [50 dids], count: 50, posts: [], rsvps: [] }, // engaged, intensity = 0.5 635 + // D and E have no mention entries → unknown 636 + } 637 + followCount = 100 638 + ``` 639 + 640 + Assert order: `[B (missed, 1.0), A (engaged, 0.99), C (engaged, 0.5), D (unknown), E (unknown)]`. 641 + 642 + **Deterministic tiebreak** (verified separately): 643 + 644 + ``` 645 + talks = [B1, B2] with rkeys "zzz" and "aaa" respectively 646 + both with follows = [], followCount = 100 // both missed, intensity 1.0 647 + 648 + Assert: result[0].rkey === "aaa" (rkey ascending) 649 + Assert: result[1].rkey === "zzz" 650 + ``` 651 + 652 + #### `rankTalks` empty inputs 653 + 654 + | Case | Expected | 655 + |---|---| 656 + | `talks = []` | `[]` | 657 + | `mentions = null`, `talks = [3 entries]` | All three `unknown`, sorted by rkey | 658 + | `followCount = 0`, `talks = [3 entries]` | All three `unknown`, sorted by rkey | 659 + 660 + --- 661 + 662 + ## 12. Edge cases 663 + 664 + - **Empty crawl** — followCount > 0 but no talks have any follows engaged. All talks classified `missed`, sorted by `rkey`. The user sees a fully-glowing feed, which is the correct outcome ("your whole network missed all of these"). 665 + - **Zero follows** — `followCount === 0`. All talks `unknown`. UI should show a banner via the future hook ("connect your account to crawl your network"); the scoring module itself is silent. 666 + - **Crawl partial failure** — `mentions` is non-null but some talks have no entry. Per the crawler implementation, every talk in scope is initialized with empty mention. Talks NOT in scope (no `eventUri`) are absent → fall through to `unknown`. Acceptable. 667 + - **Slider out of range** — caller passes `surpriseSlider: 1.5` or `-0.3`. Combine logic clamps the final intensity to `[0, 1]`. We do not range-check sliders themselves; that's the caller's responsibility. 668 + - **NaN inputs (sliders or stub scores)** — covered by `safe(n)` in `combineLayers` (§8). Any non-finite numeric input is coerced to 0 before arithmetic, preventing `NaN` from propagating into the sort key. This is a defense-in-depth guard for cases where uninitialized React state, JSON-parsed nulls, or future numeric inputs sneak past TypeScript types. Tests in §11.2 lock in the behavior. 669 + 670 + --- 671 + 672 + ## 13. Non-goals 673 + 674 + - **Computing user embeddings.** Layer 2 stub returns 0; the real implementation belongs to issues #23/#24. 675 + - **Reading friend recommendations.** Layer 3 stub returns 0; the real implementation belongs to #18. 676 + - **Slider UI components.** Belongs to #20. 677 + - **Feed page rendering.** Belongs to #12. 678 + - **A React hook wrapping `rankTalks`.** Belongs to #20 or #12, whichever lands first. 679 + - **Server-side scoring.** Out of scope. The design is client-side only because the user controls the weights live. 680 + - **Caching scoring results.** The functions are cheap and deterministic; React `useMemo` in the future hook is sufficient. 681 + 682 + --- 683 + 684 + ## 14. Out-of-scope follow-ups (do not include in this PR) 685 + 686 + - React hook (`useTalkScores`) — comes with #20 or #12. 687 + - The actual `/feed` route and its UI — comes with #12. 688 + - Slider components — comes with #20. 689 + - Layer 2 and 3 real implementations — come with #18, #21–24. 690 + - Wiring scoring into the existing `/talks` and `/talk/[rkey]` pages — separate UX work, possibly in #10 or a follow-up. 691 + 692 + --- 693 + 694 + ## 15. Acceptance criteria 695 + 696 + - [ ] `src/lib/scoring/` module exists with all files listed in §3.1 697 + - [ ] All public exports are typed and documented with JSDoc 698 + - [ ] `computeLayer1`, `combineLayers` (with `ActiveLayers` parameter and rescaling), `scoreTalk`, `rankTalks` implemented per this spec 699 + - [ ] `ActiveLayers` and `DEFAULT_ACTIVE_LAYERS` exported from `combine.ts`; re-exported from `types.ts` 700 + - [ ] Layer 2 and Layer 3 stubs return zero, with JSDoc pointing at the unblocking issues (#21–24 for L2, #18 for L3) 701 + - [ ] `safe()` NaN guard applied to all slider and stub-score inputs in `combineLayers` 702 + - [ ] `reachRatio` clamped via `Math.min(1, ...)` in `computeLayer1` 703 + - [ ] Vitest installed via `vitest` + `vite-tsconfig-paths`; `vitest.config.ts` matches §11.1 704 + - [ ] `npm test` runs the suite and exits 0 705 + - [ ] All test cases from §11.2 pass with the precise numeric assertions specified 706 + - [ ] The §11.2 regression test (`intensityB > intensityA` for the per-talk discontinuity case) passes 707 + - [ ] `npx tsc --noEmit` clean 708 + - [ ] `npx eslint src/` clean 709 + - [ ] `npm run build` succeeds; no new bundle warnings; `/api/crawl` still listed as a dynamic route (we shouldn't have touched it, but verify)