@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,381 @@
1
+ 'use strict';
2
+
3
+ // modules/economy/idea-size.js — SIZE: an idea's total ideator payout from its
4
+ // descent (task 1004093, BV2.R12, goal 1000095; spec
5
+ // docs/specs/bongos-v2-ideator-surface.md decisions 4, 5 and 7).
6
+ //
7
+ // WHAT SIZE IS. Stream 1 pays an idea's people every time a task under it ships
8
+ // — the direct task at full share, and since BV2.R09 every task DESCENDED from
9
+ // it at `decay^depth` (modules/economy/credits.js: awardIdeaTaskShipCredit and
10
+ // awardIdeaDescentShipCredit). Size is the sum of those payouts. Nothing else:
11
+ // not the Full-Idea pass (stream 2), not the board ratification (stream 3), not
12
+ // the promotion bonus. Those pay for the THOUGHT; size is what its WORK earned,
13
+ // and mixing them would make a well-written idea that caused nothing draw as
14
+ // large as one that caused a version.
15
+ //
16
+ // WHY IT IS CREDITS AND NOT KARMA (spec decision 5). `karma_log` carries no task
17
+ // key and no idea key, and migrations/040_builder_karma.sql:13 says that is
18
+ // deliberate — so "the karma a thought caused" is not a quantity that exists to
19
+ // be summed. Credits are already idea-attributed at the moment they are booked.
20
+ //
21
+ // WHY NO MIGRATION. scripts/gds/sequence.js flagged this task `needs_migration`,
22
+ // but that flag is a keyword scan of the title and description
23
+ // (scripts/gds/sequence.js:169), not a fact about the schema. The reason key
24
+ // stream 1 books under ALREADY embeds the idea:
25
+ //
26
+ // idea.credit.task_ship:<ideaId>:<taskId>
27
+ //
28
+ // so size is a pure read over credit_log, needing no column and no table. A
29
+ // schema change of that kind would be a second copy of a fact the ledger
30
+ // already holds, and a second copy of money is how the number and the ledger
31
+ // drift.
32
+ //
33
+ // IT DOES NEED AN INDEX, though — which is a different thing from a schema
34
+ // change and was missed on the first round of this task. credit_log is the
35
+ // whole-instance, append-only ledger, and this read runs on every view of a
36
+ // builder's idea trail, so unindexed it is a sequential scan of every credit
37
+ // event the instance has ever booked. `migrations/core_251_credit_log_idea_
38
+ // size_index.sql` adds a partial expression index matching this query exactly;
39
+ // see the note on TASK_SHIP_PREFIX_SQL below for why the predicate has to be a
40
+ // literal on both sides for that index to ever be chosen.
41
+ //
42
+ // SIZE NEVER FALLS, and the reason is in the writers, not in a clamp here.
43
+ // credit_log is append-only; both stream-1 writers skip a payment whose rounded
44
+ // delta is `<= 0`, and `splitIdeaCredit` independently returns no payees for a
45
+ // non-positive total — so the property is held in TWO places and survives either
46
+ // one being removed (probed: removing one guard reds nothing, removing both reds
47
+ // the test below). No non-positive row is ever booked under this prefix; and
48
+ // ADR 0313 settles that a removal claws nothing back — `shipped` stays terminal.
49
+ // The sum is therefore monotonic by construction. This file does NOT clamp the
50
+ // result to zero, because a clamp would hide exactly the anomaly it claims to
51
+ // prevent: if a reversal ever appeared under this prefix, the honest answer is
52
+ // the smaller number, not a floor that quietly disagrees with the ledger.
53
+ // tests/idea_size.mjs drives the real writers and pins that they book nothing
54
+ // non-positive, which is where that invariant actually lives.
55
+
56
+ const api = require('../../src/module-api');
57
+ const { pool, resolveOptional } = api;
58
+ // The historical half (task 1004097 / BV2.R18) — the pricing of a descent the
59
+ // ledger was never there to pay for. Its own file because it is arithmetic
60
+ // rather than a read, and because it must import credits.js's payout helpers
61
+ // directly rather than growing a second copy of them here.
62
+ const { priceDescentForIdea } = require('./idea-size-historical');
63
+ const { rewardConfig } = require('./credits');
64
+ // The prefix, taken from the writers rather than retyped. It is the idempotency
65
+ // predicate stream 1 books under, so a literal here that drifted from it would
66
+ // read an empty ledger and every star would go out at once.
67
+ const { IDEA_STREAM_REASON_PREFIXES } = require('./credits');
68
+
69
+ // THE PREFIX GOES INTO THE SQL TEXT AS A LITERAL, NOT AS A PARAMETER, and that
70
+ // is the one unobvious line in this file.
71
+ //
72
+ // `migrations/core_251_credit_log_idea_size_index.sql` indexes this read with a
73
+ // PARTIAL index predicated on `reason LIKE 'idea.credit.task_ship:%'`. Postgres
74
+ // uses a partial index only when it can prove the query's WHERE implies the
75
+ // index predicate — and it cannot prove that about `reason LIKE $1 || '%'`,
76
+ // because the parameter has no value when a generic plan is built. Parameterised,
77
+ // the index is simply never chosen and the sequential scan the migration exists
78
+ // to remove comes straight back, silently.
79
+ //
80
+ // Interpolating a string into SQL is the thing one does not do, so it is fenced:
81
+ // this is a module-owned constant that never touches a request, and the assert
82
+ // below refuses anything that is not plain prefix characters. A constant that
83
+ // grew a quote would fail at module load — at boot, loudly — rather than
84
+ // becoming an injection point.
85
+ //
86
+ // THE UNDERSCORE IN `task_ship` IS A LIKE WILDCARD. `_` matches any single
87
+ // character, so an unescaped prefix would also match `idea.credit.taskXship:`.
88
+ // No such key exists today, which is exactly why it would never be noticed —
89
+ // so it is escaped, the way credits.js `creditLedger` already escapes a caller's
90
+ // reasonPrefix. Backslash is LIKE's default escape in Postgres, so no ESCAPE
91
+ // clause is needed; `migrations/core_251_*.sql` carries the identical string.
92
+ if (!/^[a-z0-9_.]+:$/i.test(IDEA_STREAM_REASON_PREFIXES.task_ship)) {
93
+ throw new Error('economy/idea-size: the stream-1 reason prefix is not a plain literal; refusing to inline it into SQL');
94
+ }
95
+ const TASK_SHIP_PREFIX_SQL = `'${IDEA_STREAM_REASON_PREFIXES.task_ship.replace(/([\\%_])/g, '\\$1')}%'`;
96
+
97
+ // The credits at which the drawn scale reaches 1. At the base 5c per shipped
98
+ // task this is on the order of a hundred shipped tasks under one thought — far
99
+ // beyond anything an instance has today, so the compression spends its range on
100
+ // the ideas that exist rather than saturating them all at the top.
101
+ const IDEA_SIZE_SCALE_REF = 500;
102
+
103
+ // compressIdeaSize — the drawn magnitude of a size, in [0, 1]. Pure.
104
+ //
105
+ // Payout sums are long-tailed: most ideas cause one task and earn 5, a handful
106
+ // cause a version and earn hundreds. Drawn linearly, the tail is the only thing
107
+ // visible and everything else is the same dot. log1p spends the range where the
108
+ // ideas are, and log1p (not log) is what lets an idea that earned NOTHING sit at
109
+ // a clean 0 instead of at negative infinity.
110
+ //
111
+ // ABSOLUTE, NOT RELATIVE TO THE SET — and this is the decision in this file.
112
+ // Normalising against the largest idea on screen would draw better, and it would
113
+ // break the one property size exists to have: a thought's size would FALL the
114
+ // moment a bigger thought appeared beside it, without a credit changing hands.
115
+ // Size is supposed to be the channel that never falls (brightness, BV2.R16, is
116
+ // the one that does). So the reference is a constant, the same for every idea in
117
+ // every frame, and a set of ideas can be compared across two different pages.
118
+ //
119
+ // Monotonic and saturating: more credits never draws smaller, and past the
120
+ // reference the scale holds at 1 while the TRUE figure — which the panel shows —
121
+ // keeps climbing.
122
+ function compressIdeaSize(credits) {
123
+ const c = Number(credits);
124
+ if (!Number.isFinite(c) || c <= 0) return 0;
125
+ const scale = Math.log1p(c) / Math.log1p(IDEA_SIZE_SCALE_REF);
126
+ return scale >= 1 ? 1 : scale;
127
+ }
128
+
129
+ // A positive integer id as it arrives off a row or a caller — where it may be a
130
+ // bigint serialised to a string. Anything else is dropped rather than sent to
131
+ // the database, and never coerced with Number() (bigint precision).
132
+ function idOf(v) {
133
+ return /^[1-9]\d*$/.test(String(v)) ? String(v) : null;
134
+ }
135
+
136
+ // ideaSizes — size for MANY ideas in ONE query, keyed by idea id (string).
137
+ //
138
+ // The grouping key is split_part(reason, ':', 2), which is the idea id the
139
+ // writer embedded. It is read INSTEAD of a per-idea LIKE for two reasons, and
140
+ // the second is the one that would have been a bug:
141
+ //
142
+ // • one scan for a whole page of ideas, not one scan per idea;
143
+ // • a prefix match has a boundary. `reason LIKE 'idea.credit.task_ship:1:%'`
144
+ // is right only because of the trailing colon — drop it and idea 1 absorbs
145
+ // every payout of ideas 10, 100 and 1000. Splitting on the separator has no
146
+ // such edge to get wrong. tests/idea_size.mjs pins the 1-vs-10 case anyway,
147
+ // because the next person to "simplify" this back to a LIKE needs to be
148
+ // told by a red test rather than by an ideator's inflated star.
149
+ //
150
+ // The stream-1 prefix stays in the WHERE regardless: split_part would happily
151
+ // pull the same idea id out of an `idea.credit.full_pass:<ideaId>` row, and that
152
+ // row is stream 2 — the thought, not the work it caused.
153
+ //
154
+ // Returns Map<ideaIdString, { credits, paid, estimated, payments, scale,
155
+ // historical }>. Every id asked for is present, at zero when it has been paid
156
+ // nothing: absent and zero are different answers to a renderer, and an idea that
157
+ // caused nothing yet is a real state the surface has to draw, not a gap.
158
+ //
159
+ // THE PAID / ESTIMATED SPLIT (task 1004097 / BV2.R18, spec decision 17).
160
+ // `paid` is money that moved. `estimated` is what an idea's descent WOULD have
161
+ // earned for the hops that shipped before there was a descent payout to earn it
162
+ // — real work, never paid, and deliberately never paid retroactively. `credits`
163
+ // is the two together, which is the figure size means, and `historical` says
164
+ // whether any of it is the second kind. A total with no marker is the quiet lie
165
+ // the spec names; carrying the split is what lets every surface say which kind
166
+ // of number it is showing.
167
+ //
168
+ // `historical` is DERIVED, never stored — it is simply `estimated > 0`. No
169
+ // column, no cutoff date, no flag to rot: an idea stops being historical the
170
+ // moment its descent has actually been paid for, which is the true condition and
171
+ // not a date that approximates it. And when the descent could not be established
172
+ // at all, `estimated` and `historical` are NULL rather than 0 and false, because
173
+ // "nothing was estimated" and "we could not look" are different answers and only
174
+ // one of them may be rendered as a plain figure.
175
+ //
176
+ // ideaIds — array of ids (bigint-safe strings or numbers)
177
+ // client — optional tx client; falls back to the pool
178
+ // descentByIdea — Map<ideaIdString, [{ task_id, depth, status }]>. The
179
+ // caller's already-walked descent. The trail has one in hand
180
+ // (trail-descent.js ran first), so passing it makes the
181
+ // historical half cost ZERO extra round trips. Omit it and
182
+ // the walk is done here, through the `ideas.descent` port.
183
+ async function ideaSizes(ideaIds, { client = null, descentByIdea = null } = {}) {
184
+ const ids = [...new Set((Array.isArray(ideaIds) ? ideaIds : []).map(idOf).filter(Boolean))];
185
+ const out = new Map();
186
+ for (const id of ids) {
187
+ out.set(id, { credits: 0, paid: 0, estimated: 0, payments: 0, scale: 0, historical: false });
188
+ }
189
+ if (ids.length === 0) return out;
190
+
191
+ const q = client || pool;
192
+ // PER (idea, task), not per idea. The totals still fall out of it, and the
193
+ // per-task breakdown is what tells the backfill which hops the ledger already
194
+ // paid for — without it, an estimate would be added on top of a payment that
195
+ // really happened and every long-lived idea would read double.
196
+ const { rows } = await q.query(
197
+ `SELECT split_part(reason, ':', 2) AS idea_id,
198
+ task_id,
199
+ COALESCE(SUM(delta), 0)::int AS credits,
200
+ COUNT(*)::int AS payments
201
+ FROM credit_log
202
+ WHERE reason LIKE ${TASK_SHIP_PREFIX_SQL}
203
+ AND split_part(reason, ':', 2) = ANY($1::text[])
204
+ GROUP BY 1, 2`,
205
+ [ids]
206
+ );
207
+
208
+ // Map<ideaId, { total, payments, byTask: Map<taskId, credits> }>
209
+ const ledger = new Map();
210
+ for (const row of rows) {
211
+ const key = idOf(row.idea_id);
212
+ if (!key || !out.has(key)) continue;
213
+ const acc = ledger.get(key) || { total: 0, payments: 0, byTask: new Map() };
214
+ const credits = Number(row.credits) || 0;
215
+ acc.total += credits;
216
+ acc.payments += Number(row.payments) || 0;
217
+ // A stream-1 row always carries its task_id; a null would mean a row this
218
+ // read does not understand, and it is counted in the total but cannot key
219
+ // the backfill, which is the conservative direction (it can only cause an
220
+ // estimate to be ADDED for a task, and that is what `historical` marks).
221
+ if (row.task_id != null) acc.byTask.set(String(row.task_id), credits);
222
+ ledger.set(key, acc);
223
+ }
224
+
225
+ const descent = descentByIdea || await walkDescentForIdeas(ids, { client: q, ledger });
226
+ const grades = descent ? await ideaGradesFor(ids, q) : null;
227
+ const decay = rewardConfig().ideaDescentDecay;
228
+
229
+ for (const id of ids) {
230
+ const acc = ledger.get(id) || { total: 0, payments: 0, byTask: new Map() };
231
+ if (!descent) {
232
+ // The descent could not be established — no port, and no map from the
233
+ // caller. The paid figure is still true; the historical half is unknown,
234
+ // and says so rather than rendering as "nothing was estimated".
235
+ out.set(id, {
236
+ credits: acc.total, paid: acc.total, estimated: null,
237
+ payments: acc.payments, scale: compressIdeaSize(acc.total), historical: null,
238
+ });
239
+ continue;
240
+ }
241
+ const g = (grades && grades.get(id)) || { grade: 'quick', completeness: null };
242
+ const priced = priceDescentForIdea({
243
+ tasks: descent.get(id) || [],
244
+ grade: g.grade,
245
+ completeness: g.completeness,
246
+ decay,
247
+ paidByTask: acc.byTask,
248
+ });
249
+ // The PAID total comes off the ledger, not off the pricing loop: a payment
250
+ // for a task the descent no longer reaches (a re-parented task, a deleted
251
+ // edge) is still money this idea was paid, and dropping it would make size
252
+ // fall.
253
+ const credits = acc.total + priced.estimated;
254
+ out.set(id, {
255
+ credits,
256
+ paid: acc.total,
257
+ estimated: priced.estimated,
258
+ payments: acc.payments + priced.estimatedPayments,
259
+ scale: compressIdeaSize(credits),
260
+ historical: priced.estimated > 0,
261
+ });
262
+ }
263
+ return out;
264
+ }
265
+
266
+ // ideaGradesFor — what each idea's work is worth before depth, in one query.
267
+ // LEFT JOIN + COALESCE to 'quick', the rule every idea read in the ideas module
268
+ // is built from: an idea with no ideas_grades row IS quick, and an inner join
269
+ // here would silently price every un-graded idea at zero.
270
+ async function ideaGradesFor(ids, q) {
271
+ const out = new Map();
272
+ if (!ids.length) return out;
273
+ const { rows } = await q.query(
274
+ `SELECT i.id,
275
+ COALESCE(g.grade, 'quick') AS grade,
276
+ g.completeness_score AS completeness
277
+ FROM idea_inbox i
278
+ LEFT JOIN ideas_grades g ON g.idea_id = i.id
279
+ WHERE i.id = ANY($1::bigint[])`,
280
+ [ids.map(Number)]
281
+ );
282
+ for (const row of rows) out.set(String(row.id), { grade: row.grade, completeness: row.completeness });
283
+ return out;
284
+ }
285
+
286
+ // walkDescentForIdeas — the fallback for a caller with no descent in hand.
287
+ //
288
+ // Returns null when the walk is unavailable, which is what makes `historical`
289
+ // null rather than false upstream. The ideas module owns the walk and economy
290
+ // reaches it through the `ideas.descent` port — never a cross-module require
291
+ // (ADR 0083 rule 1), and never a second copy of a breadth-first shortest-depth
292
+ // walk, because a drift there is a different amount of money.
293
+ //
294
+ // The trail passes its own map instead, so this path is for a single-idea panel
295
+ // rather than a page: it is one walk per direct task, which is right for the one
296
+ // or two an idea has and would be wrong for a page of thirty. That asymmetry is
297
+ // why the parameter exists at all.
298
+ // THE WALK RETURNS NO STATUS, and that is the trap in this function. Its rows
299
+ // are { id, from_id, relation, depth } off ideas_task_descent — an edge table
300
+ // with no join to `tasks` — so reading `row.status` gets `undefined`, every
301
+ // descendant fails the shipped test, and the backfill silently prices nothing
302
+ // while looking like it worked. The statuses are hydrated here in one query,
303
+ // exactly as trail-descent.js hydrates its titles for the same reason.
304
+ async function walkDescentForIdeas(ids, { client }) {
305
+ const port = resolveOptional && resolveOptional('ideas.descent');
306
+ if (!port || typeof port.descendantsOfTask !== 'function') return null;
307
+
308
+ const roots = await client.query(
309
+ `SELECT l.idea_id, l.task_id, t.status
310
+ FROM ideas_task_lineage l
311
+ JOIN tasks t ON t.id = l.task_id
312
+ WHERE l.idea_id = ANY($1::bigint[])`,
313
+ [ids.map(Number)]
314
+ );
315
+
316
+ const byIdea = new Map();
317
+ for (const id of ids) byIdea.set(id, []);
318
+ const rootsByIdea = new Map();
319
+ for (const row of roots.rows) {
320
+ const key = String(row.idea_id);
321
+ if (!byIdea.has(key)) continue;
322
+ byIdea.get(key).push({ task_id: String(row.task_id), depth: 0, status: row.status });
323
+ if (!rootsByIdea.has(key)) rootsByIdea.set(key, []);
324
+ rootsByIdea.get(key).push(String(row.task_id));
325
+ }
326
+
327
+ const bestByIdea = new Map();
328
+ const found = new Set();
329
+ for (const [ideaId, taskIds] of rootsByIdea) {
330
+ // Shortest depth wins when a descendant hangs below two of this idea's own
331
+ // tasks — the rule the walk applies within one root, applied across them.
332
+ const best = new Map();
333
+ for (const rootId of taskIds) {
334
+ const rows = await port.descendantsOfTask(rootId, { client });
335
+ for (const row of rows || []) {
336
+ const key = String(row.id);
337
+ if (taskIds.includes(key)) continue; // a root is already a depth-0 row
338
+ const prev = best.get(key);
339
+ if (prev && prev.depth <= row.depth) continue;
340
+ best.set(key, { depth: row.depth });
341
+ found.add(key);
342
+ }
343
+ }
344
+ bestByIdea.set(ideaId, best);
345
+ }
346
+
347
+ const status = new Map();
348
+ if (found.size > 0) {
349
+ const { rows } = await client.query(
350
+ `SELECT id, status FROM tasks WHERE id = ANY($1::bigint[])`,
351
+ [[...found].map(Number)]
352
+ );
353
+ for (const row of rows) status.set(String(row.id), row.status);
354
+ }
355
+
356
+ for (const [ideaId, best] of bestByIdea) {
357
+ const rows = byIdea.get(ideaId);
358
+ for (const [taskId, hit] of best) {
359
+ rows.push({ task_id: taskId, depth: hit.depth, status: status.get(taskId) ?? null });
360
+ }
361
+ }
362
+ return byIdea;
363
+ }
364
+
365
+ // ideaSize — the same answer for one idea. A thin wrapper on purpose: two
366
+ // implementations of "what is this idea's size" is two answers, and this is the
367
+ // shape a single-idea panel wants.
368
+ async function ideaSize(ideaId, opts = {}) {
369
+ const key = idOf(ideaId);
370
+ const zero = { credits: 0, payments: 0, scale: 0 };
371
+ if (!key) return zero;
372
+ const sizes = await ideaSizes([key], opts);
373
+ return sizes.get(key) || zero;
374
+ }
375
+
376
+ module.exports = {
377
+ ideaSize,
378
+ ideaSizes,
379
+ compressIdeaSize,
380
+ IDEA_SIZE_SCALE_REF,
381
+ };
@@ -19,6 +19,8 @@ const credits = require('./credits');
19
19
  const achievements = require('./achievements');
