···11+# Layer 1 Scoring Algorithm Spec
22+33+**Date:** 2026-04-09
44+**Issue:** Chainlink #19
55+**Status:** Approved (pending review)
66+**Depends on:** PR #5 (social graph crawler — merged)
77+**Unblocks:** #10 (network attention display), #12 (personalized feed page), #20 (slider UI)
88+99+---
1010+1111+## 1. Goal
1212+1313+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.
1414+1515+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."
1616+1717+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.
1818+1919+---
2020+2121+## 2. Background
2222+2323+The crawler in `src/lib/crawl/` returns a `TalkMentions` map per authenticated user, keyed by talk rkey:
2424+2525+```ts
2626+interface TalkMention {
2727+ count: number; // unique follows engaged (RSVPs ∪ posters)
2828+ follows: string[]; // DIDs of those follows
2929+ posts: string[]; // URIs of every matching post (a single follow may post multiple)
3030+ rsvps: string[]; // DIDs of follows who RSVPed (subset of follows)
3131+}
3232+```
3333+3434+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:
3535+3636+```
3737+final_score = (attention_inverse * 0.5)
3838+ + (effective_interest * 0.3)
3939+ + (friend_boost * friends_slider * 0.2)
4040+```
4141+4242+Layers 2 and 3 require data from issues that don't exist yet:
4343+4444+- **Layer 2** needs `topicIndex` records: blocked on #21 (transcript embeddings), #22 (publish topicIndex), #23 (user interest profiling), #24 (cosine similarity matching).
4545+- **Layer 3** needs friend recommendation reading: blocked on #18 (friend rec reader) and #5 (publish lexicons).
4646+4747+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.
4848+4949+---
5050+5151+## 3. Architecture
5252+5353+### 3.1 File layout
5454+5555+```
5656+src/lib/scoring/
5757+├── types.ts Shared types (TalkScore, ScoringWeights, etc.)
5858+├── networkAttention.ts Layer 1 — pure functions
5959+├── interestStub.ts Layer 2 stub (returns 0)
6060+├── friendStub.ts Layer 3 stub (returns 0)
6161+├── combine.ts Weighted combine + renormalization
6262+├── rank.ts Public API: scoreTalk() and rankTalks()
6363+└── index.ts Re-exports
6464+```
6565+6666+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.
6767+6868+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.
6969+7070+### 3.2 Data flow
7171+7272+```
7373+Server-side Client-side
7474+───────────── ─────────────
7575+data/talks.json ──┐
7676+ ├──► /api/crawl ──► TalkMentions ──┐
7777+session.agent ──┘ │
7878+ ▼
7979+ talks (fetched alongside) + mentions + weights
8080+ │
8181+ ▼
8282+ rankTalks()
8383+ │
8484+ ▼
8585+ TalkScore[] (sorted)
8686+ │
8787+ ▼
8888+ Feed page / glow / sliders
8989+```
9090+9191+---
9292+9393+## 4. Types
9494+9595+```ts
9696+// src/lib/scoring/types.ts
9797+import type { TalkEntry } from "@/lib/types";
9898+import type { TalkMention, TalkMentions } from "@/lib/crawl/types";
9999+// `ActiveLayers` is declared in combine.ts (see §8) and re-exported here so
100100+// call sites don't have to know its origin module.
101101+export type { ActiveLayers } from "./combine";
102102+103103+export type TalkScoreState = "engaged" | "missed" | "unknown";
104104+105105+export interface Layer1Result {
106106+ uniqueFollows: number;
107107+ totalFollows: number;
108108+ reachRatio: number; // uniqueFollows / totalFollows, clamped to [0, 1]
109109+ attentionInverse: number; // 1 - reachRatio, clamped to [0, 1]
110110+}
111111+112112+export interface TalkScore {
113113+ rkey: string;
114114+ intensity: number; // 0–1; UI uses for glow + ordering
115115+ state: TalkScoreState;
116116+ layer1: Layer1Result;
117117+ layer2?: { interestScore: number };
118118+ layer3?: { friendBoost: number; recommenders: string[] };
119119+}
120120+121121+export interface ScoringWeights {
122122+ surpriseSlider: number; // 0–1; controls Layer 2 contribution (high = serendipity)
123123+ friendsSlider: number; // 0–1; controls Layer 3 contribution (high = friends override)
124124+}
125125+126126+export interface ScoringInputs {
127127+ talks: TalkEntry[];
128128+ mentions: TalkMentions | null; // null = crawl not yet completed
129129+ followCount: number; // from CrawlResult.followCount
130130+ weights?: ScoringWeights;
131131+ active?: ActiveLayers; // omitted = layer 1 only (today's deployment)
132132+}
133133+134134+export const DEFAULT_WEIGHTS: ScoringWeights = {
135135+ surpriseSlider: 0.5,
136136+ friendsSlider: 0.5,
137137+};
138138+```
139139+140140+`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.
141141+142142+---
143143+144144+## 5. Layer 1 — Network Attention (inverted)
145145+146146+### 5.1 Raw signal
147147+148148+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.
149149+150150+> **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.
151151+152152+The crawler already de-duplicates by follow in `TalkMention.follows`, so the raw signal is:
153153+154154+```ts
155155+const uniqueFollows = mention.follows.length;
156156+```
157157+158158+### 5.2 Normalization
159159+160160+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:
161161+162162+```ts
163163+const reachRatio = followCount > 0 ? uniqueFollows / followCount : 0;
164164+const attentionInverse = 1 - reachRatio;
165165+```
166166+167167+> "0.95" literally means "95% of the people you follow didn't talk about this."
168168+169169+### 5.3 The function
170170+171171+```ts
172172+// src/lib/scoring/networkAttention.ts
173173+import type { TalkMention } from "@/lib/crawl/types";
174174+import type { Layer1Result } from "./types";
175175+176176+export function computeLayer1(
177177+ mention: TalkMention | undefined,
178178+ followCount: number,
179179+): Layer1Result {
180180+ const uniqueFollows = mention?.follows.length ?? 0;
181181+ // Clamp to [0, 1]: uniqueFollows can theoretically exceed followCount if a
182182+ // CrawlResult is reused after the user's follow list changes (someone
183183+ // unfollowed but still appears in the cached mentions). The clamp prevents
184184+ // attentionInverse from going negative in that edge case.
185185+ const reachRatio =
186186+ followCount > 0 ? Math.min(1, uniqueFollows / followCount) : 0;
187187+ const attentionInverse = 1 - reachRatio;
188188+ return {
189189+ uniqueFollows,
190190+ totalFollows: followCount,
191191+ reachRatio,
192192+ attentionInverse,
193193+ };
194194+}
195195+```
196196+197197+> **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`.
198198+199199+---
200200+201201+## 6. State derivation
202202+203203+The three-state classifier separates "missed" from "unknown" so the UI can render them distinctly. The mapping is **evaluated in order; first match wins**:
204204+205205+| Order | Condition | State | Notes |
206206+|-------|--------------------------|-----------|--------------------------------------------|
207207+| 1 | `mentions` is `null` | `unknown` | Crawl has not yet run |
208208+| 2 | `followCount === 0` | `unknown` | User has zero follows; reach is undefined |
209209+| 3 | `mention === undefined` | `unknown` | Talk not in crawl scope (e.g. no eventUri) |
210210+| 4 | `uniqueFollows === 0` | `missed` | Network missed it — full glow |
211211+| 5 | `uniqueFollows > 0` | `engaged` | Network engaged with it — fade |
212212+213213+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.
214214+215215+---
216216+217217+## 7. Layer 2 + Layer 3 stubs
218218+219219+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.
220220+221221+```ts
222222+// src/lib/scoring/interestStub.ts
223223+import type { TalkEntry } from "@/lib/types";
224224+225225+export interface InterestStubResult {
226226+ interestScore: number;
227227+}
228228+229229+/**
230230+ * Layer 2 stub. Returns 0 until issues #21–24 land:
231231+ * - #21: generate transcript embeddings
232232+ * - #22: publish topicIndex records
233233+ * - #23: user interest profiling
234234+ * - #24: cosine similarity matching
235235+ *
236236+ * When implemented, this should return cosine similarity in [0, 1] between
237237+ * the user's recent-post embedding and the talk's topicIndex embedding.
238238+ */
239239+export function computeInterestStub(_talk: TalkEntry): InterestStubResult {
240240+ return { interestScore: 0 };
241241+}
242242+```
243243+244244+```ts
245245+// src/lib/scoring/friendStub.ts
246246+import type { TalkEntry } from "@/lib/types";
247247+248248+export interface FriendStubResult {
249249+ friendBoost: number;
250250+ recommenders: string[];
251251+}
252252+253253+/**
254254+ * Layer 3 stub. Returns 0 until issue #18 (friend recommendation reader)
255255+ * and #5 (publish lexicons) land. When implemented, this should return the
256256+ * normalized sum of friend recommendation intensities (1–3 each, capped at
257257+ * a sensible max) and the DIDs of the recommending follows.
258258+ */
259259+export function computeFriendStub(_talk: TalkEntry): FriendStubResult {
260260+ return { friendBoost: 0, recommenders: [] };
261261+}
262262+```
263263+264264+The leading underscore on `_talk` is the project convention for unused parameters; ESLint allows it.
265265+266266+---
267267+268268+## 8. Combine + intensity
269269+270270+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:
271271+272272+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.
273273+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.
274274+275275+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.
276276+277277+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.
278278+279279+```ts
280280+// src/lib/scoring/combine.ts
281281+import type { Layer1Result, ScoringWeights } from "./types";
282282+import type { InterestStubResult } from "./interestStub";
283283+import type { FriendStubResult } from "./friendStub";
284284+285285+function clamp(n: number, min: number, max: number): number {
286286+ return Math.max(min, Math.min(max, n));
287287+}
288288+289289+/**
290290+ * Coerce non-finite numeric inputs (NaN, ±Infinity) to 0. Defense in depth
291291+ * against uninitialized React state or JSON-parsed nulls slipping past
292292+ * TypeScript types and propagating into the sort key.
293293+ */
294294+function safe(n: number): number {
295295+ return Number.isFinite(n) ? n : 0;
296296+}
297297+298298+/**
299299+ * Which scoring layers have a live data source. Layer 1 is always live;
300300+ * Layers 2 and 3 flip to true when their respective implementations land
301301+ * (#21–24 for Layer 2, #18 for Layer 3). Today both are false.
302302+ */
303303+export interface ActiveLayers {
304304+ layer2: boolean;
305305+ layer3: boolean;
306306+}
307307+308308+export const DEFAULT_ACTIVE_LAYERS: ActiveLayers = {
309309+ layer2: false,
310310+ layer3: false,
311311+};
312312+313313+/**
314314+ * Design-doc weights from `docs/understory-design.md` §"The Scoring Algorithm".
315315+ * These values are the canonical contribution shares when all three layers
316316+ * are live; they are rescaled in `combineLayers` for partial deployments.
317317+ */
318318+const DESIGN_WEIGHTS = {
319319+ layer1: 0.5,
320320+ layer2: 0.3,
321321+ layer3: 0.2,
322322+} as const;
323323+324324+/**
325325+ * Combine the three layers into a 0–1 intensity score.
326326+ *
327327+ * Per the design doc:
328328+ * final = (attention_inverse * 0.5)
329329+ * + (interest_score * (1 - surprise_slider) * 0.3)
330330+ * + (friend_boost * friends_slider * 0.2)
331331+ *
332332+ * Weights are rescaled over the active layer set so the maximum achievable
333333+ * intensity is always 1.0:
334334+ * - Today (layer 1 only): w1 = 0.5/0.5 = 1.0 → intensity == attentionInverse
335335+ * - Layer 1 + 2: w1 = 0.5/0.8, w2 = 0.3/0.8 (sum = 1.0)
336336+ * - Layer 1 + 3: w1 = 0.5/0.7, w3 = 0.2/0.7 (sum = 1.0)
337337+ * - All three: w1 = 0.5, w2 = 0.3, w3 = 0.2 (already sum to 1.0)
338338+ *
339339+ * Stubs are still consulted when their layer is inactive, but their values
340340+ * are multiplied by a zero weight — so swapping a stub for a real
341341+ * implementation is purely a data change once the active flag flips.
342342+ */
343343+export function combineLayers(
344344+ layer1: Layer1Result,
345345+ layer2: InterestStubResult,
346346+ layer3: FriendStubResult,
347347+ weights: ScoringWeights,
348348+ active: ActiveLayers = DEFAULT_ACTIVE_LAYERS,
349349+): number {
350350+ const w1 = DESIGN_WEIGHTS.layer1;
351351+ const w2 = active.layer2 ? DESIGN_WEIGHTS.layer2 : 0;
352352+ const w3 = active.layer3 ? DESIGN_WEIGHTS.layer3 : 0;
353353+ const total = w1 + w2 + w3; // always > 0 because layer 1 is always live
354354+355355+ // Defensive: coerce non-finite slider/score values to 0 so a stray NaN
356356+ // can't propagate into the sort key. Caller is still responsible for
357357+ // sane slider input; this is a last-resort guard.
358358+ const l1 = safe(layer1.attentionInverse);
359359+ const l2 = active.layer2 ? safe(layer2.interestScore) : 0;
360360+ const l3 = active.layer3 ? safe(layer3.friendBoost) : 0;
361361+ const surprise = safe(weights.surpriseSlider);
362362+ const friends = safe(weights.friendsSlider);
363363+364364+ const raw =
365365+ l1 * w1 +
366366+ l2 * (1 - surprise) * w2 +
367367+ l3 * friends * w3;
368368+369369+ return clamp(raw / total, 0, 1);
370370+}
371371+```
372372+373373+### 8.1 Why per-talk discontinuities are catastrophic
374374+375375+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:
376376+377377+| Talk | layer1.attentionInverse | layer2.interestScore | branch | intensity |
378378+|---|---|---|---|---|
379379+| A | 0.95 | 0.0 | "stub-only" → returns layer 1 | **0.95** |
380380+| B | 0.95 | 0.4 | "weighted" → applies design weights | 0.95·0.5 + 0.4·0.5·0.3 ≈ **0.535** |
381381+382382+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.
383383+384384+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.
385385+386386+### 8.2 When do the active flags flip?
387387+388388+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.
389389+390390+---
391391+392392+## 9. Public API
393393+394394+```ts
395395+// src/lib/scoring/rank.ts
396396+import type { TalkEntry } from "@/lib/types";
397397+import type { TalkMentions } from "@/lib/crawl/types";
398398+import {
399399+ type TalkScore,
400400+ type TalkScoreState,
401401+ type ScoringInputs,
402402+ type ScoringWeights,
403403+ DEFAULT_WEIGHTS,
404404+} from "./types";
405405+import { computeLayer1 } from "./networkAttention";
406406+import { computeInterestStub } from "./interestStub";
407407+import { computeFriendStub } from "./friendStub";
408408+import {
409409+ type ActiveLayers,
410410+ DEFAULT_ACTIVE_LAYERS,
411411+ combineLayers,
412412+} from "./combine";
413413+414414+function unknownScore(rkey: string, followCount: number): TalkScore {
415415+ return {
416416+ rkey,
417417+ intensity: 0,
418418+ state: "unknown",
419419+ layer1: {
420420+ uniqueFollows: 0,
421421+ totalFollows: followCount,
422422+ reachRatio: 0,
423423+ attentionInverse: 0,
424424+ },
425425+ };
426426+}
427427+428428+/**
429429+ * Score a single talk. Pass the full `mentions` map (or null if no crawl
430430+ * has run yet) — the function looks up the talk's mention internally so
431431+ * callers don't have to encode "do we have crawl data" as a separate flag.
432432+ *
433433+ * Returns `unknown` state when:
434434+ * - mentions is null (crawl hasn't run)
435435+ * - followCount is 0 (user has no follows; reach is undefined)
436436+ * - mention is absent (talk is out of crawl scope, e.g. no eventUri)
437437+ *
438438+ * Otherwise, runs Layer 1 + the two stubs through `combineLayers` with the
439439+ * given weights and active layer flags.
440440+ */
441441+export function scoreTalk(
442442+ talk: TalkEntry,
443443+ mentions: TalkMentions | null,
444444+ followCount: number,
445445+ weights: ScoringWeights = DEFAULT_WEIGHTS,
446446+ active: ActiveLayers = DEFAULT_ACTIVE_LAYERS,
447447+): TalkScore {
448448+ if (mentions === null || followCount === 0) {
449449+ return unknownScore(talk.rkey, followCount);
450450+ }
451451+ const mention = mentions[talk.rkey];
452452+ if (!mention) {
453453+ // Talk is not in crawl scope (e.g. no eventUri so the crawler skipped it).
454454+ return unknownScore(talk.rkey, followCount);
455455+ }
456456+457457+ const layer1 = computeLayer1(mention, followCount);
458458+ const layer2 = computeInterestStub(talk);
459459+ const layer3 = computeFriendStub(talk);
460460+ const intensity = combineLayers(layer1, layer2, layer3, weights, active);
461461+462462+ const state: TalkScoreState =
463463+ layer1.uniqueFollows === 0 ? "missed" : "engaged";
464464+465465+ return { rkey: talk.rkey, intensity, state, layer1 };
466466+}
467467+468468+const STATE_ORDER: Record<TalkScoreState, number> = {
469469+ missed: 0,
470470+ engaged: 1,
471471+ unknown: 2,
472472+};
473473+474474+function compareTalkScores(a: TalkScore, b: TalkScore): number {
475475+ // Primary: state group (missed first, then engaged, then unknown)
476476+ const stateDelta = STATE_ORDER[a.state] - STATE_ORDER[b.state];
477477+ if (stateDelta !== 0) return stateDelta;
478478+ // Secondary: intensity descending (highest glow first within each state)
479479+ const intensityDelta = b.intensity - a.intensity;
480480+ if (intensityDelta !== 0) return intensityDelta;
481481+ // Tertiary: rkey ascending — deterministic tiebreak so the order is stable
482482+ // across renders (matters for React reconciliation and predictable UX).
483483+ return a.rkey.localeCompare(b.rkey);
484484+}
485485+486486+export function rankTalks(inputs: ScoringInputs): TalkScore[] {
487487+ const {
488488+ talks,
489489+ mentions,
490490+ followCount,
491491+ weights = DEFAULT_WEIGHTS,
492492+ active = DEFAULT_ACTIVE_LAYERS,
493493+ } = inputs;
494494+ return talks
495495+ .map((talk) => scoreTalk(talk, mentions, followCount, weights, active))
496496+ .sort(compareTalkScores);
497497+}
498498+```
499499+500500+Note: `ScoringInputs` (in §4) gains an optional `active?: ActiveLayers` field. See the updated type sketch in §4.
501501+502502+### 9.1 Sort order rationale
503503+504504+`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.
505505+506506+The deterministic `rkey` tiebreak ensures stable order across renders, which matters for React reconciliation and predictable UX when sliders move.
507507+508508+---
509509+510510+## 10. UI requirements (handed off to #20 / #12)
511511+512512+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:
513513+514514+- Both sliders render fully live and accept user input (no disabled state).
515515+- Slider state is passed into `rankTalks` so the API contract is exercised end-to-end, even though Layer 1 doesn't read it.
516516+- Each slider has a label or `ⓘ` affordance communicating its inactive state, e.g.:
517517+ - **"Surprise Me ↔ For Me"**: "Coming soon — interest matching unlocks once talk embeddings are live."
518518+ - **"Algorithm ↔ Friends"**: "Coming soon — friend recommendations unlock once friend rec records are published."
519519+- The exact wording is at the discretion of #20; this spec only requires the existence and intent of the affordance.
520520+521521+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.
522522+523523+---
524524+525525+## 11. Testing
526526+527527+The project does not currently have a test runner installed. Vitest will be added as part of this work because:
528528+529529+- Pure scoring functions with clear inputs/outputs are exactly the kind of code that benefits most from unit tests
530530+- Setting up Vitest (~15 min of config) pays for itself the first time anyone touches the math
531531+- Future scoring layers (#21–24, #18) will land safer with a test foundation already in place
532532+533533+### 11.1 Vitest setup
534534+535535+Add as devDependencies:
536536+537537+- `vitest` — the test runner
538538+- `vite-tsconfig-paths` — so Vitest resolves the `@/*` alias from `tsconfig.json` without duplicating config
539539+540540+Add to `package.json` scripts:
541541+542542+```json
543543+"test": "vitest run",
544544+"test:watch": "vitest"
545545+```
546546+547547+Add `vitest.config.ts` at the repo root:
548548+549549+```ts
550550+import { defineConfig } from "vitest/config";
551551+import tsconfigPaths from "vite-tsconfig-paths";
552552+553553+export default defineConfig({
554554+ plugins: [tsconfigPaths()],
555555+ test: {
556556+ environment: "node",
557557+ },
558558+});
559559+```
560560+561561+Tests live next to source files: `src/lib/scoring/*.test.ts`.
562562+563563+### 11.2 Test cases
564564+565565+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.
566566+567567+#### `computeLayer1`
568568+569569+| Case | Inputs | Expected `attentionInverse` |
570570+|---|---|---|
571571+| Zero follows engaged | `mention.follows = []`, `followCount = 100` | `1.0` |
572572+| Half engaged | `mention.follows = [50 dids]`, `followCount = 100` | `0.5` |
573573+| Fully engaged | `mention.follows = [100 dids]`, `followCount = 100` | `0.0` |
574574+| Divide-by-zero | `mention.follows = [3 dids]`, `followCount = 0` | `1.0` (reachRatio is 0) |
575575+| Stale data overflow | `mention.follows = [110 dids]`, `followCount = 100` | `0.0` (reachRatio clamped to 1) |
576576+| Mention undefined | `mention = undefined`, `followCount = 100` | `1.0` (uniqueFollows defaults to 0) |
577577+578578+#### `combineLayers`
579579+580580+`weights = { surpriseSlider: 0.5, friendsSlider: 0.5 }` unless otherwise stated. Numbers chosen so the expected values are easy to verify by hand.
581581+582582+| Case | active | layer1 | layer2.interestScore | layer3.friendBoost | weights | Expected intensity |
583583+|---|---|---|---|---|---|---|
584584+| Layer 1 only — fully missed | `{l2:false, l3:false}` | `0.95` | `0` | `0` | default | `0.95` |
585585+| Layer 1 only — partially engaged | `{l2:false, l3:false}` | `0.4` | `0` | `0` | default | `0.4` |
586586+| 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` |
587587+| 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` |
588588+| 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` |
589589+| 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` |
590590+| 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` |
591591+| NaN slider | `{l2:true, l3:false}` | `1.0` | `1.0` | `0` | `surprise=NaN` | `safe(NaN)=0` → equivalent to `surprise=0` case = `1.0` |
592592+593593+**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:
594594+595595+```
596596+active = { l2: true, l3: false }, weights = default
597597+598598+Talk A: layer1.attentionInverse = 0.95, layer2.interestScore = 0.0
599599+Talk B: layer1.attentionInverse = 0.95, layer2.interestScore = 0.4
600600+601601+intensityA = (0.95·0.5 + 0.0·0.5·0.3) / 0.8 = 0.59375
602602+intensityB = (0.95·0.5 + 0.4·0.5·0.3) / 0.8 = 0.66875
603603+604604+Assert: intensityB > intensityA (talk the user actually cares about ranks higher)
605605+```
606606+607607+#### `scoreTalk` state derivation
608608+609609+| Case | Expected `state` |
610610+|---|---|
611611+| `mentions = null`, `followCount = 100` | `unknown` |
612612+| `mentions = {}`, `followCount = 0` | `unknown` |
613613+| `mentions = {}` (talk has no entry), `followCount = 100` | `unknown` |
614614+| `mentions = { rkey: { follows: [], ... } }`, `followCount = 100` | `missed` |
615615+| `mentions = { rkey: { follows: [3 dids], ... } }`, `followCount = 100` | `engaged` |
616616+617617+#### `scoreTalk` defaults
618618+619619+| Case | Behavior |
620620+|---|---|
621621+| Omit `weights` | Uses `DEFAULT_WEIGHTS` (surprise=0.5, friends=0.5) |
622622+| Omit `active` | Uses `DEFAULT_ACTIVE_LAYERS` (both false) |
623623+| Omit both | Equivalent to today's deployment: Layer 1 only |
624624+625625+#### `rankTalks` sort order
626626+627627+Construct fixtures like:
628628+629629+```
630630+talks = [A, B, C, D, E] // each with distinct rkeys
631631+mentions = {
632632+ A: { follows: [1 did], count: 1, posts: [], rsvps: [] }, // engaged, intensity = 0.99
633633+ B: { follows: [], count: 0, posts: [], rsvps: [] }, // missed, intensity = 1.0
634634+ C: { follows: [50 dids], count: 50, posts: [], rsvps: [] }, // engaged, intensity = 0.5
635635+ // D and E have no mention entries → unknown
636636+}
637637+followCount = 100
638638+```
639639+640640+Assert order: `[B (missed, 1.0), A (engaged, 0.99), C (engaged, 0.5), D (unknown), E (unknown)]`.
641641+642642+**Deterministic tiebreak** (verified separately):
643643+644644+```
645645+talks = [B1, B2] with rkeys "zzz" and "aaa" respectively
646646+both with follows = [], followCount = 100 // both missed, intensity 1.0
647647+648648+Assert: result[0].rkey === "aaa" (rkey ascending)
649649+Assert: result[1].rkey === "zzz"
650650+```
651651+652652+#### `rankTalks` empty inputs
653653+654654+| Case | Expected |
655655+|---|---|
656656+| `talks = []` | `[]` |
657657+| `mentions = null`, `talks = [3 entries]` | All three `unknown`, sorted by rkey |
658658+| `followCount = 0`, `talks = [3 entries]` | All three `unknown`, sorted by rkey |
659659+660660+---
661661+662662+## 12. Edge cases
663663+664664+- **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").
665665+- **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.
666666+- **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.
667667+- **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.
668668+- **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.
669669+670670+---
671671+672672+## 13. Non-goals
673673+674674+- **Computing user embeddings.** Layer 2 stub returns 0; the real implementation belongs to issues #23/#24.
675675+- **Reading friend recommendations.** Layer 3 stub returns 0; the real implementation belongs to #18.
676676+- **Slider UI components.** Belongs to #20.
677677+- **Feed page rendering.** Belongs to #12.
678678+- **A React hook wrapping `rankTalks`.** Belongs to #20 or #12, whichever lands first.
679679+- **Server-side scoring.** Out of scope. The design is client-side only because the user controls the weights live.
680680+- **Caching scoring results.** The functions are cheap and deterministic; React `useMemo` in the future hook is sufficient.
681681+682682+---
683683+684684+## 14. Out-of-scope follow-ups (do not include in this PR)
685685+686686+- React hook (`useTalkScores`) — comes with #20 or #12.
687687+- The actual `/feed` route and its UI — comes with #12.
688688+- Slider components — comes with #20.
689689+- Layer 2 and 3 real implementations — come with #18, #21–24.
690690+- Wiring scoring into the existing `/talks` and `/talk/[rkey]` pages — separate UX work, possibly in #10 or a follow-up.
691691+692692+---
693693+694694+## 15. Acceptance criteria
695695+696696+- [ ] `src/lib/scoring/` module exists with all files listed in §3.1
697697+- [ ] All public exports are typed and documented with JSDoc
698698+- [ ] `computeLayer1`, `combineLayers` (with `ActiveLayers` parameter and rescaling), `scoreTalk`, `rankTalks` implemented per this spec
699699+- [ ] `ActiveLayers` and `DEFAULT_ACTIVE_LAYERS` exported from `combine.ts`; re-exported from `types.ts`
700700+- [ ] Layer 2 and Layer 3 stubs return zero, with JSDoc pointing at the unblocking issues (#21–24 for L2, #18 for L3)
701701+- [ ] `safe()` NaN guard applied to all slider and stub-score inputs in `combineLayers`
702702+- [ ] `reachRatio` clamped via `Math.min(1, ...)` in `computeLayer1`
703703+- [ ] Vitest installed via `vitest` + `vite-tsconfig-paths`; `vitest.config.ts` matches §11.1
704704+- [ ] `npm test` runs the suite and exits 0
705705+- [ ] All test cases from §11.2 pass with the precise numeric assertions specified
706706+- [ ] The §11.2 regression test (`intensityB > intensityA` for the per-talk discontinuity case) passes
707707+- [ ] `npx tsc --noEmit` clean
708708+- [ ] `npx eslint src/` clean
709709+- [ ] `npm run build` succeeds; no new bundle warnings; `/api/crawl` still listed as a dynamic route (we shouldn't have touched it, but verify)