/** * 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(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(); 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"; }