linkedin-studio/scripts/trends/src/arc.ts
Kjell Tore Guttormsen 9de38c4939 feat(linkedin-studio): N7.5 — MR-F9 etterspørsels-sveip (Tier-5-kilder + smertepunkt + vokabular-oversettelse + arc-kontrakt) [skip-docs]
The «innenfra og ut» demand-sweep: the mechanism that FILLS the N6 reader fields
(readerQuestion/painPoint/saturation). Discovery finds "what happened"; this layer
translates it into "the problem the reader is stuck on".

- demand-spotter agent (agents 19->20, inherits session): three passes after
  discovery, before drafting — demand-sweep -> pain-point map -> vocabulary translation.
- Tier 5 demand sources in config/trends-sources.template.md (inverse of Tier 1-4;
  honest blind spots: YouTube API, Reddit approximate, HN/GitHub ground truth).
- demand signal on TrendRecord (strength + answered) — rankable twin of the verbatim
  saturation text; additive-optional, store schema stays v4 (no migration).
- arc.ts (§4 output contract): groupIntoArcs (relatedIds transitive closure),
  rankArcQuestions (etterspørsel x kan-svare x ikke-besvart), classifyMarketGap
  (supply-gap != demand-gap != saturated). Pure + deterministic (TDD).
- arcs CLI verb + /linkedin:trends --demand mode (commands stay 30); morning brief
  shows the per-candidate demand signal.

Suites: trends 276->300, test-runner 139->140, tsc clean. Others unchanged
(brain 134, hooks 140, tests 35, render 60).

