@fangorn-network/westmarch 0.0.1-dev

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,537 @@
1
+ // What the signal MEANS. The half of §9 that was missing.
2
+ //
3
+ // cohort.js returns a publisher a number and a direction: `matched`, a
4
+ // like/skip split, and a 128-d unit vector attention pointed along. That is an
5
+ // honest measurement and it is not a product. A publisher cannot commission a
6
+ // film from a unit vector, and asked what to do with one, the honest answer has
7
+ // so far been "rank your shelf against it and look" — which is a research task,
8
+ // performed by the person who least wants to perform it.
9
+ //
10
+ // The gap is not more data. The publisher already holds the one asset that makes
11
+ // the vector legible: THEIR OWN SHELF, vectors and declared tags included. A
12
+ // direction is meaningless in the abstract and precise against a catalogue.
13
+ //
14
+ // So this file does exactly three things with a readout the publisher just paid
15
+ // for:
16
+ //
17
+ // 1. NAMES the direction in the publisher's own declared tag vocabulary
18
+ // (taste-doc's `vocabulary` + `affinities`, unchanged — the reader-facing
19
+ // code that says "you are drawn to cold war documentaries" is the same code
20
+ // that says "demand is pointing at cold war documentaries"),
21
+ // 2. MEASURES what the publisher owns along it — not "do you have rows", every
22
+ // shelf has rows in every direction, but whether the shelf has anything
23
+ // SHARP there: how far its best row stands above its own mean, in the
24
+ // shelf's own sigma,
25
+ // 3. RETURNS A VERDICT, one of five, each of which names an action with a
26
+ // budget line: commission, serve, fix, retire, cold.
27
+ //
28
+ // WHY PERCENTILES AND SIGMA, NEVER COSINE
29
+ // ---------------------------------------
30
+ // Raw nomic cosine is offset per query. 0.55 against one direction admits three
31
+ // rows and against another admits the entire catalogue — measured, and it
32
+ // returned byte-identical answers to three different questions. Everything below
33
+ // is stated in the shelf's own distribution for the direction being asked about,
34
+ // which is the only frame in which two of a publisher's questions are comparable.
35
+ //
36
+ // WHAT IT REFUSES TO DO
37
+ // ---------------------
38
+ // A withheld direction stays withheld. cohort.js declines to report a centroid
39
+ // standing on fewer than five reactions, because a mean over one row IS that row;
40
+ // a report that quietly filled that in with a plausible guess would defeat the
41
+ // entire mechanism at the last step, in the file the publisher actually reads.
42
+ // `withheld` findings carry counts, no direction, and instructions to widen.
43
+ //
44
+ // ponytail: no forecast, no attribution model, no "expected lift". The report
45
+ // says what was measured and what the publisher owns near it. Everything past
46
+ // that is a model of a market that has run five simulated rounds.
47
+
48
+ import { vocabulary, affinities } from "../consume/taste-doc.js";
49
+
50
+ /** Matched reactions per reader below which a region is not drawing attention.
51
+ * 0.5 = the median reader reacted to something here at least every other
52
+ * round. Below that a "region" is a handful of people, and the counts are
53
+ * still reported — it is the VERDICT that goes cold, not the number. */
54
+ export const ATTENTION_FLOOR = 0.5;
55
+
56
+ /** How far above its own mean a shelf row must stand to count as supply for a
57
+ * direction. Same z as rank.js uses for a hit, and for the same reason: a
58
+ * shelf answers every direction a little, and "a little" is not an answer. */
59
+ export const SUPPLY_Z = 1;
60
+
61
+ /**
62
+ * How far the shelf's best row must stand out, RELATIVE TO CHANCE, to count as
63
+ * supply.
64
+ *
65
+ * The first version of this counted rows above mean + 1σ and called three of
66
+ * them a stocked shelf. That number is a function of the shelf's size and
67
+ * nothing else: cosines against a fixed direction are roughly normal, so ~16% of
68
+ * ANY shelf clears mean + 1σ, and a 21,000-row catalogue "stocks" every
69
+ * direction with 3,300 rows including directions it has never heard of.
70
+ *
71
+ * The scale-free question is whether the best row stands out MORE than the best
72
+ * of n arbitrary rows would. The maximum of n standard normals sits near
73
+ * sqrt(2·ln n) — 3.2σ at 200 rows, 4.1σ at 21,000 — so the statistic is the
74
+ * ratio, and 1.0 is "no better than a shelf of noise". Measured on a real
75
+ * 20,986-row archive: a genuinely served region scores above 1, and the region
76
+ * the shelf merely brushes scores below it while still putting 3,300 rows over
77
+ * a 1σ floor.
78
+ */
79
+ export const STANDOUT = 1;
80
+
81
+ /** Rows beating the chance line below which the shelf has nothing to sell here.
82
+ * For a shelf of pure noise this count is 1 by construction, so three is
83
+ * inventory rather than coincidence — at 200 rows and at 200,000. */
84
+ export const THIN = 3;
85
+
86
+ /** Truncate to the direction's length and renormalise — a truncated unit vector
87
+ * is not a unit vector, and rank.js's `cosine` reuses a norm taken over the
88
+ * full 256 dims, which systematically underrates a row whose energy sits in the
89
+ * tail. Fine for a 256-d query, wrong against a 128-d cohort centroid. */
90
+ const cosTrunc = (vec, dir) => {
91
+ const n = Math.min(vec.length, dir.length);
92
+ let d = 0, vn = 0, dn = 0;
93
+ for (let i = 0; i < n; i++) { d += vec[i] * dir[i]; vn += vec[i] * vec[i]; dn += dir[i] * dir[i]; }
94
+ return d / ((Math.sqrt(vn) || 1) * (Math.sqrt(dn) || 1));
95
+ };
96
+
97
+ /**
98
+ * The publisher's own supply along a direction, in the publisher's own sigma.
99
+ *
100
+ * `depth` is how many rows clear mean + z·σ; `sharpness` is where the best one
101
+ * lands on that scale. A shelf with depth 40 and sharpness 1.4 is a shelf that
102
+ * is vaguely about this and precisely about nothing.
103
+ */
104
+ export function supply(shelf, dir, { z = SUPPLY_Z, limit = 5, title = null } = {}) {
105
+ const rows = shelf.filter((r) => r.vector?.length);
106
+ if (!rows.length || !dir?.length) return { depth: 0, sharpness: 0, standout: 0, top: [], scored: 0 };
107
+ const scored = rows.map((r) => ({ r, s: cosTrunc(r.vector, dir) }));
108
+ // THE BASELINE MUST NOT CONTAIN THE REGION. Taking mean and sd over the whole
109
+ // shelf lets a well-stocked region set its own floor, and the statistic then
110
+ // runs BACKWARDS: measured on a 200-row shelf, one genuine row in a region
111
+ // scores 2.19 and forty score 0.66, so the publisher who serves a region best
112
+ // is the one told it has nothing there. Trimming the top decile before
113
+ // computing the baseline breaks that feedback — the region can no longer
114
+ // inflate the sd it is being judged against.
115
+ const asc = scored.map((x) => x.s).sort((a, b) => a - b);
116
+ const keep = asc.slice(0, Math.max(2, Math.floor(asc.length * 0.9)));
117
+ const mean = keep.reduce((a, x) => a + x, 0) / keep.length;
118
+ const sd = Math.sqrt(keep.reduce((a, x) => a + (x - mean) ** 2, 0) / keep.length) || 1e-9;
119
+ scored.sort((a, b) => b.s - a.s);
120
+ const sharpness = (scored[0].s - mean) / sd;
121
+ // The best of n roughly-normal scores sits near sqrt(2·ln n) — the mode of
122
+ // that maximum, not its mean, so this line is conservative by roughly 15%
123
+ // and `standout` medians ~0.85 on pure noise rather than 1.0. Stated against
124
+ // THAT rather than 1σ, because 1σ admits ~16% of any shelf of any size for
125
+ // any direction — measured, 3,284 rows of a 20,986-row archive "supplied" a
126
+ // region it barely touches.
127
+ const chance = Math.sqrt(2 * Math.log(Math.max(2, scored.length)));
128
+ const floor = mean + Math.max(z, chance) * sd;
129
+ return {
130
+ scored: scored.length,
131
+ // Rows that beat chance, not rows that beat the mean: for pure noise
132
+ // this is 1 by construction, so 3 of them is inventory.
133
+ depth: scored.filter((x) => x.s >= floor).length,
134
+ sharpness: +sharpness.toFixed(2),
135
+ // >1 means the best row stands out further than the best of this many
136
+ // arbitrary rows would. This, and not `depth`, decides the verdict.
137
+ standout: +(sharpness / chance).toFixed(2),
138
+ chance: +chance.toFixed(2),
139
+ top: scored.slice(0, limit).map((x) => ({
140
+ id: x.r.id, z: +((x.s - mean) / sd).toFixed(2),
141
+ ...(title && x.r[title] !== undefined ? { title: String(x.r[title]) } : {}),
142
+ })),
143
+ };
144
+ }
145
+
146
+ /**
147
+ * The verdict. Five outcomes, because there are five different things a
148
+ * publisher does next and each costs a different amount of money.
149
+ *
150
+ * The pair that matters is (does attention go there) × (does the shelf answer
151
+ * it). Sentiment splits the served case, and only that case: a region nobody
152
+ * visits has no sentiment worth acting on, and a region the shelf cannot answer
153
+ * has no sentiment about the shelf at all.
154
+ */
155
+ export function verdict({ perReader, sentiment, standout, depth, stands = STANDOUT, thin = THIN, floor = ATTENTION_FLOOR }) {
156
+ const warm = perReader >= floor;
157
+ const stocked = depth >= thin;
158
+ // A direction that is no sharper against this shelf than a random one is not
159
+ // a finding about the shelf, it is a finding about the question: an anchor
160
+ // admitting a third of the catalogue returns the catalogue's own centre, and
161
+ // ranking a shelf against its own centre is a sorted list of nothing. This
162
+ // is Remark 9.3 arriving from the other side — the fix is a narrower
163
+ // percentile, and saying so is more useful than a confident verdict.
164
+ if (standout < stands) {
165
+ return { verdict: "diffuse", act: `the answer is no sharper against your shelf than chance (${standout}) — narrow the anchor percentile and ask again` };
166
+ }
167
+ if (warm && !stocked) return { verdict: "commission", act: "demand you do not serve — this is the one worth money" };
168
+ if (warm && stocked && (sentiment ?? 0) < 0) return { verdict: "fix", act: "they find it and bounce — check the free projection, the price, or the file" };
169
+ if (warm && stocked) return { verdict: "serve", act: "working — promote it, and price the next one like it" };
170
+ if (!warm && stocked) return { verdict: "retire", act: "shelf you carry and nobody walks to" };
171
+ return { verdict: "cold", act: "no demand and no supply — ignore" };
172
+ }
173
+
174
+ /**
175
+ * One question, answered.
176
+ *
177
+ * `question` is the publisher's own words, `readout` is cohort.js's, verbatim.
178
+ * `revenue` is what the publisher's own sales ledger says the top rows earned —
179
+ * supplied by the caller because the ledger is the app's, not the library's.
180
+ */
181
+ export function finding({ question, readout: out, shelf, vocab, roles = {}, opts = {} }) {
182
+ const base = { question, readers: out.readers, matched: out.matched, perReader: out.perReader, sentiment: out.sentiment };
183
+ if (!out.ok) return { ...base, verdict: "failed", act: out.why, terms: [], top: [] };
184
+ if (!out.centroid) {
185
+ // Counts survive; the direction does not. Reported as a finding rather
186
+ // than dropped, because "we asked and it was too narrow to answer" is
187
+ // itself a fact about the region, and the next question depends on it.
188
+ return { ...base, verdict: "withheld", act: `${out.withheld} Widen the anchor or pool more rounds.`, terms: [], top: [] };
189
+ }
190
+ const sup = supply(shelf, out.centroid, { ...opts, title: roles.title ?? null });
191
+ return {
192
+ ...base,
193
+ ...verdict({ perReader: out.perReader, sentiment: out.sentiment, standout: sup.standout, depth: sup.depth, ...opts }),
194
+ terms: affinities(vocab, out.centroid, { limit: 5 }),
195
+ depth: sup.depth, sharpness: sup.sharpness, standout: sup.standout, chance: sup.chance, ofRows: sup.scored,
196
+ top: sup.top,
197
+ };
198
+ }
199
+
200
+ /**
201
+ * Where demand is MOVING — the one thing a single round cannot say.
202
+ *
203
+ * ONE QUESTION, ASKED REPEATEDLY. This is not a detail. Two centroids answering
204
+ * two DIFFERENT anchors differ because the questions differ, and subtracting one
205
+ * from the other measures the publisher's own choice of anchor, not any movement
206
+ * in demand. Handed a mixed series it would print a confident heading off a
207
+ * quantity nobody measured — so the mixed series is refused by name.
208
+ *
209
+ * `series` is `[{ question, centroid }]` in time order. It is a heading, not a
210
+ * place, so it is named with `center: false`: subtracting the catalogue's centre
211
+ * from a difference is meaningless (taste-doc says the same of the kernel's `v`).
212
+ */
213
+ export function heading(series = [], vocab, { limit = 4 } = {}) {
214
+ const byQ = new Map();
215
+ for (const x of series.filter((y) => y?.centroid?.length)) byQ.set(x.question, [...(byQ.get(x.question) ?? []), x]);
216
+ // The LONGEST run of one question, never the pooled set: centroids answering
217
+ // different anchors differ because the anchors differ, and their difference
218
+ // measures the publisher's own choice of question.
219
+ const ok = [...byQ.values()].sort((a, b) => b.length - a.length)[0] ?? [];
220
+ if (ok.length < 4) {
221
+ return { terms: [], why: byQ.size > 1
222
+ ? `no question has been asked enough times — ${byQ.size} different questions, the most-repeated answered ${ok.length} time${ok.length === 1 ? "" : "s"}, 4 needed. `
223
+ + "Two anchors differ because the anchors differ; that is not a movement in demand."
224
+ : `a heading needs two windows — ${ok.length} answered round${ok.length === 1 ? "" : "s"} so far, 4 minimum` };
225
+ }
226
+ const half = Math.floor(ok.length / 2);
227
+ const mean = (xs) => xs[0].centroid.map((_, i) => xs.reduce((a, v) => a + v.centroid[i], 0) / xs.length);
228
+ const older = mean(ok.slice(0, half)), later = mean(ok.slice(half));
229
+ const d = later.map((x, i) => x - older[i]);
230
+ // `affinities` unit-normalises whatever it is handed, which throws away the
231
+ // one quantity that says whether demand moved AT ALL. Without this check a
232
+ // static series with 2% sampling noise was named a heading 199 times out of
233
+ // 200 — the "has not moved enough" branch was dead code. The movement has to
234
+ // clear the spread WITHIN each window before it is a movement between them.
235
+ const mag = Math.sqrt(d.reduce((a, x) => a + x * x, 0));
236
+ const spread = (xs, m) => Math.sqrt(xs.reduce((a, v) =>
237
+ a + v.centroid.reduce((b, x, i) => b + (x - m[i]) ** 2, 0), 0) / Math.max(1, xs.length));
238
+ const wobble = Math.max(spread(ok.slice(0, half), older), spread(ok.slice(half), later));
239
+ // Two windows of k samples drawn from ONE static distribution still have
240
+ // means about wobble·sqrt(2/k) apart. That, not the wobble itself, is what a
241
+ // real movement has to beat — and it has to beat it by a margin, because
242
+ // being right about half the static series is not a guard.
243
+ const noise = 2 * wobble * Math.sqrt(2 / Math.max(1, half));
244
+ if (mag <= noise) {
245
+ return { terms: [], rounds: ok.length,
246
+ why: `demand has not moved: the shift between windows (${mag.toFixed(3)}) is inside what ${half} rounds of a standing question would show anyway (${noise.toFixed(3)})` };
247
+ }
248
+ const terms = affinities(vocab, d, { limit, center: false });
249
+ return terms.length
250
+ ? { terms, rounds: ok.length, question: ok[0].question }
251
+ : { terms: [], why: "demand has not moved enough to name a heading" };
252
+ }
253
+
254
+ /**
255
+ * The report. `probes` are `[{ question, readout }]`, in time order.
256
+ *
257
+ * `spend` is the plain arithmetic a publisher needs before the next round and
258
+ * which nothing else in this repo was doing: what the answers cost, against what
259
+ * the rows they point at have actually earned. No projection — earned, from the
260
+ * caller's own ledger.
261
+ */
262
+ export function demandReport({ shelf = [], roles = {}, probes = [], paidPerReader = 0n, earned = null, opts = {} } = {}) {
263
+ // THE VOCABULARY MUST LIVE AT THE ANSWER'S DIMENSION. A cohort centroid is
264
+ // 128-d and a shelf vector is 256-d; a term direction built at 256 and
265
+ // compared against a 128-d answer reads `undefined` past the halfway point
266
+ // and the dot product is NaN, which `affinities` filters out as "below the
267
+ // floor" — so every region came back unnameable and nothing errored. Found
268
+ // against a real 20,986-row shelf that declares seven tag fields.
269
+ const dim = Math.min(...probes.map((p) => p.readout?.centroid?.length ?? Infinity));
270
+ const cut = Number.isFinite(dim)
271
+ ? shelf.map((r) => (r.vector?.length > dim ? { ...r, vector: r.vector.slice(0, dim) } : r))
272
+ : shelf;
273
+ const vocab = vocabulary(cut, roles, opts.minSupport ? { minSupport: opts.minSupport } : {});
274
+ const findings = probes.map((p) => finding({ ...p, shelf, vocab, roles, opts }));
275
+ const spent = probes.reduce((a, p) => a + BigInt(p.readout?.readers ?? 0) * BigInt(paidPerReader), 0n);
276
+ return {
277
+ unsafe: differencing(probes),
278
+ shelf: { rows: shelf.length, vectors: shelf.filter((r) => r.vector?.length).length, terms: vocab.terms.length, dim: Number.isFinite(dim) ? dim : null },
279
+ findings,
280
+ heading: heading(probes.map((p, i) => (findings[i].verdict === "withheld" || findings[i].verdict === "failed"
281
+ ? null : { question: p.question, centroid: p.readout.centroid })), vocab),
282
+ spend: { probes: probes.length, paidToReaders: spent, shelfEarned: earned },
283
+ blind: findings.filter((f) => f.verdict === "withheld" || f.verdict === "failed").map((f) => f.question),
284
+ };
285
+ }
286
+
287
+ /**
288
+ * The attack the per-round floor cannot see.
289
+ *
290
+ * cohort.js guards ONE round: five readers, five matched reactions, or no
291
+ * direction. Nothing in it composes across rounds, and this file is what made
292
+ * composing them a product — `probes` is an array and `heading` asks for four.
293
+ *
294
+ * Two rounds on the same anchor at slightly different widths return two answered
295
+ * directions, both far above the floor, whose difference is the handful of
296
+ * reactions that fell between the widths. Measured against a 400-row catalogue
297
+ * and 8 readers: matched 39 and 38, the normalised difference of the two
298
+ * centroids landed at cosine 1.0000 on one public row — one reader's film,
299
+ * recovered exactly, from two rounds that individually disclosed nothing.
300
+ *
301
+ * The floor is on rows per round; this has to be on the SERIES, and this file is
302
+ * the only one that sees a series. So the report names the pairs rather than
303
+ * pretending the per-round guard covered them.
304
+ *
305
+ * ponytail: named, not enforced. A budget that refuses to compute is the right
306
+ * mechanism and it belongs where rounds are PAID FOR — refusing here only moves
307
+ * the arithmetic one file over, since the publisher holds both readouts either
308
+ * way. What this can do honestly is refuse to be the tool that performs it, and
309
+ * say so at the top of the brief.
310
+ */
311
+ export const CLOSE = 5;
312
+
313
+ export function differencing(probes = [], { close = CLOSE } = {}) {
314
+ const answered = probes.map((p, i) => ({ i, q: p.question, r: p.readout }))
315
+ .filter((x) => x.r?.ok && x.r.centroid);
316
+ const out = [];
317
+ for (let a = 0; a < answered.length; a++) {
318
+ for (let b = a + 1; b < answered.length; b++) {
319
+ const gap = Math.abs(answered[a].r.matched - answered[b].r.matched);
320
+ if (gap === 0 || gap >= close) continue;
321
+ out.push({ questions: [answered[a].q, answered[b].q], matched: [answered[a].r.matched, answered[b].r.matched], gap });
322
+ }
323
+ }
324
+ return out;
325
+ }
326
+
327
+ const usd = (base) => `${(Number(base) / 1e6).toFixed(2)} USDC`;
328
+ const named = (ts) => (ts.length ? ts.map((t) => `${t.value}`).join(", ") : "an unnamed region — no declared tag of yours describes it");
329
+
330
+ /**
331
+ * The brief, as an agent reads it out.
332
+ *
333
+ * Markdown rather than a table of numbers, because the consumer of this is a
334
+ * publisher's agent answering "what should I make next", and the answer to that
335
+ * is a sentence. Every number in it is measured; nothing is projected.
336
+ */
337
+ export function brief(rep, { title = "demand" } = {}) {
338
+ const order = { commission: 0, fix: 1, serve: 2, retire: 3, diffuse: 4, withheld: 5, cold: 6, failed: 7 };
339
+ // One question asked weekly is ONE finding with six rounds behind it, not six
340
+ // findings. Printing it six times buries the one question that was asked once
341
+ // and mattered, which is the failure mode of every dashboard.
342
+ const seen = new Map();
343
+ for (const f of rep.findings) {
344
+ const k = `${f.question}|${f.verdict}`;
345
+ const p = seen.get(k);
346
+ if (p) { p.rounds++; p.matched = Math.round((p.matched * (p.rounds - 1) + f.matched) / p.rounds); }
347
+ else seen.set(k, { ...f, rounds: 1 });
348
+ }
349
+ const fs = [...seen.values()].sort((a, b) => (order[a.verdict] ?? 9) - (order[b.verdict] ?? 9));
350
+ const L = [`# ${title}`, "",
351
+ `${rep.shelf.rows} rows on your shelf, ${rep.shelf.vectors} with vectors, ${rep.shelf.terms} of your tags carry enough rows to name a region.`,
352
+ `${rep.spend.probes} question${rep.spend.probes === 1 ? "" : "s"} asked, ${usd(rep.spend.paidToReaders)} paid to readers.`
353
+ + (rep.spend.shelfEarned === null ? "" : ` This shelf has earned ${usd(rep.spend.shelfEarned)} over the same period.`), ""];
354
+ if (rep.unsafe?.length) {
355
+ L.push("> **Do not difference these answers.** " + rep.unsafe.map((u) =>
356
+ `“${u.questions[0]}” and “${u.questions[1]}” matched ${u.matched.join(" and ")} reactions`).join("; ")
357
+ + `. Two answers ${rep.unsafe[0].gap} reaction${rep.unsafe[0].gap === 1 ? "" : "s"} apart differ by those reactions, `
358
+ + "and a reaction's vector is a copy of a row anyone can download. The per-round floor does not see a series.", "");
359
+ }
360
+
361
+ for (const f of fs) {
362
+ L.push(`## ${f.verdict} — ${f.question}`);
363
+ L.push(`*${f.act}*`, "");
364
+ // A failed round's totals are noise, so there are no counts to print —
365
+ // `readout` returns only `{ok, why}` and everything else renders
366
+ // "undefined reactions from undefined readers", which reads as data.
367
+ if (f.verdict === "failed") { L.push(""); continue; }
368
+ L.push((f.rounds > 1 ? `asked ${f.rounds}×, mean ` : "") + `${f.matched} reaction${f.matched === 1 ? "" : "s"} from ${f.readers} readers (${f.perReader}/reader)`
369
+ + (f.sentiment === null ? "" : `, sentiment ${f.sentiment > 0 ? "+" : ""}${f.sentiment}`) + ".");
370
+ if (f.verdict === "withheld") { L.push(""); continue; }
371
+ if (f.verdict === "diffuse") { L.push(`Your shelf: best row at ${f.sharpness}σ against ${f.chance}σ for the best of ${f.ofRows} arbitrary rows.`, ""); continue; }
372
+ L.push(`Demand points at: **${named(f.terms)}**.`);
373
+ L.push(`Your shelf: best row at ${f.sharpness}σ against ${f.chance}σ for the best of ${f.ofRows} arbitrary rows — standout ${f.standout}.`);
374
+ if (f.top.length) L.push("", ...f.top.map((t) => `- ${t.title ? `**${t.title}**` : `\`${t.id}\``} ${t.z}σ`));
375
+ L.push("");
376
+ }
377
+ L.push("## heading", "");
378
+ L.push(rep.heading.terms.length
379
+ ? `Demand is moving toward **${named(rep.heading.terms)}** over ${rep.heading.rounds} answered rounds.`
380
+ : rep.heading.why);
381
+ if (rep.blind.length) L.push("", "## not answered", "", ...rep.blind.map((q) => `- ${q}`));
382
+ return L.join("\n");
383
+ }
384
+
385
+ // ── self-check: `node publish/demand.js` ────────────────────────────────────
386
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
387
+ const { keypair, statistics, contribute, aggregate, readout } = await import("./cohort.js");
388
+
389
+ // A shelf with two genuine regions and a hole between them. 128 dims so the
390
+ // shelf and a cohort centroid line up without truncation doing the work.
391
+ const D = 128;
392
+ const axis = (k) => { const v = new Float32Array(D); v[k] = 1; return v; };
393
+ const jitter = (v, i) => { const o = Float32Array.from(v); o[(i * 7) % D] += 0.08; return o; };
394
+ // A deterministic background of 200 unrelated rows, because a shelf of
395
+ // nothing but two clusters has an sd so wide that NO row clears the chance
396
+ // line, and the statistic below is calibrated for a real catalogue whose
397
+ // cosines against one direction are roughly normal. The toy shelf without
398
+ // this background reported depth 0 for a region it obviously serves.
399
+ let seed = 7;
400
+ const rnd = () => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff) - 0.5;
401
+ const shelf = [
402
+ ...Array.from({ length: 200 }, (_, i) => ({
403
+ id: `bg-${i}`, vector: Float32Array.from({ length: D }, rnd), genre: "misc", year: 1970,
404
+ })),
405
+ ...Array.from({ length: 12 }, (_, i) => ({ id: `sub-${i}`, vector: jitter(axis(0), i), genre: "submarine", year: 1960 })),
406
+ ...Array.from({ length: 12 }, (_, i) => ({ id: `cartoon-${i}`, vector: jitter(axis(1), i), genre: "cartoon", year: 1950 })),
407
+ // Two rows only, in a third direction: a region the shelf does not serve.
408
+ ...Array.from({ length: 2 }, (_, i) => ({ id: `noir-${i}`, vector: jitter(axis(2), i), genre: "noir", year: 1948 })),
409
+ ];
410
+ const roles = { tags: ["genre"] };
411
+ const vocab = vocabulary(shelf, roles);
412
+ if (vocab.terms.length !== 3) throw new Error(`only tags with real support may name a region: ${vocab.terms.length}`);
413
+ if (vocab.terms.some((t) => t.value === "noir")) throw new Error("two rows is below the support floor — noir must not be a term");
414
+
415
+ // ── the shelf's own sigma, not a cosine ───────────────────────────────
416
+ const sub = supply(shelf, Array.from(axis(0)));
417
+ const noir = supply(shelf, Array.from(axis(2)));
418
+ if (sub.depth < 10) throw new Error(`the shelf answers submarine deeply: ${sub.depth}`);
419
+ if (noir.depth > 4) throw new Error(`…and answers noir with almost nothing: ${noir.depth}`);
420
+ if (!(sub.sharpness > 1)) throw new Error("a served direction stands above the shelf's own mean");
421
+ if (!sub.top[0].id.startsWith("sub-")) throw new Error("the top row must be from the region asked about");
422
+
423
+ // ── a real round, end to end, through cohort.js ────────────────────────
424
+ // Six readers who all reacted in the noir direction: attention with no supply.
425
+ const R = 6;
426
+ const log = (seed) => Array.from({ length: 4 }, (_, i) => ({
427
+ id: `x${seed}${i}`, corpus: "films", reaction: "like", vector: jitter(axis(2), seed + i),
428
+ }));
429
+ const ks = []; for (let i = 0; i < R; i++) ks.push(await keypair());
430
+ const pubs = ks.map((k) => k.pub);
431
+ const run = async (logs) => {
432
+ const shares = [];
433
+ for (let i = 0; i < R; i++) shares.push(await contribute(statistics(logs[i], { corpus: "films" }), ks[i], pubs));
434
+ return readout(aggregate(shares), R);
435
+ };
436
+ const hot = await run(Array.from({ length: R }, (_, i) => log(i)));
437
+ if (!hot.ok || !hot.centroid) throw new Error("24 reactions from 6 readers is answerable");
438
+
439
+ const rep = demandReport({
440
+ shelf, roles,
441
+ probes: [{ question: "how did readers react around our noir?", readout: hot }],
442
+ paidPerReader: 1000n,
443
+ revenue: (id) => (id.startsWith("noir") ? 250000n : 0n),
444
+ });
445
+ const f = rep.findings[0];
446
+ if (f.verdict !== "commission") throw new Error(`attention with no supply is a commission, not a ${f.verdict}`);
447
+ if (!f.act.includes("do not serve")) throw new Error("the verdict must name the action, not just the label");
448
+ if (f.terms.length) throw new Error("no declared tag of this publisher describes the noir region — it must say so, not pick the nearest tag");
449
+ if (rep.spend.paidToReaders !== 6000n) throw new Error(`six readers at 1000 base units: ${rep.spend.paidToReaders}`);
450
+
451
+ // Same shelf, attention in a direction it DOES serve: the verdict flips, and
452
+ // nothing else about the machinery changes.
453
+ const served = await run(Array.from({ length: R }, (_, i) =>
454
+ Array.from({ length: 4 }, (_, j) => ({ id: `s${i}${j}`, corpus: "films", reaction: "like", vector: jitter(axis(0), i + j) }))));
455
+ const rep2 = demandReport({ shelf, roles, probes: [{ question: "and around our submarines?", readout: served }] });
456
+ if (rep2.findings[0].verdict !== "serve") throw new Error(`attention the shelf answers is "serve": ${rep2.findings[0].verdict}`);
457
+ if (rep2.findings[0].terms[0]?.value !== "submarine") throw new Error("…and it is named in the publisher's OWN tag");
458
+
459
+ // Attention the shelf answers, and readers bounce off: a different action.
460
+ const bounced = { ...served, sentiment: -0.6 };
461
+ if (demandReport({ shelf, roles, probes: [{ question: "q", readout: bounced }] }).findings[0].verdict !== "fix") {
462
+ throw new Error("supply + attention + negative sentiment is a fix, not a promotion");
463
+ }
464
+ // Supply nobody walks to.
465
+ const quiet = { ...served, perReader: 0.1 };
466
+ if (demandReport({ shelf, roles, probes: [{ question: "q", readout: quiet }] }).findings[0].verdict !== "retire") {
467
+ throw new Error("shelf with no attention is shelf to retire");
468
+ }
469
+
470
+ // ── a withheld direction stays withheld, all the way to the brief ──────
471
+ // This is the one that would quietly undo cohort.js: the round correctly
472
+ // refuses to name a direction over one reaction, and a report that filled it
473
+ // in from the nearest shelf row would publish exactly the row the floor
474
+ // exists to hide.
475
+ const lonely = [log(0).slice(0, 1), ...Array.from({ length: R - 1 }, () => [])];
476
+ const thin = await run(lonely);
477
+ if (thin.centroid) throw new Error("cohort.js should have withheld this");
478
+ const rep3 = demandReport({ shelf, roles, probes: [{ question: "the narrow one", readout: thin }] });
479
+ if (rep3.findings[0].verdict !== "withheld") throw new Error("a withheld direction is a withheld finding");
480
+ if (rep3.findings[0].terms.length || rep3.findings[0].top?.length) throw new Error("a withheld finding names NOTHING on the shelf");
481
+ if (!rep3.blind.includes("the narrow one")) throw new Error("and the report says which questions it could not answer");
482
+ const b3 = brief(rep3);
483
+ if (/noir|sub-|cartoon/.test(b3)) throw new Error("no shelf row may appear in a brief built on a withheld direction");
484
+ if (!b3.includes("Widen")) throw new Error("the brief must tell the publisher what to do about it");
485
+
486
+ // ── the differencing oracle the per-round floor cannot see ─────────────
487
+ // Two answers a few reactions apart differ BY those reactions, and every
488
+ // reaction vector is a copy of a public row. Both rounds here are far above
489
+ // MIN_SUPPORT and neither discloses anything on its own.
490
+ const wide = { ...hot, matched: 39 }, narrow = { ...hot, matched: 38 };
491
+ const risky = demandReport({ shelf, roles, probes: [
492
+ { question: "near our noir, top 2.5%", readout: wide },
493
+ { question: "near our noir, top 3.0%", readout: narrow }] });
494
+ if (risky.unsafe.length !== 1) throw new Error("two answers one reaction apart must be named as a pair");
495
+ if (!brief(risky).includes("Do not difference")) throw new Error("…at the top of the brief, before any finding");
496
+ const apart = demandReport({ shelf, roles, probes: [
497
+ { question: "a", readout: { ...hot, matched: 40 } },
498
+ { question: "b", readout: { ...hot, matched: 12 } }] });
499
+ if (apart.unsafe.length) throw new Error("answers far apart are not a differencing pair");
500
+
501
+ // ── a failed round prints no counts, because it HAS none ───────────────
502
+ const broke = brief(demandReport({ shelf, roles, probes: [{ question: "q", readout: { ok: false, why: "the masks did not cancel" } }] }));
503
+ if (broke.includes("undefined")) throw new Error("a failed round must not render undefined as a measurement");
504
+
505
+ // ── the heading needs two windows and refuses to invent one ────────────
506
+ const one = demandReport({ shelf, roles, probes: [{ question: "q", readout: hot }] });
507
+ if (one.heading.terms.length) throw new Error("one round is not a heading");
508
+ if (!one.heading.why.includes("4 minimum")) throw new Error("…and it must say why rather than returning empty");
509
+ const drift = [axis(0), axis(0), axis(1), axis(1)].map((v) => ({ question: "same one, weekly", centroid: Array.from(v) }));
510
+ const h = heading(drift, vocab);
511
+ // A static series must not be named. `affinities` unit-normalises the
512
+ // difference, so without a magnitude check every series has a heading.
513
+ const noisy = () => { const v = Array.from(axis(0)); for (let i = 0; i < D; i++) v[i] += rnd() * 0.04; return v; };
514
+ const still = Array.from({ length: 6 }, () => ({ question: "s", centroid: noisy() }));
515
+ if (heading(still, vocab).terms.length) throw new Error("a series that did not move has no heading");
516
+ if (!heading(still, vocab).why.includes("has not moved")) throw new Error("…and it must compare the shift to the wobble");
517
+ // …and a series of DIFFERENT questions is not a heading at all.
518
+ const mixed = drift.map((x, i) => ({ ...x, question: `q${i}` }));
519
+ if (heading(mixed, vocab).terms.length) throw new Error("four different questions are not a time series");
520
+ if (!heading(mixed, vocab).why.includes("anchors differ")) throw new Error("…and it must say exactly why");
521
+ if (h.terms[0]?.value !== "cartoon") throw new Error(`a heading is where demand MOVED to, in the publisher's tags: ${JSON.stringify(h.terms)}`);
522
+
523
+ // ── the brief is what an agent reads ──────────────────────────────────
524
+ const text = brief(demandReport({
525
+ shelf, roles,
526
+ probes: [{ question: "noir?", readout: hot }, { question: "submarines?", readout: served }],
527
+ paidPerReader: 1000n, revenue: (id) => (id.startsWith("sub") ? 50000n : 0n),
528
+ }));
529
+ if (text.indexOf("## commission") > text.indexOf("## serve")) throw new Error("the money finding goes first — a brief is read from the top");
530
+ if (!text.includes("0.01 USDC")) throw new Error("what it cost, in money, not base units");
531
+ if (!text.includes("submarine")) throw new Error("the served region is named in the publisher's own vocabulary");
532
+
533
+ console.log("demand.js self-check ok — a direction is named in the publisher's own declared tags or admitted to be unnameable, "
534
+ + "supply is measured in the shelf's own sigma so two questions are comparable, attention without supply is the finding "
535
+ + "worth money and sorts to the top, a withheld direction names nothing on the shelf all the way through the brief, "
536
+ + "a heading refuses to exist before two windows of ONE repeated question, and the spend is stated in money");
537
+ }