@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.
- package/LICENSE.md +20 -0
- package/README.md +280 -0
- package/consume/apps.js +111 -0
- package/consume/corpora.js +297 -0
- package/consume/directory.js +262 -0
- package/consume/embed.js +251 -0
- package/consume/embed.worker.js +26 -0
- package/consume/lint.js +500 -0
- package/consume/mcp.js +381 -0
- package/consume/rank.js +218 -0
- package/consume/roles.js +556 -0
- package/consume/shard.js +726 -0
- package/consume/steam.js +341 -0
- package/consume/taste-doc.js +342 -0
- package/consume/taste.js +350 -0
- package/consume/tools.js +358 -0
- package/consume/ui.js +724 -0
- package/docs/app-to-agent.md +351 -0
- package/package.json +70 -0
- package/publish/agent-card.js +134 -0
- package/publish/cli.js +336 -0
- package/publish/cohort.js +349 -0
- package/publish/demand.js +537 -0
- package/publish/enrich.js +211 -0
- package/publish/envelope.js +185 -0
- package/publish/graph.js +307 -0
- package/publish/manifest.js +142 -0
- package/publish/reactions.js +343 -0
- package/publish/settle.js +165 -0
- package/publish/terms.js +200 -0
- package/publish/vault.js +162 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// taste.md — the human half of AGENTS.md.
|
|
2
|
+
//
|
|
3
|
+
// A taste is 108 bytes of int8 in a space nobody can read. That is a fine thing
|
|
4
|
+
// to compute with and a terrible thing to own: you cannot check it, correct it,
|
|
5
|
+
// argue with it, or decide you have changed. `exportTaste` already carries the
|
|
6
|
+
// TITLES it was built from for exactly that reason, which helps and is not
|
|
7
|
+
// enough — a list of twelve films you already know you watched tells you nothing
|
|
8
|
+
// you did not know.
|
|
9
|
+
//
|
|
10
|
+
// So this file writes the vector down in words, against the vocabulary the
|
|
11
|
+
// publishers themselves declared, and the result is a document with the same
|
|
12
|
+
// job as AGENTS.md: something a person reads to see what is being assumed about
|
|
13
|
+
// them, edits to say otherwise, and hands to an agent as the brief.
|
|
14
|
+
//
|
|
15
|
+
// WHY THE PUBLISHERS' OWN VOCABULARY
|
|
16
|
+
// ----------------------------------
|
|
17
|
+
// The alternative is to embed a fixed list of adjectives and score those, which
|
|
18
|
+
// would describe the reader in the model's terms and be wrong in the specific
|
|
19
|
+
// way that matters: it would say the same things about a person on a catalogue
|
|
20
|
+
// of silent film and a catalogue of restaurants. Here a term exists only because
|
|
21
|
+
// some publisher declared it as a `tags` role and at least MIN_SUPPORT of their
|
|
22
|
+
// rows carry it, so the words available to describe you are the words the
|
|
23
|
+
// catalogue is actually organised by.
|
|
24
|
+
//
|
|
25
|
+
// WHY EVERY TERM IS CENTRED
|
|
26
|
+
// -------------------------
|
|
27
|
+
// A term's meaning is the mean of the rows carrying it, and the mean of 5,000
|
|
28
|
+
// rows tagged `drama` is very close to the mean of the whole catalogue — so raw
|
|
29
|
+
// cosine ranks the biggest tags first for everyone, and every reader is told
|
|
30
|
+
// they like drama. Subtracting the corpus mean from both sides asks the only
|
|
31
|
+
// question worth asking: of the ways this catalogue varies, which ones are you?
|
|
32
|
+
import { values } from "./roles.js";
|
|
33
|
+
|
|
34
|
+
/** Below this a term's "meaning" is a handful of rows, and on a small enough
|
|
35
|
+
* handful it is one row wearing a word — which is both noise and, on a corpus
|
|
36
|
+
* of reactions, a disclosure. Same floor as `reactions.js`. */
|
|
37
|
+
export const MIN_SUPPORT = 8;
|
|
38
|
+
|
|
39
|
+
const unit = (d) => {
|
|
40
|
+
let n = 0;
|
|
41
|
+
for (const x of d) n += x * x;
|
|
42
|
+
n = Math.sqrt(n);
|
|
43
|
+
if (n < 1e-9) return null;
|
|
44
|
+
for (let i = 0; i < d.length; i++) d[i] /= n;
|
|
45
|
+
return d;
|
|
46
|
+
};
|
|
47
|
+
const dot = (a, b) => { let s = 0; for (let i = 0; i < a.length && i < b.length; i++) s += a[i] * b[i]; return s; };
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* What this catalogue can say about a reader: every declared tag value with
|
|
51
|
+
* enough rows behind it, as a direction away from the catalogue's own centre.
|
|
52
|
+
*/
|
|
53
|
+
export function vocabulary(rows = [], roles = {}, { minSupport = MIN_SUPPORT } = {}) {
|
|
54
|
+
const dim = rows.find((r) => r.vector)?.vector.length ?? 0;
|
|
55
|
+
if (!dim) return { mean: null, dim: 0, terms: [] };
|
|
56
|
+
|
|
57
|
+
const mean = new Float64Array(dim);
|
|
58
|
+
let n = 0;
|
|
59
|
+
for (const r of rows) {
|
|
60
|
+
if (!r.vector) continue;
|
|
61
|
+
n++;
|
|
62
|
+
for (let i = 0; i < dim; i++) mean[i] += r.vector[i];
|
|
63
|
+
}
|
|
64
|
+
for (let i = 0; i < dim; i++) mean[i] /= n;
|
|
65
|
+
|
|
66
|
+
const acc = new Map();
|
|
67
|
+
for (const r of rows) {
|
|
68
|
+
if (!r.vector) continue;
|
|
69
|
+
for (const f of roles.tags ?? []) {
|
|
70
|
+
for (const v of values(r[f])) {
|
|
71
|
+
const k = `${f} ${v}`;
|
|
72
|
+
let a = acc.get(k);
|
|
73
|
+
if (!a) acc.set(k, (a = { field: f, value: v, n: 0, sum: new Float64Array(dim) }));
|
|
74
|
+
a.n++;
|
|
75
|
+
for (let i = 0; i < dim; i++) a.sum[i] += r.vector[i];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const terms = [];
|
|
81
|
+
for (const a of acc.values()) {
|
|
82
|
+
if (a.n < minSupport) continue;
|
|
83
|
+
const d = new Float64Array(dim);
|
|
84
|
+
for (let i = 0; i < dim; i++) d[i] = a.sum[i] / a.n - mean[i];
|
|
85
|
+
if (unit(d)) terms.push({ field: a.field, value: a.value, n: a.n, vec: d });
|
|
86
|
+
}
|
|
87
|
+
return { mean, dim, terms };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The terms a direction is most like.
|
|
92
|
+
*
|
|
93
|
+
* `center` is false for a vector that is ALREADY a difference — the kernel's `v`
|
|
94
|
+
* is (recent picks minus older picks), a heading rather than a place, and taking
|
|
95
|
+
* the catalogue's centre off a heading is meaningless.
|
|
96
|
+
*/
|
|
97
|
+
/** Two tags this close are one fact wearing two words. Not MMR: MMR reorders and
|
|
98
|
+
* still fills the list, so with nothing but aliases left to pick it prints all
|
|
99
|
+
* of them. An alias has to be DROPPED. 0.92 is well above anything two genuinely
|
|
100
|
+
* different tags reach and well below the ~0.99 of two names for one show. */
|
|
101
|
+
export const REDUNDANT = 0.92;
|
|
102
|
+
|
|
103
|
+
export function affinities(vocab, vec, { limit = 6, center = true, floor = 0.05, redundant = REDUNDANT } = {}) {
|
|
104
|
+
if (!vocab?.terms?.length || !vec) return [];
|
|
105
|
+
const d = new Float64Array(vocab.dim);
|
|
106
|
+
for (let i = 0; i < vocab.dim; i++) d[i] = vec[i] - (center ? vocab.mean[i] : 0);
|
|
107
|
+
if (!unit(d)) return [];
|
|
108
|
+
const ranked = vocab.terms
|
|
109
|
+
.map((t) => ({ t, score: dot(t.vec, d) }))
|
|
110
|
+
.filter((x) => x.score > floor)
|
|
111
|
+
.sort((a, b) => b.score - a.score);
|
|
112
|
+
|
|
113
|
+
// A cartoon called Eek carries `eek`, `eek the cat`, `fox` and its voice
|
|
114
|
+
// actor across one set of 59 rows, so the raw ranking opened with four
|
|
115
|
+
// bullets saying "you watched Eek the Cat". A description that repeats
|
|
116
|
+
// itself is not shorter than the vector it describes, and being shorter than
|
|
117
|
+
// the vector is the only reason this document exists.
|
|
118
|
+
const out = [];
|
|
119
|
+
for (const { t, score } of ranked) {
|
|
120
|
+
if (out.length >= limit) break;
|
|
121
|
+
if (out.some((k) => dot(k.vec, t.vec) > redundant)) continue;
|
|
122
|
+
out.push({ vec: t.vec, field: t.field, value: t.value, n: t.n, score });
|
|
123
|
+
}
|
|
124
|
+
return out.map(({ vec: _, ...rest }) => rest);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const pct = (x) => `${(x * 100).toFixed(0)}`;
|
|
128
|
+
const list = (xs) => xs.map((x) => `\`${x}\``).join(", ");
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The document.
|
|
132
|
+
*
|
|
133
|
+
* `catalogues` are `[{ name, rows, roles }]` — whatever this reader happens to
|
|
134
|
+
* have open. The document is therefore about a reader AS SEEN BY the catalogues
|
|
135
|
+
* they have actually touched, which is the honest scope: nothing here claims to
|
|
136
|
+
* describe a person, only how their choices land against these shelves.
|
|
137
|
+
*
|
|
138
|
+
* `instructions` is the half a person WRITES. It is round-tripped untouched and
|
|
139
|
+
* placed above everything derived, because a document whose authored section is
|
|
140
|
+
* a footnote to the machine's section is not a brief, it is a report.
|
|
141
|
+
*/
|
|
142
|
+
export function tasteDoc({ t = null, catalogues = [], instructions = "", now = new Date() } = {}) {
|
|
143
|
+
const day = now.toISOString().slice(0, 10);
|
|
144
|
+
const head = [
|
|
145
|
+
"# taste.md",
|
|
146
|
+
"",
|
|
147
|
+
"> Your preferences, as your own agent has them. Derived on this machine from the",
|
|
148
|
+
"> things you named; never sent anywhere. Everything below the line is rewritten",
|
|
149
|
+
"> each time you react — the *Standing instructions* are yours.",
|
|
150
|
+
"",
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
const authored = [
|
|
154
|
+
"## Standing instructions",
|
|
155
|
+
"",
|
|
156
|
+
instructions.trim()
|
|
157
|
+
|| "_Nothing yet. Write what an agent should do for you — everything below is an\nobservation, and this is the part where you get to answer back._",
|
|
158
|
+
"",
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
if (!t) {
|
|
162
|
+
return [...head, ...authored,
|
|
163
|
+
"---", "",
|
|
164
|
+
"## Observed", "",
|
|
165
|
+
"Nothing observed yet: no likes or rejections have been recorded in this browser.",
|
|
166
|
+
"React to a few things and this document fills in.", ""].join("\n");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const vocabs = catalogues
|
|
170
|
+
.map((c) => ({ name: c.name, v: vocabulary(c.rows ?? [], c.roles ?? {}) }))
|
|
171
|
+
.filter((c) => c.v.terms.length);
|
|
172
|
+
|
|
173
|
+
const across = (vec, { limit = 6, center = true } = {}) => vocabs
|
|
174
|
+
.flatMap(({ name, v }) => affinities(v, vec, { center, limit: 4 }).map((a) => ({ ...a, corpus: name })))
|
|
175
|
+
.sort((a, b) => b.score - a.score)
|
|
176
|
+
.slice(0, limit);
|
|
177
|
+
|
|
178
|
+
const draw = across(t.q, { limit: 7 });
|
|
179
|
+
const pass = t.no ? across(t.no, { limit: 5 }) : [];
|
|
180
|
+
const toward = t.v ? across(t.v, { limit: 4, center: false }) : [];
|
|
181
|
+
const away = t.v ? across(Float64Array.from(t.v, (x) => -x), { limit: 4, center: false }) : [];
|
|
182
|
+
|
|
183
|
+
const bullets = (xs) => (xs.length
|
|
184
|
+
? xs.map((a) => `- **${a.value}** — ${a.corpus} · ${a.field} · ${a.n.toLocaleString()} rows · ${pct(a.score)}`).join("\n")
|
|
185
|
+
: "_Nothing separated itself from the catalogue's average. Usually this means too few picks._");
|
|
186
|
+
|
|
187
|
+
const body = [
|
|
188
|
+
"---",
|
|
189
|
+
"",
|
|
190
|
+
"## What I am drawn to",
|
|
191
|
+
"",
|
|
192
|
+
bullets(draw),
|
|
193
|
+
"",
|
|
194
|
+
"> Read as: of the directions these catalogues vary in, these are the ones you are.",
|
|
195
|
+
"> The number is correlation against the catalogue's own centre, not a share of anything.",
|
|
196
|
+
"",
|
|
197
|
+
];
|
|
198
|
+
|
|
199
|
+
if (pass.length) body.push("## What I pass on", "", bullets(pass), "");
|
|
200
|
+
|
|
201
|
+
if (t.v) {
|
|
202
|
+
body.push("## Where I am heading", "",
|
|
203
|
+
`Your last ${Math.ceil(t.n / 2)} picks differ from the ones before them, so the agent ranks`,
|
|
204
|
+
`${(t.drift * 100).toFixed(0)}% past where you are, in this direction:`, "",
|
|
205
|
+
toward.length ? `**toward** ${list(toward.map((a) => a.value))}` : "_no clear heading_",
|
|
206
|
+
...(away.length ? ["", `**away from** ${list(away.map((a) => a.value))}`] : []), "");
|
|
207
|
+
} else {
|
|
208
|
+
// Deliberately not "N named, four needed": with the heading now carried
|
|
209
|
+
// across sessions, `n` and the absence of a direction stopped agreeing,
|
|
210
|
+
// and the document printed "7 named, four needed" at a reader who had
|
|
211
|
+
// named seven things. The honest statement is about the kernel, not the
|
|
212
|
+
// count.
|
|
213
|
+
body.push("## Where I am heading", "",
|
|
214
|
+
"No direction yet. The kernel infers one only once four picks give it an older half",
|
|
215
|
+
"and a newer half to compare, and it will not invent one from fewer — so",
|
|
216
|
+
"recommendations mirror what you named rather than leaning past it.", "");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
body.push("## What I named", "",
|
|
220
|
+
`**liked** (${t.n}) — ${t.from.length ? list(t.from) : "_none_"}`, "",
|
|
221
|
+
`**passed** — ${t.rejected.length ? list(t.rejected) : "_none_"}`, "",
|
|
222
|
+
"Delete a line here and the sentence above it stops being true. That is what writing",
|
|
223
|
+
"it down is for: a taste you cannot read is a taste you cannot correct.", "",
|
|
224
|
+
"## For an agent reading this", "",
|
|
225
|
+
"- Rank candidates by cosine to `q`, minus 0.6 times the positive part of cosine to `no`.",
|
|
226
|
+
"- Every corpus on this network embeds with `nomic-embed-text-v1.5` truncated to 256",
|
|
227
|
+
" dimensions, so `q` is comparable against a catalogue this reader has never opened.",
|
|
228
|
+
"- Diversify the result (MMR, lambda 0.7). Six episodes of one series is not a recommendation.",
|
|
229
|
+
"- Do not re-recommend anything under *What I named*.",
|
|
230
|
+
"- Read the *Standing instructions* above as overriding anything derived below them.",
|
|
231
|
+
"",
|
|
232
|
+
`_Generated ${day} · ${t.n} likes, ${t.rejected.length} rejections · ${vocabs.length} catalogue`
|
|
233
|
+
+ `${vocabs.length === 1 ? "" : "s"} consulted · nothing left this machine._`, "");
|
|
234
|
+
|
|
235
|
+
return [...head, ...authored, ...body].join("\n");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── self-check: `node consume/taste-doc.js` ─────────────────────────────────
|
|
239
|
+
if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
|
|
240
|
+
const { taste } = await import("./taste.js");
|
|
241
|
+
|
|
242
|
+
// A catalogue with a KNOWN shape: two axes, and a tag at each end of each.
|
|
243
|
+
// Anything the document says had better follow from that and not from how
|
|
244
|
+
// many rows a tag happens to have.
|
|
245
|
+
const D = 8;
|
|
246
|
+
const vec = (a, b, jitter) => {
|
|
247
|
+
const v = new Float32Array(D);
|
|
248
|
+
v[0] = a; v[1] = b;
|
|
249
|
+
for (let i = 2; i < D; i++) v[i] = ((jitter * (i + 1) * 37) % 13) / 100;
|
|
250
|
+
return v;
|
|
251
|
+
};
|
|
252
|
+
const rows = [];
|
|
253
|
+
let id = 0;
|
|
254
|
+
const make = (tag, a, b, n) => { for (let i = 0; i < n; i++) rows.push({ id: `r${id++}`, genre: [tag], vector: vec(a, b, i) }); };
|
|
255
|
+
make("silent", 1, 0, 40);
|
|
256
|
+
make("newsreel", -1, 0, 40);
|
|
257
|
+
make("comedy", 0, 1, 40);
|
|
258
|
+
make("horror", 0, -1, 40);
|
|
259
|
+
make("obscure", 1, 1, 3); // …and one the floor must swallow whole
|
|
260
|
+
|
|
261
|
+
const roles = { tags: ["genre"] };
|
|
262
|
+
const vocab = vocabulary(rows, roles);
|
|
263
|
+
const names = vocab.terms.map((t) => t.value).sort();
|
|
264
|
+
if (names.join(",") !== "comedy,horror,newsreel,silent") throw new Error(`the support floor must drop a 3-row tag: ${names}`);
|
|
265
|
+
|
|
266
|
+
// A reader who likes silents. The document must say silent, must NOT say
|
|
267
|
+
// newsreel, and must not be dragged around by tag size — every surviving tag
|
|
268
|
+
// has exactly 40 rows, so any ranking that appears is about direction alone.
|
|
269
|
+
const likes = rows.filter((r) => r.genre[0] === "silent").slice(0, 6).map((r) => ({ id: r.id, title: r.id, vector: r.vector }));
|
|
270
|
+
const dislikes = rows.filter((r) => r.genre[0] === "horror").slice(0, 3).map((r) => ({ id: r.id, title: r.id, vector: r.vector }));
|
|
271
|
+
const t = taste(likes, dislikes);
|
|
272
|
+
|
|
273
|
+
const drawn = affinities(vocab, t.q);
|
|
274
|
+
if (drawn[0]?.value !== "silent") throw new Error(`the top affinity must be what was liked: ${JSON.stringify(drawn)}`);
|
|
275
|
+
if (drawn.some((a) => a.value === "newsreel")) throw new Error("the opposite of what you liked must not appear as an affinity");
|
|
276
|
+
const passed = affinities(vocab, t.no);
|
|
277
|
+
if (passed[0]?.value !== "horror") throw new Error(`rejections must be described from 'no', not 'q': ${JSON.stringify(passed)}`);
|
|
278
|
+
|
|
279
|
+
// Centring is the whole reason this is not a popularity list.
|
|
280
|
+
{
|
|
281
|
+
// A tag on EVERY row has the catalogue mean for a centroid, so its
|
|
282
|
+
// direction is the zero vector and it is not a term at all — which is
|
|
283
|
+
// the right answer: `film` on a corpus of films says nothing about
|
|
284
|
+
// anyone, and the raw-cosine version of this ranked it first for
|
|
285
|
+
// everybody.
|
|
286
|
+
const all = rows.map((r) => ({ ...r, genre: [...r.genre, "film"] }));
|
|
287
|
+
if (vocabulary(all, roles).terms.some((x) => x.value === "film")) {
|
|
288
|
+
throw new Error("a tag carried by the whole catalogue has no direction and must not survive as a term");
|
|
289
|
+
}
|
|
290
|
+
// And a tag on most of it survives, but must not outrank the one that
|
|
291
|
+
// actually points where the reader is.
|
|
292
|
+
const most = rows.map((r) => (r.genre[0] === "horror" ? r : { ...r, genre: [...r.genre, "feature"] }));
|
|
293
|
+
const v2 = vocabulary(most, roles);
|
|
294
|
+
const a2 = affinities(v2, t.q, { limit: 10 });
|
|
295
|
+
if (!v2.terms.some((x) => x.value === "feature")) throw new Error("a tag on three quarters of the rows is still a term");
|
|
296
|
+
if (a2[0]?.value !== "silent") throw new Error(`bulk must not outrank direction: ${JSON.stringify(a2.slice(0, 3))}`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Co-occurring tags are one fact wearing several words. On the real archive
|
|
300
|
+
// bundle the top four affinities were `fox`, `eek the cat`, `eek` and the
|
|
301
|
+
// voice actor — 59 identical rows, four bullets, one cartoon.
|
|
302
|
+
{
|
|
303
|
+
const dupes = rows.map((r) => (r.genre[0] === "silent" ? { ...r, genre: [...r.genre, "mute", "no-sound", "pre-talkie"] } : r));
|
|
304
|
+
const vd = vocabulary(dupes, roles);
|
|
305
|
+
if (vd.terms.length !== 7) throw new Error(`the aliases must all be terms before ranking: ${vd.terms.length}`);
|
|
306
|
+
const a = affinities(vd, t.q, { limit: 4 });
|
|
307
|
+
const said = a.filter((x) => ["silent", "mute", "no-sound", "pre-talkie"].includes(x.value)).length;
|
|
308
|
+
if (said > 1) throw new Error(`one fact must be said once: ${JSON.stringify(a.map((x) => x.value))}`);
|
|
309
|
+
if (!a.length) throw new Error("…and it must still be said");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const md = tasteDoc({ t, catalogues: [{ name: "films", rows, roles }], instructions: " No trailers. Ever. " });
|
|
313
|
+
if (!md.startsWith("# taste.md")) throw new Error("it is a document, and it says so first");
|
|
314
|
+
if (md.indexOf("No trailers. Ever.") > md.indexOf("What I am drawn to")) throw new Error("what the person wrote comes before what the machine inferred");
|
|
315
|
+
if (!md.includes("**silent**") || md.includes("**newsreel**")) throw new Error("the prose must follow the vectors");
|
|
316
|
+
if (!md.includes("nomic-embed-text-v1.5")) throw new Error("an agent reading this must be told what space q lives in");
|
|
317
|
+
for (const h of ["## Standing instructions", "## What I am drawn to", "## What I pass on", "## Where I am heading", "## What I named", "## For an agent reading this"]) {
|
|
318
|
+
if (!md.includes(h)) throw new Error(`missing section: ${h}`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// No taste, and no catalogue: still a document, never a crash, never a
|
|
322
|
+
// fabricated observation.
|
|
323
|
+
const empty = tasteDoc({ t: null, instructions: "" });
|
|
324
|
+
if (!empty.includes("Nothing observed yet")) throw new Error("an empty taste must say so rather than inventing one");
|
|
325
|
+
if (empty.includes("drawn to")) throw new Error("…and must claim nothing");
|
|
326
|
+
if (!tasteDoc({ t, catalogues: [] }).includes("## What I am drawn to")) throw new Error("a taste with no catalogue open still renders its sections");
|
|
327
|
+
|
|
328
|
+
// Four picks is where the kernel starts inferring a heading; three is not.
|
|
329
|
+
const few = taste(likes.slice(0, 3), []);
|
|
330
|
+
{
|
|
331
|
+
const doc = tasteDoc({ t: few, catalogues: [{ name: "films", rows, roles }] });
|
|
332
|
+
if (!doc.includes("once four picks")) throw new Error("too few picks for a direction must be stated, not silently omitted");
|
|
333
|
+
// The document must never print a count next to a claim the count
|
|
334
|
+
// contradicts — a carried heading and a carried `n` are independent now.
|
|
335
|
+
if (/\d+ named, four needed/.test(doc)) throw new Error("a reader who named seven things must not be told four are needed");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
console.log("taste-doc.js self-check ok — terms are the publishers' own declared tags above a support floor, "
|
|
339
|
+
+ "centred so the catalogue's bulk describes nobody, affinities follow q and rejections follow no, co-occurring tags are said once, "
|
|
340
|
+
+ "a heading is read off v and withheld when the kernel has none, the authored section outranks the derived one, "
|
|
341
|
+
+ "and an empty taste writes a document rather than an invention");
|
|
342
|
+
}
|