/** * N7.5 — MR-F9 «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". Three passes after discovery, before drafting: * demand-sweep (Pass 1) → pain-point map (Pass 2) → vocabulary translation (Pass 3), with a new * output unit — the ARC (åre), not the news-item. * * This suite pins the deterministic code half: * (1) the additive-optional `demand` signal (strength + answered) — the rankable twin of the * verbatim `saturation` text; capture round-trip + hard-fail on malformed, backward-compat; * (2) `classifyMarketGap` — the §4 "ærlig markedsdom" asymmetry (supply-gap ≠ demand-gap); * (3) `rankArcQuestions` — the §4 ranking `etterspørsel × kan-svare × ikke-besvart`, total order; * (4) `groupIntoArcs` — relatedIds transitive closure into veins, BÆRENDE vs STØTTE partition; * (5) `renderArcs` — the §4 output-contract, reader's words in the title, honest-null, deterministic. * * GENERIC BY ARCHITECTURE: the fixtures use a neutral domain — no KTG pillar/source/calibration is * baked into the mechanism (what counts as demand is the sweep's judgment, carried by the fields). */ import { describe, test } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { mkdtempSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { emptyStore, loadStore, saveStore, addTrend } from "../src/store.js"; import { normalizeItem, itemToInput } from "../src/item.js"; import { SCHEMA_VERSION } from "../src/types.js"; import type { TrendRecord, DemandSignal } from "../src/types.js"; import { classifyMarketGap, toArcQuestion, rankArcQuestions, groupIntoArcs, renderArcs, } from "../src/arc.js"; const tmp = () => mkdtempSync(join(tmpdir(), "trends-n75-")); /** Build a store record with N7.5-relevant fields; generic domain, no KTG specifics. */ function rec(over: Partial & Pick): TrendRecord { return { source: "tavily", capturedAt: "2026-07-20", topics: ["ai"], ...over, } as TrendRecord; } // ── (1) demand schema — additive-optional, capture round-trip ────────────────────────────── describe("N7.5 — demand signal schema (rankable twin of saturation text)", () => { test("addTrend persists demand + the F9 reader fields, load/save round-trips them", () => { const dir = tmp(); try { const path = join(dir, "trends.json"); const { store } = addTrend(emptyStore(), { title: "RAG on your own documents", url: "https://example.com/rag", source: "tavily", capturedAt: "2026-07-20", topics: ["ai", "search"], readerQuestion: "Can we point a chatbot at our own files?", painPoint: "competes against the M365 licence they already pay for", saturation: "setup guides saturated; the cost/ownership questions are open", demand: { strength: "strong", answered: false }, verdict: "BÆRENDE", actionability: { formulated: true }, }); saveStore(path, store); const t = loadStore(path).trends[0]; assert.deepEqual(t.demand, { strength: "strong", answered: false }); assert.equal(t.readerQuestion, "Can we point a chatbot at our own files?"); assert.equal(t.painPoint, "competes against the M365 licence they already pay for"); assert.equal(t.saturation, "setup guides saturated; the cost/ownership questions are open"); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("schema version stays 4 — demand is additive-optional, no migration", () => { assert.equal(SCHEMA_VERSION, 4); }); test("normalizeItem accepts a valid demand and carries it through itemToInput", () => { const res = normalizeItem({ title: "T", url: "https://x.test/a", source: "tavily", topics: ["ai"], demand: { strength: "moderate", answered: true }, }); assert.equal(res.ok, true); if (!res.ok) return; assert.deepEqual(res.item.demand, { strength: "moderate", answered: true }); const input = itemToInput(res.item, "2026-07-20"); assert.deepEqual(input.demand, { strength: "moderate", answered: true }); }); test("normalizeItem hard-fails a malformed demand (bad strength vocab)", () => { const res = normalizeItem({ title: "T", url: "https://x.test/a", source: "tavily", topics: ["ai"], demand: { strength: "huge", answered: false }, }); assert.equal(res.ok, false); if (res.ok) return; assert.ok(res.errors.some((e) => /demand/i.test(e)), "should report a demand error"); }); test("normalizeItem hard-fails a demand with a non-boolean answered", () => { const res = normalizeItem({ title: "T", url: "https://x.test/a", source: "tavily", topics: ["ai"], demand: { strength: "thin", answered: "no" }, }); assert.equal(res.ok, false); }); test("demand absent → key omitted (a pre-N7.5 record reads unchanged)", () => { const res = normalizeItem({ title: "T", url: "https://x.test/a", source: "tavily", topics: ["ai"] }); assert.equal(res.ok, true); if (!res.ok) return; assert.equal("demand" in res.item, false); assert.equal("demand" in itemToInput(res.item, "2026-07-20"), false); }); }); // ── (2) classifyMarketGap — the §4 "ærlig markedsdom" asymmetry ───────────────────────────── describe("N7.5 — classifyMarketGap (supply-gap ≠ demand-gap ≠ saturated)", () => { const strong: DemandSignal = { strength: "strong", answered: false }; test("many ask, none answer → supply-gap (the gold)", () => { assert.equal(classifyMarketGap({ demand: strong }), "supply-gap"); assert.equal(classifyMarketGap({ demand: { strength: "moderate", answered: false } }), "supply-gap"); }); test("few ask but the sender can answer → demand-gap (highest value, lowest guaranteed audience)", () => { assert.equal( classifyMarketGap({ demand: { strength: "thin", answered: false }, verdict: "BÆRENDE" }), "demand-gap", ); assert.equal( classifyMarketGap({ demand: { strength: "thin", answered: false }, actionability: { formulated: true } }), "demand-gap", ); }); test("already answered → saturated (don't write), regardless of demand strength", () => { assert.equal(classifyMarketGap({ demand: { strength: "strong", answered: true } }), "saturated"); }); test("no demand signal → unknown (honest null, never invented)", () => { assert.equal(classifyMarketGap({}), "unknown"); assert.equal(classifyMarketGap({ demand: { strength: "thin", answered: false } }), "unknown"); }); }); // ── (3) rankArcQuestions — §4 ranking, total order ────────────────────────────────────────── describe("N7.5 — rankArcQuestions (etterspørsel × kan-svare × ikke-besvart)", () => { test("supply-gap outranks demand-gap outranks saturated; unmeasured sinks last", () => { const supply = rec({ id: "aaa1", title: "supply", url: "u1", demand: { strength: "strong", answered: false }, verdict: "BÆRENDE", actionability: { formulated: true }, }); const demand = rec({ id: "bbb2", title: "demand", url: "u2", demand: { strength: "thin", answered: false }, verdict: "BÆRENDE", actionability: { formulated: true }, }); const saturated = rec({ id: "ccc3", title: "saturated", url: "u3", demand: { strength: "strong", answered: true }, }); const unmeasured = rec({ id: "ddd4", title: "unmeasured", url: "u4" }); const ranked = rankArcQuestions([saturated, unmeasured, demand, supply]); assert.deepEqual( ranked.map((q) => q.id), ["aaa1", "bbb2", "ccc3", "ddd4"], ); assert.equal(ranked[0].gap, "supply-gap"); assert.equal(ranked[1].gap, "demand-gap"); assert.equal(ranked[2].gap, "saturated"); assert.equal(ranked[3].gap, "unknown"); }); test("total order is deterministic — ties broken by id ascending", () => { const a = rec({ id: "zzz9", title: "z", url: "uz", demand: { strength: "moderate", answered: false } }); const b = rec({ id: "aaa1", title: "a", url: "ua", demand: { strength: "moderate", answered: false } }); const ranked = rankArcQuestions([a, b]); assert.deepEqual(ranked.map((q) => q.id), ["aaa1", "zzz9"]); // Same input, second call → identical order (pure). assert.deepEqual(rankArcQuestions([a, b]), rankArcQuestions([b, a])); }); test("saturated ranks strictly below any unanswered measured demand", () => { const sat = rec({ id: "s", title: "s", url: "us", demand: { strength: "strong", answered: true } }); const open = rec({ id: "o", title: "o", url: "uo", demand: { strength: "thin", answered: false } }); const ranked = rankArcQuestions([sat, open]); assert.equal(ranked[0].id, "o"); assert.equal(ranked[1].id, "s"); }); }); // ── (4) toArcQuestion + groupIntoArcs — reader's words, relatedIds veins ───────────────────── describe("N7.5 — arc grouping (the vein, not the news-item)", () => { test("toArcQuestion uses the reader's words (pass 3), keeping the field's words alongside", () => { const q = toArcQuestion( rec({ id: "x1", title: "OKF-bundle second-brain interop", url: "u", readerQuestion: "Will our chat logs become public record?", painPoint: "arkivplikt", }), ); assert.equal(q.question, "Will our chat logs become public record?"); assert.equal(q.fieldQuestion, "OKF-bundle second-brain interop"); assert.equal(q.painPoint, "arkivplikt"); }); test("toArcQuestion falls back to the title when no reader question is set (backward-compat)", () => { const q = toArcQuestion(rec({ id: "x2", title: "Only a headline", url: "u" })); assert.equal(q.question, "Only a headline"); }); test("relatedIds cluster transitively into one arc; unrelated records stay separate", () => { const a = rec({ id: "a1", title: "A", url: "ua", relatedIds: ["b2"] }); const b = rec({ id: "b2", title: "B", url: "ub", relatedIds: ["c3"] }); // a→b→c transitive const c = rec({ id: "c3", title: "C", url: "uc" }); const lone = rec({ id: "z9", title: "Z", url: "uz" }); const arcs = groupIntoArcs([a, b, c, lone]); assert.equal(arcs.length, 2); const vein = arcs.find((ar) => ar.questions.some((q) => q.id === "a1"))!; assert.deepEqual(vein.questions.map((q) => q.id).sort(), ["a1", "b2", "c3"]); const solo = arcs.find((ar) => ar.questions.some((q) => q.id === "z9"))!; assert.deepEqual(solo.questions.map((q) => q.id), ["z9"]); }); test("an arc partitions its questions into BÆRENDE (bearing) vs the rest (support)", () => { const bearing = rec({ id: "a1", title: "bear", url: "ua", relatedIds: ["b2"], verdict: "BÆRENDE" }); const support = rec({ id: "b2", title: "supp", url: "ub", verdict: "STØTTE" }); const arc = groupIntoArcs([bearing, support])[0]; assert.deepEqual(arc.bearing.map((q) => q.id), ["a1"]); assert.deepEqual(arc.support.map((q) => q.id), ["b2"]); }); test("arcs are ordered by their top question's rank (highest-demand vein first)", () => { const hot = rec({ id: "h1", title: "hot", url: "uh", demand: { strength: "strong", answered: false }, verdict: "BÆRENDE" }); const cold = rec({ id: "c1", title: "cold", url: "uc", demand: { strength: "thin", answered: true } }); const arcs = groupIntoArcs([cold, hot]); assert.equal(arcs[0].questions[0].id, "h1"); }); }); // ── (5) renderArcs — §4 output-contract, deterministic, honest-null ────────────────────────── describe("N7.5 — renderArcs (§4 output-contract)", () => { const supplyVein = () => groupIntoArcs([ rec({ id: "a1", title: "LLM wiki OKF-bundle", url: "ua", relatedIds: ["b2"], readerQuestion: "Is our knowledge base going to leak?", painPoint: "informasjonssikkerhet", saturation: "no independent answer yet", demand: { strength: "strong", answered: false }, verdict: "BÆRENDE", actionability: { formulated: true }, }), rec({ id: "b2", title: "second brain agent memory", url: "ub", readerQuestion: "Who owns the notes when we are five people?", demand: { strength: "moderate", answered: false }, verdict: "STØTTE", }), ]); test("renders the reader's words, not the field's jargon, in the question inventory", () => { const md = renderArcs(supplyVein()); assert.ok(md.includes("Is our knowledge base going to leak?"), "reader question present"); assert.ok(!/^#.*OKF-bundle/m.test(md), "field jargon must not head a section"); }); test("renders the honest market verdict per vein (supply-gap = write here)", () => { const md = renderArcs(supplyVein()); assert.ok(/markedsdom/i.test(md), "explicit market verdict"); assert.ok(/hull i tilbudet|supply-gap/i.test(md), "supply-gap named"); }); test("renders the BÆRENDE vs STØTTE arc proposal (reading order)", () => { const md = renderArcs(supplyVein()); assert.ok(/BÆRENDE/.test(md) && /STØTTE/.test(md), "both roles surfaced"); }); test("an unmeasured / thin vein renders an explicit thin-signal marker (no invented demand)", () => { const thin = groupIntoArcs([rec({ id: "t1", title: "quiet topic", url: "ut" })]); const md = renderArcs(thin); assert.ok(/tynt signal|unknown|umålt/i.test(md), "honest null surfaced, not filled in"); }); test("render is deterministic — same arcs → byte-identical output", () => { assert.equal(renderArcs(supplyVein()), renderArcs(supplyVein())); }); }); // ── (6) CLI roundtrip — the AC: a swept item flows verbatim capture → arcs §4 map ─────────── describe("N7.5 — CLI roundtrip (capture demand-swept item → arcs)", () => { const trendsDir = fileURLToPath(new URL("..", import.meta.url)); const runCli = (args: string[], input = ""): { status: number | null; stdout: string } => { const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", ...args], { input, encoding: "utf8", cwd: trendsDir, }); return { status: res.status, stdout: res.stdout }; }; test("readerQuestion/painPoint/saturation/demand survive capture and drive the arc map", () => { const dir = tmp(); try { const store = join(dir, "trends.json"); const item = JSON.stringify({ source: "tavily", title: "OKF second-brain interop", url: "https://example.com/okf", topics: ["ai", "knowledge"], readerQuestion: "Will our knowledge base become public record?", painPoint: "arkivplikt + innsyn", saturation: "setup guides saturated; the ownership question is open", demand: { strength: "strong", answered: false }, verdict: "BÆRENDE", actionability: { formulated: true }, }); assert.equal(runCli(["capture", "--store", store, "--json"], item).status, 0); const md = runCli(["arcs", "--store", store]); assert.equal(md.status, 0); assert.ok( /## Åre 1: Will our knowledge base become public record\?/.test(md.stdout), "the vein heading uses the reader's words, not the field's jargon", ); assert.ok(/hull i tilbudet|supply-gap/i.test(md.stdout), "the honest market verdict is rendered"); assert.ok(/feltets ord: OKF second-brain interop/.test(md.stdout), "field words kept alongside"); const j = runCli(["arcs", "--store", store, "--json"]); const parsed = JSON.parse(j.stdout); assert.equal(parsed[0].questions[0].demand.strength, "strong"); assert.equal(parsed[0].gap, "supply-gap"); } finally { rmSync(dir, { recursive: true, force: true }); } }); });