@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,349 @@
|
|
|
1
|
+
// Buying an answer instead of buying everybody's rows.
|
|
2
|
+
//
|
|
3
|
+
// THE PROBLEM WITH THE OBVIOUS VERSION
|
|
4
|
+
// ------------------------------------
|
|
5
|
+
// A publisher wants to know how its catalogue landed. The obvious way is to buy
|
|
6
|
+
// readers' reaction corpora and add them up — which works, pays the readers, and
|
|
7
|
+
// ends with the publisher holding every reader's private log. That is surveillance
|
|
8
|
+
// with an invoice attached. The reader is paid once and watched forever, and the
|
|
9
|
+
// publisher now owns a liability it cannot delete from anyone else's copy.
|
|
10
|
+
//
|
|
11
|
+
// So the publisher buys the AGGREGATE and never the rows:
|
|
12
|
+
//
|
|
13
|
+
// 1. the publisher posts a question and a budget,
|
|
14
|
+
// 2. each reader evaluates the question against their own log, LOCALLY,
|
|
15
|
+
// producing a short vector of integers — counts, and a sum of the vectors
|
|
16
|
+
// of the things they reacted to,
|
|
17
|
+
// 3. each reader masks that vector with values that cancel across the cohort,
|
|
18
|
+
// 4. anyone sums the masked shares. The masks vanish; the totals remain.
|
|
19
|
+
//
|
|
20
|
+
// The publisher learns the totals. Nobody — not the publisher, not the other
|
|
21
|
+
// readers, not whoever does the summing — learns any individual's contribution,
|
|
22
|
+
// because an individual's share is their real answer plus values that are
|
|
23
|
+
// indistinguishable from random until every other share is added to it.
|
|
24
|
+
//
|
|
25
|
+
// HOW THE MASKS CANCEL
|
|
26
|
+
// --------------------
|
|
27
|
+
// For every pair of readers (i, j) a shared secret is derived by ECDH over their
|
|
28
|
+
// published keys, expanded by HKDF into a stream of 64-bit words. The reader with
|
|
29
|
+
// the lower public key ADDS that stream, the other SUBTRACTS it. Sum the cohort
|
|
30
|
+
// and every pair contributes +m and -m. This is textbook pairwise-masked secure
|
|
31
|
+
// aggregation; nothing here is novel and that is the point — the novel part would
|
|
32
|
+
// be the part that is wrong.
|
|
33
|
+
//
|
|
34
|
+
// Arithmetic is mod 2^64 so the cancellation is exact rather than approximate. A
|
|
35
|
+
// single share is uniform over that ring, which is what makes it safe to publish.
|
|
36
|
+
//
|
|
37
|
+
// WHAT THIS DOES NOT DO
|
|
38
|
+
// ---------------------
|
|
39
|
+
// ponytail: no dropout recovery. If a reader commits to a round and then does not
|
|
40
|
+
// submit, their pairs never cancel and the total is noise — the round fails and
|
|
41
|
+
// nobody is paid, rather than the publisher receiving a plausible wrong answer.
|
|
42
|
+
// That is the correct failure, and `verify` below detects it for free. Bonawitz's
|
|
43
|
+
// secret-shared recovery fixes it properly; add it when real readers on real
|
|
44
|
+
// networks start dropping, not before.
|
|
45
|
+
//
|
|
46
|
+
// ponytail: the cohort is also not protected against a publisher that colludes
|
|
47
|
+
// with n-1 readers to isolate the nth. MIN_COHORT bounds the damage but does not
|
|
48
|
+
// remove it; only differential privacy noise does, and noise costs accuracy that
|
|
49
|
+
// a cohort this small cannot spare. Named here rather than papered over.
|
|
50
|
+
|
|
51
|
+
const MOD = 1n << 64n;
|
|
52
|
+
const mod = (x) => ((x % MOD) + MOD) % MOD;
|
|
53
|
+
|
|
54
|
+
/** Fewest readers that may answer a question. A cohort of one is an interview. */
|
|
55
|
+
export const MIN_COHORT = 5;
|
|
56
|
+
/**
|
|
57
|
+
* Fewest MATCHED reactions before a direction is reported.
|
|
58
|
+
*
|
|
59
|
+
* The masking protects WHO answered and is silent about WHAT. Six readers were
|
|
60
|
+
* asked how they reacted around Nosferatu; exactly one reaction matched; the
|
|
61
|
+
* centroid came back and ranked the publisher's own shelf as
|
|
62
|
+
* `nosferatu-1of5`, `Grave-of-the-Vampire`, `Nosferatu_1922_Symphony_of_Horror`.
|
|
63
|
+
* A perfect recovery of one reader's film, through a mechanism whose whole claim
|
|
64
|
+
* is that it does not do that.
|
|
65
|
+
*
|
|
66
|
+
* A mean over k things is those things when k is small, however many people were
|
|
67
|
+
* standing behind it. So the counts may be reported at any support — they are
|
|
68
|
+
* genuinely aggregate — and the DIRECTION is withheld until enough reactions
|
|
69
|
+
* stand behind it to be a direction rather than a disclosure.
|
|
70
|
+
*/
|
|
71
|
+
export const MIN_SUPPORT = 5;
|
|
72
|
+
/** Leading components of the attention centroid the cohort reports. */
|
|
73
|
+
export const STAT_DIM = 128;
|
|
74
|
+
/** Fixed point for the vector sum: floats live in [-1,1] and must add exactly. */
|
|
75
|
+
export const SCALE = 1n << 20n;
|
|
76
|
+
|
|
77
|
+
/** Slot 0 is a presence tally — every reader contributes exactly 1.
|
|
78
|
+
* It is what makes a botched round detectable: see `verify`. */
|
|
79
|
+
export const SLOTS = ["present", "matched", "like", "skip", "share"];
|
|
80
|
+
export const WIDTH = SLOTS.length + STAT_DIM;
|
|
81
|
+
|
|
82
|
+
const enc = new TextEncoder();
|
|
83
|
+
const bytesOf = (buf) => new Uint8Array(buf);
|
|
84
|
+
const hex = (u8) => [...u8].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
85
|
+
|
|
86
|
+
/** A reader's identity for one round. Ephemeral by default — a key reused across
|
|
87
|
+
* rounds lets an observer link the same reader's answers into a profile, which
|
|
88
|
+
* is the thing the whole mechanism exists to prevent. */
|
|
89
|
+
export async function keypair() {
|
|
90
|
+
const kp = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
|
|
91
|
+
const pub = bytesOf(await crypto.subtle.exportKey("raw", kp.publicKey));
|
|
92
|
+
return { privateKey: kp.privateKey, publicKey: kp.publicKey, pub, id: hex(pub) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* What one reader answers, before masking.
|
|
97
|
+
*
|
|
98
|
+
* `query` is `{ corpus, items, near, minScore }` — any of them. It runs on the
|
|
99
|
+
* reader's own machine against their own log, so the question is answered without
|
|
100
|
+
* the log ever moving. A question that matches nothing still produces a share, and
|
|
101
|
+
* must: a reader who only replies when they have something to say has disclosed
|
|
102
|
+
* that they have something to say.
|
|
103
|
+
*/
|
|
104
|
+
export function statistics(reactions = [], query = {}) {
|
|
105
|
+
const out = new Array(WIDTH).fill(0n);
|
|
106
|
+
out[0] = 1n; // present
|
|
107
|
+
const items = query.items ? new Set(query.items.map(String)) : null;
|
|
108
|
+
const near = query.near ? unit(query.near) : null;
|
|
109
|
+
const floor = query.minScore ?? 0.35;
|
|
110
|
+
|
|
111
|
+
for (const r of reactions) {
|
|
112
|
+
if (query.corpus && r.corpus !== query.corpus) continue;
|
|
113
|
+
if (items && !items.has(String(r.id))) continue;
|
|
114
|
+
const v = r.vector ?? (r.v ? Float32Array.from(r.v) : null);
|
|
115
|
+
if (near) {
|
|
116
|
+
if (!v) continue;
|
|
117
|
+
let s = 0; const u = unit(v);
|
|
118
|
+
for (let i = 0; i < Math.min(u.length, near.length); i++) s += u[i] * near[i];
|
|
119
|
+
if (s < floor) continue;
|
|
120
|
+
}
|
|
121
|
+
out[1] += 1n; // matched
|
|
122
|
+
const k = SLOTS.indexOf(String(r.reaction ?? "like"));
|
|
123
|
+
if (k > 1) out[k] += 1n;
|
|
124
|
+
if (v) {
|
|
125
|
+
const u = unit(v);
|
|
126
|
+
for (let i = 0; i < Math.min(STAT_DIM, u.length); i++) {
|
|
127
|
+
out[SLOTS.length + i] += BigInt(Math.round(u[i] * Number(SCALE)));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const unit = (v) => {
|
|
135
|
+
let n = 0; for (let i = 0; i < v.length; i++) n += v[i] * v[i];
|
|
136
|
+
n = Math.sqrt(n) || 1;
|
|
137
|
+
const o = new Float32Array(v.length);
|
|
138
|
+
for (let i = 0; i < v.length; i++) o[i] = v[i] / n;
|
|
139
|
+
return o;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/** The pairwise mask stream between two readers, as WIDTH 64-bit words. */
|
|
143
|
+
async function pairMask(priv, peerPubRaw, round) {
|
|
144
|
+
const peer = await crypto.subtle.importKey("raw", peerPubRaw, { name: "ECDH", namedCurve: "P-256" }, false, []);
|
|
145
|
+
const shared = await crypto.subtle.deriveBits({ name: "ECDH", public: peer }, priv, 256);
|
|
146
|
+
const key = await crypto.subtle.importKey("raw", shared, "HKDF", false, ["deriveBits"]);
|
|
147
|
+
const bits = await crypto.subtle.deriveBits(
|
|
148
|
+
{ name: "HKDF", hash: "SHA-256", salt: enc.encode("westmarch-cohort-v1"), info: enc.encode(String(round)) },
|
|
149
|
+
key, WIDTH * 64);
|
|
150
|
+
const dv = new DataView(bits);
|
|
151
|
+
return Array.from({ length: WIDTH }, (_, i) => dv.getBigUint64(i * 8));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* One reader's share: their real answer, made uniform.
|
|
156
|
+
*
|
|
157
|
+
* `peers` are every OTHER reader's raw public key in the round. Direction is
|
|
158
|
+
* decided by comparing public keys, so the two sides of a pair agree without
|
|
159
|
+
* talking to each other.
|
|
160
|
+
*/
|
|
161
|
+
export async function contribute(stats, me, peers, round = 0) {
|
|
162
|
+
const share = stats.map(mod);
|
|
163
|
+
for (const p of peers) {
|
|
164
|
+
const peerHex = hex(p);
|
|
165
|
+
if (peerHex === me.id) continue;
|
|
166
|
+
const m = await pairMask(me.privateKey, p, round);
|
|
167
|
+
const add = me.id < peerHex;
|
|
168
|
+
for (let i = 0; i < WIDTH; i++) share[i] = mod(add ? share[i] + m[i] : share[i] - m[i]);
|
|
169
|
+
}
|
|
170
|
+
return share;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Sum the shares. The masks cancel; the answer is exact.
|
|
175
|
+
*
|
|
176
|
+
* Refuses below `minCohort` — the guard is on the number of PEOPLE, not rows,
|
|
177
|
+
* because an aggregate over two readers is two readers' data with a total sign on
|
|
178
|
+
* it. This is the same floor that shows up everywhere else in this codebase, and
|
|
179
|
+
* it is the only line between "an aggregate" and "a dossier".
|
|
180
|
+
*/
|
|
181
|
+
export function aggregate(shares, { minCohort = MIN_COHORT } = {}) {
|
|
182
|
+
if (shares.length < minCohort) {
|
|
183
|
+
throw new Error(`a cohort of ${shares.length} is not an aggregate — ${minCohort} readers minimum`);
|
|
184
|
+
}
|
|
185
|
+
const total = new Array(WIDTH).fill(0n);
|
|
186
|
+
for (const s of shares) for (let i = 0; i < WIDTH; i++) total[i] = mod(total[i] + s[i]);
|
|
187
|
+
return total;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Signed read-back: counts are small positives, vector sums straddle zero. */
|
|
191
|
+
const signed = (x) => (x >= MOD / 2n ? x - MOD : x);
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Did the masks cancel?
|
|
195
|
+
*
|
|
196
|
+
* Slot 0 is 1 per reader, so the total must equal the cohort size exactly. If any
|
|
197
|
+
* reader dropped out or sent a share from the wrong round, slot 0 lands somewhere
|
|
198
|
+
* uniform in 2^64 and this catches it. One integer comparison, and it turns a
|
|
199
|
+
* silently wrong answer into a failed round.
|
|
200
|
+
*/
|
|
201
|
+
export function verify(total, expected) {
|
|
202
|
+
return signed(total[0]) === BigInt(expected);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The total, as the publisher reads it — and ONLY as the publisher reads it.
|
|
207
|
+
*
|
|
208
|
+
* There is no per-reader anything in here, because there is no per-reader
|
|
209
|
+
* anything in the input. What comes back is the count, the sentiment split, and
|
|
210
|
+
* the direction attention pointed: a unit vector the publisher can rank its own
|
|
211
|
+
* catalogue against to see which of its rows the cohort actually leaned toward.
|
|
212
|
+
*/
|
|
213
|
+
export function readout(total, cohort, { minSupport = MIN_SUPPORT } = {}) {
|
|
214
|
+
if (!verify(total, cohort)) {
|
|
215
|
+
return { ok: false, why: "the masks did not cancel — a reader dropped out or answered a different round" };
|
|
216
|
+
}
|
|
217
|
+
const n = (i) => Number(signed(total[i]));
|
|
218
|
+
const matched = n(1);
|
|
219
|
+
const supported = matched >= minSupport;
|
|
220
|
+
const centroid = total.slice(SLOTS.length).map((x) => Number(signed(x)) / Number(SCALE));
|
|
221
|
+
const norm = Math.sqrt(centroid.reduce((s, x) => s + x * x, 0)) || 1;
|
|
222
|
+
return {
|
|
223
|
+
ok: true,
|
|
224
|
+
readers: cohort,
|
|
225
|
+
matched,
|
|
226
|
+
like: n(2), skip: n(3), share: n(4),
|
|
227
|
+
// A MEAN over readers, which is not the same as "one enthusiast cannot
|
|
228
|
+
// outvote the cohort" — one reader with every match sets this on their
|
|
229
|
+
// own. It says how much attention the region drew per head, and nothing
|
|
230
|
+
// about how many heads.
|
|
231
|
+
perReader: +(matched / cohort).toFixed(2),
|
|
232
|
+
// Withheld on exactly the same support as the direction. At matched = 1
|
|
233
|
+
// this is ±1: one identified reader's polarity on one item, under a
|
|
234
|
+
// question the publisher wrote and an anchor the publisher chose. Counts
|
|
235
|
+
// are genuinely aggregate; a mean over one row is that row, and that is
|
|
236
|
+
// as true of a sentiment as it is of a centroid.
|
|
237
|
+
sentiment: supported && matched ? +((n(2) + n(4) - n(3)) / matched).toFixed(3) : null,
|
|
238
|
+
// Withheld, not zeroed — a caller must be able to tell "nobody went there"
|
|
239
|
+
// from "not enough people went there to say where".
|
|
240
|
+
centroid: supported ? centroid.map((x) => x / norm) : null,
|
|
241
|
+
...(supported ? {} : {
|
|
242
|
+
withheld: `direction withheld: ${matched} matched reaction${matched === 1 ? "" : "s"} is not a direction, `
|
|
243
|
+
+ `it is ${matched === 1 ? "one reader's row" : "a handful of rows"} with a mean drawn round ${matched === 1 ? "it" : "them"}. `
|
|
244
|
+
+ `${minSupport} needed.`,
|
|
245
|
+
}),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── self-check: `node publish/cohort.js` ──────────────────────────────────
|
|
250
|
+
if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
|
|
251
|
+
const vec = (a, b) => { const v = new Float32Array(STAT_DIM); v[0] = a; v[1] = b; return v; };
|
|
252
|
+
const log = (n, seed) => Array.from({ length: n }, (_, i) => ({
|
|
253
|
+
id: `row-${seed}-${i}`, corpus: "films", reaction: i % 4 === 3 ? "skip" : "like",
|
|
254
|
+
vector: vec(1 - (i % 3) * 0.1, (i % 3) * 0.1),
|
|
255
|
+
}));
|
|
256
|
+
|
|
257
|
+
// ── statistics run locally and always answer ──
|
|
258
|
+
const s0 = statistics(log(4, "a"), { corpus: "films" });
|
|
259
|
+
if (s0[0] !== 1n || s0[1] !== 4n) throw new Error("presence and matched counts");
|
|
260
|
+
if (s0[2] !== 3n || s0[3] !== 1n) throw new Error(`like/skip split: ${s0[2]}/${s0[3]}`);
|
|
261
|
+
const none = statistics(log(4, "a"), { corpus: "games" });
|
|
262
|
+
if (none[0] !== 1n || none[1] !== 0n) throw new Error("a reader with nothing to say must still answer — silence is a disclosure");
|
|
263
|
+
|
|
264
|
+
// ── a round of 6 readers ──
|
|
265
|
+
const R = 6;
|
|
266
|
+
const keys = [];
|
|
267
|
+
for (let i = 0; i < R; i++) keys.push(await keypair());
|
|
268
|
+
const pubs = keys.map((k) => k.pub);
|
|
269
|
+
const logs = keys.map((_, i) => log(3 + i, `r${i}`));
|
|
270
|
+
const truth = logs.map((l) => statistics(l, { corpus: "films" }))
|
|
271
|
+
.reduce((a, b) => a.map((x, i) => x + b[i]));
|
|
272
|
+
|
|
273
|
+
const shares = [];
|
|
274
|
+
for (let i = 0; i < R; i++) shares.push(await contribute(statistics(logs[i], { corpus: "films" }), keys[i], pubs));
|
|
275
|
+
|
|
276
|
+
// A single share must look like nothing. Slot 0 is 1 in the clear; masked it
|
|
277
|
+
// must be an enormous ring element, and it must differ every round.
|
|
278
|
+
const bare = statistics(logs[0], { corpus: "films" });
|
|
279
|
+
if (shares[0][0] === bare[0]) throw new Error("a published share must not equal the answer it hides");
|
|
280
|
+
if (shares[0][0] < (1n << 40n)) throw new Error(`a masked count must be uniform over the ring, got ${shares[0][0]}`);
|
|
281
|
+
if (shares.filter((s) => s[1] < (1n << 40n)).length > 1) throw new Error("shares must not be recognisably small");
|
|
282
|
+
|
|
283
|
+
const total = aggregate(shares);
|
|
284
|
+
if (!verify(total, R)) throw new Error("the masks must cancel exactly");
|
|
285
|
+
for (let i = 0; i < WIDTH; i++) {
|
|
286
|
+
if (total[i] !== mod(truth[i])) throw new Error(`slot ${i} lost the sum: ${total[i]} vs ${mod(truth[i])}`);
|
|
287
|
+
}
|
|
288
|
+
const out = readout(total, R);
|
|
289
|
+
if (!out.ok || out.readers !== R) throw new Error("the readout must be clean");
|
|
290
|
+
if (out.matched !== Number(truth[1])) throw new Error(`matched: ${out.matched} vs ${truth[1]}`);
|
|
291
|
+
if (out.like + out.skip !== out.matched) throw new Error("every match is a like or a skip here");
|
|
292
|
+
if (out.centroid.length !== STAT_DIM) throw new Error("the publisher gets a direction to rank against");
|
|
293
|
+
if (Math.abs(Math.sqrt(out.centroid.reduce((s, x) => s + x * x, 0)) - 1) > 1e-6) throw new Error("the centroid must be a unit vector");
|
|
294
|
+
if (out.centroid[0] < 0.9) throw new Error(`attention pointed at the first axis: ${out.centroid[0].toFixed(3)}`);
|
|
295
|
+
|
|
296
|
+
// ── a direction over too few reactions IS those reactions ──────────────
|
|
297
|
+
// This is not hypothetical. Six readers were asked about the region around
|
|
298
|
+
// Nosferatu; one reaction matched; the returned centroid ranked the
|
|
299
|
+
// publisher's own catalogue and put that exact film first. The cohort floor
|
|
300
|
+
// did nothing, because it counts people and the leak was in the arithmetic.
|
|
301
|
+
const lonelyLogs = [[{ id: "secret", corpus: "films", reaction: "like", vector: vec(0, 1) }],
|
|
302
|
+
...Array.from({ length: 4 }, () => [{ id: "elsewhere", corpus: "games", reaction: "like", vector: vec(1, 0) }])];
|
|
303
|
+
const lk = []; for (let i = 0; i < 5; i++) lk.push(await keypair());
|
|
304
|
+
const lp = lk.map((k) => k.pub);
|
|
305
|
+
const ls = [];
|
|
306
|
+
for (let i = 0; i < 5; i++) ls.push(await contribute(statistics(lonelyLogs[i], { corpus: "films" }), lk[i], lp));
|
|
307
|
+
const lonely = readout(aggregate(ls), 5);
|
|
308
|
+
if (!lonely.ok) throw new Error("the round itself is fine — it is the reporting that must hold back");
|
|
309
|
+
if (lonely.matched !== 1) throw new Error("one reaction matched");
|
|
310
|
+
if (lonely.centroid) throw new Error("a centroid over ONE reaction is that reaction — it must be withheld");
|
|
311
|
+
if (!lonely.withheld?.includes("1 matched")) throw new Error("and it must say why, so a publisher knows to widen the question");
|
|
312
|
+
if (lonely.readers !== 5 || lonely.like !== 1) throw new Error("counts are genuinely aggregate and stay");
|
|
313
|
+
// …but a sentiment over one reaction is that reader's polarity on that row,
|
|
314
|
+
// and it went out under the same question and anchor the publisher chose.
|
|
315
|
+
if (lonely.sentiment !== null) throw new Error("a sentiment over one reaction is one reader's opinion of one film");
|
|
316
|
+
// With support, the direction comes back.
|
|
317
|
+
if (!readout(aggregate(ls), 5, { minSupport: 1 }).centroid) throw new Error("the floor is a dial, not a wall");
|
|
318
|
+
|
|
319
|
+
// ── a dropout must FAIL, not answer plausibly ──
|
|
320
|
+
const short = aggregate(shares.slice(0, R - 1), { minCohort: 2 });
|
|
321
|
+
if (verify(short, R - 1)) throw new Error("a missing reader must break the sum, not shrink it");
|
|
322
|
+
if (readout(short, R - 1).ok) throw new Error("a broken round must refuse to report rather than report noise");
|
|
323
|
+
|
|
324
|
+
// ── the floor is on PEOPLE ──
|
|
325
|
+
let threw = false;
|
|
326
|
+
try { aggregate(shares.slice(0, MIN_COHORT - 1)); } catch { threw = true; }
|
|
327
|
+
if (!threw) throw new Error(`fewer than ${MIN_COHORT} readers is an interview, not an aggregate`);
|
|
328
|
+
|
|
329
|
+
// ── a reader's log never moves, and the share does not encode it ──
|
|
330
|
+
// Two readers with IDENTICAL logs produce different shares, so a share cannot
|
|
331
|
+
// be matched against a guess at what someone watched.
|
|
332
|
+
const twin = [await keypair(), await keypair(), await keypair(), await keypair(), await keypair()];
|
|
333
|
+
const same = log(5, "same");
|
|
334
|
+
const tShares = [];
|
|
335
|
+
for (let i = 0; i < 5; i++) tShares.push(await contribute(statistics(same, { corpus: "films" }), twin[i], twin.map((k) => k.pub)));
|
|
336
|
+
if (tShares[0].join() === tShares[1].join()) throw new Error("identical logs must not produce identical shares");
|
|
337
|
+
const tTotal = aggregate(tShares);
|
|
338
|
+
if (Number(signed(tTotal[1])) !== 5 * 5) throw new Error("…and must still sum correctly");
|
|
339
|
+
|
|
340
|
+
// ── the query runs on the reader's side: `near` selects a region ──
|
|
341
|
+
const mixed = [...log(3, "x"), { id: "far", corpus: "films", reaction: "like", vector: vec(0, 1) }];
|
|
342
|
+
const region = statistics(mixed, { corpus: "films", near: vec(1, 0), minScore: 0.9 });
|
|
343
|
+
if (Number(region[1]) !== 3) throw new Error(`a region query must exclude what is outside it: ${region[1]}`);
|
|
344
|
+
|
|
345
|
+
console.log("cohort.js self-check ok — a question is answered on the reader's machine, shares are uniform over 2^64 and "
|
|
346
|
+
+ "differ for identical logs, the masks cancel to the exact total, a dropout fails the round instead of faking it, "
|
|
347
|
+
+ "fewer than five readers is refused as an interview, and a direction — and the sentiment beside it — over too few reactions is withheld "
|
|
348
|
+
+ "because a mean over one row IS that row however many people stood behind it");
|
|
349
|
+
}
|