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
This commit is contained in:
Kjell Tore Guttormsen 2026-07-23 22:26:39 +02:00
commit 9de38c4939
16 changed files with 968 additions and 8 deletions

255
scripts/trends/src/arc.ts Normal file
View file

@ -0,0 +1,255 @@
/**
* 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 (AB, BC {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";
}