MR-F9 built (bygget-men-ubevist) — the (a)/(b)/(c) evidence gate is a runtime
demonstration, proven consumer-side (plugin agents don't resolve in the dev repo).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014bE7VbkmR3cqHFEeGfzgwb
2026-07-23 22:26:39 +02:00

255 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* The arc (åre) layer — MR-F9 «innenfra og ut» output contract (N7.5).
*
* Discovery ranks candidates on SENDER fit ("does this suit my pillars"). This layer inverts the
* view: it reasons over the demand-sweep's READER-side judgment — which questions are actually asked
* (etterspørsel), whether the sender can answer them (kan-svare), and whether they are already
* answered elsewhere (ikke-besvart) — and groups related records into VEINS (arcs), the unit a warm
* topic really is. Each question in a vein is one actionable edition.
*
* Pure + deterministic by contract (like brief.ts): no fs, no clock, no AI, no network. The reader
* judgments (readerQuestion / painPoint / saturation / demand / verdict / actionability) are the
* demand-spotter agent's output, persisted on the record; this module only combines + classifies +
* ranks + renders them. GENERIC BY ARCHITECTURE: no niche calibration here — what counts as demand
* or as a grip is the sweep's judgment, carried by the fields, never hard-coded.
*/
import type { TrendRecord, TrendVerdict, DemandSignal } from "./types.js";
/**
* The honest market verdict for a question (§4 "ærlig markedsdom"). The asymmetry the engine MUST
* express: a gap in the SUPPLY (many ask, none answer = gold) is not a gap in the DEMAND (none ask,
* but the sender can answer = highest value, lowest guaranteed audience). `saturated` = answered
* already (don't write). `unknown` = the sweep did not measure enough to judge (honest null).
*/
export type MarketGap = "supply-gap" | "demand-gap" | "saturated" | "unknown";
/** A reader-side view of the demand inputs, redeclared structurally so callers pass a plain shape. */
interface GapInputs {
demand?: DemandSignal;
verdict?: TrendVerdict;
actionability?: { formulated: boolean };
}
/**
* Classify the honest market verdict (§4). Answered ⇒ saturated. Unanswered-and-asked (strong/
* moderate) ⇒ supply-gap. Unanswered-but-thin, yet the sender can carry it (BÆRENDE verdict or a
* formulated grip) ⇒ demand-gap. Everything else (no demand, or thin with no way to answer) ⇒
* unknown — never invented into a gap. Pure.
*/
export function classifyMarketGap(r: GapInputs): MarketGap {
const d = r.demand;
if (!d) return "unknown";
if (d.answered) return "saturated";
if (d.strength === "strong" || d.strength === "moderate") return "supply-gap";
// thin, unanswered — a demand-gap only when the sender can actually answer it.
if (r.verdict === "BÆRENDE" || r.actionability?.formulated === true) return "demand-gap";
return "unknown";
}
/** One reader question inside a vein — the record projected onto its demand-side facets, plus the derived gap + rank. */
export interface ArcQuestion {
id: string;
/** The reader's OWN words (pass 3) — falls back to the record title when no readerQuestion is set. */
question: string;
/** The field's words (the record title) — kept alongside so the "begge, side om side" contract holds. */
fieldQuestion: string;
painPoint?: string;
saturation?: string;
verdict?: TrendVerdict;
/** actionability.formulated — whether a reader-actionable grip is formulated. */
gripFormulated?: boolean;
demand?: DemandSignal;
/** The honest market verdict (§4). */
gap: MarketGap;
/** Descending demand rank: `etterspørsel × kan-svare × ikke-besvart` (higher = write this first). */
rank: number;
}
/** etterspørsel — how strongly asked. Absent (unmeasured) ⇒ 0, so an unmeasured question never floats up. */
function demandWeight(d: DemandSignal | undefined): number {
if (!d) return 0;
return d.strength === "strong" ? 3 : d.strength === "moderate" ? 2 : 1;
}
/** ikke-besvart — false (open) outweighs unmeasured outweighs answered (saturated ⇒ 0, sinks). */
function openWeight(d: DemandSignal | undefined): number {
if (!d) return 1; // unmeasured openness — between answered and known-open
return d.answered ? 0 : 2;
}
/** kan-avsenderen-svare — the verdict (BÆRENDE > STØTTE > NYHET) plus a bump for a formulated grip. Min 1. */
function answerWeight(verdict: TrendVerdict | undefined, grip: boolean | undefined): number {
const base = verdict === "BÆRENDE" ? 3 : verdict === "STØTTE" ? 2 : 1;
return base + (grip ? 1 : 0);
}
/**
* Project a record onto a reader question: reader's words with a title fallback, the demand-side
* facets, the derived gap, and the multiplicative rank. Multiplicative by design (§4's `×`): a zero
* on any axis kills the rank — answered ⇒ saturated ⇒ 0, unmeasured demand ⇒ 0 — so saturated and
* unmeasured questions sink beneath any genuinely-asked, unanswered one. Pure.
*/
export function toArcQuestion(t: TrendRecord): ArcQuestion {
const grip = t.actionability?.formulated;
const rank = demandWeight(t.demand) * openWeight(t.demand) * answerWeight(t.verdict, grip);
return {
id: t.id,
question: t.readerQuestion && t.readerQuestion.trim().length > 0 ? t.readerQuestion : t.title,
fieldQuestion: t.title,
...(t.painPoint !== undefined ? { painPoint: t.painPoint } : {}),
...(t.saturation !== undefined ? { saturation: t.saturation } : {}),
...(t.verdict !== undefined ? { verdict: t.verdict } : {}),
...(grip !== undefined ? { gripFormulated: grip } : {}),
...(t.demand !== undefined ? { demand: t.demand } : {}),
gap: classifyMarketGap(t),
rank,
};
}
/** rank desc, then id asc — a total order (id is the store's unique dedupe hash), so the output is fixed. */
function byRankThenId(a: ArcQuestion, b: ArcQuestion): number {
return b.rank - a.rank || a.id.localeCompare(b.id);
}
/** Rank a set of records as arc questions on `etterspørsel × kan-svare × ikke-besvart`. Pure, total order. */
export function rankArcQuestions(records: TrendRecord[]): ArcQuestion[] {
return records.map(toArcQuestion).sort(byRankThenId);
}
/** A vein: related records clustered, their questions ranked, and split into bearing (BÆRENDE) vs support. */
export interface Arc {
/** Stable arc id — the lexicographically-smallest member id (deterministic, independent of input order). */
id: string;
/** The vein's questions, ranked (rankArcQuestions order). */
questions: ArcQuestion[];
/** The bearing questions (verdict BÆRENDE) — what an edition is built on. */
bearing: ArcQuestion[];
/** The supporting questions (STØTTE / NYHET / unjudged) — context around the bearing ones. */
support: ArcQuestion[];
/** The vein's honest market verdict — the top-ranked question's gap (the dominant signal). */
gap: MarketGap;
}
/**
* Cluster records into veins by relatedIds TRANSITIVE closure (A→B, B→C ⇒ {A,B,C}), undirected: an
* edge exists if either record names the other. A record with no link and none pointing at it is its
* own singleton vein. Union-find over the record set; ids naming absent records are ignored. Pure.
*/
export function groupIntoArcs(records: TrendRecord[]): Arc[] {
const index = new Map(records.map((r) => [r.id, r]));
const parent = new Map<string, string>(records.map((r) => [r.id, r.id]));
const find = (x: string): string => {
let root = x;
while (parent.get(root) !== root) root = parent.get(root)!;
// Path-compress for stability across repeated finds.
let cur = x;
while (parent.get(cur) !== root) {
const next = parent.get(cur)!;
parent.set(cur, root);
cur = next;
}
return root;
};
const union = (a: string, b: string): void => {
const ra = find(a);
const rb = find(b);
if (ra === rb) return;
// Attach the lexicographically-greater root under the smaller, so the component root is its min id.
if (ra < rb) parent.set(rb, ra);
else parent.set(ra, rb);
};
for (const r of records) {
for (const rel of r.relatedIds ?? []) {
if (index.has(rel)) union(r.id, rel);
}
}
const groups = new Map<string, TrendRecord[]>();
for (const r of records) {
const root = find(r.id);
const bucket = groups.get(root);
if (bucket) bucket.push(r);
else groups.set(root, [r]);
}
const arcs: Arc[] = [];
for (const [root, members] of groups) {
const questions = rankArcQuestions(members);
arcs.push({
id: root,
questions,
bearing: questions.filter((q) => q.verdict === "BÆRENDE"),
support: questions.filter((q) => q.verdict !== "BÆRENDE"),
gap: questions[0]?.gap ?? "unknown",
});
}
// Veins ordered by their hottest question, then arc id — highest-demand vein first, deterministically.
return arcs.sort((a, b) => (b.questions[0]?.rank ?? 0) - (a.questions[0]?.rank ?? 0) || a.id.localeCompare(b.id));
}
/** The human-readable market verdict line per gap (§4) — the honest "write here / don't". */
function marketVerdictLine(gap: MarketGap): string {
switch (gap) {
case "supply-gap":
return "hull i tilbudet (mange spør, ingen svarer) — skriv her";
case "demand-gap":
return "hull i etterspørselen (få spør, men avsenderen kan svare) — høyest verdi, lavest garantert publikum";
case "saturated":
return "mettet (allerede besvart) — ikke skriv";
case "unknown":
return "tynt / umålt signal — ikke fyll ut";
}
}
/** The compact demand token for a question line: strength + answered, when measured. */
function demandToken(q: ArcQuestion): string {
if (!q.demand) return "umålt etterspørsel";
return `etterspørsel: ${q.demand.strength} · besvart: ${q.demand.answered ? "ja" : "nei"}`;
}
/** Render one question's inventory entry — the reader's words lead; the field's words follow (both, side by side). */
function renderQuestion(q: ArcQuestion, n: number): string[] {
const out = [`${n}. ${q.question} · ${demandToken(q)} · [${q.gap}] · rank ${q.rank}`];
if (q.fieldQuestion !== q.question) out.push(` - feltets ord: ${q.fieldQuestion}`);
if (q.painPoint) out.push(` - 🩹 smertepunkt: ${q.painPoint}`);
const grip = q.gripFormulated === undefined ? "—" : q.gripFormulated ? "ja" : "nei";
out.push(` - 🎯 dom: ${q.verdict ?? "—"} · leser-grep: ${grip}`);
if (q.saturation) out.push(` - 🌡️ metning: ${q.saturation}`);
return out;
}
/** A short, jargon-free vein label — the reader's words of its top question (never the field's title). */
function arcTitle(arc: Arc): string {
return arc.questions[0]?.question ?? arc.id;
}
/**
* Render the §4 output contract for a set of veins: per arc a jargon-free heading (the reader's
* words), the honest market verdict, the ranked question inventory, and the BÆRENDE-vs-STØTTE reading
* order. Deterministic — same arcs → byte-identical output (pure, no clock/fs). An empty set renders
* an explicit marker rather than nothing.
*/
export function renderArcs(arcs: Arc[]): string {
const lines: string[] = ["# Årer — etterspørsels-kart (innenfra og ut)", ""];
if (arcs.length === 0) {
lines.push("_Ingen årer — tynt signal i denne kjøringen._", "");
return lines.join("\n") + "\n";
}
arcs.forEach((arc, i) => {
lines.push(`## Åre ${i + 1}: ${arcTitle(arc)}`);
lines.push(`- **Ærlig markedsdom:** ${marketVerdictLine(arc.gap)}`);
lines.push("");
lines.push("### Spørsmålsinventar (rangert: etterspørsel × kan-svare × ikke-besvart)");
arc.questions.forEach((q, n) => lines.push(...renderQuestion(q, n + 1)));
lines.push("");
lines.push("### Arc-forslag (leserekkefølge)");
lines.push(`- BÆRENDE: ${arc.bearing.length > 0 ? arc.bearing.map((q) => q.question).join(" · ") : "—"}`);
lines.push(`- STØTTE: ${arc.support.length > 0 ? arc.support.map((q) => q.question).join(" · ") : "—"}`);
lines.push("");
});
return lines.join("\n") + "\n";
}