···11+import type { Layer1Result, ScoringWeights } from "./types";
22+import type { InterestStubResult } from "./interestStub";
33+import type { FriendStubResult } from "./friendStub";
44+55+function clamp(n: number, min: number, max: number): number {
66+ return Math.max(min, Math.min(max, n));
77+}
88+99+/**
1010+ * Coerce non-finite numeric inputs (NaN, ±Infinity) to 0. Defense in depth
1111+ * against uninitialized React state or JSON-parsed nulls slipping past
1212+ * TypeScript types and propagating into the sort key.
1313+ */
1414+function safe(n: number): number {
1515+ return Number.isFinite(n) ? n : 0;
1616+}
1717+1818+/**
1919+ * Which scoring layers have a live data source. Layer 1 is always live;
2020+ * Layers 2 and 3 flip to true when their respective implementations land
2121+ * (#21–24 for Layer 2, #18 for Layer 3). Today both are false.
2222+ */
2323+export interface ActiveLayers {
2424+ readonly layer2: boolean;
2525+ readonly layer3: boolean;
2626+}
2727+2828+// Frozen so the exported sentinel can't be mutated by accident — it's used
2929+// as a default parameter value in combineLayers/scoreTalk/rankTalks, so any
3030+// mutation would corrupt every subsequent call that takes the default.
3131+export const DEFAULT_ACTIVE_LAYERS: Readonly<ActiveLayers> = Object.freeze({
3232+ layer2: false,
3333+ layer3: false,
3434+});
3535+3636+/**
3737+ * Design-doc weights from `docs/understory-design.md` §"The Scoring Algorithm".
3838+ * These values are the canonical contribution shares when all three layers
3939+ * are live; they are rescaled in `combineLayers` for partial deployments.
4040+ */
4141+const DESIGN_WEIGHTS = {
4242+ layer1: 0.5,
4343+ layer2: 0.3,
4444+ layer3: 0.2,
4545+} as const;
4646+4747+/**
4848+ * Combine the three layers into a 0–1 intensity score.
4949+ *
5050+ * Per the design doc:
5151+ * final = (attention_inverse * 0.5)
5252+ * + (interest_score * (1 - surprise_slider) * 0.3)
5353+ * + (friend_boost * friends_slider * 0.2)
5454+ *
5555+ * Weights are rescaled over the active layer set so the maximum achievable
5656+ * intensity is always 1.0:
5757+ * - Today (layer 1 only): w1 = 0.5/0.5 = 1.0 → intensity == attentionInverse
5858+ * - Layer 1 + 2: w1 = 0.5/0.8, w2 = 0.3/0.8 (sum = 1.0)
5959+ * - Layer 1 + 3: w1 = 0.5/0.7, w3 = 0.2/0.7 (sum = 1.0)
6060+ * - All three: w1 = 0.5, w2 = 0.3, w3 = 0.2 (already sum to 1.0)
6161+ *
6262+ * Stubs are still consulted when their layer is inactive, but their values
6363+ * are multiplied by a zero weight — so swapping a stub for a real
6464+ * implementation is purely a data change once the active flag flips.
6565+ */
6666+export function combineLayers(
6767+ layer1: Layer1Result,
6868+ layer2: InterestStubResult,
6969+ layer3: FriendStubResult,
7070+ weights: ScoringWeights,
7171+ active: ActiveLayers = DEFAULT_ACTIVE_LAYERS,
7272+): number {
7373+ const w1 = DESIGN_WEIGHTS.layer1;
7474+ const w2 = active.layer2 ? DESIGN_WEIGHTS.layer2 : 0;
7575+ const w3 = active.layer3 ? DESIGN_WEIGHTS.layer3 : 0;
7676+ const total = w1 + w2 + w3; // always > 0 because layer 1 is always live
7777+7878+ const l1 = safe(layer1.attentionInverse);
7979+ const l2 = active.layer2 ? safe(layer2.interestScore) : 0;
8080+ const l3 = active.layer3 ? safe(layer3.friendBoost) : 0;
8181+ const surprise = safe(weights.surpriseSlider);
8282+ const friends = safe(weights.friendsSlider);
8383+8484+ const raw =
8585+ l1 * w1 +
8686+ l2 * (1 - surprise) * w2 +
8787+ l3 * friends * w3;
8888+8989+ return clamp(raw / total, 0, 1);
9090+}
+20
src/lib/scoring/friendStub.ts
···11+import type { TalkEntry } from "@/lib/types";
22+33+export interface FriendStubResult {
44+ friendBoost: number;
55+ recommenders: string[];
66+}
77+88+/**
99+ * Layer 3 stub. Returns 0 until the following issues land:
1010+ * - #18: friend recommendation reader
1111+ * - #5: publish lexicons
1212+ *
1313+ * When implemented, this should return the normalized sum of friend
1414+ * recommendation intensities (1–3 each, capped at a sensible max) and the
1515+ * DIDs of the recommending follows.
1616+ */
1717+export function computeFriendStub(talk: TalkEntry): FriendStubResult {
1818+ void talk;
1919+ return { friendBoost: 0, recommenders: [] };
2020+}
+21
src/lib/scoring/index.ts
···11+// Public surface for the scoring module. Consumers should import from
22+// `@/lib/scoring`, not from individual files, so refactors inside the module
33+// don't break call sites.
44+55+export type {
66+ TalkScore,
77+ TalkScoreState,
88+ Layer1Result,
99+ ScoringWeights,
1010+ ScoringInputs,
1111+ TalkMention,
1212+ TalkMentions,
1313+} from "./types";
1414+1515+export { DEFAULT_WEIGHTS } from "./types";
1616+1717+export type { ActiveLayers } from "./combine";
1818+export { DEFAULT_ACTIVE_LAYERS, combineLayers } from "./combine";
1919+2020+export { computeLayer1 } from "./networkAttention";
2121+export { scoreTalk, rankTalks } from "./rank";
+24
src/lib/scoring/interestStub.ts
···11+import type { TalkEntry } from "@/lib/types";
22+33+export interface InterestStubResult {
44+ interestScore: number;
55+}
66+77+/**
88+ * Layer 2 stub. Returns 0 until the following issues land:
99+ * - #21: generate transcript embeddings
1010+ * - #22: publish topicIndex records
1111+ * - #23: user interest profiling
1212+ * - #24: cosine similarity matching
1313+ *
1414+ * When implemented, this should return cosine similarity in [0, 1] between
1515+ * the user's recent-post embedding and the talk's topicIndex embedding.
1616+ *
1717+ * The `void talk;` statement marks the parameter as intentionally unused so
1818+ * `@typescript-eslint/no-unused-vars` doesn't fire while the stub awaits its
1919+ * real implementation.
2020+ */
2121+export function computeInterestStub(talk: TalkEntry): InterestStubResult {
2222+ void talk;
2323+ return { interestScore: 0 };
2424+}
+56
src/lib/scoring/networkAttention.test.ts
···11+import { describe, it, expect } from "vitest";
22+import { computeLayer1 } from "./networkAttention";
33+import type { TalkMention } from "@/lib/crawl/types";
44+55+// Build a TalkMention with `n` distinct follow DIDs.
66+// Note: this is "follows engaged with this talk", NOT the user's total
77+// follow count — that's passed separately as the second arg to computeLayer1.
88+function makeMention(n: number): TalkMention {
99+ const follows = Array.from({ length: n }, (_, i) => `did:plc:f${i}`);
1010+ return {
1111+ count: n,
1212+ follows,
1313+ posts: [],
1414+ rsvps: [],
1515+ };
1616+}
1717+1818+describe("computeLayer1", () => {
1919+ it("returns attentionInverse 1.0 when zero follows engaged", () => {
2020+ const result = computeLayer1(makeMention(0), 100);
2121+ expect(result.uniqueFollows).toBe(0);
2222+ expect(result.totalFollows).toBe(100);
2323+ expect(result.reachRatio).toBeCloseTo(0, 6);
2424+ expect(result.attentionInverse).toBeCloseTo(1.0, 6);
2525+ });
2626+2727+ it("returns attentionInverse 0.5 when half engaged", () => {
2828+ const result = computeLayer1(makeMention(50), 100);
2929+ expect(result.reachRatio).toBeCloseTo(0.5, 6);
3030+ expect(result.attentionInverse).toBeCloseTo(0.5, 6);
3131+ });
3232+3333+ it("returns attentionInverse 0.0 when fully engaged", () => {
3434+ const result = computeLayer1(makeMention(100), 100);
3535+ expect(result.reachRatio).toBeCloseTo(1.0, 6);
3636+ expect(result.attentionInverse).toBeCloseTo(0.0, 6);
3737+ });
3838+3939+ it("returns attentionInverse 1.0 when followCount is 0 (divide-by-zero guard)", () => {
4040+ const result = computeLayer1(makeMention(3), 0);
4141+ expect(result.reachRatio).toBeCloseTo(0, 6);
4242+ expect(result.attentionInverse).toBeCloseTo(1.0, 6);
4343+ });
4444+4545+ it("clamps reachRatio to 1 when stale data has more follows than followCount", () => {
4646+ const result = computeLayer1(makeMention(110), 100);
4747+ expect(result.reachRatio).toBeCloseTo(1.0, 6);
4848+ expect(result.attentionInverse).toBeCloseTo(0.0, 6);
4949+ });
5050+5151+ it("treats undefined mention as zero engagement", () => {
5252+ const result = computeLayer1(undefined, 100);
5353+ expect(result.uniqueFollows).toBe(0);
5454+ expect(result.attentionInverse).toBeCloseTo(1.0, 6);
5555+ });
5656+});
+34
src/lib/scoring/networkAttention.ts
···11+import type { TalkMention } from "@/lib/crawl/types";
22+import type { Layer1Result } from "./types";
33+44+/**
55+ * Compute the Layer 1 (network attention, inverted) score for a single talk.
66+ *
77+ * Returns the fraction of the user's follows who engaged with the talk
88+ * (`reachRatio`) and its inverse (`attentionInverse`), where 1.0 means
99+ * "nobody in your network engaged" and 0.0 means "every single one of your
1010+ * follows engaged."
1111+ *
1212+ * We use `mention.follows.length` rather than `mention.count` so the algorithm
1313+ * is robust to a future crawler change that decouples the two. Today the
1414+ * crawler enforces `count === follows.length`.
1515+ */
1616+export function computeLayer1(
1717+ mention: TalkMention | undefined,
1818+ followCount: number,
1919+): Layer1Result {
2020+ const uniqueFollows = mention?.follows.length ?? 0;
2121+ // Clamp to [0, 1]: uniqueFollows can theoretically exceed followCount if a
2222+ // CrawlResult is reused after the user's follow list changes (someone
2323+ // unfollowed but still appears in cached mentions). The clamp prevents
2424+ // attentionInverse from going negative in that edge case.
2525+ const reachRatio =
2626+ followCount > 0 ? Math.min(1, uniqueFollows / followCount) : 0;
2727+ const attentionInverse = 1 - reachRatio;
2828+ return {
2929+ uniqueFollows,
3030+ totalFollows: followCount,
3131+ reachRatio,
3232+ attentionInverse,
3333+ };
3434+}
+237
src/lib/scoring/rank.test.ts
···11+import { describe, it, expect } from "vitest";
22+import { scoreTalk, rankTalks } from "./rank";
33+import { DEFAULT_WEIGHTS } from "./types";
44+import { DEFAULT_ACTIVE_LAYERS, type ActiveLayers } from "./combine";
55+import type { TalkEntry } from "@/lib/types";
66+import type { TalkMention, TalkMentions } from "@/lib/crawl/types";
77+88+function makeTalk(rkey: string, overrides: Partial<TalkEntry> = {}): TalkEntry {
99+ return {
1010+ rkey,
1111+ title: `Talk ${rkey}`,
1212+ vodUri: `at://example/${rkey}`,
1313+ vodCid: "bafy",
1414+ hlsUrl: "",
1515+ durationMs: 0,
1616+ createdAt: "",
1717+ eventUri: `at://event/${rkey}`,
1818+ description: null,
1919+ speakers: [],
2020+ room: null,
2121+ talkType: null,
2222+ category: null,
2323+ startsAt: null,
2424+ endsAt: null,
2525+ transcriptFile: null,
2626+ ...overrides,
2727+ };
2828+}
2929+3030+// Build a TalkMention with `n` distinct follow DIDs (engaged follows for
3131+// this talk, NOT the user's total follow count — that's a separate arg).
3232+function makeMention(n: number): TalkMention {
3333+ const follows = Array.from({ length: n }, (_, i) => `did:plc:f${i}`);
3434+ return {
3535+ count: n,
3636+ follows,
3737+ posts: [],
3838+ rsvps: [],
3939+ };
4040+}
4141+4242+describe("scoreTalk — state derivation", () => {
4343+ const talk = makeTalk("a");
4444+4545+ it("returns unknown when mentions is null", () => {
4646+ const score = scoreTalk(talk, null, 100);
4747+ expect(score.state).toBe("unknown");
4848+ expect(score.intensity).toBe(0);
4949+ });
5050+5151+ it("returns unknown when followCount is 0", () => {
5252+ const score = scoreTalk(talk, { a: makeMention(5) }, 0);
5353+ expect(score.state).toBe("unknown");
5454+ });
5555+5656+ it("returns unknown when the talk has no mention entry (out of crawl scope)", () => {
5757+ const score = scoreTalk(talk, {}, 100);
5858+ expect(score.state).toBe("unknown");
5959+ });
6060+6161+ it("returns missed when uniqueFollows is 0 but talk is in scope", () => {
6262+ const score = scoreTalk(talk, { a: makeMention(0) }, 100);
6363+ expect(score.state).toBe("missed");
6464+ expect(score.intensity).toBeCloseTo(1.0, 6);
6565+ });
6666+6767+ it("returns engaged when at least one follow engaged", () => {
6868+ const score = scoreTalk(talk, { a: makeMention(3) }, 100);
6969+ expect(score.state).toBe("engaged");
7070+ expect(score.intensity).toBeCloseTo(0.97, 6);
7171+ });
7272+7373+ it("returns unknown when followCount is negative", () => {
7474+ const score = scoreTalk(talk, { a: makeMention(5) }, -3);
7575+ expect(score.state).toBe("unknown");
7676+ // totalFollows is sanitized to 0, not the bogus -3, so JSON serialization
7777+ // and downstream consumers see a stable shape.
7878+ expect(score.layer1.totalFollows).toBe(0);
7979+ });
8080+8181+ it("returns unknown when followCount is NaN", () => {
8282+ const score = scoreTalk(talk, { a: makeMention(5) }, Number.NaN);
8383+ expect(score.state).toBe("unknown");
8484+ expect(score.layer1.totalFollows).toBe(0);
8585+ });
8686+8787+ it("returns unknown when followCount is +Infinity", () => {
8888+ const score = scoreTalk(
8989+ talk,
9090+ { a: makeMention(5) },
9191+ Number.POSITIVE_INFINITY,
9292+ );
9393+ expect(score.state).toBe("unknown");
9494+ expect(score.layer1.totalFollows).toBe(0);
9595+ });
9696+9797+ it("returns unknown when followCount is -Infinity", () => {
9898+ const score = scoreTalk(
9999+ talk,
100100+ { a: makeMention(5) },
101101+ Number.NEGATIVE_INFINITY,
102102+ );
103103+ expect(score.state).toBe("unknown");
104104+ expect(score.layer1.totalFollows).toBe(0);
105105+ });
106106+});
107107+108108+describe("DEFAULT_WEIGHTS / DEFAULT_ACTIVE_LAYERS — frozen sentinels", () => {
109109+ it("DEFAULT_WEIGHTS is frozen so accidental mutation throws or no-ops", () => {
110110+ // Object.freeze makes assignment a silent no-op in sloppy mode and throws
111111+ // in strict mode. Either way, the value cannot change.
112112+ expect(Object.isFrozen(DEFAULT_WEIGHTS)).toBe(true);
113113+ });
114114+115115+ it("DEFAULT_ACTIVE_LAYERS is frozen so accidental mutation throws or no-ops", () => {
116116+ expect(Object.isFrozen(DEFAULT_ACTIVE_LAYERS)).toBe(true);
117117+ });
118118+});
119119+120120+describe("scoreTalk — defaults", () => {
121121+ const talk = makeTalk("a");
122122+ const mentions: TalkMentions = { a: makeMention(0) };
123123+124124+ it("uses both DEFAULT_WEIGHTS and DEFAULT_ACTIVE_LAYERS when both omitted", () => {
125125+ const score = scoreTalk(talk, mentions, 100);
126126+ expect(score.intensity).toBeCloseTo(1.0, 6);
127127+ });
128128+129129+ it("uses DEFAULT_ACTIVE_LAYERS when active omitted but explicit weights supplied", () => {
130130+ const score = scoreTalk(talk, mentions, 100, {
131131+ surpriseSlider: 0.25,
132132+ friendsSlider: 0.75,
133133+ });
134134+ // active defaults to both-off → L1-only branch → weights don't enter
135135+ // the math at all → intensity == layer1.attentionInverse == 1.0
136136+ expect(score.intensity).toBeCloseTo(1.0, 6);
137137+ });
138138+139139+ it("uses DEFAULT_WEIGHTS when weights omitted but explicit active supplied", () => {
140140+ const score = scoreTalk(talk, mentions, 100, undefined, {
141141+ layer2: true,
142142+ layer3: false,
143143+ });
144144+ // L1+L2 active, L2 stub returns 0, default surprise=0.5
145145+ // (1.0*0.5 + 0*0.5*0.3) / 0.8 = 0.625
146146+ expect(score.intensity).toBeCloseTo(0.625, 6);
147147+ });
148148+});
149149+150150+describe("rankTalks — sort order", () => {
151151+ const A = makeTalk("aaa");
152152+ const B = makeTalk("bbb");
153153+ const C = makeTalk("ccc");
154154+ const D = makeTalk("ddd");
155155+ const E = makeTalk("eee");
156156+157157+ it("sorts missed first, then engaged (intensity desc), then unknown", () => {
158158+ const mentions: TalkMentions = {
159159+ aaa: makeMention(1), // engaged, intensity 0.99
160160+ bbb: makeMention(0), // missed, intensity 1.0
161161+ ccc: makeMention(50), // engaged, intensity 0.5
162162+ // D, E: no mentions → unknown
163163+ };
164164+ const result = rankTalks({
165165+ talks: [A, B, C, D, E],
166166+ mentions,
167167+ followCount: 100,
168168+ });
169169+170170+ expect(result.map((s) => s.rkey)).toEqual(["bbb", "aaa", "ccc", "ddd", "eee"]);
171171+ });
172172+173173+ it("uses rkey ascending as a deterministic tiebreak", () => {
174174+ const Z = makeTalk("zzz");
175175+ const A = makeTalk("aaa");
176176+ const mentions: TalkMentions = {
177177+ zzz: makeMention(0),
178178+ aaa: makeMention(0),
179179+ };
180180+ const result = rankTalks({
181181+ talks: [Z, A], // intentionally not in rkey order
182182+ mentions,
183183+ followCount: 100,
184184+ });
185185+186186+ // Both missed with intensity 1.0; tiebreak puts "aaa" before "zzz"
187187+ expect(result[0].rkey).toBe("aaa");
188188+ expect(result[1].rkey).toBe("zzz");
189189+ });
190190+191191+ it("threads weights and active flags through to combineLayers", () => {
192192+ const active: ActiveLayers = { layer2: true, layer3: false };
193193+ const mentions: TalkMentions = { aaa: makeMention(0) };
194194+ const result = rankTalks({
195195+ talks: [A],
196196+ mentions,
197197+ followCount: 100,
198198+ active,
199199+ });
200200+ // L1 only contributes; L2 stub returns 0; rescale: 0.5/0.8 = 0.625
201201+ expect(result[0].intensity).toBeCloseTo(0.625, 6);
202202+ });
203203+});
204204+205205+describe("rankTalks — empty / degenerate inputs", () => {
206206+ it("returns [] for empty talks array", () => {
207207+ const result = rankTalks({
208208+ talks: [],
209209+ mentions: {},
210210+ followCount: 100,
211211+ });
212212+ expect(result).toEqual([]);
213213+ });
214214+215215+ it("returns all unknown sorted by rkey when mentions is null", () => {
216216+ const result = rankTalks({
217217+ talks: [makeTalk("ccc"), makeTalk("aaa"), makeTalk("bbb")],
218218+ mentions: null,
219219+ followCount: 100,
220220+ });
221221+ expect(result.map((s) => s.state)).toEqual(["unknown", "unknown", "unknown"]);
222222+ expect(result.map((s) => s.rkey)).toEqual(["aaa", "bbb", "ccc"]);
223223+ });
224224+225225+ it("returns all unknown when followCount is 0", () => {
226226+ const result = rankTalks({
227227+ talks: [makeTalk("aaa"), makeTalk("bbb"), makeTalk("ccc")],
228228+ mentions: {
229229+ aaa: makeMention(5),
230230+ bbb: makeMention(10),
231231+ ccc: makeMention(0),
232232+ },
233233+ followCount: 0,
234234+ });
235235+ expect(result.map((s) => s.state)).toEqual(["unknown", "unknown", "unknown"]);
236236+ });
237237+});
+112
src/lib/scoring/rank.ts
···11+import type { TalkEntry } from "@/lib/types";
22+import type { TalkMentions } from "@/lib/crawl/types";
33+import {
44+ type TalkScore,
55+ type TalkScoreState,
66+ type ScoringInputs,
77+ type ScoringWeights,
88+ DEFAULT_WEIGHTS,
99+} from "./types";
1010+import { computeLayer1 } from "./networkAttention";
1111+import { computeInterestStub } from "./interestStub";
1212+import { computeFriendStub } from "./friendStub";
1313+import {
1414+ type ActiveLayers,
1515+ DEFAULT_ACTIVE_LAYERS,
1616+ combineLayers,
1717+} from "./combine";
1818+1919+function unknownScore(rkey: string, followCount: number): TalkScore {
2020+ // Sanitize: if followCount is non-finite or negative (e.g., from a corrupted
2121+ // cache or a slider-derived value that wasn't validated upstream), don't
2222+ // propagate the bad number into the result. Stash 0 so JSON serialization,
2323+ // React rendering, and downstream consumers see a stable shape.
2424+ const safeTotalFollows =
2525+ Number.isFinite(followCount) && followCount > 0 ? followCount : 0;
2626+ return {
2727+ rkey,
2828+ intensity: 0,
2929+ state: "unknown",
3030+ layer1: {
3131+ uniqueFollows: 0,
3232+ totalFollows: safeTotalFollows,
3333+ reachRatio: 0,
3434+ attentionInverse: 0,
3535+ },
3636+ };
3737+}
3838+3939+/**
4040+ * Score a single talk. Pass the full `mentions` map (or null if no crawl
4141+ * has run yet) — the function looks up the talk's mention internally so
4242+ * callers don't have to encode "do we have crawl data" as a separate flag.
4343+ *
4444+ * Returns `unknown` state when:
4545+ * - mentions is null (crawl hasn't run)
4646+ * - followCount is non-finite or ≤ 0 (user has no follows OR a corrupted
4747+ * value snuck through — reach is undefined either way)
4848+ * - mention is absent (talk is out of crawl scope, e.g. no eventUri)
4949+ *
5050+ * Otherwise runs Layer 1 + the two stubs through `combineLayers` with the
5151+ * given weights and active layer flags.
5252+ */
5353+export function scoreTalk(
5454+ talk: TalkEntry,
5555+ mentions: TalkMentions | null,
5656+ followCount: number,
5757+ weights: ScoringWeights = DEFAULT_WEIGHTS,
5858+ active: ActiveLayers = DEFAULT_ACTIVE_LAYERS,
5959+): TalkScore {
6060+ // Robust guard: catches null mentions, zero/negative followCount, NaN, and
6161+ // ±Infinity in a single check. Anything that isn't a finite positive integer
6262+ // routes to `unknown` rather than silently producing a wrong "missed".
6363+ if (mentions === null || !Number.isFinite(followCount) || followCount <= 0) {
6464+ return unknownScore(talk.rkey, followCount);
6565+ }
6666+ const mention = mentions[talk.rkey];
6767+ if (!mention) {
6868+ // Talk is not in crawl scope (e.g. no eventUri so the crawler skipped it).
6969+ return unknownScore(talk.rkey, followCount);
7070+ }
7171+7272+ const layer1 = computeLayer1(mention, followCount);
7373+ const layer2 = computeInterestStub(talk);
7474+ const layer3 = computeFriendStub(talk);
7575+ const intensity = combineLayers(layer1, layer2, layer3, weights, active);
7676+7777+ const state: TalkScoreState =
7878+ layer1.uniqueFollows === 0 ? "missed" : "engaged";
7979+8080+ return { rkey: talk.rkey, intensity, state, layer1 };
8181+}
8282+8383+const STATE_ORDER: Record<TalkScoreState, number> = {
8484+ missed: 0,
8585+ engaged: 1,
8686+ unknown: 2,
8787+};
8888+8989+function compareTalkScores(a: TalkScore, b: TalkScore): number {
9090+ // Primary: state group (missed first, then engaged, then unknown)
9191+ const stateDelta = STATE_ORDER[a.state] - STATE_ORDER[b.state];
9292+ if (stateDelta !== 0) return stateDelta;
9393+ // Secondary: intensity descending (highest glow first within each state)
9494+ const intensityDelta = b.intensity - a.intensity;
9595+ if (intensityDelta !== 0) return intensityDelta;
9696+ // Tertiary: rkey ascending — deterministic tiebreak so the order is stable
9797+ // across renders (matters for React reconciliation).
9898+ return a.rkey.localeCompare(b.rkey);
9999+}
100100+101101+export function rankTalks(inputs: ScoringInputs): TalkScore[] {
102102+ const {
103103+ talks,
104104+ mentions,
105105+ followCount,
106106+ weights = DEFAULT_WEIGHTS,
107107+ active = DEFAULT_ACTIVE_LAYERS,
108108+ } = inputs;
109109+ return talks
110110+ .map((talk) => scoreTalk(talk, mentions, followCount, weights, active))
111111+ .sort(compareTalkScores);
112112+}
+53
src/lib/scoring/types.ts
···11+import type { TalkEntry } from "@/lib/types";
22+import type { TalkMention, TalkMentions } from "@/lib/crawl/types";
33+// Local-import-then-re-export so `ScoringInputs` gets a usable local binding
44+// for `ActiveLayers` AND callers can `import { ActiveLayers } from "@/lib/scoring/types"`
55+// without needing to know `combine.ts` owns it.
66+import type { ActiveLayers } from "./combine";
77+export type { ActiveLayers };
88+// Runtime re-export of the default sentinel so callers importing from
99+// scoring/types get both the type and its default value in one place.
1010+export { DEFAULT_ACTIVE_LAYERS } from "./combine";
1111+1212+export type TalkScoreState = "engaged" | "missed" | "unknown";
1313+1414+export interface Layer1Result {
1515+ uniqueFollows: number;
1616+ totalFollows: number;
1717+ reachRatio: number; // uniqueFollows / totalFollows, clamped to [0, 1]
1818+ attentionInverse: number; // 1 - reachRatio, clamped to [0, 1]
1919+}
2020+2121+export interface TalkScore {
2222+ rkey: string;
2323+ intensity: number; // 0–1; UI uses for glow + ordering
2424+ state: TalkScoreState;
2525+ layer1: Layer1Result;
2626+ layer2?: { interestScore: number };
2727+ layer3?: { friendBoost: number; recommenders: string[] };
2828+}
2929+3030+export interface ScoringWeights {
3131+ readonly surpriseSlider: number; // 0–1; controls Layer 2 contribution (high = serendipity)
3232+ readonly friendsSlider: number; // 0–1; controls Layer 3 contribution (high = friends override)
3333+}
3434+3535+// Frozen so the exported sentinel can't be mutated by accident — it's used
3636+// as a default parameter value in scoreTalk/rankTalks, so any mutation would
3737+// corrupt every subsequent call that takes the default.
3838+export const DEFAULT_WEIGHTS: Readonly<ScoringWeights> = Object.freeze({
3939+ surpriseSlider: 0.5,
4040+ friendsSlider: 0.5,
4141+});
4242+4343+export interface ScoringInputs {
4444+ talks: TalkEntry[];
4545+ mentions: TalkMentions | null; // null = crawl not yet completed
4646+ followCount: number; // from CrawlResult.followCount
4747+ weights?: ScoringWeights;
4848+ active?: ActiveLayers; // omitted = layer 1 only (today's deployment)
4949+}
5050+5151+// Re-export TalkMention for downstream consumers that import only from
5252+// scoring/types — saves them having to know about the crawl module.
5353+export type { TalkMention, TalkMentions };