20
20
  const cost = require('./cost');
21
21
  const streak = require('./streak');
22
+ const ideaSizeLib = require('./idea-size');
23
+ const ideaSizeHistorical = require('./idea-size-historical');
22
24
 
23
25
  module.exports = {
24
26
  // credit allocation — transaction-participant: caller passes its tx `client`
@@ -93,6 +95,21 @@ module.exports = {
93
95
  // most wants it for is a HALL view — "what did this idea earn me" — and a core
94
96
  // route must reach economy through here, never by importing the module.
95
97
  creditLedger: (...a) => credits.creditLedger(...a),
98
+ // SIZE — an idea's total stream-1 payout (task 1004093 / BV2.R12). On the port
99
+ // for the same reason creditLedger is: credit_log is economy's table, the
100
+ // surface that wants the number is the ideas module's trail and the hall, and
101
+ // a module may only cross the wall through here (ADR 0083 / 0093 §2). ideas
102
+ // already declares `reward` in its `consumes`, so no new dependency edge is
103
+ // created by this — and `compressIdeaSize` rides along so a renderer draws
104
+ // with the SAME compression the read applied, never a second curve.
105
+ ideaSize: (...a) => ideaSizeLib.ideaSize(...a),
106
+ ideaSizes: (...a) => ideaSizeLib.ideaSizes(...a),
107
+ compressIdeaSize: (...a) => ideaSizeLib.compressIdeaSize(...a),
108
+ // BV2.R18 — the trail's already-walked descent, in the shape the pricing
109
+ // wants. On the port beside the read so a caller never hand-builds the
110
+ // projection and quietly drops a field the pricing needs.
111
+ descentRowsFromTrail: (...a) => ideaSizeHistorical.descentRowsFromTrail(...a),
112
+ get IDEA_SIZE_SCALE_REF() { return ideaSizeLib.IDEA_SIZE_SCALE_REF; },
96
113
  computeStreak: (...a) => streak.computeStreak(...a),
97
114
  wouldExtendStreak: (...a) => streak.wouldExtendStreak(...a),
98
115
  };
@@ -419,6 +419,39 @@
419
419
  + cells + more;
420
420
  }
