@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.
@@ -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
- const out = { paid: 0, estimated: 0, payments: 0, estimatedPayments: 0 };
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
- out.paid += Number(paidMap.get(key)) || 0;
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 }>. Every id asked for is present, at zero when it has been paid
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
- out.set(id, { credits: 0, paid: 0, estimated: 0, payments: 0, scale: 0, historical: false });
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