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
This commit is contained in:
Kjell Tore Guttormsen 2026-07-23 22:26:39 +02:00
commit 9de38c4939
16 changed files with 968 additions and 8 deletions

255
scripts/trends/src/arc.ts Normal file
View file

@ -0,0 +1,255 @@
/**
* 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 (AB, BC {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";
}

View file

@ -280,6 +280,8 @@ function proposalLines(t: TrendRecord): string[] {
if (t.readerQuestion) out.push(`- ❓ Leserspørsmål: ${t.readerQuestion}`);
if (t.painPoint) out.push(`- 🩹 Smertepunkt: ${t.painPoint}`);
if (t.saturation) out.push(`- 🌡️ Metning: ${t.saturation}`);
// N7.5 (MR-F9): the demand-sweep's rankable signal, shown alongside the verbatim saturation above.
if (t.demand) out.push(`- 📊 Etterspørsel: ${t.demand.strength} · besvart: ${t.demand.answered ? "ja" : "nei"}`);
if (t.relatedIds && t.relatedIds.length > 0) out.push(`- ↔️ Relatert: ${t.relatedIds.join(", ")}`);
return out;
}

View file

@ -13,6 +13,7 @@
* echo '<raw item|batch>' | node --import tsx src/cli.ts capture [--store <path>] [--json]
* node --import tsx src/cli.ts brief [--pillars <a,b>] [--fresh-days N] [--first-mover-days N] [--saturation-at N]
* [--out <dir>] [--no-mark] [--store <path>] [--json]
* node --import tsx src/cli.ts arcs [--topics <a,b>] [--out <dir>] [--store <path>] [--json]
* node --import tsx src/cli.ts schedule --pillars <a,b> [--at HH:MM] [--fresh-days N]
* [--platform auto|launchd|cron] [--install|--uninstall] [--store <path>]
*
@ -83,6 +84,7 @@ import {
} from "./brief.js";
import { launchdPlist, crontabLine, installInstructions, uninstallInstructions, defaultLabel } from "./schedule.js";
import type { ScheduleSpec } from "./schedule.js";
import { groupIntoArcs, renderArcs } from "./arc.js";
function parseFlags(args: string[]): Record<string, string> {
const out: Record<string, string> = {};
@ -123,6 +125,7 @@ function usage(msg: string): never {
" score [--mode kortform|long-form] [--threshold N] < scored-candidates.json\n" +
" capture [--store <path>] [--json] < raw-item-or-batch.json\n" +
" brief [--pillars <a,b>] [--fresh-days N] [--first-mover-days N] [--saturation-at N] [--out <dir>] [--no-mark] [--store <path>] [--json]\n" +
" arcs [--topics <a,b>] [--out <dir>] [--store <path>] [--json]\n" +
" schedule --pillars <a,b> [--at HH:MM] [--fresh-days N] [--platform auto|launchd|cron] [--install|--uninstall] [--store <path>]",
);
process.exit(2);
@ -418,6 +421,44 @@ function main(): void {
return;
}
if (command === "arcs") {
// The demand-sweep's §4 output (MR-F9, N7.5): group the demand-swept records into veins (arcs)
// and render the reader-side output contract — question inventory ranked on etterspørsel × kan-svare
// × ikke-besvart, per-vein market verdict (supply-gap ≠ demand-gap ≠ saturated), BÆRENDE/STØTTE order.
// Reads only records that carry a demand-side signal (the sweep populated them); an optional --topics
// narrows the pool to a theme. Print to stdout by default; --out <dir> writes a dated arc map; --json
// emits the arc structures. No store mutation (a read-only view, like brief without the surfacing write).
const store = loadStore(storePath);
const wanted = splitTopics(flags.topics).map((t) => t.toLowerCase());
const swept = store.trends.filter((t) => {
const hasSignal =
t.demand !== undefined ||
t.readerQuestion !== undefined ||
t.painPoint !== undefined ||
t.saturation !== undefined;
if (!hasSignal) return false;
if (wanted.length === 0) return true;
const have = new Set(t.topics.map((x) => x.toLowerCase()));
return wanted.some((w) => have.has(w));
});
const arcs = groupIntoArcs(swept);
if (asJson) {
console.log(JSON.stringify(arcs, null, 2));
return;
}
const md = renderArcs(arcs);
if (flags.out && flags.out !== "true") {
const outDir = flags.out;
mkdirSync(outDir, { recursive: true });
const path = join(outDir, `${today()}-arcs.md`);
writeFileSync(path, md, "utf8");
console.log(`Wrote arc map: ${path} (${arcs.length} åre(r), ${swept.length} swept record(s))`);
return;
}
process.stdout.write(md);
return;
}
if (command === "schedule") {
// RE-R3c — print-first autonomous trigger. Emits (or installs) a launchd plist / cron line firing
// the DETERMINISTIC brief daily via run-daily.sh; never runs launchctl or the cron table (C2).

View file

@ -20,7 +20,7 @@ import { normalizeField } from "./store.js";
import type { TrendInput } from "./store.js";
import { requiredDimensions, scoreEnvelope, capForActionability } from "./score.js";
import type { ScoreMode, DimensionScores } from "./score.js";
import type { TrendVerdict, Actionability } from "./types.js";
import type { TrendVerdict, Actionability, DemandSignal } from "./types.js";
export interface TrendItem {
/** Capture origin: a research-MCP name ("tavily"), "websearch", or "manual". Stored VERBATIM. */
@ -67,6 +67,8 @@ export interface TrendItem {
painPoint?: string;
/** Market saturation judgment (MR-F9). Blank/absent -> omitted. */
saturation?: string;
/** Demand-sweep signal (MR-F9, N7.5). Present-but-malformed -> validation error (like actionability). */
demand?: DemandSignal;
}
export type NormalizeResult = { ok: true; item: TrendItem } | { ok: false; errors: string[] };
@ -131,6 +133,26 @@ function validateScore(
/** The closed reader-side utility vocabulary (MR-F7) — mechanism, not niche calibration. */
const VERDICTS = ["BÆRENDE", "STØTTE", "NYHET"] as const;
/** The closed demand-strength vocabulary (MR-F9, N7.5) — the sweep's honest ordinal, not niche calibration. */
const DEMAND_STRENGTHS = ["strong", "moderate", "thin"] as const;
/**
* Validate an optional `demand` (MR-F9, N7.5) never throws. `strength` must be one of the closed
* vocabulary; `answered` is a REQUIRED boolean (the rankable "is it already answered" the arc layer
* reads). Hard-fail when present-but-malformed, like actionability a bad payload is caught at the
* seam, not persisted silently.
*/
function validateDemand(raw: unknown): { ok: true; value: DemandSignal } | { ok: false; reason: string } {
if (!isPlainObject(raw)) return { ok: false, reason: "demand must be an object" };
if (typeof raw.strength !== "string" || !(DEMAND_STRENGTHS as readonly string[]).includes(raw.strength)) {
return { ok: false, reason: `demand.strength must be one of ${DEMAND_STRENGTHS.join(", ")} (got ${String(raw.strength)})` };
}
if (typeof raw.answered !== "boolean") {
return { ok: false, reason: `demand.answered must be a boolean (got ${String(raw.answered)})` };
}
return { ok: true, value: { strength: raw.strength as DemandSignal["strength"], answered: raw.answered } };
}
/** The free-text proposal fields, all validated by the `summary` idiom (non-empty string -> kept verbatim, else omitted). */
const FREE_TEXT_FIELDS = ["angle", "targetLevel", "rationale", "readerQuestion", "painPoint", "saturation"] as const;
@ -238,6 +260,13 @@ export function normalizeItem(raw: unknown): NormalizeResult {
}
}
let demand: DemandSignal | undefined;
if (r.demand !== undefined && r.demand !== null) {
const res = validateDemand(r.demand);
if (!res.ok) errors.push(`invalid demand: ${res.reason}`);
else demand = res.value;
}
if (errors.length > 0) return { ok: false, errors };
// N6 free-text fields: the summary idiom (non-empty string kept verbatim, else the key is omitted).
@ -259,6 +288,7 @@ export function normalizeItem(raw: unknown): NormalizeResult {
...(relatedIds.length > 0 ? { relatedIds } : {}),
...(actionability !== undefined ? { actionability } : {}),
...(verdict !== undefined ? { verdict } : {}),
...(demand !== undefined ? { demand } : {}),
};
return { ok: true, item };
}
@ -298,6 +328,7 @@ export function itemToInput(item: TrendItem, capturedAt: string): TrendInput {
...(item.readerQuestion !== undefined ? { readerQuestion: item.readerQuestion } : {}),
...(item.painPoint !== undefined ? { painPoint: item.painPoint } : {}),
...(item.saturation !== undefined ? { saturation: item.saturation } : {}),
...(item.demand !== undefined ? { demand: item.demand } : {}),
};
}