421
421
 
422
+ // SIZE — what this thought's work has actually earned (task 1004093, BV2.R12;
423
+ // spec decision 4). Served as `size` = { credits, payments, scale }.
424
+ //
425
+ // THE TRUE FIGURE IS WHAT PRINTS HERE. `scale` is the compressed magnitude a
426
+ // DRAWING uses (the sky, BV2.R18) — a person reading a number wants the number,
427
+ // and printing a compressed one would make an ideator's earnings unverifiable
428
+ // against their own ledger.
429
+ //
430
+ // It renders independently of the descent, because it has to: an idea promoted
431
+ // straight into one task that shipped has a size and NO descent at all, and
432
+ // hanging this off descentHtml would show that ideator nothing. `size: null`
433
+ // means the ledger could not be asked (the reward port was absent), which is
434
+ // not the same as zero and must not print as "earned 0".
435
+ // THE HISTORICAL MARKER (task 1004097, BV2.R18; spec decision 17). A thought
436
+ // whose work shipped before the descent payout existed has a size that is
437
+ // partly an ESTIMATE — real work, never paid, and deliberately never paid
438
+ // retroactively. The done-when is that anywhere size is shown makes clear
439
+ // which kind of number it is, so the split is SAID, not implied: the row names
440
+ // the estimated part rather than folding it into one total and hoping nobody
441
+ // asks. `historical: null` means the estimate could not be established at all,
442
+ // which is a third state and must not be drawn as either of the other two.
443
+ function sizeHtml(idea) {
444
+ const size = idea.size;
445
+ if (!size || typeof size.credits !== 'number' || size.credits <= 0) return '';
446
+ const n = size.credits;
447
+ const unit = n === 1 ? 'credit' : 'credits';
448
+ const est = typeof size.estimated === 'number' ? size.estimated : null;
449
+ const body = (size.historical === true && est > 0)
450
+ ? `Worth ${escapeHtml(String(n))} ${unit} — ${escapeHtml(String(est))} of it estimated from work that shipped before this was paid`
451
+ : `Earned ${escapeHtml(String(n))} ${unit} so far`;
452
+ return `<span style="display:block;margin-top:4px;font-size:12px;color:var(--ink-faint)">${body}</span>`;
453
+ }
454
+
422
455
  // What became of one idea, in a sentence a person can read. The four outcomes
