linkedin-studio/scripts/trends/tests/n7_5-demand-sweep.test.ts
Kjell Tore Guttormsen 9de38c4939 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
2026-07-23 22:26:39 +02:00

372 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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