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
255 lines
12 KiB
TypeScript
255 lines
12 KiB
TypeScript
/**
|
||
* 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";
|
||
}
|