423
456
  // the criterion names, each linked through to the thing it became.
424
457
  function ideaOutcomeHtml(idea) {
@@ -427,6 +460,10 @@
427
460
  // "nothing is lost" means), and an idea whose first task was later split
428
461
  // has a descent whatever its own status says.
429
462
  const descent = descentHtml(idea);
463
+ // Size rides beside the descent under every outcome, and for the same
464
+ // reason: a discarded or merged idea can still have earned, and ADR 0313
465
+ // claws nothing back when work is removed.
466
+ const size = sizeHtml(idea);
430
467
  const note = idea.triage_note
431
468
  ? `<span style="display:block;margin-top:2px;font-size:12px;color:var(--ink-faint)">${escapeHtml(idea.triage_note)}</span>`
432
469
  : '';
@@ -442,19 +479,19 @@
442
479
  : []);
443
480
  // 'promoted' with nothing to point at is a real (old) state, not a bug to
444
481
  // paper over — say what is known rather than inventing a link.
445
- if (!links.length) return `<span class="task__tag">Promoted</span>${note}${descent}`;
446
- return `<span class="task__tag">Became ${links.length === 1 ? 'a task' : `${links.length} tasks`}</span> ${links.join(' · ')}${note}${descent}`;
482
+ if (!links.length) return `<span class="task__tag">Promoted</span>${note}${descent}${size}`;
483
+ return `<span class="task__tag">Became ${links.length === 1 ? 'a task' : `${links.length} tasks`}</span> ${links.join(' · ')}${note}${descent}${size}`;
447
484
  }
