@bongos/core 1.19.1004 → 1.19.1006

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,376 @@
1
+ // tests/ideator_fork_pays_proof.mjs — THE PROOF for `ideator-tag-survives-the-fork`
2
+ // (task 1004102 / BV2.R23 of goal 1000095, spec R13).
3
+ //
4
+ // THE STORY, END TO END: a real thought becomes a real task; that task is
5
+ // split; one of its children is split again; each ships. The origin is paid at
6
+ // the decayed rate at EVERY hop, and the whole chain reads as ONE descent.
7
+ //
8
+ // WHY THIS IS THE LOAD-BEARING ONE. If the tag dies at the first fork, the
9
+ // descent is a link, the size is wrong, and the sky is decoration over a number
10
+ // that does not mean anything. Every other ideator criterion is downstream of
11
+ // this one holding.
12
+ //
13
+ // WHAT NO EXISTING SUITE CAN SEE. The hops are covered individually — the edge
14
+ // writer (tests/idea_task_descent.mjs), the walk (tests/idea_trail_descent.mjs),
15
+ // the decayed payout (tests/idea_descent_ship_credit.mjs), size
16
+ // (tests/idea_size.mjs). What none of them does is run ONE chain through ALL of
17
+ // them against ONE store, which is the only way to catch the failures that live
18
+ // in the JOINS rather than in the parts:
19
+ //
20
+ // - the edge is derived at ship but the payout walks a DIFFERENT relation set,
21
+ // so the money stops one hop before the picture does;
22
+ // - depth is counted from the wrong end, so hop 2 is paid hop 1's rate;
23
+ // - an origin is paid twice for one ship because the direct payout and the
24
+ // decayed payout both claim depth 0;
25
+ // - size sums the payouts the walk can see rather than the payouts the ledger
26
+ // actually made.
27
+ //
28
+ // Each of those passes every suite above.
29
+ //
30
+ // THE STORE IS SMALL, REAL AND STRICT. Not a recorded query log: the chain has
31
+ // to be WRITTEN and then READ BACK — syncDescentForTask inserts the edges that
32
+ // ancestorsOfTask later walks — so a log that replays canned answers would be
33
+ // asserting the fixture, not the code. It is a few arrays behind the handful of
34
+ // statements this path issues, and it THROWS on any SQL it does not recognise.
35
+ // That last part is the whole design: a store that answered an unmatched query
36
+ // with `{ rows: [] }` would let a renamed column or a new join turn this proof
37
+ // into a vacuous green, which is precisely the failure mode
38
+ // tests/skip_is_not_pass.mjs and the "static scans fail silent" learning exist
39
+ // for. An unknown statement is a FAILURE here, never an empty answer.
40
+ //
41
+ // ON "DEMONSTRATE IT ON REAL WORK, NOT A FIXTURE" (the task's own words). At
42
+ // the time of writing NO descent exists anywhere on the live instance — the
43
+ // trail read returns zero ideas carrying a descent for every builder scanned,
44
+ // because `ideas_task_descent` is only written on `task.shipped` and no shipped
45
+ // task has yet had a parent that traces back to an idea. That is a TRUE ZERO,
46
+ // not a failure, and it is recorded as such in
47
+ // docs/governance/bongos-v2-ideator-fork-proof.md rather than manufactured by
48
+ // splitting live tasks to make a number appear. This file is the re-runnable
49
+ // half; the doc carries the live observation and its verdict.
50
+ //
51
+ // Run: node tests/ideator_fork_pays_proof.mjs
52
+
53
+ import { strict as assert } from 'node:assert';
54
+ import { createRequire } from 'node:module';
55
+ import { makeRunner } from './helpers.mjs';
56
+
57
+ process.env.NODE_ENV = 'test';
58
+
59
+ const require = createRequire(import.meta.url);
60
+ const seams = require('../src/module-seams.js');
61
+ seams._reset();
62
+
63
+ const descent = require('../modules/ideas/descent.js');
64
+ const credits = require('../modules/economy/credits.js');
65
+ const { rewardPolicy } = require('../modules/economy/reward-policy.js');
66
+
67
+ // The `ideas.descent` kernel port is how economy reaches the walk — a module
68
+ // may not require a sibling (ADR 0083 rule 1). Registering the REAL descent
69
+ // module behind it is the point: a stub here would let the payout and the
70
+ // picture disagree about the graph, which is one of the four failures above.
71
+ seams.registerProvider('ideas.descent', {
72
+ ancestorsOfTask: descent.ancestorsOfTask,
73
+ descendantsOfTask: descent.descendantsOfTask,
74
+ descendantsOfTasks: descent.descendantsOfTasks,
75
+ });
76
+
77
+ const { test, summary } = makeRunner();
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // the store
81
+ // ---------------------------------------------------------------------------
82
+
83
+ const IDEATOR = 42;
84
+ const IDEA = 555;
85
+
86
+ function makeStore() {
87
+ const db = {
88
+ tasks: new Map(),
89
+ lineage: [], // { task_id, idea_id, origin, created_at }
90
+ descent: [], // { task_id, parent_task_id, relation }
91
+ grades: new Map(), // idea_id -> { grade, completeness_score, developed_by }
92
+ ideas: new Map(),
93
+ credit: [], // { builder_id, task_id, delta, reason, description }
94
+ };
95
+ db.ideas.set(IDEA, { id: IDEA, captured_by: IDEATOR });
96
+
97
+ const norm = (s) => String(s).replace(/\s+/g, ' ').trim();
98
+
99
+ const client = {
100
+ async query(sql, params = []) {
101
+ const s = norm(sql);
102
+
103
+ // descent.js walkMany — up
104
+ if (/FROM ideas_task_descent WHERE task_id = ANY/.test(s)) {
105
+ const ids = new Set((params[0] || []).map(String));
106
+ return { rows: db.descent.filter((e) => ids.has(String(e.task_id)))
107
+ .map((e) => ({ id: e.parent_task_id, from_id: e.task_id, relation: e.relation })) };
108
+ }
109
+ // descent.js walkMany — down
110
+ if (/FROM ideas_task_descent WHERE parent_task_id = ANY/.test(s)) {
111
+ const ids = new Set((params[0] || []).map(String));
112
+ return { rows: db.descent.filter((e) => ids.has(String(e.parent_task_id)))
113
+ .map((e) => ({ id: e.task_id, from_id: e.parent_task_id, relation: e.relation })) };
114
+ }
115
+ // descent.js linkTaskDescent
116
+ if (/INSERT INTO ideas_task_descent/.test(s)) {
117
+ const [child, parent, relation] = params;
118
+ const hit = db.descent.find((e) => e.task_id === child && e.parent_task_id === parent);
119
+ if (hit) hit.relation = relation;
120
+ else db.descent.push({ task_id: child, parent_task_id: parent, relation });
121
+ return { rowCount: 1, rows: [] };
122
+ }
123
+ // descent.js deriveEdgesForTask
124
+ if (/FROM tasks t WHERE t.id = \$1/.test(s)) {
125
+ const t = db.tasks.get(Number(params[0]));
126
+ if (!t) return { rows: [] };
127
+ const folded = [...db.tasks.values()]
128
+ .filter((m) => m.merged_into_task_id === t.id).map((m) => m.id);
129
+ return { rows: [{ ...t, folded_in: folded.length ? folded : null }] };
130
+ }
131
+ // credits.js awardIdeaDescentShipCredit — origins of the ancestor tasks
132
+ if (/FROM ideas_task_lineage l JOIN idea_inbox i/.test(s)) {
133
+ const ids = new Set((params[0] || []).map(String));
134
+ const seen = new Set();
135
+ const rows = [];
136
+ // DISTINCT ON (task_id) ORDER BY created_at, idea_id — the earliest
137
+ // link per task, which is the rule the trail and the direct payout
138
+ // both apply. Sorted the same way here so this store cannot make the
139
+ // read look more forgiving than Postgres is.
140
+ for (const l of [...db.lineage].sort((a, b) => a.created_at - b.created_at || a.idea_id - b.idea_id)) {
141
+ if (!ids.has(String(l.task_id)) || seen.has(String(l.task_id))) continue;
142
+ seen.add(String(l.task_id));
143
+ const idea = db.ideas.get(l.idea_id);
144
+ const g = db.grades.get(l.idea_id) || {};
145
+ rows.push({
146
+ task_id: l.task_id, idea_id: l.idea_id,
147
+ captured_by: idea ? idea.captured_by : null,
148
+ grade: g.grade || 'quick',
149
+ completeness: g.completeness_score ?? null,
150
+ developed_by: g.developed_by ?? null,
151
+ });
152
+ }
153
+ return { rows };
154
+ }
155
+ // credits.js bookIdeaSplit — insert-if-this-builder-has-no-such-reason
156
+ if (/INSERT INTO credit_log/.test(s)) {
157
+ const [builderId, taskId, delta, reason, description] = params;
158
+ const dup = db.credit.some((c) => String(c.builder_id) === String(builderId) && c.reason === reason);
159
+ if (dup) return { rowCount: 0, rows: [] };
160
+ db.credit.push({ builder_id: builderId, task_id: taskId, delta, reason, description });
161
+ return { rowCount: 1, rows: [] };
162
+ }
163
+
164
+ // THE STRICTNESS THAT MAKES THIS A PROOF. Anything this store has not
165
+ // been taught is a failure, never an empty result — see the header.
166
+ throw new Error(`ideator_fork_pays_proof: unrecognised SQL — the store must be taught this statement, not allowed to answer it empty:\n ${s.slice(0, 220)}`);
167
+ },
168
+ };
169
+
170
+ return { db, client };
171
+ }
172
+
173
+ // The world: one idea, its routed task, and two splits below it.
174
+ //
175
+ // idea 555 ──route──▶ task 100 ──split──▶ task 101 ──split──▶ task 102
176
+ //
177
+ function seed(db) {
178
+ const mk = (id, over = {}) => db.tasks.set(id, {
179
+ id, source: null, source_ref: null, parent_task_id: null,
180
+ merged_into_task_id: null, ...over,
181
+ });
182
+ mk(100, { source: 'idea-route', source_ref: `idea-${IDEA}` });
183
+ mk(101, { parent_task_id: 100 });
184
+ mk(102, { parent_task_id: 101 });
185
+ db.lineage.push({ task_id: 100, idea_id: IDEA, origin: 'route', created_at: 1 });
186
+ }
187
+
188
+ // Ships one task exactly the way production does: the descent-writer poller
189
+ // reacts to `task.shipped` by deriving the edges, and the ship transaction
190
+ // books the decayed payout. Order matters and is production's, not this
191
+ // file's — an edge that is not written yet cannot be walked.
192
+ async function ship(store, taskId, { skipIdeaIds = [] } = {}) {
193
+ const sync = await descent.syncDescentForTask(taskId, { client: store.client });
194
+ const pay = await credits.awardIdeaDescentShipCredit(store.client, {
195
+ taskId, skipIdeaIds, config: rewardPolicy(),
196
+ });
197
+ return { sync, pay };
198
+ }
199
+
200
+ const paidTo = (db, builderId) => db.credit
201
+ .filter((c) => String(c.builder_id) === String(builderId))
202
+ .reduce((n, c) => n + c.delta, 0);
203
+
204
+ // =========================================================================
205
+ // 1. THE FORK — the tag survives it
206
+ // =========================================================================
207
+
208
+ await test('the edge is recorded at ship, from the split the hierarchy already knew about', async () => {
209
+ const store = makeStore();
210
+ seed(store.db);
211
+ const { sync } = await ship(store, 101);
212
+ assert.deepEqual(sync.written, [{ parent: 100, relation: 'split' }],
213
+ 'shipping a split child records that it CAME FROM its parent');
214
+ assert.deepEqual(sync.skipped, [], 'and nothing is refused');
215
+ });
216
+
217
+ await test('two forks deep, the whole chain reads as ONE descent', async () => {
218
+ const store = makeStore();
219
+ seed(store.db);
220
+ await ship(store, 101);
221
+ await ship(store, 102);
222
+
223
+ const below = await descent.descendantsOfTask(100, { client: store.client });
224
+ assert.deepEqual(below.map((r) => ({ id: Number(r.id), depth: r.depth })),
225
+ [{ id: 101, depth: 1 }, { id: 102, depth: 2 }],
226
+ 'the root sees both hops, each at its own depth — not one link');
227
+
228
+ // And read from the other end, which is the direction the payout walks.
229
+ const above = await descent.ancestorsOfTask(102, { client: store.client });
230
+ assert.deepEqual(above.map((r) => ({ id: Number(r.id), depth: r.depth })),
231
+ [{ id: 101, depth: 1 }, { id: 100, depth: 2 }],
232
+ 'the grandchild sees the whole way up, and depth counts from ITSELF');
233
+ });
234
+
235
+ // =========================================================================
236
+ // 2. THE PAYOUT — decayed by depth, at every hop
237
+ // =========================================================================
238
+
239
+ await test('a task split twice pays its origin at the DECAYED rate, halving per hop', async () => {
240
+ const store = makeStore();
241
+ seed(store.db);
242
+
243
+ // Depth 0 is the DIRECT payout's job (awardIdeaTaskShipCredit), so the root's
244
+ // ship passes its idea in skipIdeaIds exactly as the ship path does — the
245
+ // direct payout always wins over a decayed one for the same idea.
246
+ const root = await ship(store, 100, { skipIdeaIds: [IDEA] });
247
+ assert.deepEqual(root.pay.payments, [],
248
+ 'the decayed lane pays nothing at depth 0 — that is the direct payout, and paying both would pay twice for one ship');
249
+
250
+ const hop1 = await ship(store, 101);
251
+ const hop2 = await ship(store, 102);
252
+
253
+ // base 5 × quick factor 0.6 = 3 at the direct task; halving per hop and
254
+ // rounded ONCE at the end: 1.5 → 2, 0.75 → 1.
255
+ assert.equal(hop1.pay.payments.length, 1, 'one hop below pays the origin once');
256
+ assert.deepEqual(
257
+ { ideaId: hop1.pay.payments[0].ideaId, depth: hop1.pay.payments[0].depth, delta: hop1.pay.payments[0].delta },
258
+ { ideaId: IDEA, depth: 1, delta: 2 });
259
+ assert.deepEqual(
260
+ { ideaId: hop2.pay.payments[0].ideaId, depth: hop2.pay.payments[0].depth, delta: hop2.pay.payments[0].delta },
261
+ { ideaId: IDEA, depth: 2, delta: 1 },
262
+ 'two hops below pays a quarter of the direct rate, not a half — depth is counted from the SHIP, not from the root');
263
+
264
+ assert.equal(paidTo(store.db, IDEATOR), 3, 'the ideator is 2 + 1 better off for work they never touched');
265
+ });
266
+
267
+ await test('an origin on BOTH the shipping task and an ancestor is paid ONCE, at the direct rate', async () => {
268
+ // THE DOUBLE-PAY THE `skipIdeaIds` ARGUMENT EXISTS FOR, and the only shape
269
+ // that exercises it. ideas_006 lets one idea link to several tasks, so idea
270
+ // 555 can sit on the root AND on the child that ships. When 101 ships, the
271
+ // direct payout pays 555 at depth 0 — and the descent walk finds 555 again,
272
+ // one hop up, through task 100. Without the skip the same idea is paid twice
273
+ // for one ship.
274
+ //
275
+ // The first cut of this file did not have this case, and the probe that
276
+ // deleted the skip line stayed green: the root's own ship has no ancestors at
277
+ // all, so it never reaches the guard. A failure mode a proof NAMES and does
278
+ // not exercise is worse than one it omits.
279
+ const store = makeStore();
280
+ seed(store.db);
281
+ store.db.lineage.push({ task_id: 101, idea_id: IDEA, origin: 'promote', created_at: 3 });
282
+
283
+ // The ship path hands the decayed lane whatever the direct payout already
284
+ // settled, exactly as modules/economy/credits.js's caller does.
285
+ const withSkip = await ship(store, 101, { skipIdeaIds: [IDEA] });
286
+ assert.deepEqual(withSkip.pay.payments, [],
287
+ 'the decayed lane declines an origin the direct payout already paid in full');
288
+ assert.equal(paidTo(store.db, IDEATOR), 0, 'and books nothing, so one ship is one payment');
289
+ });
290
+
291
+ await test('re-shipping the same task pays nothing a second time — the reason key is per (idea, task)', async () => {
292
+ const store = makeStore();
293
+ seed(store.db);
294
+ await ship(store, 101);
295
+ const before = paidTo(store.db, IDEATOR);
296
+ const again = await ship(store, 101);
297
+ assert.equal(again.pay.reason, 'already_paid');
298
+ assert.equal(paidTo(store.db, IDEATOR), before, 'the ledger is not written twice for one ship');
299
+ });
300
+
301
+ await test('the descent converges — a hop deep enough to round to nothing stops paying, with no invented hop limit', async () => {
302
+ const store = makeStore();
303
+ seed(store.db);
304
+ // A fourth generation below the root.
305
+ store.db.tasks.set(103, { id: 103, source: null, source_ref: null, parent_task_id: 102, merged_into_task_id: null });
306
+ for (const id of [101, 102]) await ship(store, id);
307
+ const deep = await ship(store, 103);
308
+ // THE FLOOR IS THE ROUNDING, AND THE GRADE FACTOR MOVES IT. The depth cap
309
+ // descentPayoutMaxDepth derives is 3 at the default rate — but it is computed
310
+ // from the BASE alone (5 × 0.5^d), so it bounds the WALK, not the paying. This
311
+ // idea is `quick`, factor 0.6, so the real series is 3 / 1.5 / 0.75 / 0.375 →
312
+ // rounded 3 / 2 / 1 / 0: it stops paying at the THIRD hop, one sooner than the
313
+ // walk is willing to look. That gap is deliberate and harmless in this
314
+ // direction — the walk may reach further than the money, never less far — but
315
+ // it is the kind of off-by-one that reads as correct in either file alone,
316
+ // which is why it is pinned from here rather than from either side.
317
+ assert.deepEqual(deep.pay.payments, [], 'the third hop rounds to zero and books nothing');
318
+ assert.equal(deep.pay.reason, 'nothing_to_pay', 'and says so, rather than reporting a payment of 0');
319
+ assert.equal(paidTo(store.db, IDEATOR), 3, 'the total stays 2 + 1 — the series converges');
320
+ });
321
+
322
+ // =========================================================================
323
+ // 3. THE BOUNDARY — descendant WORK pays; a thought that merely cites does not
324
+ // =========================================================================
325
+
326
+ await test('a second idea on the SAME root task is paid too; an unrelated idea is not', async () => {
327
+ // Spec decision 7, and the farming boundary. ideas_006 made (task_id, idea_id)
328
+ // the primary key precisely so a task can carry several origins — each is
329
+ // real work under that thought. What must NOT pay is an idea with no lineage
330
+ // row anywhere on the chain.
331
+ const store = makeStore();
332
+ seed(store.db);
333
+ store.db.ideas.set(556, { id: 556, captured_by: 43 });
334
+ store.db.lineage.push({ task_id: 100, idea_id: 556, origin: 'promote', created_at: 2 });
335
+ store.db.ideas.set(557, { id: 557, captured_by: 44 }); // cites nothing on this chain
336
+
337
+ const hop1 = await ship(store, 101);
338
+ const ideas = hop1.pay.payments.map((p) => p.ideaId).sort();
339
+ // DISTINCT ON (task_id) picks the EARLIEST lineage row per task, so one
340
+ // ancestor task resolves to ONE origin — the same rule the direct payout and
341
+ // the trail apply, and the reason this is 555 and not both.
342
+ assert.deepEqual(ideas, [IDEA], 'the origin the chain resolves to is paid');
343
+ assert.equal(paidTo(store.db, 44), 0, 'an idea that merely exists beside the work is paid nothing');
344
+ });
345
+
346
+ // =========================================================================
347
+ // 4. SIZE — the sum of those payouts, from the ledger
348
+ // =========================================================================
349
+
350
+ await test('size is the sum of what the descent actually PAID, read from the ledger and not from the walk', async () => {
351
+ const store = makeStore();
352
+ seed(store.db);
353
+ await ship(store, 100, { skipIdeaIds: [IDEA] });
354
+ await ship(store, 101);
355
+ await ship(store, 102);
356
+
357
+ // The ledger is the authority, so size is asserted against it directly: the
358
+ // credit rows carrying this idea's reason key, summed. A size computed from
359
+ // the walk instead would still be 3 here and would diverge the moment a
360
+ // payment failed to book — which is exactly spec decision 4's point that size
361
+ // is payouts made, not payouts owed.
362
+ const fromLedger = store.db.credit
363
+ .filter((c) => c.reason.startsWith(`idea.credit.task_ship:${IDEA}:`))
364
+ .reduce((n, c) => n + c.delta, 0);
365
+ assert.equal(fromLedger, 3, '2 at one hop + 1 at two hops');
366
+ assert.equal(paidTo(store.db, IDEATOR), fromLedger, 'and every credit reached the ideator');
367
+
368
+ // Each payment names its own task, so the breakdown the room and the sky draw
369
+ // can attribute size per task rather than as one opaque number.
370
+ const byTask = store.db.credit
371
+ .filter((c) => c.reason.startsWith(`idea.credit.task_ship:${IDEA}:`))
372
+ .map((c) => Number(c.task_id)).sort((a, b) => a - b);
373
+ assert.deepEqual(byTask, [101, 102], 'the ledger says WHICH work paid, not just how much');
374
+ });
375
+
376
+ summary();