@bongos/core 1.19.1004 → 1.19.1005
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/.bongos-core.json +16 -11
- package/docs/module-api-changelog.md +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/ideator_fork_pays_proof.mjs +376 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.1005",
|
|
6
|
+
"core_contract": "1.19.1005",
|
|
7
|
+
"source_commit": "842c95a46165535e59544ac5872feaac60a25c23",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-24T17:12:06.145Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 521,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2412,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2959,
|
|
20
|
+
"tree_sha256": "aa338793bdf8c6e5b4e6dccc1306cd1e86b816328cfaf503d54c2e1eadd02c95",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2722,7 +2722,7 @@
|
|
|
2722
2722
|
{
|
|
2723
2723
|
"path": "docs/module-api-changelog.md",
|
|
2724
2724
|
"mode": "0000644",
|
|
2725
|
-
"sha256": "
|
|
2725
|
+
"sha256": "5f9398c51d4fd2ed060f238a31f85bc1b46a24bd8b5ae71ab02ffdd7609230f4"
|
|
2726
2726
|
},
|
|
2727
2727
|
{
|
|
2728
2728
|
"path": "docs/modules-contract.md",
|
|
@@ -8207,12 +8207,12 @@
|
|
|
8207
8207
|
{
|
|
8208
8208
|
"path": "package-lock.json",
|
|
8209
8209
|
"mode": "0000644",
|
|
8210
|
-
"sha256": "
|
|
8210
|
+
"sha256": "8209d8d53c6b31074ce909c3bcc4bc64523edcf5e23f5c47a243f41b9a9674d6"
|
|
8211
8211
|
},
|
|
8212
8212
|
{
|
|
8213
8213
|
"path": "package.json",
|
|
8214
8214
|
"mode": "0000644",
|
|
8215
|
-
"sha256": "
|
|
8215
|
+
"sha256": "822c55d8ba960e5095ac95f837ad46b65e89cef48d111bf9292125251728c54f"
|
|
8216
8216
|
},
|
|
8217
8217
|
{
|
|
8218
8218
|
"path": "public-docs/index.html",
|
|
@@ -10257,7 +10257,7 @@
|
|
|
10257
10257
|
{
|
|
10258
10258
|
"path": "src/module-api.js",
|
|
10259
10259
|
"mode": "0000644",
|
|
10260
|
-
"sha256": "
|
|
10260
|
+
"sha256": "1d9e6f1fda0f2b88160464360749f32b189b62e186e42e0a5ed7e1acc6ae984f"
|
|
10261
10261
|
},
|
|
10262
10262
|
{
|
|
10263
10263
|
"path": "src/module-loader/catalog.js",
|
|
@@ -12524,6 +12524,11 @@
|
|
|
12524
12524
|
"mode": "0000644",
|
|
12525
12525
|
"sha256": "35334314ea15cfa5cd262c18def917bea04f569cfa52a0a86b663710de2bd172"
|
|
12526
12526
|
},
|
|
12527
|
+
{
|
|
12528
|
+
"path": "tests/ideator_fork_pays_proof.mjs",
|
|
12529
|
+
"mode": "0000644",
|
|
12530
|
+
"sha256": "15d8693638a47644c54c4a2d3e7b7064feaaeff017be30211261c8f6d3ab5649"
|
|
12531
|
+
},
|
|
12527
12532
|
{
|
|
12528
12533
|
"path": "tests/ideator_traceable_proof.mjs",
|
|
12529
12534
|
"mode": "0000644",
|
|
@@ -2482,5 +2482,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2482
2482
|
landed since 1.19.1002 with no explicit bump. run 36028273772. (task 1002620)
|
|
2483
2483
|
1.19.1004 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2484
2484
|
landed since 1.19.1003 with no explicit bump. run 36030644887. (task 1002620)
|
|
2485
|
+
1.19.1005 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2486
|
+
landed since 1.19.1004 with no explicit bump. run 36032559593. (task 1002620)
|
|
2485
2487
|
---------------------------------------------------------------------------
|
|
2486
2488
|
```
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1005",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.1005",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1005",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.1005'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -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();
|