448
485
  if (idea.status === 'merged') {
449
486
  const target = idea.merged_into_idea_id
450
487
  ? ` <a href="${ideaHref(idea.merged_into_idea_id)}">#${escapeHtml(String(idea.merged_into_idea_id))} ${escapeHtml(idea.merged_into_idea_title || '')}</a>`
451
488
  : '';
452
- return `<span class="task__tag">Merged into</span>${target}${note}${descent}`;
489
+ return `<span class="task__tag">Merged into</span>${target}${note}${descent}${size}`;
453
490
  }
454
491
  if (idea.status === 'discarded') {
455
- return `<span class="task__tag">Discarded</span>${note}${descent}`;
492
+ return `<span class="task__tag">Discarded</span>${note}${descent}${size}`;
456
493
  }
457
- return `<span class="task__tag">Still open</span>${note}${descent}`;
494
+ return `<span class="task__tag">Still open</span>${note}${descent}${size}`;
458
495
  }
459
496
 
460
497
  // One ledger driven by one server-paged endpoint. `fetchPage(offset, perPage)`
@@ -39,6 +39,10 @@ const inbox = require('../inbox');
39
39
  // own file because inbox.js is AT the size ratchet and because a task->task
40
40
  // walk is a different relation from the idea->task trail.
41
41
  const { attachDescentToTrail } = require('../trail-descent');