View file

@ -18,7 +18,7 @@ import { homedir } from "node:os";
import { createHash } from "node:crypto";
import { SCHEMA_VERSION } from "./types.js";
import type { TrendStore, TrendRecord, TrendQueryHit, TrendStatus, TrendVerdict, Actionability } from "./types.js";
import type { TrendStore, TrendRecord, TrendQueryHit, TrendStatus, TrendVerdict, Actionability, DemandSignal } from "./types.js";
import type { TrendScore } from "./score.js";
export { SCHEMA_VERSION } from "./types.js";
@ -54,6 +54,8 @@ export interface TrendInput {
painPoint?: string;
/** Market saturation judgment (MR-F9). */
saturation?: string;
/** Demand-sweep signal — rankable etterspørsel/ikke-besvart (MR-F9, N7.5). */
demand?: DemandSignal;
}
export interface AddResult {
@ -179,6 +181,7 @@ export function addTrend(store: TrendStore, input: TrendInput): AddResult {
...(input.readerQuestion !== undefined ? { readerQuestion: input.readerQuestion } : {}),
...(input.painPoint !== undefined ? { painPoint: input.painPoint } : {}),
...(input.saturation !== undefined ? { saturation: input.saturation } : {}),
...(input.demand !== undefined ? { demand: input.demand } : {}),
};
store.trends.push(trend);
return { store, added: true, merged: false };

View file

@ -56,6 +56,25 @@ export interface Actionability {
note?: string;
}
/**
* The demand-side signal (N7.5 / MR-F9): the «innenfra og ut» demand-sweep's RANKABLE judgment of
* a reader question how strongly it is actually asked (etterspørsel) and whether a good answer is
* already out there (ikke-besvart). The rankable twin of the verbatim `saturation` text, exactly as
* `actionability.formulated` (bool) is the rankable twin of its verbatim `note` one for arithmetic,
* one for the human. Avsender-neutral mechanism: what counts as demand is the sweep's judgment, never
* niche calibration hard-coded here. Absent on a record UNMEASURED (the honest null the sweep is
* allowed to say "thin signal" via `strength: "thin"`, or nothing at all, rather than invent demand).
*/
export interface DemandSignal {
/**
* How strongly the question is asked the sweep's honest ordinal. `"thin"` is a real thin-signal
* FINDING (measured, and low), distinct from the field being absent (never measured).
*/
strength: "strong" | "moderate" | "thin";
/** Is a good answer already out there? `true` ⇒ saturated (the supply gap is closed — don't write). */
answered: boolean;
}
export interface TrendRecord {
/** Stable id — a short hash of the normalized title+url; doubles as the dedupe key. */
id: string;
@ -131,6 +150,11 @@ export interface TrendRecord {
painPoint?: string;
/** The market saturation judgment — is this already answered by others? (metningsdom), VERBATIM. */
saturation?: string;
/**
* The demand-sweep's rankable demand signal (N7.5). First-sight like the other N6/F9 fields a
* re-capture never clobbers a triaged demand read. Absent unmeasured. See {@link DemandSignal}.
*/
demand?: DemandSignal;
}
export interface TrendStore {
@ -146,10 +170,11 @@ export interface TrendQueryHit {
}
/**
* The store schema version. Still 4 after N6: the eight N6 proposal fields
* The store schema version. Still 4 after N6 AND N7.5: the eight N6 proposal fields
* (angle/targetLevel/rationale/relatedIds/actionability/verdict/readerQuestion/painPoint/saturation)
* are additive-optional a pre-N6 v4 record is already a valid post-N6 v4 record that simply lacks
* them, so NO record needs migrating and a version bump would be a marker with no migration behind it.
* (Earlier slices bumped because they were the first to add fields at all; the choice is deliberate.)
* plus the N7.5 `demand` signal are all additive-optional a pre-N6 v4 record is already a valid
* post-N7.5 v4 record that simply lacks them, so NO record needs migrating and a version bump would
* be a marker with no migration behind it. (Earlier slices bumped because they were the first to add
* fields at all; the choice is deliberate.)
*/
export const SCHEMA_VERSION = 4;

View file

@ -0,0 +1,372 @@
/**
* 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 });
}
});
});