@bongos/core 1.19.921 → 1.19.923
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 +64 -24
- package/docs/adr/0321-brightness-is-a-weighted-share-and-the-targets-are-a-row.md +163 -0
- package/docs/adr/0322-a-remnant-needs-evidence-and-merged-wins.md +110 -0
- package/docs/adr/README.md +2 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/economy/idea-brightness.js +212 -0
- package/modules/economy/idea-size-historical.js +22 -3
- package/modules/economy/idea-size.js +30 -3
- package/modules/economy/reward.js +11 -0
- package/modules/ideas/idea-state.js +254 -0
- package/modules/ideas/routes/inbox.js +8 -0
- package/modules/ideas/trail.js +13 -1
- package/modules/lifecycle/cascade.js +36 -0
- package/modules/lifecycle/migrations/lifecycle_010_removal_targets.sql +116 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/removal-targets.js +217 -0
- package/modules/lifecycle/routes/lifecycle.js +9 -0
- package/modules/lifecycle/routes/removal-proposals.js +33 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/provision-core-upgrade.js +26 -2
- package/src/module-api.js +1 -1
- package/tests/core_upgrade_runner.mjs +56 -0
- package/tests/idea_brightness.mjs +550 -0
- package/tests/idea_state.mjs +399 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/economy/idea-brightness.js — BRIGHTNESS: the live share of an idea's
|
|
4
|
+
// descent (task 1004099 / BV2.R16, goal 1000095; spec
|
|
5
|
+
// docs/specs/bongos-v2-ideator-surface.md decisions 4 and 20; ADR 0321, under
|
|
6
|
+
// ADR 0313 + 0319).
|
|
7
|
+
//
|
|
8
|
+
// WHAT BRIGHTNESS IS. Size is an idea's total ideator payout from its descent and
|
|
9
|
+
// NEVER falls (./idea-size.js). Brightness is how much of that payout is still
|
|
10
|
+
// alive: the same sum, restricted to the descent tasks the project has not since
|
|
11
|
+
// confirmed as removed, over the whole. Two channels, two facts — and the pair is
|
|
12
|
+
// the entire point. A thought that caused a version the project later tore out
|
|
13
|
+
// draws LARGE AND DARK, which is visibly different from a thought that never
|
|
14
|
+
// caused anything and draws small and dark. One number could not say that.
|
|
15
|
+
//
|
|
16
|
+
// GRADED, NOT BINARY (spec decision 20). A descent of five things with three
|
|
17
|
+
// still alive is partly lit. It goes out only when nothing survives. So this is a
|
|
18
|
+
// share in [0, 1] and not a flag — an idea dims progressively as pieces of its
|
|
19
|
+
// work are removed.
|
|
20
|
+
//
|
|
21
|
+
// WEIGHTED BY PAYOUT, NOT BY COUNT, and that is the decision in this file.
|
|
22
|
+
// Counting tasks would make removing a one-line fix dim a star as much as
|
|
23
|
+
// removing the feature it belongs to. Weighting by what each hop actually earned
|
|
24
|
+
// means brightness falls in proportion to how much of the idea's WORTH died,
|
|
25
|
+
// which is the same quantity size is drawn from — so the two channels are a
|
|
26
|
+
// share of one number rather than two unrelated measurements that happen to sit
|
|
27
|
+
// side by side. It also makes "large and dead" exact: full size, zero brightness.
|
|
28
|
+
//
|
|
29
|
+
// THE BASIS IS PAID + ESTIMATED, the same as size. A historical idea's descent
|
|
30
|
+
// was never paid for (./idea-size-historical.js prices those hops), and if
|
|
31
|
+
// brightness counted only banked credits, removing an estimated hop would dim
|
|
32
|
+
// nothing while the size drawn beside it still counted that hop. The share would
|
|
33
|
+
// then be a fraction of a different number than the one on screen.
|
|
34
|
+
//
|
|
35
|
+
// BRIGHTNESS NEVER CLAWS BACK CREDITS, and nothing here could. This module only
|
|
36
|
+
// READS — no writer, no ledger row, no status change. ADR 0313 settles that
|
|
37
|
+
// `shipped` is terminal and the ledger is never rewritten when the project
|
|
38
|
+
// changes its mind: only the picture changes. Size is computed from credit_log
|
|
39
|
+
// and is untouched by anything in this file.
|
|
40
|
+
//
|
|
41
|
+
// WHAT COUNTS AS REMOVED IS A PERSON'S ANSWER, never a derived one. A ship that
|
|
42
|
+
// deletes an earlier shipped task's files files a QUESTION (the
|
|
43
|
+
// `removal-proposed-on-ship` cascade), someone confirms or dismisses it, and only
|
|
44
|
+
// 'confirmed' reaches this read. Refactors, renames and file moves all look like
|
|
45
|
+
// deletions to a diff, so a derived-only signal would dim the sky continuously —
|
|
46
|
+
// ADR 0313's central finding, and the reason `dismissed` exists at all.
|
|
47
|
+
//
|
|
48
|
+
// MODULE BOUNDARY: the removal facts live in the LIFECYCLE module and are reached
|
|
49
|
+
// through the kernel-mediated `lifecycle.removals` port, never a cross-module
|
|
50
|
+
// require (ADR 0083 rule 1) — the same way ./idea-size.js reaches `ideas.descent`.
|
|
51
|
+
|
|
52
|
+
const api = require('../../src/module-api');
|
|
53
|
+
const { resolveOptional } = api;
|
|
54
|
+
const { ideaSizes } = require('./idea-size');
|
|
55
|
+
|
|
56
|
+
// A positive integer id as it arrives off a row or a caller, where it may be a
|
|
57
|
+
// bigint serialised to a string. Never coerced with Number(); the guard
|
|
58
|
+
// ./idea-size.js uses, for the same precision reason.
|
|
59
|
+
function idOf(v) {
|
|
60
|
+
return /^[1-9]\d*$/.test(String(v)) ? String(v) : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ideaBrightnesses — brightness for MANY ideas, keyed by idea id (string).
|
|
64
|
+
//
|
|
65
|
+
// Returns Map<ideaIdString, {
|
|
66
|
+
// share number in [0,1] — the live fraction of the descent's payout,
|
|
67
|
+
// or null when it could not be established (see below)
|
|
68
|
+
// live credits still alive
|
|
69
|
+
// removed credits whose work is confirmed gone
|
|
70
|
+
// credits the total the two divide (equals size's `credits`)
|
|
71
|
+
// removedTasks [{ taskId, credits, proposalTaskId, paths }] — WHAT went out,
|
|
72
|
+
// so a surface can say why a star dimmed instead of only that it
|
|
73
|
+
// did
|
|
74
|
+
// hasWork false when the idea has earned nothing at all
|
|
75
|
+
// attributable false when the removal facts could not be read
|
|
76
|
+
// }>
|
|
77
|
+
//
|
|
78
|
+
// EVERY ID ASKED FOR IS PRESENT. Absent and dark are different answers, and an
|
|
79
|
+
// idea that caused nothing yet is a real state the sky has to draw.
|
|
80
|
+
//
|
|
81
|
+
// AN IDEA THAT EARNED NOTHING IS `share: 1`, `hasWork: false` — LIT, NOT DARK,
|
|
82
|
+
// and this is the sharpest edge in the file. Zero over zero is not zero. A
|
|
83
|
+
// freshly-filed thought has had nothing removed from it; it is a protostar, not a
|
|
84
|
+
// remnant. Returning 0 would light-for-light merge it with the thought whose
|
|
85
|
+
// version was torn out, which is exactly the distinction BV2.R19's proof exists
|
|
86
|
+
// to check. `hasWork` is what lets the five states (BV2.R17) tell the two apart
|
|
87
|
+
// without re-deriving it.
|
|
88
|
+
//
|
|
89
|
+
// WHEN THE REMOVAL FACTS CANNOT BE READ — the lifecycle module is off, or the
|
|
90
|
+
// port is unregistered — `share` is NULL and `attributable` is false, rather than
|
|
91
|
+
// 1. "Nothing was removed" and "we could not look" are different answers and only
|
|
92
|
+
// one of them may be drawn as a plain full-brightness star. Same posture
|
|
93
|
+
// ./idea-size.js takes with `historical: null` when the descent walk is
|
|
94
|
+
// unavailable.
|
|
95
|
+
//
|
|
96
|
+
// ideaIds — array of ids (bigint-safe strings or numbers)
|
|
97
|
+
// client — optional tx client; falls back to the pool inside the reads
|
|
98
|
+
// descentByIdea — the caller's already-walked descent, passed straight through
|
|
99
|
+
// to ideaSizes so a page that has one costs no extra walk
|
|
100
|
+
// sizes — an already-computed ideaSizes() result, when the caller has
|
|
101
|
+
// one. Brightness needs the same per-task figures size does,
|
|
102
|
+
// so a surface drawing both should compute sizes once and hand
|
|
103
|
+
// them here rather than paying for the whole read twice.
|
|
104
|
+
async function ideaBrightnesses(ideaIds, { client = null, descentByIdea = null, sizes = null } = {}) {
|
|
105
|
+
const ids = [...new Set((Array.isArray(ideaIds) ? ideaIds : []).map(idOf).filter(Boolean))];
|
|
106
|
+
const out = new Map();
|
|
107
|
+
if (ids.length === 0) return out;
|
|
108
|
+
|
|
109
|
+
const sized = sizes || await ideaSizes(ids, { client, descentByIdea });
|
|
110
|
+
|
|
111
|
+
// The per-task breakdown. `ideaSizes` carries the totals; `byTask` is what
|
|
112
|
+
// lets a share be taken of them, and it is absent only when the descent could
|
|
113
|
+
// not be walked at all — in which case there is nothing to attribute against
|
|
114
|
+
// and the idea reads unattributable below.
|
|
115
|
+
const perTask = new Map();
|
|
116
|
+
for (const id of ids) {
|
|
117
|
+
const s = sized.get(id);
|
|
118
|
+
perTask.set(id, (s && s.byTask instanceof Map) ? s.byTask : null);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Every descent task across every idea asked for, in ONE port call. A lookup
|
|
122
|
+
// per idea would be a round trip per star on a page of them.
|
|
123
|
+
const allTaskIds = new Set();
|
|
124
|
+
for (const byTask of perTask.values()) {
|
|
125
|
+
if (!byTask) continue;
|
|
126
|
+
for (const taskId of byTask.keys()) allTaskIds.add(taskId);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const port = resolveOptional && resolveOptional('lifecycle.removals');
|
|
130
|
+
let confirmed = null;
|
|
131
|
+
if (port && typeof port.confirmedRemovalsForTasks === 'function' && allTaskIds.size > 0) {
|
|
132
|
+
confirmed = await port.confirmedRemovalsForTasks([...allTaskIds], { client });
|
|
133
|
+
} else if (port && typeof port.confirmedRemovalsForTasks === 'function') {
|
|
134
|
+
// The port is there and there is simply nothing to ask about. That is an
|
|
135
|
+
// established answer — no removals — not an unreadable one.
|
|
136
|
+
confirmed = new Map();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const id of ids) {
|
|
140
|
+
const s = sized.get(id) || { credits: 0 };
|
|
141
|
+
const byTask = perTask.get(id);
|
|
142
|
+
const credits = Number(s.credits) || 0;
|
|
143
|
+
|
|
144
|
+
if (!confirmed || !byTask) {
|
|
145
|
+
out.set(id, {
|
|
146
|
+
share: null, live: null, removed: null, credits,
|
|
147
|
+
removedTasks: [], hasWork: credits > 0, attributable: false,
|
|
148
|
+
});
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let removed = 0;
|
|
153
|
+
const removedTasks = [];
|
|
154
|
+
for (const [taskId, taskCredits] of byTask) {
|
|
155
|
+
const hit = confirmed.get(taskId);
|
|
156
|
+
if (!hit) continue;
|
|
157
|
+
const c = Number(taskCredits) || 0;
|
|
158
|
+
removed += c;
|
|
159
|
+
removedTasks.push({
|
|
160
|
+
taskId,
|
|
161
|
+
credits: c,
|
|
162
|
+
proposalTaskId: hit.proposalTaskId,
|
|
163
|
+
paths: hit.paths,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The live half comes off the TOTAL minus what died, not off a second sum
|
|
168
|
+
// over the surviving tasks. The two differ: `credits` includes payouts for
|
|
169
|
+
// tasks the descent no longer reaches (a re-parented task, a deleted edge),
|
|
170
|
+
// which ./idea-size.js keeps on purpose so size cannot fall. Summing the
|
|
171
|
+
// survivors would quietly drop those and make a re-parenting dim a star.
|
|
172
|
+
const live = credits - removed;
|
|
173
|
+
// Clamped at the bottom only, and never at the top. `removed` is a subset of
|
|
174
|
+
// the same figures `credits` was built from so it cannot exceed it — but if
|
|
175
|
+
// it ever did, a negative brightness is a rendering bug where a zero is a
|
|
176
|
+
// legible "nothing survives". The anomaly is still visible in `live` and
|
|
177
|
+
// `removed`, which are reported unclamped.
|
|
178
|
+
const share = credits > 0 ? Math.max(0, live) / credits : 1;
|
|
179
|
+
|
|
180
|
+
out.set(id, {
|
|
181
|
+
share,
|
|
182
|
+
live,
|
|
183
|
+
removed,
|
|
184
|
+
credits,
|
|
185
|
+
// Largest loss first: a surface explaining why a star dimmed wants the
|
|
186
|
+
// piece that mattered, not the first one the descent happened to yield.
|
|
187
|
+
removedTasks: removedTasks.sort((a, b) => b.credits - a.credits),
|
|
188
|
+
hasWork: credits > 0,
|
|
189
|
+
attributable: true,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ideaBrightness — the same answer for one idea. A thin wrapper on purpose: two
|
|
196
|
+
// implementations of "how lit is this thought" is two answers, and this is the
|
|
197
|
+
// shape a single-idea panel wants. Mirrors ./idea-size.js's ideaSize.
|
|
198
|
+
async function ideaBrightness(ideaId, opts = {}) {
|
|
199
|
+
const key = idOf(ideaId);
|
|
200
|
+
const unknown = {
|
|
201
|
+
share: null, live: null, removed: null, credits: 0,
|
|
202
|
+
removedTasks: [], hasWork: false, attributable: false,
|
|
203
|
+
};
|
|
204
|
+
if (!key) return unknown;
|
|
205
|
+
const all = await ideaBrightnesses([key], opts);
|
|
206
|
+
return all.get(key) || unknown;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
module.exports = {
|
|
210
|
+
ideaBrightness,
|
|
211
|
+
ideaBrightnesses,
|
|
212
|
+
};
|
|
@@ -56,7 +56,11 @@ const {
|
|
|
56
56
|
// decay — the resolved per-instance dial (reward-policy ideaDescentDecay)
|
|
57
57
|
// paidByTask — Map<taskIdString, creditsPaid> from credit_log, per task
|
|
58
58
|
//
|
|
59
|
-
// Returns { paid, estimated, payments, estimatedPayments }
|
|
59
|
+
// Returns { paid, estimated, payments, estimatedPayments, byTask }, where
|
|
60
|
+
// `byTask` is Map<taskIdString, credits> — the per-hop breakdown brightness
|
|
61
|
+
// weights a descent by (task 1004099 / BV2.R16). The early return on an
|
|
62
|
+
// unusable decay yields an EMPTY map rather than no map, so a caller never has
|
|
63
|
+
// to test for its absence.
|
|
60
64
|
//
|
|
61
65
|
// ONLY A SHIPPED TASK COUNTS. The payout fires at ship, so a ready or abandoned
|
|
62
66
|
// descendant has earned its origin nothing and estimating for it would credit
|
|
@@ -75,7 +79,19 @@ const {
|
|
|
75
79
|
// what the same descent would have been paid hop by hop, which is the whole
|
|
76
80
|
// thing this function exists to reproduce.
|
|
77
81
|
function priceDescentForIdea({ tasks, grade, completeness, decay, paidByTask } = {}) {
|
|
78
|
-
|
|
82
|
+
// `byTask` — the same arithmetic, kept per task instead of only summed
|
|
83
|
+
// (task 1004099 / BV2.R16). Brightness is the LIVE SHARE of a size, so it has
|
|
84
|
+
// to weight each descent task by what that task contributed, and the totals
|
|
85
|
+
// alone cannot be taken apart again afterwards. Added here rather than
|
|
86
|
+
// recomputed in brightness because a second copy of this loop is a second
|
|
87
|
+
// answer to "what is this hop worth", and the two would drift the first time
|
|
88
|
+
// a decay dial or a grade factor moved.
|
|
89
|
+
//
|
|
90
|
+
// Map<taskIdString, credits>, carrying BOTH kinds — a paid hop at its ledger
|
|
91
|
+
// figure and an estimated hop at its priced one. Brightness must use the same
|
|
92
|
+
// basis as size or the share would be a fraction of a different number than
|
|
93
|
+
// the one drawn beside it.
|
|
94
|
+
const out = { paid: 0, estimated: 0, payments: 0, estimatedPayments: 0, byTask: new Map() };
|
|
79
95
|
const list = Array.isArray(tasks) ? tasks : [];
|
|
80
96
|
const paidMap = paidByTask instanceof Map ? paidByTask : new Map();
|
|
81
97
|
|
|
@@ -98,8 +114,10 @@ function priceDescentForIdea({ tasks, grade, completeness, decay, paidByTask } =
|
|
|
98
114
|
seen.add(key);
|
|
99
115
|
|
|
100
116
|
if (paidMap.has(key)) {
|
|
101
|
-
|
|
117
|
+
const banked = Number(paidMap.get(key)) || 0;
|
|
118
|
+
out.paid += banked;
|
|
102
119
|
out.payments += 1;
|
|
120
|
+
out.byTask.set(key, banked);
|
|
103
121
|
continue;
|
|
104
122
|
}
|
|
105
123
|
|
|
@@ -113,6 +131,7 @@ function priceDescentForIdea({ tasks, grade, completeness, decay, paidByTask } =
|
|
|
113
131
|
if (delta <= 0) continue;
|
|
114
132
|
out.estimated += delta;
|
|
115
133
|
out.estimatedPayments += 1;
|
|
134
|
+
out.byTask.set(key, delta);
|
|
116
135
|
}
|
|
117
136
|
return out;
|
|
118
137
|
}
|
|
@@ -152,7 +152,9 @@ function idOf(v) {
|
|
|
152
152
|
// row is stream 2 — the thought, not the work it caused.
|
|
153
153
|
//
|
|
154
154
|
// Returns Map<ideaIdString, { credits, paid, estimated, payments, scale,
|
|
155
|
-
// historical }
|
|
155
|
+
// historical, byTask }>, where `byTask` is Map<taskIdString, credits> summing to
|
|
156
|
+
// `credits` — the per-hop breakdown brightness (./idea-brightness.js, BV2.R16)
|
|
157
|
+
// takes its live share of. Every id asked for is present, at zero when it has been paid
|
|
156
158
|
// nothing: absent and zero are different answers to a renderer, and an idea that
|
|
157
159
|
// caused nothing yet is a real state the surface has to draw, not a gap.
|
|
158
160
|
//
|
|
@@ -184,7 +186,10 @@ async function ideaSizes(ideaIds, { client = null, descentByIdea = null } = {})
|
|
|
184
186
|
const ids = [...new Set((Array.isArray(ideaIds) ? ideaIds : []).map(idOf).filter(Boolean))];
|
|
185
187
|
const out = new Map();
|
|
186
188
|
for (const id of ids) {
|
|
187
|
-
|
|
189
|
+
// `byTask` empty rather than absent: an idea with no payouts has a real,
|
|
190
|
+
// established breakdown (nothing), and a caller should not have to tell
|
|
191
|
+
// that apart from a field that was never filled in.
|
|
192
|
+
out.set(id, { credits: 0, paid: 0, estimated: 0, payments: 0, scale: 0, historical: false, byTask: new Map() });
|
|
188
193
|
}
|
|
189
194
|
if (ids.length === 0) return out;
|
|
190
195
|
|
|
@@ -235,6 +240,11 @@ async function ideaSizes(ideaIds, { client = null, descentByIdea = null } = {})
|
|
|
235
240
|
out.set(id, {
|
|
236
241
|
credits: acc.total, paid: acc.total, estimated: null,
|
|
237
242
|
payments: acc.payments, scale: compressIdeaSize(acc.total), historical: null,
|
|
243
|
+
// The ledger's own per-task figures are still true even with no descent
|
|
244
|
+
// in hand, and they sum to `credits` here because `credits` IS the
|
|
245
|
+
// ledger total on this branch. Brightness can therefore still take a
|
|
246
|
+
// share; it simply cannot see hops that were never paid for.
|
|
247
|
+
byTask: acc.byTask,
|
|
238
248
|
});
|
|
239
249
|
continue;
|
|
240
250
|
}
|
|
@@ -251,6 +261,22 @@ async function ideaSizes(ideaIds, { client = null, descentByIdea = null } = {})
|
|
|
251
261
|
// edge) is still money this idea was paid, and dropping it would make size
|
|
252
262
|
// fall.
|
|
253
263
|
const credits = acc.total + priced.estimated;
|
|
264
|
+
// THE PER-TASK BREAKDOWN, for brightness (task 1004099 / BV2.R16), and it
|
|
265
|
+
// has to SUM TO `credits` or the share taken of it is a fraction of the
|
|
266
|
+
// wrong number. Two sources, in this order:
|
|
267
|
+
//
|
|
268
|
+
// • `priced.byTask` — every hop the descent reaches, at its ledger figure
|
|
269
|
+
// where one was paid and at its priced figure where it was estimated.
|
|
270
|
+
// • then any ledger payment for a task the descent NO LONGER REACHES — a
|
|
271
|
+
// re-parented task, a deleted edge. Those are in `acc.total` (kept there
|
|
272
|
+
// on purpose, a few lines up, so size cannot fall), so leaving them out
|
|
273
|
+
// here would make the parts sum to less than the whole and quietly
|
|
274
|
+
// overstate brightness. `priced.byTask` wins on a collision because a
|
|
275
|
+
// paid hop carries the same banked figure in both.
|
|
276
|
+
const byTask = new Map(priced.byTask);
|
|
277
|
+
for (const [taskId, paid] of acc.byTask) {
|
|
278
|
+
if (!byTask.has(taskId)) byTask.set(taskId, paid);
|
|
279
|
+
}
|
|
254
280
|
out.set(id, {
|
|
255
281
|
credits,
|
|
256
282
|
paid: acc.total,
|
|
@@ -258,6 +284,7 @@ async function ideaSizes(ideaIds, { client = null, descentByIdea = null } = {})
|
|
|
258
284
|
payments: acc.payments + priced.estimatedPayments,
|
|
259
285
|
scale: compressIdeaSize(credits),
|
|
260
286
|
historical: priced.estimated > 0,
|
|
287
|
+
byTask,
|
|
261
288
|
});
|
|
262
289
|
}
|
|
263
290
|
return out;
|
|
@@ -367,7 +394,7 @@ async function walkDescentForIdeas(ids, { client }) {
|
|
|
367
394
|
// shape a single-idea panel wants.
|
|
368
395
|
async function ideaSize(ideaId, opts = {}) {
|
|
369
396
|
const key = idOf(ideaId);
|
|
370
|
-
const zero = { credits: 0, payments: 0, scale: 0 };
|
|
397
|
+
const zero = { credits: 0, payments: 0, scale: 0, byTask: new Map() };
|
|
371
398
|
if (!key) return zero;
|
|
372
399
|
const sizes = await ideaSizes([key], opts);
|
|
373
400
|
return sizes.get(key) || zero;
|
|
@@ -21,6 +21,7 @@ const cost = require('./cost');
|
|
|
21
21
|
const streak = require('./streak');
|
|
22
22
|
const ideaSizeLib = require('./idea-size');
|
|
23
23
|
const ideaSizeHistorical = require('./idea-size-historical');
|
|
24
|
+
const ideaBrightnessLib = require('./idea-brightness');
|
|
24
25
|
|
|
25
26
|
module.exports = {
|
|
26
27
|
// credit allocation — transaction-participant: caller passes its tx `client`
|
|
@@ -110,6 +111,16 @@ module.exports = {
|
|
|
110
111
|
// projection and quietly drops a field the pricing needs.
|
|
111
112
|
descentRowsFromTrail: (...a) => ideaSizeHistorical.descentRowsFromTrail(...a),
|
|
112
113
|
get IDEA_SIZE_SCALE_REF() { return ideaSizeLib.IDEA_SIZE_SCALE_REF; },
|
|
114
|
+
// BRIGHTNESS — the live share of that descent (task 1004099 / BV2.R16), on the
|
|
115
|
+
// port for the same reason size is. It is the SECOND of the two channels, and
|
|
116
|
+
// the pair is what BV2.R17 resolves the five states from: a thought that
|
|
117
|
+
// earned nothing (`hasWork: false`) and one whose work was all torn out
|
|
118
|
+
// (`share: 0`) are different objects in the sky, and only a caller holding
|
|
119
|
+
// BOTH fields can tell them apart. Shipping size without this left the ideas
|
|
120
|
+
// module able to draw how big a thought was and unable to say whether it was
|
|
121
|
+
// still alive.
|
|
122
|
+
ideaBrightness: (...a) => ideaBrightnessLib.ideaBrightness(...a),
|
|
123
|
+
ideaBrightnesses: (...a) => ideaBrightnessLib.ideaBrightnesses(...a),
|
|
113
124
|
computeStreak: (...a) => streak.computeStreak(...a),
|
|
114
125
|
wouldExtendStreak: (...a) => streak.wouldExtendStreak(...a),
|
|
115
126
|
};
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// modules/ideas/idea-state.js — THE FIVE STATES (task 1004103 / BV2.R17,
|
|
2
|
+
// goal 1000095, criterion `ideator-sky-tells-the-truth`; spec
|
|
3
|
+
// docs/specs/bongos-v2-ideator-surface.md decisions 4, 19 and 20; the object
|
|
4
|
+
// vocabulary is docs/design/ideator-surface-direction.md §"The five objects";
|
|
5
|
+
// ADR 0322, under ADR 0321 + 0313).
|
|
6
|
+
//
|
|
7
|
+
// star / protostar / satellite / remnant / cold body. Every one of them is
|
|
8
|
+
// DERIVED here, from facts the ledger already holds, so that no surface has to
|
|
9
|
+
// infer one — which is the done-when. Three surfaces are about to draw these
|
|
10
|
+
// (the room, the descent, the sky) and a state decided three times is three
|
|
11
|
+
// states: the sky would say a thought went out while the room beside it still
|
|
12
|
+
// called it alive, with nothing erroring and no way to tell which was right.
|
|
13
|
+
//
|
|
14
|
+
// THE FACTS, AND WHERE EACH COMES FROM:
|
|
15
|
+
//
|
|
16
|
+
// idea_inbox.status 'open' | 'promoted' | 'discarded' | 'merged'
|
|
17
|
+
// — the triage verdict (core_195).
|
|
18
|
+
// brightness.hasWork has this thought been paid for anything at all
|
|
19
|
+
// brightness.share how much of that payout is still alive
|
|
20
|
+
// brightness.attributable whether the removal facts could be read
|
|
21
|
+
// — all three from economy's idea-brightness.js,
|
|
22
|
+
// reached through the `reward` port.
|
|
23
|
+
// tasks[].goal_id / .status the BINDING home and whether it awaits a nod
|
|
24
|
+
// — from the trail's lineage join.
|
|
25
|
+
//
|
|
26
|
+
// THE ORDER OF THE TESTS IS THE DESIGN, and each step below is a decision
|
|
27
|
+
// someone could reasonably have made the other way:
|
|
28
|
+
//
|
|
29
|
+
// 1. MERGED WINS OVER EVERYTHING — a merged thought is a satellite even when it
|
|
30
|
+
// earned money of its own. `modules/ideas/trail-size.js` is emphatic that a
|
|
31
|
+
// merged idea keeps its OWN size (stream 1 books against the idea whose
|
|
32
|
+
// lineage row named the shipping task), and that stays true: this changes
|
|
33
|
+
// what the thought IS, never what it earned. The design's word is "it orbits
|
|
34
|
+
// that one now" — once folded, it is no longer its own point in the sky, and
|
|
35
|
+
// drawing it as an independent star beside the thought that absorbed it would
|
|
36
|
+
// show one idea twice.
|
|
37
|
+
//
|
|
38
|
+
// 2. WORK BEATS A DISCARD. A discarded idea that nevertheless caused shipped,
|
|
39
|
+
// paid work is not a cold body — the design defines that object as "it never
|
|
40
|
+
// ignited, so it has no size at all", which is a stated PROPERTY, and testing
|
|
41
|
+
// work first is what keeps the property true by construction instead of by
|
|
42
|
+
// hope. This is reachable: triage can discard a duplicate whose twin already
|
|
43
|
+
// routed and paid, and `updateIdea` never touches the ledger.
|
|
44
|
+
//
|
|
45
|
+
// 3. REMNANT NEEDS EVIDENCE, NEVER ITS ABSENCE. `share === 0` says the project
|
|
46
|
+
// confirmed every piece of this descent gone. `share === null` says the
|
|
47
|
+
// removal facts could not be read at all (lifecycle off, port unregistered) —
|
|
48
|
+
// brightness is explicit that those are different answers, and treating the
|
|
49
|
+
// second as the first would draw every star in the instance as a hollow ring
|
|
50
|
+
// the moment a module failed to mount. An unreadable idea with work is a
|
|
51
|
+
// star, carrying `attributable: false` so a surface can say the brightness is
|
|
52
|
+
// unknown rather than full.
|
|
53
|
+
//
|
|
54
|
+
// 4. A PROTOSTAR IS THE ABSENCE OF WORK, not the presence of a status. Both an
|
|
55
|
+
// 'open' idea nobody has touched and a 'promoted' one whose task has not
|
|
56
|
+
// shipped yet are unignited, which is exactly right: ignition is being paid
|
|
57
|
+
// for something, not being converted into a row.
|
|
58
|
+
//
|
|
59
|
+
// POSITION IS A SEPARATE AXIS from the object, and it is the half the design
|
|
60
|
+
// says the object alone cannot carry: a protostar INSIDE A WELL has a home and
|
|
61
|
+
// awaits a nod; one in OPEN SPACE has nothing holding it. It is computed for
|
|
62
|
+
// every state (a star has a position too) but it is only load-bearing for a
|
|
63
|
+
// protostar, where it is the difference between "someone must act" and "nobody
|
|
64
|
+
// has claimed this".
|
|
65
|
+
//
|
|
66
|
+
// WHY `suggested_goal_id` IS NOT A WELL. It sits on the row, it names a goal,
|
|
67
|
+
// and it is the wrong fact: `projection.js` records that it is STRICTLY
|
|
68
|
+
// ADVISORY — the routing HINT on a filing that did NOT convert, with the binding
|
|
69
|
+
// home being `tasks.goal_id`. Reading it here would draw a protostar sitting
|
|
70
|
+
// inside a well that never caught it, and put it in a nod queue no one can act
|
|
71
|
+
// on because no task exists to promote.
|
|
72
|
+
//
|
|
73
|
+
// PURE. No DB, no clock, no port — the resolver takes facts and returns an
|
|
74
|
+
// answer, the way `routing.js` takes a filing and returns a landing. The one
|
|
75
|
+
// async function in the file is the trail attacher at the bottom, which fetches
|
|
76
|
+
// the brightness half and then calls the pure one. `tests/idea_state.mjs` drives
|
|
77
|
+
// every cell of the matrix DB-free because of that split.
|
|
78
|
+
|
|
79
|
+
const api = require('../../src/module-api');
|
|
80
|
+
|
|
81
|
+
// The five objects, spelled once. Exported so a renderer switches on the same
|
|
82
|
+
// vocabulary the resolver emits rather than a hand-copied list of strings that
|
|
83
|
+
// can drift by a hyphen.
|
|
84
|
+
const IDEA_STATES = Object.freeze({
|
|
85
|
+
STAR: 'star',
|
|
86
|
+
PROTOSTAR: 'protostar',
|
|
87
|
+
SATELLITE: 'satellite',
|
|
88
|
+
REMNANT: 'remnant',
|
|
89
|
+
COLD_BODY: 'cold-body',
|
|
90
|
+
});
|
|
91
|
+
const IDEA_STATE_VALUES = Object.freeze(Object.values(IDEA_STATES));
|
|
92
|
+
|
|
93
|
+
// The two positions. `null` is not a third one — it is "this thought has no
|
|
94
|
+
// task in any goal", which IS open space; the field is never null.
|
|
95
|
+
const IDEA_POSITIONS = Object.freeze({ WELL: 'well', OPEN_SPACE: 'open-space' });
|
|
96
|
+
|
|
97
|
+
// The task status that means "routed into a goal, one Metic+ promote from
|
|
98
|
+
// claimable" — the nod the landing matrix soft-lands every non-bug at
|
|
99
|
+
// (`routing.js` HARD_LAND_KINDS). Spelled here rather than imported because
|
|
100
|
+
// routing.js is about the DECISION to land and this is about reading the result
|
|
101
|
+
// of one; the coupling is the status vocabulary, which is core's.
|
|
102
|
+
const AWAITING_NOD_STATUS = 'backlog';
|
|
103
|
+
|
|
104
|
+
// A positive integer id as it arrives off a row, where a bigint may be a string.
|
|
105
|
+
// Never coerced with Number() — the guard economy's idea-size.js uses, for the
|
|
106
|
+
// same precision reason.
|
|
107
|
+
function idOf(v) {
|
|
108
|
+
return /^[1-9]\d*$/.test(String(v)) ? String(v) : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// taskFactsFromIdeaRow — the { goalId, status } pairs an idea's state depends
|
|
112
|
+
// on, taken off a trail row.
|
|
113
|
+
//
|
|
114
|
+
// It lives here, with the resolver that consumes it, for the reason economy's
|
|
115
|
+
// `descentRowsFromTrail` lives with the pricing rather than in the trail: a
|
|
116
|
+
// hand-built projection at the call site is a copy that can quietly stop
|
|
117
|
+
// carrying a field the consumer needs, and nothing errors when it does.
|
|
118
|
+
//
|
|
119
|
+
// BOTH SOURCES, because neither alone is complete. `lineage_tasks` is the full
|
|
120
|
+
// answer for anything promoted since `ideas_task_lineage` existed (one idea can
|
|
121
|
+
// become many tasks); `promoted_to_task_id` is the ONLY answer for promotions
|
|
122
|
+
// that predate it. The legacy row is added only when the lineage does not
|
|
123
|
+
// already name that task, so an idea carrying both is not counted twice.
|
|
124
|
+
function taskFactsFromIdeaRow(row) {
|
|
125
|
+
const out = [];
|
|
126
|
+
const seen = new Set();
|
|
127
|
+
const lineage = Array.isArray(row && row.lineage_tasks) ? row.lineage_tasks : [];
|
|
128
|
+
for (const t of lineage) {
|
|
129
|
+
if (!t) continue;
|
|
130
|
+
const taskId = idOf(t.task_id);
|
|
131
|
+
if (taskId) seen.add(taskId);
|
|
132
|
+
out.push({ taskId, goalId: idOf(t.goal_id), status: t.status || null });
|
|
133
|
+
}
|
|
134
|
+
const legacyId = idOf(row && row.promoted_to_task_id);
|
|
135
|
+
if (legacyId && !seen.has(legacyId)) {
|
|
136
|
+
out.push({
|
|
137
|
+
taskId: legacyId,
|
|
138
|
+
goalId: idOf(row.promoted_task_goal_id),
|
|
139
|
+
status: row.promoted_task_status || null,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// resolveIdeaState — the whole decision, pure.
|
|
146
|
+
//
|
|
147
|
+
// status the idea's triage status
|
|
148
|
+
// hasWork brightness.hasWork — has it been paid for anything
|
|
149
|
+
// share brightness.share — the live fraction, or null if unreadable
|
|
150
|
+
// attributable brightness.attributable — whether the removal facts were read
|
|
151
|
+
// tasks taskFactsFromIdeaRow(row)
|
|
152
|
+
//
|
|
153
|
+
// Returns { state, position, wellGoalId, awaitingNod, brightness, hasWork,
|
|
154
|
+
// attributable }
|
|
155
|
+
//
|
|
156
|
+
// `brightness` is passed straight through so a surface drawing the object and
|
|
157
|
+
// its dimness reads ONE answer: a renderer that took the state from here and the
|
|
158
|
+
// share from somewhere else could draw a full-brightness remnant.
|
|
159
|
+
//
|
|
160
|
+
// A MISSING BRIGHTNESS IS NOT ZERO WORK. When the caller has no brightness at
|
|
161
|
+
// all (the `reward` port is absent), `hasWork` arrives undefined and the idea
|
|
162
|
+
// resolves on its status alone — an unignited-looking protostar — with
|
|
163
|
+
// `attributable: false` saying the money could not be read. Defaulting `hasWork`
|
|
164
|
+
// to false would be the same claim; the flag is what stops a surface from
|
|
165
|
+
// stating it.
|
|
166
|
+
function resolveIdeaState({ status = null, hasWork = false, share = null, attributable = false, tasks = [] } = {}) {
|
|
167
|
+
const list = Array.isArray(tasks) ? tasks : [];
|
|
168
|
+
// A well is a goal that actually HOLDS one of this thought's tasks.
|
|
169
|
+
const inGoal = list.find((t) => t && t.goalId);
|
|
170
|
+
const wellGoalId = inGoal ? inGoal.goalId : null;
|
|
171
|
+
const position = wellGoalId ? IDEA_POSITIONS.WELL : IDEA_POSITIONS.OPEN_SPACE;
|
|
172
|
+
// The nod is a property of a TASK, so it is asked of every task in a goal and
|
|
173
|
+
// not only of the one that named the well: an idea that became two tasks, one
|
|
174
|
+
// promoted and one still at backlog, still has something awaiting a person.
|
|
175
|
+
const awaitingNod = list.some((t) => t && t.goalId && t.status === AWAITING_NOD_STATUS);
|
|
176
|
+
|
|
177
|
+
const common = {
|
|
178
|
+
position,
|
|
179
|
+
wellGoalId,
|
|
180
|
+
awaitingNod,
|
|
181
|
+
brightness: share,
|
|
182
|
+
hasWork: hasWork === true,
|
|
183
|
+
attributable: attributable === true,
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// 1. Folded into another thought — whatever else is true of it.
|
|
187
|
+
if (status === 'merged') return { state: IDEA_STATES.SATELLITE, ...common };
|
|
188
|
+
|
|
189
|
+
// 2/3. It ignited. Only CONFIRMED removal of the whole descent puts it out.
|
|
190
|
+
if (hasWork === true) {
|
|
191
|
+
const dead = attributable === true && share === 0;
|
|
192
|
+
return { state: dead ? IDEA_STATES.REMNANT : IDEA_STATES.STAR, ...common };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 4. Declined at triage, having caused nothing.
|
|
196
|
+
if (status === 'discarded') return { state: IDEA_STATES.COLD_BODY, ...common };
|
|
197
|
+
|
|
198
|
+
// 5. Filed, not yet ignited. `position` is the half that matters here.
|
|
199
|
+
return { state: IDEA_STATES.PROTOSTAR, ...common };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// attachStateToTrail — hangs `state` on every idea of a trail page, in place,
|
|
203
|
+
// mirroring attachDescentToTrail / attachSizeToTrail so the route composes the
|
|
204
|
+
// three the same way.
|
|
205
|
+
//
|
|
206
|
+
// COMPOSED AFTER attachSizeToTrail, and that ordering saves a whole second
|
|
207
|
+
// ledger read: size leaves `idea.size` carrying economy's own `byTask`
|
|
208
|
+
// breakdown, which is exactly the input brightness takes its share of. Handing
|
|
209
|
+
// it back means one query for the page rather than two. When an idea's size is
|
|
210
|
+
// missing or carries no breakdown — the port was absent, so size is null —
|
|
211
|
+
// brightness is asked for nothing and the ideas resolve on status alone.
|
|
212
|
+
//
|
|
213
|
+
// AN ABSENT PORT DOES NOT MAKE EVERY THOUGHT DARK. The state still resolves,
|
|
214
|
+
// carrying `attributable: false`; what it must never do is silently claim the
|
|
215
|
+
// sky is empty. Same posture trail-size.js takes with `size: null`.
|
|
216
|
+
async function attachStateToTrail(ideas, { client = null } = {}) {
|
|
217
|
+
const list = Array.isArray(ideas) ? ideas : [];
|
|
218
|
+
if (list.length === 0) return list;
|
|
219
|
+
|
|
220
|
+
const reward = api.resolveOptional && api.resolveOptional('reward');
|
|
221
|
+
let brightnesses = null;
|
|
222
|
+
if (reward && typeof reward.ideaBrightnesses === 'function') {
|
|
223
|
+
const sizes = new Map();
|
|
224
|
+
for (const idea of list) {
|
|
225
|
+
if (idea && idea.size && idea.size.byTask instanceof Map) sizes.set(String(idea.id), idea.size);
|
|
226
|
+
}
|
|
227
|
+
brightnesses = await reward.ideaBrightnesses(
|
|
228
|
+
list.map((i) => i.id),
|
|
229
|
+
{ client, ...(sizes.size === list.length ? { sizes } : {}) }
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
for (const idea of list) {
|
|
234
|
+
const b = brightnesses && brightnesses.get(String(idea.id));
|
|
235
|
+
idea.state = resolveIdeaState({
|
|
236
|
+
status: idea.status,
|
|
237
|
+
hasWork: b ? b.hasWork : false,
|
|
238
|
+
share: b ? b.share : null,
|
|
239
|
+
attributable: b ? b.attributable : false,
|
|
240
|
+
tasks: taskFactsFromIdeaRow(idea),
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
return list;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
module.exports = {
|
|
247
|
+
IDEA_STATES,
|
|
248
|
+
IDEA_STATE_VALUES,
|
|
249
|
+
IDEA_POSITIONS,
|
|
250
|
+
AWAITING_NOD_STATUS,
|
|
251
|
+
taskFactsFromIdeaRow,
|
|
252
|
+
resolveIdeaState,
|
|
253
|
+
attachStateToTrail,
|
|
254
|
+
};
|
|
@@ -43,6 +43,7 @@ const { attachDescentToTrail } = require('../trail-descent');
|
|
|
43
43
|
// file for the same two reasons trail-descent.js is: inbox.js is at the size
|
|
44
44
|
// ratchet, and a ledger read keyed by idea is a third relation again.
|
|
45
45
|
const { attachSizeToTrail } = require('../trail-size');
|
|
46
|
+
const { attachStateToTrail } = require('../idea-state');
|
|
46
47
|
// The walk itself, published as the `ideas.descent` port below.
|
|
47
48
|
const descent = require('../descent');
|
|
48
49
|
// Full Idea capture (task 1002919 / BV1.R143): the template validator (R141)
|
|
@@ -644,6 +645,13 @@ module.exports = function buildInboxRouter() {
|
|
|
644
645
|
// the number, and the number is a ledger read that does not depend on the
|
|
645
646
|
// walk having run. One query for the whole page, through the `reward` port.
|
|
646
647
|
await attachSizeToTrail(ideas);
|
|
648
|
+
// THE STATE — which of the five objects each thought is (BV2.R17). Last of
|
|
649
|
+
// the three, because it is the only one that reads the other two: it takes
|
|
650
|
+
// its share off the `byTask` breakdown size just left on the row, so the
|
|
651
|
+
// page pays for one ledger read rather than two. Derived here and not in the
|
|
652
|
+
// renderer — three surfaces are about to draw these, and a state inferred
|
|
653
|
+
// three times is three states.
|
|
654
|
+
await attachStateToTrail(ideas);
|
|
647
655
|
return res.json({ builder_id: builderId, ideas, total, limit, offset });
|
|
648
656
|
}, { errorCode: 'idea_trail_failed' }));
|
|
649
657
|
|