42
+ // SIZE — the sum of what that descent has PAID (task 1004093, BV2.R12). Its own
43
+ // file for the same two reasons trail-descent.js is: inbox.js is at the size
44
+ // ratchet, and a ledger read keyed by idea is a third relation again.
45
+ const { attachSizeToTrail } = require('../trail-size');
42
46
  // The walk itself, published as the `ideas.descent` port below.
43
47
  const descent = require('../descent');
44
48
  // Full Idea capture (task 1002919 / BV1.R143): the template validator (R141)
@@ -588,6 +592,11 @@ module.exports = function buildInboxRouter() {
588
592
  // and the walk is batched across the WHOLE page — so it stays one query per
589
593
  // level however many ideas the window carries, never one walk per idea.
590
594
  await attachDescentToTrail(ideas);
595
+ // SIZE — what that descent has actually PAID (BV2.R12, spec decision 4).
596
+ // After the descent and not inside it: the descent is the picture, size is
597
+ // the number, and the number is a ledger read that does not depend on the
598
+ // walk having run. One query for the whole page, through the `reward` port.
599
+ await attachSizeToTrail(ideas);
591
600
  return res.json({ builder_id: builderId, ideas, total, limit, offset });
592
601
  }, { errorCode: 'idea_trail_failed' }));
