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
372 lines
16 KiB
TypeScript
372 lines
16 KiB
TypeScript
/**
|
||
* 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<TrendRecord> & Pick<TrendRecord, "id" | "title" | "url">): 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 });
|
||
}
|
||
});
|
||
});
|