@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.
- package/.bongos-core.json +42 -17
- package/docs/copy-inventory.md +4 -4
- package/docs/copy-registry.json +4 -4
- package/docs/module-api-changelog.md +2 -0
- package/migrations/core_251_credit_log_idea_size_index.sql +61 -0
- package/modules/economy/credits.js +8 -0
- package/modules/economy/idea-size-historical.js +149 -0
- package/modules/economy/idea-size.js +381 -0
- package/modules/economy/reward.js +17 -0
- package/modules/hall-ui/public/profile.js +42 -5
- package/modules/ideas/routes/inbox.js +9 -0
- package/modules/ideas/trail-size.js +88 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/idea_size.mjs +776 -0
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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');
|