593
602
 
@@ -0,0 +1,88 @@
1
+ // modules/ideas/trail-size.js — SIZE on the idea trail: what each thought's
2
+ // work has actually earned (task 1004093, BV2.R12, goal 1000095; spec
3
+ // docs/specs/bongos-v2-ideator-surface.md decisions 4 + 5).
4
+ //
5
+ // The trail already answers "what became of my idea" (listIdeaTrailForBuilder)
6
+ // and, since BV2.R09, "and what did THAT go on to cause" (trail-descent.js).
7
+ // Size is the third column of the same sentence and the only one denominated in
8
+ // money: the sum of every stream-1 payout the descent has produced.
9
+ //
10
+ // WHY IT IS A SEPARATE FILE, like trail-descent.js beside it. inbox.js is AT the
11
+ // 1500-line size ratchet (scripts/gds/fitness-ratchets.js), so a line added
12
+ // there fails the build. And this is a different read again: the trail is
13
+ // idea → task, the descent is a task → task walk, and size is a read of the
14
+ // LEDGER keyed by idea. Three relations, three files.
15
+ //
16
+ // WHY IT GOES THROUGH THE `reward` PORT AND NOT A SQL READ OF ITS OWN.
17
+ // credit_log is economy's table and modules may not require each other
18
+ // (ADR 0083 rule 1). The read is small enough that a copy here would be
19
+ // tempting — and that is exactly the trap: the reason key `idea.credit.
20
+ // task_ship:<idea>:<task>` is economy's idempotency predicate, economy is free
21
+ // to change it, and a second reader of it in this module would silently start
22
+ // summing nothing the day it did. `ideas` already declares `reward` in its
23
+ // module.json `consumes`, so this adds no new dependency edge.
24
+ //
25
+ // RESOLVED OPTIONALLY EVEN SO. `consumes` means the instance will not boot
26
+ // without the port, so in production the resolve cannot fail — but tests rebuild
27
+ // the GDS router with an arbitrary subset of modules, and a trail read that
28
+ // throws because economy was not mounted is a worse failure than a trail read
29
+ // with no money on it. When the port is absent every idea gets `size: null`,
30
+ // NOT zero: zero is a real answer meaning "this thought has earned nothing yet",
31
+ // and rendering it for "we could not ask" would tell an ideator their work paid
32
+ // nothing. tests/idea_size.mjs drives both branches.
33
+
34
+ const api = require('../../src/module-api');
35
+
36
+ // attachSizeToTrail — mutates each idea in place with `size`, and returns the
37
+ // list, mirroring attachDescentToTrail's shape so the route composes the two the
38
+ // same way.
39
+ //
40
+ // `size` is `{ credits, payments, scale }` — the TRUE figure the panel prints,
41
+ // how many payouts made it up, and the compressed magnitude a drawing uses. Both
42
+ // are carried because the spec asks for both: compressed for drawing, true
43
+ // figure available (decision 4). The compression is economy's, applied once at
44
+ // the read, so nothing downstream invents a second curve.
45
+ //
46
+ // ONE query for the whole page, not one per idea — the same property
47
+ // trail-descent.js pins, for the same reason: this rides a paged trail read and
48
+ // a per-idea round trip would make a long trail quadratic in round trips.
49
+ async function attachSizeToTrail(ideas, { client = null } = {}) {
50
+ const list = Array.isArray(ideas) ? ideas : [];
51
+ if (list.length === 0) return list;
52
+
53
+ const reward = api.resolveOptional && api.resolveOptional('reward');
54
+ if (!reward || typeof reward.ideaSizes !== 'function' || typeof reward.descentRowsFromTrail !== 'function') {
55
+ for (const idea of list) idea.size = null;
56
+ return list;
57
+ }
58
+
59
+ // The ids are asked for as the idea's OWN id, never the id it merged into.
60
+ // The descent a merged idea SHOWS is inherited (trail-descent.js followMerges,
61
+ // ADR 0311 — nothing is lost), but the money it EARNED is its own: stream 1
62
+ // books against the idea whose lineage row named the shipping task, and
63
+ // crediting A's star with B's payouts would count B's work twice on one page.
64
+ // That asymmetry is the same one the payout itself draws (spec decision 7):
65
+ // the descent displays more than the number counts.
66
+ // THE DESCENT IS ALREADY IN HAND, so the historical half costs nothing
67
+ // (task 1004097 / BV2.R18). attachDescentToTrail ran first — the route
68
+ // composes them in that order — so every idea on this page already carries its
69
+ // own tasks and everything below them, each with a status and a shortest
70
+ // depth. Handing that to economy is the difference between one extra walk per
71
+ // page and none at all; economy falls back to walking only for a caller that
72
+ // has no descent, which is a single-idea panel and never this one.
73
+ //
74
+ // The SHAPE is economy's, not this module's: `descentRowsFromTrail` lives with
75
+ // the pricing that consumes it, so a change to what the pricing needs cannot
76
+ // leave a hand-built projection here quietly dropping a field.
77
+ const descentByIdea = new Map();
78
+ for (const idea of list) {
79
+ descentByIdea.set(String(idea.id), reward.descentRowsFromTrail(idea));
80
+ }
81
+ const sizes = await reward.ideaSizes(list.map((i) => i.id), { client, descentByIdea });
82
+ for (const idea of list) {
83
+ idea.size = sizes.get(String(idea.id)) || { credits: 0, payments: 0, scale: 0 };
84
+ }
85
+ return list;
86
+ }
87
+
88
+ module.exports = { attachSizeToTrail };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.904",
3
+ "version": "1.19.905",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.904",
9
+ "version": "1.19.905",
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.904",
3
+ "version": "1.19.905",
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.904'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.905'; // 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');