@bongos/core 1.19.904 → 1.19.905

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,776 @@
1
+ // tests/idea_size.mjs
2
+ //
3
+ // task 1004093 (BV2.R12, goal 1000095) — SIZE: an idea's total ideator payout
4
+ // from its descent, computable from the ledger for any idea, compressed for
5
+ // drawing, with the true figure kept. Spec
6
+ // docs/specs/bongos-v2-ideator-surface.md decisions 4, 5 and 7.
7
+ //
8
+ // WHAT CAN GO SILENTLY WRONG HERE, which is what this file is aimed at:
9
+ //
10
+ // * THE PREFIX BOUNDARY. The reason key is `idea.credit.task_ship:<idea>:
11
+ // <task>`, so "idea 1's payouts" as a naive prefix match swallows every
12
+ // payout of ideas 10, 100 and 1000. Nothing errors; one star is simply
13
+ // drawn enormous. The read groups on the SEPARATOR instead, and §2 pins
14
+ // both the behaviour and the shape of the SQL that delivers it.
15
+ // * THE WRONG STREAM. Streams 2 and 3 (`full_pass`, `governance_pass`) book
16
+ // under keys that embed the same idea id, and they pay for the THOUGHT, not
17
+ // for the work it caused. Counting them makes a well-written idea that
18
+ // caused nothing draw like one that caused a version.
19
+ // * A RELATIVE SCALE. Compressing against the biggest idea on screen draws
20
+ // better and destroys the one property size exists to have — it would FALL
21
+ // when a bigger thought appeared beside it, with no credit changing hands.
22
+ // * SIZE FALLING. The invariant is claimed in the module header, so it is
23
+ // tested where it actually lives — in the WRITERS (§4), by driving the real
24
+ // credits.js payout paths and asserting nothing non-positive is ever booked
25
+ // under this prefix. A property a comment asserts and no test drives is a
26
+ // comment.
27
+ // * A DORMANT FIELD. §5 and §6 run the real read into the real consumers —
28
+ // the trail attach, and then the real profile page — with nothing
29
+ // hand-built in between.
30
+ //
31
+ // DB-free. The ledger client is a stub, so §2 can only prove the ARITHMETIC
32
+ // against a faithful twin of the predicate; the SQL TEXT is therefore pinned
33
+ // separately, clause by clause, so the twin cannot quietly diverge from it.
34
+ //
35
+ // Run: node tests/idea_size.mjs
36
+
37
+ import { strict as assert } from 'node:assert';
38
+ import { createRequire } from 'node:module';
39
+ import fs from 'node:fs';
40
+ import path from 'node:path';
41
+ import { fileURLToPath } from 'node:url';
42
+ import { bootProfilePage, settle } from './helpers.mjs';
43
+
44
+ const require = createRequire(import.meta.url);
45
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
46
+ const ideaSizeLib = require('../modules/economy/idea-size.js');
47
+ const credits = require('../modules/economy/credits.js');
48
+ const reward = require('../modules/economy/reward.js');
49
+ const trailSize = require('../modules/ideas/trail-size.js');
50
+ const api = require('../src/module-api.js');
51
+ const { rewardPolicy } = require('../modules/economy/reward-policy.js');
52
+
53
+ let passed = 0;
54
+ let failed = 0;
55
+ async function test(name, fn) {
56
+ try { await fn(); passed += 1; console.log(`ok ${name}`); }
57
+ catch (err) { failed += 1; console.log(`FAIL ${name}\n ${err && (err.message || JSON.stringify(err))}`); }
58
+ }
59
+
60
+ const PREFIX = credits.IDEA_STREAM_REASON_PREFIXES.task_ship;
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // the ledger stub
64
+ // ---------------------------------------------------------------------------
65
+
66
+ // A twin of the read's predicate, applied to a fixture ledger — and it READS
67
+ // THE STATEMENT rather than reimplementing it from memory. Both load-bearing
68
+ // decisions are lifted out of the SQL text: which colon-delimited field the
69
+ // idea id comes from (`split_part(reason, ':', N)`), and whether the stream-1
70
+ // prefix is actually being applied (the `reason LIKE` clause). That is what
71
+ // makes these arithmetic tests bite on the real query: change the field index
72
+ // or drop the stream guard and the twin changes with it, so §2 reds instead of
73
+ // agreeing with a stale copy of the rule.
74
+ function ledgerClient(rows) {
75
+ const log = [];
76
+ return {
77
+ log,
78
+ async query(sql, params) {
79
+ log.push({ sql, params });
80
+ if (!/FROM credit_log/i.test(sql)) return { rows: [] };
81
+ const field = /split_part\(reason, ':', (\d+)\)/.exec(sql);
82
+ assert.ok(field, 'the read must take the idea id off the separator — the twin follows the SQL, not a memory of it');
83
+ const idx = Number(field[1]) - 1;
84
+ // The stream guard is a LITERAL in the statement (so the partial index can
85
+ // be chosen), so the twin reads it out of the SQL rather than off a param.
86
+ // Unescaped back to the literal the writers use: the statement escapes
87
+ // LIKE's _ wildcard, so a twin comparing the raw form would match nothing.
88
+ const guard = /reason LIKE '([^']*)%'/.exec(sql);
89
+ const prefix = guard && guard[1].replace(/\\([\\%_])/g, (m, ch) => ch);
90
+ const ids = (params[0] || []).map(String);
91
+ // Grouped per (idea, task) since BV2.R18 — the per-task breakdown is what
92
+ // tells the backfill which hops the ledger already paid for. The twin
93
+ // follows the statement here too: it groups by whatever GROUP BY names.
94
+ const byTask = /GROUP BY 1, 2/.test(sql);
95
+ const grouped = new Map();
96
+ for (const row of rows) {
97
+ if (prefix && !String(row.reason).startsWith(prefix)) continue;
98
+ const parts = String(row.reason).split(':');
99
+ const ideaId = parts[idx];
100
+ if (!ids.includes(ideaId)) continue;
101
+ const taskId = row.task_id != null ? String(row.task_id) : parts[2];
102
+ const key = byTask ? `${ideaId}|${taskId}` : ideaId;
103
+ const acc = grouped.get(key) || { idea_id: ideaId, task_id: byTask ? taskId : null, credits: 0, payments: 0 };
104
+ acc.credits += Number(row.delta);
105
+ acc.payments += 1;
106
+ grouped.set(key, acc);
107
+ }
108
+ return { rows: [...grouped.values()] };
109
+ },
110
+ };
111
+ }
112
+
113
+ const paid = (ideaId, taskId, delta) => ({ reason: `${PREFIX}${ideaId}:${taskId}`, delta });
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // 0. BEFORE any port exists — the unknown-historical state
117
+ // ---------------------------------------------------------------------------
118
+
119
+ // FIRST, and it has to be: a port is single-provider and cannot be unregistered
120
+ // (src/module-seams.js), so the only honest place to test the absent-walk branch
121
+ // is before anything registers one. The idea_descent_ship_credit.mjs precedent.
122
+ await test('with no descent port and no caller map, the historical half is NULL — not zero', async () => {
123
+ // Third state, and it must not render as either of the other two: "nothing was
124
+ // estimated" and "we could not look" are different answers, and only one of
125
+ // them may be drawn as a plain figure.
126
+ assert.equal(api.hasProvider('ideas.descent'), false, 'nothing has registered the walk yet');
127
+ const c = ledgerClient([paid(7, 500, 5)]);
128
+ const size = (await ideaSizeLib.ideaSizes(['7'], { client: c })).get('7');
129
+ assert.equal(size.paid, 5, 'what was actually paid is still known');
130
+ assert.equal(size.estimated, null);
131
+ assert.equal(size.historical, null, 'unknown is not false');
132
+ assert.equal(size.credits, 5);
133
+ });
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // 1. the compression — pure, absolute, monotonic
137
+ // ---------------------------------------------------------------------------
138
+
139
+ await test('an idea that has earned nothing compresses to a clean zero', () => {
140
+ assert.equal(ideaSizeLib.compressIdeaSize(0), 0);
141
+ assert.equal(ideaSizeLib.compressIdeaSize(null), 0, 'and a missing figure is not negative infinity');
142
+ assert.equal(ideaSizeLib.compressIdeaSize('nonsense'), 0);
143
+ assert.equal(ideaSizeLib.compressIdeaSize(-5), 0);
144
+ });
145
+
146
+ await test('the compression is MONOTONIC — more credits never draws smaller', () => {
147
+ let prev = -1;
148
+ for (const c of [0, 1, 2, 5, 10, 25, 50, 100, 250, 500, 5000]) {
149
+ const s = ideaSizeLib.compressIdeaSize(c);
150
+ assert.ok(s >= prev, `${c} credits must not draw smaller than the step below it`);
151
+ prev = s;
152
+ }
153
+ });
154
+
155
+ await test('it is bounded to [0,1] and SATURATES rather than running away', () => {
156
+ assert.equal(ideaSizeLib.compressIdeaSize(ideaSizeLib.IDEA_SIZE_SCALE_REF), 1);
157
+ assert.equal(ideaSizeLib.compressIdeaSize(1e9), 1, 'the drawing has a ceiling; the true figure does not');
158
+ });
159
+
160
+ await test('it COMPRESSES — the long tail is not allowed to eat the picture', () => {
161
+ // 100x the credits must not be 100x the drawn magnitude, or a single large
162
+ // idea renders every other thought in the project as the same dot.
163
+ const small = ideaSizeLib.compressIdeaSize(5);
164
+ const large = ideaSizeLib.compressIdeaSize(500);
165
+ assert.ok(large / small < 10, `500c draws ${(large / small).toFixed(1)}x a 5c idea, not 100x`);
166
+ assert.ok(large > small, 'while still being visibly bigger');
167
+ });
168
+
169
+ await test('the scale is ABSOLUTE, not relative to whoever else is on screen', async () => {
170
+ // THE PROPERTY SIZE EXISTS TO HAVE. An idea's drawn magnitude must be the same
171
+ // number whether it is read alone or beside a thought ten times its size — a
172
+ // set-relative normalisation would make it FALL when a bigger star appeared,
173
+ // which is brightness's job (BV2.R16) and never size's.
174
+ const alone = await ideaSizeLib.ideaSizes(['7'], { client: ledgerClient([paid(7, 500, 5)]) });
175
+ const crowded = await ideaSizeLib.ideaSizes(['7', '8'], {
176
+ client: ledgerClient([paid(7, 500, 5), paid(8, 501, 400)]),
177
+ });
178
+ assert.equal(alone.get('7').scale, crowded.get('7').scale,
179
+ 'the same idea draws the same size in both frames');
180
+ assert.ok(crowded.get('8').scale > crowded.get('7').scale, 'and the bigger thought is still bigger');
181
+ });
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // 2. the read — the boundary, the streams, and the shape of the SQL
185
+ // ---------------------------------------------------------------------------
186
+
187
+ await test('size is the SUM of every stream-1 payout under an idea', async () => {
188
+ const c = ledgerClient([paid(7, 500, 5), paid(7, 501, 3), paid(7, 502, 1)]);
189
+ const size = await ideaSizeLib.ideaSize('7', { client: c });
190
+ assert.equal(size.credits, 9, 'the direct payout and every decayed one below it');
191
+ assert.equal(size.payments, 3, 'and how many payouts made it up');
192
+ assert.equal(size.scale, ideaSizeLib.compressIdeaSize(9), 'with the drawn magnitude applied once, at the read');
193
+ });
194
+
195
+ await test('THE BOUNDARY: idea 1 does not absorb the payouts of ideas 10 and 100', async () => {
196
+ // The bug a prefix match without its trailing separator produces. It raises no
197
+ // error and renders no differently — one ideator is simply paid-looking for
198
+ // everyone else's work.
199
+ const c = ledgerClient([paid(1, 500, 5), paid(10, 501, 40), paid(100, 502, 400), paid(1, 503, 2)]);
200
+ const sizes = await ideaSizeLib.ideaSizes(['1', '10', '100'], { client: c });
201
+ assert.equal(sizes.get('1').credits, 7, 'idea 1 is worth its own two payouts and nothing else');
202
+ assert.equal(sizes.get('10').credits, 40);
203
+ assert.equal(sizes.get('100').credits, 400);
204
+ });
205
+
206
+ await test('and the SQL that delivers it groups on the SEPARATOR, never a per-idea LIKE', async () => {
207
+ // The twin above can only prove the arithmetic of a predicate it implements
208
+ // itself, so the real statement is pinned clause by clause. Rewriting this
209
+ // read as `reason LIKE 'idea.credit.task_ship:' || id || ':%'` would pass the
210
+ // test above and reintroduce the boundary the day someone drops the colon.
211
+ const c = ledgerClient([]);
212
+ await ideaSizeLib.ideaSizes(['1'], { client: c });
213
+ const { sql } = c.log[0];
214
+ assert.match(sql, /split_part\(reason, ':', 2\)/, 'the idea id comes off the separator');
215
+ assert.match(sql, /GROUP BY/, 'one grouped scan, not one query per idea');
216
+ assert.ok(sql.includes("reason LIKE '") && sql.includes("%'"),
217
+ 'the stream guard is the writers own constant, not a retyped literal');
218
+ assert.doesNotMatch(sql, /\|\|\s*':'/, 'no hand-built per-idea key');
219
+ });
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // 2b. the INDEX — and the drift that would silently un-index the read
223
+ // ---------------------------------------------------------------------------
224
+
225
+ // A grader panel caught this on the first round of task 1004093: credit_log is
226
+ // the whole-instance, append-only ledger, so an unindexed size read is a
227
+ // sequential scan of every credit event the instance has ever booked, on every
228
+ // view of a builder's idea trail. core_251 adds a partial expression index.
229
+ //
230
+ // The failure mode of a PARTIAL index is the quiet one, which is why it is
231
+ // tested rather than trusted: Postgres uses it only when it can prove the
232
+ // query's WHERE implies the index predicate. If the two strings stop matching —
233
+ // because the reason key changed, or because someone "tidied" the literal back
234
+ // into a bind parameter — nothing errors, nothing renders differently, and the
235
+ // scan simply returns.
236
+
237
+ const MIGRATION = fs.readFileSync(
238
+ path.join(ROOT, 'migrations', 'core_251_credit_log_idea_size_index.sql'), 'utf8');
239
+
240
+ await test('the migration indexes the SAME expression the read groups on', () => {
241
+ assert.match(MIGRATION, /CREATE INDEX IF NOT EXISTS/, 'additive and idempotent');
242
+ assert.match(MIGRATION, /ON credit_log \(\(split_part\(reason, ':', 2\)\)\)/,
243
+ 'an index on the grouping EXPRESSION — an index on `reason` alone cannot serve it');
244
+ // The STATEMENT, not the file — the header explains why CONCURRENTLY is
245
+ // absent, and a naive scan of the whole file would read its own explanation
246
+ // as the violation.
247
+ const statements = MIGRATION.split('\n').filter((l) => !l.trim().startsWith('--')).join('\n');
248
+ assert.match(statements, /CREATE INDEX/, 'the probe found the statement, so this is not vacuous');
249
+ assert.doesNotMatch(statements, /CONCURRENTLY/,
250
+ 'the runner wraps each file in a transaction, and CREATE INDEX CONCURRENTLY cannot run inside one');
251
+ });
252
+
253
+ await test('the index predicate and the query guard are the SAME literal', async () => {
254
+ // The drift guard. Held against the economy constant on one side and the
255
+ // statement the read actually issues on the other, so neither can move alone.
256
+ const unescape = (s) => s.replace(/\\([\\%_])/g, (m, ch) => ch);
257
+ const inMigration = /WHERE reason LIKE '([^']*)%'/.exec(MIGRATION);
258
+ assert.ok(inMigration, 'the index is partial on the stream-1 prefix');
259
+ assert.equal(unescape(inMigration[1]), PREFIX,
260
+ 'the migration predicate is the writers own reason prefix');
261
+
262
+ const c = ledgerClient([]);
263
+ await ideaSizeLib.ideaSizes(['1'], { client: c });
264
+ const inQuery = /WHERE reason LIKE '([^']*)%'/.exec(c.log[0].sql);
265
+ assert.ok(inQuery, 'and the query spells it as a LITERAL, not a bind parameter');
266
+ assert.equal(inQuery[1], inMigration[1],
267
+ 'a parameterised predicate cannot be proven to imply the partial index, so the index would never be chosen');
268
+ });
269
+
270
+ await test('the inlined prefix is fenced, and its LIKE wildcard is ESCAPED', async () => {
271
+ // The literal is module-owned and never touches a request, but inlining any
272
+ // string into SQL earns a guard: the module refuses to build the fragment from
273
+ // anything that is not plain prefix characters.
274
+ assert.match(PREFIX, /^[a-z0-9_.]+:$/i, 'the constant this file inlines is a plain literal');
275
+ assert.doesNotMatch(PREFIX, /['\\%]/, 'no quote and no escape character to break out of the literal');
276
+
277
+ // AND `task_ship` carries an underscore, which in LIKE matches ANY single
278
+ // character — so an unescaped predicate also matches `idea.credit.taskXship:`.
279
+ // No such key exists, which is exactly why it would never be noticed.
280
+ assert.match(PREFIX, /_/, 'the prefix really does contain a wildcard, so this is not vacuous');
281
+ const c = ledgerClient([]);
282
+ await ideaSizeLib.ideaSizes(['1'], { client: c });
283
+ assert.ok(c.log[0].sql.includes('task\\_ship'), 'the query escapes it');
284
+ assert.ok(MIGRATION.includes('task\\_ship'), 'and the index predicate escapes it identically');
285
+ });
286
+
287
+ await test('a whole PAGE of ideas costs ONE query, not one per idea', async () => {
288
+ const c = ledgerClient([]);
289
+ await ideaSizeLib.ideaSizes(['1', '2', '3', '4', '5', '6', '7', '8'], { client: c });
290
+ assert.equal(c.log.length, 1, 'the round-trip count is independent of how many ideas the page carries');
291
+ });
292
+
293
+ await test('the OTHER ideator streams are not size — they pay for the thought, not its work', async () => {
294
+ const c = ledgerClient([
295
+ paid(7, 500, 5),
296
+ { reason: `${credits.IDEA_STREAM_REASON_PREFIXES.full_pass}7`, delta: 5 },
297
+ { reason: `${credits.IDEA_STREAM_REASON_PREFIXES.governance_pass}7`, delta: 100 },
298
+ { reason: 'idea_promotion_bonus', delta: 12 },
299
+ { reason: 'task.shipped', delta: 30 },
300
+ ]);
301
+ const size = await ideaSizeLib.ideaSize('7', { client: c });
302
+ assert.equal(size.credits, 5, 'only stream 1 — a Full Idea that caused nothing must not draw large');
303
+ assert.equal(size.payments, 1);
304
+ });
305
+
306
+ await test('every idea asked for comes back, at zero when nothing has been paid', async () => {
307
+ const sizes = await ideaSizeLib.ideaSizes(['7', '8'], { client: ledgerClient([paid(7, 500, 5)]) });
308
+ assert.equal(sizes.get('8').credits, 0,
309
+ '"this thought has earned nothing yet" is an answer a renderer must be able to draw');
310
+ assert.equal(sizes.get('8').paid, 0);
311
+ assert.ok(sizes.has('8'), 'absent and zero are different answers; the read never returns a gap');
312
+ });
313
+
314
+ await test('an id that is not a positive integer never reaches the database', async () => {
315
+ const c = ledgerClient([]);
316
+ const sizes = await ideaSizeLib.ideaSizes(['7', "1; DROP TABLE credit_log", '-3', null, '007x'], { client: c });
317
+ assert.deepEqual([...sizes.keys()], ['7'], 'only the legitimate id survives');
318
+ assert.doesNotMatch(JSON.stringify(c.log[0].params), /DROP TABLE/);
319
+ });
320
+
321
+ await test('asking about nothing asks the database nothing', async () => {
322
+ const c = ledgerClient([]);
323
+ assert.equal((await ideaSizeLib.ideaSizes([], { client: c })).size, 0);
324
+ assert.equal((await ideaSizeLib.ideaSize('nonsense', { client: c })).credits, 0);
325
+ assert.equal(c.log.length, 0);
326
+ });
327
+
328
+ // ---------------------------------------------------------------------------
329
+ // 3. the port
330
+ // ---------------------------------------------------------------------------
331
+
332
+ await test('size rides the `reward` port, so ideas never reads credit_log itself', async () => {
333
+ for (const name of ['ideaSize', 'ideaSizes', 'compressIdeaSize']) {
334
+ assert.equal(typeof reward[name], 'function', `${name} is on the port surface`);
335
+ }
336
+ assert.equal(reward.IDEA_SIZE_SCALE_REF, ideaSizeLib.IDEA_SIZE_SCALE_REF,
337
+ 'and the reference constant, so a drawing uses the same curve the read applied');
338
+ const c = ledgerClient([paid(7, 500, 5)]);
339
+ assert.equal((await reward.ideaSize('7', { client: c })).credits, 5, 'the port delegates to the real read');
340
+ });
341
+
342
+ // ---------------------------------------------------------------------------
343
+ // 4. SIZE NEVER FALLS — tested where the invariant actually lives
344
+ // ---------------------------------------------------------------------------
345
+
346
+ // The module header claims size cannot fall, and the read applies no clamp to
347
+ // make that true — deliberately, because a clamp would hide the anomaly rather
348
+ // than prevent it. The property lives in the WRITERS: both stream-1 payout paths
349
+ // drop a payment whose rounded delta is <= 0, and ADR 0313 claws nothing back.
350
+ // So it is driven here, through the real credits.js, rather than asserted in a
351
+ // comment nothing runs.
352
+
353
+ function writerClient(lineage = {}) {
354
+ const booked = [];
355
+ return {
356
+ booked,
357
+ async query(sql, params) {
358
+ if (/FROM ideas_task_lineage/i.test(sql)) {
359
+ const ids = (params[0] || []).map(String);
360
+ return { rows: ids.map((id) => lineage[id]).filter(Boolean) };
361
+ }
362
+ if (/INSERT INTO credit_log/i.test(sql)) {
363
+ booked.push({ builder_id: params[0], task_id: params[1], delta: params[2], reason: params[3] });
364
+ return { rowCount: 1, rows: [] };
365
+ }
366
+ return { rows: [] };
367
+ },
368
+ };
369
+ }
370
+
371
+ let ancestorsFixture = [];
372
+ // Descendants keyed by root task id. Registered on the SAME provider object as
373
+ // the walk the payout uses, because a port is single-provider — §5c varies this
374
+ // fixture rather than re-registering.
375
+ let descendantsFixture = new Map();
376
+ api.registerProvider('ideas.descent', {
377
+ ancestorsOfTask: async () => ancestorsFixture,
378
+ // Deliberately shaped like the REAL walk: rows off ideas_task_descent, which
379
+ // is an edge table with no join to `tasks`, so they carry id/depth/relation
380
+ // and NO STATUS. A stub that helpfully added one would hide the exact bug the
381
+ // hydration in walkDescentForIdeas exists to prevent.
382
+ descendantsOfTask: async (taskId) => (descendantsFixture.get(String(taskId)) || []),
383
+ });
384
+
385
+ await test('no stream-1 writer ever books a non-positive delta — the source of "size never falls"', async () => {
386
+ // A descent deep enough that the decayed share rounds away entirely at the
387
+ // bottom. Those hops must contribute NOTHING, never a zero or negative row —
388
+ // a zero row is harmless arithmetic and a lie about how many payouts an idea
389
+ // received; a negative one would make a star shrink.
390
+ const lineage = {};
391
+ ancestorsFixture = [];
392
+ for (let d = 1; d <= 8; d += 1) {
393
+ const taskId = 600 + d;
394
+ ancestorsFixture.push({ id: taskId, depth: d, relation: 'split' });
395
+ lineage[String(taskId)] = {
396
+ task_id: String(taskId), idea_id: 70 + d, captured_by: 3,
397
+ grade: 'quick', completeness: null, developed_by: null,
398
+ };
399
+ }
400
+ const c = writerClient(lineage);
401
+ await credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: rewardPolicy({}) });
402
+ assert.ok(c.booked.length > 0, 'the near hops did pay, so this is not vacuous');
403
+ for (const row of c.booked) {
404
+ assert.ok(String(row.reason).startsWith(PREFIX), 'every row booked here is a stream-1 row');
405
+ assert.ok(row.delta > 0, `a payout of ${row.delta} was booked under ${row.reason}`);
406
+ }
407
+ // And the rounded-away hops are genuinely absent rather than booked at zero.
408
+ const deepest = c.booked.map((r) => Number(String(r.reason).split(':')[1])).sort((a, b) => b - a)[0];
409
+ assert.ok(deepest < 78, 'the hops whose share no longer rounds to a credit book nothing at all');
410
+ });
411
+
412
+ await test('a size read over a real writer run is the sum of what was actually booked', async () => {
413
+ // The two halves meeting: what the writers put in the ledger is what the read
414
+ // adds up. Nothing between them is hand-built.
415
+ const c = writerClient({
416
+ 601: { task_id: '601', idea_id: 71, captured_by: 3, grade: 'quick', completeness: null, developed_by: null },
417
+ 602: { task_id: '602', idea_id: 71, captured_by: 3, grade: 'quick', completeness: null, developed_by: null },
418
+ });
419
+ ancestorsFixture = [{ id: 601, depth: 1, relation: 'split' }];
420
+ await credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: rewardPolicy({}) });
421
+ ancestorsFixture = [{ id: 602, depth: 1, relation: 'split' }];
422
+ await credits.awardIdeaDescentShipCredit(c, { taskId: 901, config: rewardPolicy({}) });
423
+ const expected = c.booked.reduce((n, r) => n + r.delta, 0);
424
+ assert.ok(expected > 0);
425
+ const size = await ideaSizeLib.ideaSize('71', { client: ledgerClient(c.booked) });
426
+ assert.equal(size.credits, expected, 'the ledger is the only source of the figure');
427
+ assert.equal(size.payments, c.booked.length);
428
+ });
429
+
430
+ // ---------------------------------------------------------------------------
431
+ // 5. the trail attach — and the branch where the port is not there
432
+ // ---------------------------------------------------------------------------
433
+
434
+ await test('with the reward port absent, size is NULL — which is not zero', async () => {
435
+ // Run before the port is registered: a port is single-provider and cannot be
436
+ // unregistered, so this is the only honest place for the absent branch.
437
+ assert.equal(api.hasProvider('reward'), false, 'nothing has registered it yet');
438
+ const ideas = [{ id: '7' }, { id: '8' }];
439
+ await trailSize.attachSizeToTrail(ideas);
440
+ assert.equal(ideas[0].size, null, '"we could not ask the ledger" must not render as "you earned nothing"');
441
+ assert.equal(ideas[1].size, null);
442
+ });
443
+
444
+ api.registerProvider('reward', reward);
445
+
446
+ await test('the trail attaches each idea its OWN size, in one read for the page', async () => {
447
+ const c = ledgerClient([paid(7, 500, 5), paid(7, 501, 2), paid(9, 502, 40)]);
448
+ const ideas = [{ id: '7' }, { id: '8' }, { id: '9' }];
449
+ await trailSize.attachSizeToTrail(ideas, { client: c });
450
+ assert.equal(ideas[0].size.credits, 7);
451
+ assert.equal(ideas[1].size.credits, 0);
452
+ assert.equal(ideas[1].size.historical, false, 'an idea with no descent has nothing estimated');
453
+ assert.equal(ideas[2].size.credits, 40);
454
+ // The property is that the round trips do NOT scale with the page, not that
455
+ // there is exactly one of them: BV2.R18 added a second read (the grades that
456
+ // price the historical half), and pinning the literal 1 would have made a
457
+ // correct change look like a regression. A page of twelve must cost what a
458
+ // page of three costs — that is the claim, and it is invisible in review.
459
+ const small = c.log.length;
460
+ const big = ledgerClient([paid(7, 500, 5)]);
461
+ const many = [];
462
+ for (let i = 0; i < 12; i += 1) many.push({ id: String(20 + i) });
463
+ await trailSize.attachSizeToTrail(many, { client: big });
464
+ assert.equal(big.log.length, small, 'a page of twelve costs what a page of three costs');
465
+ });
466
+
467
+ await test('a MERGED idea keeps its own size — the descent it shows is inherited, the money is not', async () => {
468
+ // trail-descent.js deliberately hands a merged idea the TARGET's tasks, so the
469
+ // easy mistake is to hand it the target's payouts too. Stream 1 books against
470
+ // the idea whose lineage row named the shipping task; crediting both would
471
+ // count one piece of work twice on a single page.
472
+ const c = ledgerClient([paid(901, 500, 40)]);
473
+ const ideas = [
474
+ { id: '903', status: 'merged', merged_into_idea_id: '901' },
475
+ { id: '901', status: 'promoted' },
476
+ ];
477
+ await trailSize.attachSizeToTrail(ideas, { client: c });
478
+ assert.equal(ideas[0].size.credits, 0, 'the merged thought earned nothing of its own');
479
+ assert.equal(ideas[1].size.credits, 40, 'the target keeps what its work was paid');
480
+ assert.doesNotMatch(JSON.stringify(c.log[0].params), /"901"[\s\S]*"901"/,
481
+ 'and the merge target is never asked for twice');
482
+ });
483
+
484
+ // ---------------------------------------------------------------------------
485
+ // 5b. THE HISTORICAL HALF — task 1004097 / BV2.R18, spec decision 17
486
+ // ---------------------------------------------------------------------------
487
+
488
+ // A pre-existing idea's ledger figure is PARTIAL, not absent: the direct payout
489
+ // has existed since ADR 0172, the DESCENT payout only since task 1004088. So the
490
+ // backfill is the gap, it is computed at read time, and NO credits are booked.
491
+ // The failure modes are all quiet ones:
492
+ //
493
+ // * DOUBLE-COUNTING. Estimating for a hop the ledger already paid makes every
494
+ // long-lived idea read roughly twice its real size, with nothing to show it.
495
+ // * PAYING FOR WORK THAT HAS NOT HAPPENED. Estimating for a task that has not
496
+ // shipped credits a payout that no ship triggered — and makes size FALL when
497
+ // that task is later abandoned, breaking BV2.R12's headline invariant.
498
+ // * AN UNMARKED TOTAL. The spec calls this the quiet lie: a number that is
499
+ // partly an estimate, rendered as though it were money that moved.
500
+
501
+ await test('the backfill estimates ONLY the hops the ledger never paid for', async () => {
502
+ // Depth 0 was paid (5c, really banked). The two hops below it shipped before
503
+ // the descent payout existed, so they were never paid and are estimated at the
504
+ // decayed rate: quick factor 0.6 × base 5 × 0.5 = 2 at depth 1, × 0.25 = 1 at
505
+ // depth 2. Paid 5 + estimated 3 = 8.
506
+ const c = ledgerClient([paid(7, 500, 5)]);
507
+ const descentByIdea = new Map([['7', [
508
+ { task_id: '500', depth: 0, status: 'shipped' },
509
+ { task_id: '501', depth: 1, status: 'shipped' },
510
+ { task_id: '502', depth: 2, status: 'shipped' },
511
+ ]]]);
512
+ const size = (await ideaSizeLib.ideaSizes(['7'], { client: c, descentByIdea })).get('7');
513
+ assert.equal(size.paid, 5, 'money that moved is read off the ledger, never re-derived');
514
+ assert.equal(size.estimated, 3, 'and only the unpaid hops are priced');
515
+ assert.equal(size.credits, 8, 'size is the two together');
516
+ assert.equal(size.historical, true, 'and it says so');
517
+ });
518
+
519
+ await test('a hop the ledger DID pay is never estimated on top of', async () => {
520
+ // The double-counting case. Every task in the descent has a real payment, so
521
+ // there is nothing to estimate and the idea is not historical at all.
522
+ const c = ledgerClient([paid(7, 500, 5), paid(7, 501, 2), paid(7, 502, 1)]);
523
+ const descentByIdea = new Map([['7', [
524
+ { task_id: '500', depth: 0, status: 'shipped' },
525
+ { task_id: '501', depth: 1, status: 'shipped' },
526
+ { task_id: '502', depth: 2, status: 'shipped' },
527
+ ]]]);
528
+ const size = (await ideaSizeLib.ideaSizes(['7'], { client: c, descentByIdea })).get('7');
529
+ assert.equal(size.paid, 8);
530
+ assert.equal(size.estimated, 0, 'a paid hop contributes its PAYMENT, not a second estimate');
531
+ assert.equal(size.credits, 8, 'not 16');
532
+ assert.equal(size.historical, false, 'an idea whose descent was fully paid is not historical');
533
+ });
534
+
535
+ await test('an UNSHIPPED descendant is worth nothing — a payout happens at ship', async () => {
536
+ // Estimating for work that has not shipped would credit a payout nothing
537
+ // triggered, and would make size FALL the day that task is abandoned.
538
+ const c = ledgerClient([]);
539
+ const before = new Map([['7', [
540
+ { task_id: '500', depth: 0, status: 'shipped' },
541
+ { task_id: '501', depth: 1, status: 'ready' },
542
+ { task_id: '502', depth: 1, status: 'abandoned' },
543
+ ]]]);
544
+ const size = (await ideaSizeLib.ideaSizes(['7'], { client: c, descentByIdea: before })).get('7');
545
+ assert.equal(size.estimated, 3, 'only the shipped depth-0 task is priced (5 × 0.6)');
546
+
547
+ // And when the ready one ships, size RISES. It must never have been counted
548
+ // early, or this would be a fall.
549
+ const after = new Map([['7', [
550
+ { task_id: '500', depth: 0, status: 'shipped' },
551
+ { task_id: '501', depth: 1, status: 'shipped' },
552
+ { task_id: '502', depth: 1, status: 'abandoned' },
553
+ ]]]);
554
+ const later = (await ideaSizeLib.ideaSizes(['7'], { client: ledgerClient([]), descentByIdea: after })).get('7');
555
+ assert.ok(later.credits > size.credits, 'a ship raises size; nothing here can lower it');
556
+ });
557
+
558
+ await test('the backfill BOOKS NOTHING — it is a read, and the ledger is untouched', async () => {
559
+ const c = ledgerClient([]);
560
+ const descentByIdea = new Map([['7', [{ task_id: '500', depth: 0, status: 'shipped' }]]]);
561
+ await ideaSizeLib.ideaSizes(['7'], { client: c, descentByIdea });
562
+ for (const q of c.log) {
563
+ assert.doesNotMatch(q.sql, /INSERT|UPDATE|DELETE/i,
564
+ 'no credits are paid retroactively — the owner declined that, and a write here would also forge stream-1 idempotency keys');
565
+ }
566
+ });
567
+
568
+ await test('the estimate uses the SAME arithmetic the real payout uses', async () => {
569
+ // Not a second copy. A full idea at 100% completeness is worth the full base
570
+ // at depth 0 — which is what ideaGradeFactor says and what the writer applies.
571
+ const c = ledgerClient([]);
572
+ c.query = async (sql, params) => {
573
+ if (/FROM idea_inbox/i.test(sql)) return { rows: [{ id: 7, grade: 'full', completeness: 100 }] };
574
+ return { rows: [] };
575
+ };
576
+ const descentByIdea = new Map([['7', [{ task_id: '500', depth: 0, status: 'shipped' }]]]);
577
+ const size = (await ideaSizeLib.ideaSizes(['7'], { client: c, descentByIdea })).get('7');
578
+ assert.equal(size.estimated, credits.IDEA_TASK_SHIP_BASE,
579
+ 'a 100%-complete Full Idea earns the full base at depth 0, exactly as the writer pays it');
580
+ assert.equal(credits.ideaGradeFactor('full', 100), 1, 'and the factor is credits.js own, not a copy');
581
+ });
582
+
583
+ // ---------------------------------------------------------------------------
584
+ // 5c. the WALK FALLBACK — for a caller with no descent in hand
585
+ // ---------------------------------------------------------------------------
586
+
587
+ // The trail passes its own already-walked descent, so this path is for a
588
+ // single-idea panel. It is a real branch with a real trap in it, so it is driven
589
+ // rather than assumed.
590
+
591
+ // A client that answers the three shapes this path issues: the ledger read, the
592
+ // lineage roots, and the task-status hydration.
593
+ function panelClient(ledgerRows, { roots = [], statuses = {}, grade = null } = {}) {
594
+ const base = ledgerClient(ledgerRows);
595
+ const log = base.log;
596
+ return {
597
+ log,
598
+ async query(sql, params) {
599
+ if (/FROM credit_log/i.test(sql)) return base.query(sql, params);
600
+ log.push({ sql, params });
601
+ if (/FROM ideas_task_lineage/i.test(sql)) return { rows: roots };
602
+ if (/FROM idea_inbox/i.test(sql)) {
603
+ return { rows: grade ? [grade] : [] };
604
+ }
605
+ if (/FROM tasks/i.test(sql)) {
606
+ return { rows: (params[0] || []).map((id) => ({ id, status: statuses[String(id)] ?? null })).filter((r) => r.status != null) };
607
+ }
608
+ return { rows: [] };
609
+ },
610
+ };
611
+ }
612
+
613
+ await test('with no map, the walk is done through the PORT — never a second copy of it', async () => {
614
+ descendantsFixture = new Map([['500', [{ id: 501, from_id: 500, relation: 'split', depth: 1 }]]]);
615
+ const c = panelClient([paid(7, 500, 5)], {
616
+ roots: [{ idea_id: 7, task_id: 500, status: 'shipped' }],
617
+ statuses: { 501: 'shipped' },
618
+ });
619
+ const size = (await ideaSizeLib.ideaSizes(['7'], { client: c })).get('7');
620
+ assert.equal(size.paid, 5, 'the direct hop was paid');
621
+ assert.equal(size.estimated, 2, 'and the hop below it is priced from the walk');
622
+ assert.equal(size.historical, true);
623
+ descendantsFixture = new Map();
624
+ });
625
+
626
+ await test('THE WALK RETURNS NO STATUS, so the fallback hydrates it — or it prices nothing', async () => {
627
+ // The trap this path was written around. ideas_task_descent is an EDGE table
628
+ // with no join to `tasks`, so the walk's rows carry no status at all. Read
629
+ // naively, every descendant fails the shipped test, the backfill silently
630
+ // prices nothing, and it looks exactly like an idea that simply has no
631
+ // descent. The hydration query is what makes the difference, so its absence
632
+ // is what this asserts against: a descendant the tasks read says is SHIPPED
633
+ // must be priced, and one it says is READY must not.
634
+ descendantsFixture = new Map([['500', [
635
+ { id: 501, from_id: 500, relation: 'split', depth: 1 },
636
+ { id: 502, from_id: 500, relation: 'split', depth: 1 },
637
+ ]]]);
638
+ const c = panelClient([], {
639
+ roots: [{ idea_id: 7, task_id: 500, status: 'ready' }],
640
+ statuses: { 501: 'shipped', 502: 'ready' },
641
+ });
642
+ const size = (await ideaSizeLib.ideaSizes(['7'], { client: c })).get('7');
643
+ assert.ok(c.log.some((q) => /FROM tasks/i.test(q.sql)), 'the statuses are fetched, not read off the walk');
644
+ assert.equal(size.estimated, 2, 'only the shipped descendant is priced — the root is not shipped, nor is 502');
645
+ descendantsFixture = new Map();
646
+ });
647
+
648
+ // ---------------------------------------------------------------------------
649
+ // 6. the WHOLE PATH — the real read into the real page
650
+ // ---------------------------------------------------------------------------
651
+
652
+ const renderTrail = async (trail) => {
653
+ const p = bootProfilePage({ trail });
654
+ await settle(); await settle(); await settle();
655
+ const html = p.htmlFor('activity-ideas');
656
+ assert.ok(html, 'the idea trail rendered');
657
+ return html;
658
+ };
659
+
660
+ await test('WHOLE PATH: the real read feeds the real page, and it prints the TRUE figure', async () => {
661
+ // Nothing hand-built between the ledger and the HTML: the fixture is credit_log
662
+ // rows, the read is the real one, and the page is the real profile renderer.
663
+ const c = ledgerClient([paid(901, 5001, 5), paid(901, 5002, 2), paid(901, 5003, 1)]);
664
+ const trail = await trailSize.attachSizeToTrail([
665
+ {
666
+ id: '901', title: 'A shared changelog', status: 'promoted', captured_at: '2026-08-01T00:00:00Z',
667
+ lineage_tasks: [{ task_id: '5001', origin: 'promote', title: 'The first task', status: 'shipped' }],
668
+ descent_tasks: [],
669
+ },
670
+ ], { client: c });
671
+
672
+ const html = await renderTrail(trail);
673
+ assert.ok(html.includes('Earned 8 credits so far'),
674
+ 'the page prints the summed ledger figure, not the compressed magnitude');
675
+ assert.ok(!/Earned 0\.\d/.test(html), 'the drawn scale never reaches the panel');
676
+ });
677
+
678
+ await test('RENDERED: an idea with a size but NO descent still shows what it earned', async () => {
679
+ // The failure mode of hanging this off the descent block: an idea promoted
680
+ // straight into one task that shipped has a size and no descent at all, and
681
+ // its ideator would be shown nothing.
682
+ const c = ledgerClient([paid(902, 5001, 5)]);
683
+ const trail = await trailSize.attachSizeToTrail([
684
+ {
685
+ id: '902', title: 'A small thought', status: 'promoted', captured_at: '2026-08-01T00:00:00Z',
686
+ lineage_tasks: [{ task_id: '5001', origin: 'promote', title: 'The only task', status: 'shipped' }],
687
+ descent_tasks: [],
688
+ },
689
+ ], { client: c });
690
+ const html = await renderTrail(trail);
691
+ assert.ok(html.includes('Earned 5 credits so far'));
692
+ assert.ok(!html.includes('Then caused'), 'and it genuinely has no descent to have hung it from');
693
+ });
694
+
695
+ await test('RENDERED: an idea that has earned nothing says nothing, and a null size says nothing either', async () => {
696
+ // Two different reasons to print no figure, and neither may print a zero: "you
697
+ // have earned nothing yet" is the ledger's honest answer and belongs in the
698
+ // empty state the outcome line already gives, and a null means the figure was
699
+ // never established.
700
+ const html = await renderTrail([
701
+ { id: '904', title: 'Still thinking', status: 'open', captured_at: '2026-05-01T00:00:00Z', lineage_tasks: [], descent_tasks: [], size: { credits: 0, payments: 0, scale: 0 } },
702
+ { id: '905', title: 'Unknowable', status: 'open', captured_at: '2026-05-01T00:00:00Z', lineage_tasks: [], descent_tasks: [], size: null },
703
+ ]);
704
+ assert.doesNotMatch(html, /Earned 0 credits/, 'zero is not a thing to boast about on a ledger row');
705
+ assert.doesNotMatch(html, /Earned .* credits so far[\s\S]*Earned/, 'and neither row printed a figure');
706
+ });
707
+
708
+ await test('WHOLE PATH: a HISTORICAL idea says on the page which kind of number it is', async () => {
709
+ // The done-when of BV2.R18: anywhere size is shown makes clear which kind of
710
+ // number it is. Nothing hand-built between the ledger and the HTML — the
711
+ // fixture is credit_log rows plus a real descent, the read is the real one,
712
+ // and the page is the real profile renderer.
713
+ const c = ledgerClient([paid(907, 5001, 5)]);
714
+ const trail = await trailSize.attachSizeToTrail([
715
+ {
716
+ id: '907', title: 'An old thought', status: 'promoted', captured_at: '2026-02-01T00:00:00Z',
717
+ lineage_tasks: [{ task_id: '5001', origin: 'promote', title: 'The first task', status: 'shipped' }],
718
+ descent_tasks: [
719
+ { task_id: '5002', depth: 1, relation: 'split', origin_task_id: '5001', title: 'Split out', status: 'shipped', version_id: 'BONGOS-V2', kind: 'feature' },
720
+ ],
721
+ },
722
+ ], { client: c });
723
+
724
+ assert.equal(trail[0].size.paid, 5, 'the direct hop really was paid');
725
+ assert.equal(trail[0].size.estimated, 2, 'the hop below it never was');
726
+ assert.equal(trail[0].size.historical, true);
727
+
728
+ const html = await renderTrail(trail);
729
+ assert.ok(html.includes('Worth 7 credits'), 'the total is shown');
730
+ assert.match(html, /2 of it estimated/, 'and the part that is NOT money that moved is named');
731
+ assert.ok(!html.includes('Earned 7 credits so far'),
732
+ 'it must not be phrased as earnings — that is the quiet lie the spec names');
733
+ });
734
+
735
+ await test('RENDERED: a fully-paid idea is NOT marked historical', async () => {
736
+ // The opposite direction, so the marker is not simply always on.
737
+ const html = await renderTrail([
738
+ {
739
+ id: '908', title: 'A recent thought', status: 'promoted', captured_at: '2026-09-01T00:00:00Z',
740
+ lineage_tasks: [], descent_tasks: [],
741
+ size: { credits: 5, paid: 5, estimated: 0, payments: 1, scale: 0.3, historical: false },
742
+ },
743
+ ]);
744
+ assert.ok(html.includes('Earned 5 credits so far'), 'plain earnings wording');
745
+ assert.doesNotMatch(html, /estimated/, 'and no marker where none is due');
746
+ });
747
+
748
+ await test('RENDERED: an UNKNOWN historical state claims neither', async () => {
749
+ // historical: null — the estimate could not be established. It must not print
750
+ // as "estimated" (a claim) nor be silently folded in as though it were all
751
+ // money that moved, so it falls back to the plain figure and no marker.
752
+ const html = await renderTrail([
753
+ {
754
+ id: '909', title: 'Unknowable', status: 'promoted', captured_at: '2026-09-01T00:00:00Z',
755
+ lineage_tasks: [], descent_tasks: [],
756
+ size: { credits: 5, paid: 5, estimated: null, payments: 1, scale: 0.3, historical: null },
757
+ },
758
+ ]);
759
+ assert.doesNotMatch(html, /estimated/, 'unknown is not a claim that anything was estimated');
760
+ assert.ok(html.includes('5 credit'), 'but what IS known still shows');
761
+ });
762
+
763
+ await test('RENDERED: the figure is a number, so a hostile size cannot reach the page as markup', async () => {
764
+ const html = await renderTrail([
765
+ {
766
+ id: '906', title: 'A thought', status: 'promoted', captured_at: '2026-05-01T00:00:00Z',
767
+ lineage_tasks: [], descent_tasks: [],
768
+ size: { credits: '<script>alert(1)</script>', payments: 1, scale: 0 },
769
+ },
770
+ ]);
771
+ assert.doesNotMatch(html, /<script>/, 'a non-numeric credits value is refused outright, not escaped and printed');
772
+ assert.doesNotMatch(html, /Earned/, 'the row simply carries no figure');
773
+ });
774
+
775
+ console.log(`\n${passed} passed, ${failed} failed`);
776
+ process.exit(failed === 0 ? 0 : 1);