@bongos/core 1.19.901 → 1.19.902
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 +22 -17
- package/docs/module-api-changelog.md +2 -0
- package/modules/economy/credits.js +216 -9
- package/modules/economy/reward-policy.js +34 -1
- package/modules/economy/reward.js +4 -0
- package/modules/ideas/module.json +2 -1
- package/modules/ideas/routes/inbox.js +26 -0
- package/modules/lifecycle/db-ship.js +15 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/idea_descent_ship_credit.mjs +481 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.902",
|
|
6
|
+
"core_contract": "1.19.902",
|
|
7
|
+
"source_commit": "2ec480ac926f1b9c899eef2576b1e6a7409e9013",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-20T07:49:00.223Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 502,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2284,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2812,
|
|
20
|
+
"tree_sha256": "e1c4784e88618bc042732cabcda2e40e5af8c8a4f21282de67baa72990fe7b6b",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2632,7 +2632,7 @@
|
|
|
2632
2632
|
{
|
|
2633
2633
|
"path": "docs/module-api-changelog.md",
|
|
2634
2634
|
"mode": "0000644",
|
|
2635
|
-
"sha256": "
|
|
2635
|
+
"sha256": "367d63ea84eaa28d8ccbadc967d41fb94a689906a8d8438e8b6a123c6ff4489e"
|
|
2636
2636
|
},
|
|
2637
2637
|
{
|
|
2638
2638
|
"path": "docs/modules-contract.md",
|
|
@@ -4182,7 +4182,7 @@
|
|
|
4182
4182
|
{
|
|
4183
4183
|
"path": "modules/economy/credits.js",
|
|
4184
4184
|
"mode": "0000644",
|
|
4185
|
-
"sha256": "
|
|
4185
|
+
"sha256": "b0f36f5d71bc376e090b14c6c95b3ac005fecd464e59bc73213d1d3a2b662469"
|
|
4186
4186
|
},
|
|
4187
4187
|
{
|
|
4188
4188
|
"path": "modules/economy/migrations/economy_001_fix_gds_shipper_description.sql",
|
|
@@ -4202,12 +4202,12 @@
|
|
|
4202
4202
|
{
|
|
4203
4203
|
"path": "modules/economy/reward-policy.js",
|
|
4204
4204
|
"mode": "0000644",
|
|
4205
|
-
"sha256": "
|
|
4205
|
+
"sha256": "817f400faa29aabcfdeb98eabeca45790203dff2dbca2ed01336d69078dd2be2"
|
|
4206
4206
|
},
|
|
4207
4207
|
{
|
|
4208
4208
|
"path": "modules/economy/reward.js",
|
|
4209
4209
|
"mode": "0000644",
|
|
4210
|
-
"sha256": "
|
|
4210
|
+
"sha256": "05f2aa731ed9bfc19088c0c6a59e0f91c6096806285e74e74b48f4530bcfa5f0"
|
|
4211
4211
|
},
|
|
4212
4212
|
{
|
|
4213
4213
|
"path": "modules/economy/routes/cost.js",
|
|
@@ -5362,7 +5362,7 @@
|
|
|
5362
5362
|
{
|
|
5363
5363
|
"path": "modules/ideas/module.json",
|
|
5364
5364
|
"mode": "0000644",
|
|
5365
|
-
"sha256": "
|
|
5365
|
+
"sha256": "c23099e7c3a4768dc68778292c42988ee21234ed81d1832347e9fd272f6137d9"
|
|
5366
5366
|
},
|
|
5367
5367
|
{
|
|
5368
5368
|
"path": "modules/ideas/pollers/descent-writer.js",
|
|
@@ -5377,7 +5377,7 @@
|
|
|
5377
5377
|
{
|
|
5378
5378
|
"path": "modules/ideas/routes/inbox.js",
|
|
5379
5379
|
"mode": "0000644",
|
|
5380
|
-
"sha256": "
|
|
5380
|
+
"sha256": "d4c91dcc603e4fc72ef6110a32350b2f59df19858f83a70962645a7fa09a55c4"
|
|
5381
5381
|
},
|
|
5382
5382
|
{
|
|
5383
5383
|
"path": "modules/ideas/routing.js",
|
|
@@ -5517,7 +5517,7 @@
|
|
|
5517
5517
|
{
|
|
5518
5518
|
"path": "modules/lifecycle/db-ship.js",
|
|
5519
5519
|
"mode": "0000644",
|
|
5520
|
-
"sha256": "
|
|
5520
|
+
"sha256": "14c269ad1bf569976421c71e0814d47338bd11f9382e9949e2e01f6f6f15f6c7"
|
|
5521
5521
|
},
|
|
5522
5522
|
{
|
|
5523
5523
|
"path": "modules/lifecycle/db-tasks.js",
|
|
@@ -7762,12 +7762,12 @@
|
|
|
7762
7762
|
{
|
|
7763
7763
|
"path": "package-lock.json",
|
|
7764
7764
|
"mode": "0000644",
|
|
7765
|
-
"sha256": "
|
|
7765
|
+
"sha256": "b072b5799f9a792ea474fc50364b099b477a89037abac29f31a80a4e0afd56a4"
|
|
7766
7766
|
},
|
|
7767
7767
|
{
|
|
7768
7768
|
"path": "package.json",
|
|
7769
7769
|
"mode": "0000644",
|
|
7770
|
-
"sha256": "
|
|
7770
|
+
"sha256": "70ca20ce45a63886eff37d367e9574ab1ecc6d77191d500bc9c080ac80e71f8a"
|
|
7771
7771
|
},
|
|
7772
7772
|
{
|
|
7773
7773
|
"path": "public-docs/index.html",
|
|
@@ -9777,7 +9777,7 @@
|
|
|
9777
9777
|
{
|
|
9778
9778
|
"path": "src/module-api.js",
|
|
9779
9779
|
"mode": "0000644",
|
|
9780
|
-
"sha256": "
|
|
9780
|
+
"sha256": "46c60fef08ac1cdeedec15a52a867d37f30a61e0ddbf9092a489fc7f5eae3a01"
|
|
9781
9781
|
},
|
|
9782
9782
|
{
|
|
9783
9783
|
"path": "src/module-loader/catalog.js",
|
|
@@ -11814,6 +11814,11 @@
|
|
|
11814
11814
|
"mode": "0000644",
|
|
11815
11815
|
"sha256": "3e43b2afa7ea9e8ccee99759c27f7ab28f7a49823cc3b7e921071714d2d0d7f1"
|
|
11816
11816
|
},
|
|
11817
|
+
{
|
|
11818
|
+
"path": "tests/idea_descent_ship_credit.mjs",
|
|
11819
|
+
"mode": "0000644",
|
|
11820
|
+
"sha256": "e2bc31f771f80549cf50f9614b5473485f6c9db9b9179f3830a76738e1247ac7"
|
|
11821
|
+
},
|
|
11817
11822
|
{
|
|
11818
11823
|
"path": "tests/idea_detail_page.mjs",
|
|
11819
11824
|
"mode": "0000644",
|
|
@@ -2276,5 +2276,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2276
2276
|
landed since 1.19.899 with no explicit bump. run 35492534307. (task 1002620)
|
|
2277
2277
|
1.19.901 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2278
2278
|
landed since 1.19.900 with no explicit bump. run 35494090938. (task 1002620)
|
|
2279
|
+
1.19.902 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2280
|
+
landed since 1.19.901 with no explicit bump. run 35497860558. (task 1002620)
|
|
2279
2281
|
---------------------------------------------------------------------------
|
|
2280
2282
|
```
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// (db.applyGrade/confirmTask/upsertSessionRecordWithReward) resolves these
|
|
10
10
|
// through the `reward` kernel PORT rather than importing this module.
|
|
11
11
|
|
|
12
|
-
const { pool, branding } = require('../../src/module-api');
|
|
12
|
+
const { pool, branding, resolveOptional } = require('../../src/module-api');
|
|
13
13
|
const { priceModelUsage, PRICES } = require('../../src/module-api').llmPricing; // cost-plus session reward (task 1005)
|
|
14
14
|
const achievements = require('./achievements');
|
|
15
15
|
|
|
@@ -157,7 +157,7 @@ const IDEA_GOVERNANCE_PASS_FLOOR = 30; // owner: floor ~30 — see the note on
|
|
|
157
157
|
// Re-exported below, so this module is still the one place reward resolution lives
|
|
158
158
|
// (ADR 0146) and every existing caller is unchanged.
|
|
159
159
|
const {
|
|
160
|
-
REWARD_MARGIN_PCT, REWARD_MODE_DEFAULT, REWARD_MODE_COST_PLUS_ONLY, rewardPolicy,
|
|
160
|
+
REWARD_MARGIN_PCT, REWARD_MODE_DEFAULT, REWARD_MODE_COST_PLUS_ONLY, IDEA_DESCENT_DECAY, rewardPolicy,
|
|
161
161
|
} = require('./reward-policy');
|
|
162
162
|
|
|
163
163
|
// Per-instance reward policy, resolved over the code defaults. Stays HERE, not in
|
|
@@ -502,6 +502,40 @@ async function awardIdeaFullPassCredit(client, { ideaId, authorId, completenessS
|
|
|
502
502
|
};
|
|
503
503
|
}
|
|
504
504
|
|
|
505
|
+
// descentPayoutMaxDepth — the deepest hop that can still pay a single credit.
|
|
506
|
+
//
|
|
507
|
+
// The decayed share is base × factor × decay^d, and the best case for factor is
|
|
508
|
+
// 1 (a 100%-complete Full Idea). Once that rounds to 0 no deeper ancestor can
|
|
509
|
+
// ever be paid, whatever its grade, so walking further is pure round trips.
|
|
510
|
+
// Returned as a DEPTH CAP for the walk rather than a break in the payout loop,
|
|
511
|
+
// because the round trips happen inside the walk, not in the loop.
|
|
512
|
+
//
|
|
513
|
+
// Derived, never picked: at the default half it comes out at 4, and a steeper
|
|
514
|
+
// rate gives a smaller number. DESCENT_MAX_DEPTH remains the outer backstop.
|
|
515
|
+
function descentPayoutMaxDepth(decay, base = IDEA_TASK_SHIP_BASE, cap = 64) {
|
|
516
|
+
if (!(decay > 0)) return 0;
|
|
517
|
+
for (let d = 1; d <= cap; d += 1) {
|
|
518
|
+
if (Math.round(base * Math.pow(decay, d)) <= 0) return d - 1;
|
|
519
|
+
}
|
|
520
|
+
return cap;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// ideaGradeFactor — what one shipped task under an idea is worth, BEFORE depth.
|
|
524
|
+
//
|
|
525
|
+
// A Full Idea earns in proportion to its completeness; a Quick or Spark idea
|
|
526
|
+
// (no completeness score) earns the flat quick factor. A full idea with no score
|
|
527
|
+
// yet also falls back to the quick factor rather than 0 — the work shipped, and
|
|
528
|
+
// the idea is at least quick-grade.
|
|
529
|
+
//
|
|
530
|
+
// EXTRACTED so the direct payout and the decayed descent payout below cannot
|
|
531
|
+
// disagree about what an idea's work is worth: the two differ only by the decay
|
|
532
|
+
// term, and a second copy of this clamp would make them differ by more.
|
|
533
|
+
function ideaGradeFactor(grade, completeness) {
|
|
534
|
+
return (grade === 'full' && completeness != null && Number.isFinite(Number(completeness)))
|
|
535
|
+
? Math.max(0, Math.min(1, Number(completeness) / 100))
|
|
536
|
+
: QUICK_IDEA_FACTOR;
|
|
537
|
+
}
|
|
538
|
+
|
|
505
539
|
// Stream 1 (ADR 0172 / goal 1000067 IC4): as each task descended from an idea
|
|
506
540
|
// SHIPS, pay the idea's author IDEA_TASK_SHIP_BASE credits, SCALED by the idea's
|
|
507
541
|
// grade — a Full Idea by its completeness, a Quick/Spark idea by a flat factor.
|
|
@@ -560,13 +594,7 @@ async function awardIdeaTaskShipCredit(client, { taskId, config = rewardConfig()
|
|
|
560
594
|
return { credited: 0, reason: 'no_source_idea', ideaId: null, authorId: null };
|
|
561
595
|
}
|
|
562
596
|
const { idea_id: ideaId, captured_by: authorId, grade, completeness, developed_by: developedBy } = rows[0];
|
|
563
|
-
|
|
564
|
-
// or Spark idea (no completeness score) earns the flat quick factor. A full
|
|
565
|
-
// idea with no score yet also falls back to the quick factor rather than 0 —
|
|
566
|
-
// the work shipped, the idea is at least quick-grade.
|
|
567
|
-
const factor = (grade === 'full' && completeness != null && Number.isFinite(Number(completeness)))
|
|
568
|
-
? Math.max(0, Math.min(1, Number(completeness) / 100))
|
|
569
|
-
: QUICK_IDEA_FACTOR;
|
|
597
|
+
const factor = ideaGradeFactor(grade, completeness);
|
|
570
598
|
const delta = Math.round(IDEA_TASK_SHIP_BASE * factor);
|
|
571
599
|
if (delta <= 0) {
|
|
572
600
|
return { credited: 0, reason: 'zero_after_scaling', ideaId: Number(ideaId), authorId: Number(authorId) };
|
|
@@ -592,6 +620,184 @@ async function awardIdeaTaskShipCredit(client, { taskId, config = rewardConfig()
|
|
|
592
620
|
: { credited: 0, reason: 'already_paid', ideaId: Number(ideaId), authorId: Number(authorId) };
|
|
593
621
|
}
|
|
594
622
|
|
|
623
|
+
// awardIdeaDescentShipCredit — the same stream 1 payout, for the origins the
|
|
624
|
+
// shipping task descends from RATHER THAN the one it was promoted from
|
|
625
|
+
// (task 1004088, BV2.R09; ADR 0312, spec decisions 6 + 7 + 16).
|
|
626
|
+
//
|
|
627
|
+
// WHAT WAS MISSING. awardIdeaTaskShipCredit above pays the idea whose
|
|
628
|
+
// ideas_task_lineage row names THIS task. That is one hop. A task split off a
|
|
629
|
+
// task an idea produced, a cascade its ship fired, a child of a child — all of
|
|
630
|
+
// them ship and pay the origin nothing, because the origin is not on their
|
|
631
|
+
// lineage row and never will be. The idea's descent grew; its ledger did not.
|
|
632
|
+
//
|
|
633
|
+
// THE RULE: full share at the direct task, `decay` per hop below it. At the
|
|
634
|
+
// default half, an unbounded descent is worth at most twice the direct task, so
|
|
635
|
+
// the series converges and no hop limit has to be invented. The rate is a
|
|
636
|
+
// per-instance dial (config.ideaDescentDecay; modules/economy/reward-policy.js).
|
|
637
|
+
//
|
|
638
|
+
// THE BOUNDARY THAT STOPS FARMING (spec decision 7): DESCENDANT WORK pays the
|
|
639
|
+
// origin; a separate thought that merely cites it does not. Nothing here reads
|
|
640
|
+
// an idea→idea relation — not merged_into_idea_id, not the trail's merge follow.
|
|
641
|
+
// It walks ideas_task_descent, which carries only task→task edges the system
|
|
642
|
+
// derived from a cascade, a split or a fold. That asymmetry is deliberate and is
|
|
643
|
+
// the difference between the two surfaces: the descent DISPLAYS more than the
|
|
644
|
+
// payout COUNTS.
|
|
645
|
+
//
|
|
646
|
+
// ONE PAYMENT PER ORIGIN PER SHIP, AT THE SHORTEST DEPTH. A merge makes the
|
|
647
|
+
// descent a DAG, so one origin can sit above a shipping task by two routes of
|
|
648
|
+
// different length, and two ancestors can share a source idea. Each origin is
|
|
649
|
+
// therefore resolved once, at the smallest depth — the same rule the walk itself
|
|
650
|
+
// applies, for the same reason: at two depths it would be paid twice for one
|
|
651
|
+
// piece of work. The reason key is already per (idea, task), so the ledger would
|
|
652
|
+
// have caught the duplicate; resolving it here means the RETURNED numbers are
|
|
653
|
+
// right too, rather than depending on which booking ran first.
|
|
654
|
+
//
|
|
655
|
+
// FAIL-SOFT AROUND THE WALK, NOT AROUND THE MONEY. If the ideas module is absent
|
|
656
|
+
// (no provider) or has no edges yet, this returns nothing paid and the direct
|
|
657
|
+
// payout above is untouched — which is exactly today's behaviour, and is what
|
|
658
|
+
// "existing first-hop behaviour preserved" means. A walk that THROWS is a
|
|
659
|
+
// different matter and is left to propagate: this runs inside the ship
|
|
660
|
+
// transaction beside the direct credit, and silently swallowing a failure here
|
|
661
|
+
// would under-pay an origin with no record that it happened.
|
|
662
|
+
//
|
|
663
|
+
// client — pg client / pool (transaction participant)
|
|
664
|
+
// taskId — the task that just shipped
|
|
665
|
+
// skipIdeaIds — origins already paid for this ship at depth 0, so the direct
|
|
666
|
+
// payout always wins over a decayed one for the same idea
|
|
667
|
+
// config — resolved reward policy; gates on config.ideatorCredit
|
|
668
|
+
//
|
|
669
|
+
// Returns { credited, reason, payments: [{ ideaId, depth, delta, payees }] }.
|
|
670
|
+
async function awardIdeaDescentShipCredit(client, { taskId, skipIdeaIds = [], config = rewardConfig() } = {}) {
|
|
671
|
+
if (config && config.ideatorCredit === false) {
|
|
672
|
+
return { credited: 0, reason: 'ideator_lane_off', payments: [] };
|
|
673
|
+
}
|
|
674
|
+
// BOUNDED HERE TOO, not only in rewardPolicy(). This is a PORT method: the
|
|
675
|
+
// reward port hands it to any module that resolves it, and a caller may pass
|
|
676
|
+
// its own `config` object that never went through the resolver. A rate of 1
|
|
677
|
+
// or more does not converge — the comment on IDEA_DESCENT_DECAY promises it
|
|
678
|
+
// cannot happen, and a promise the function does not enforce is a promise the
|
|
679
|
+
// next caller breaks. Out of range falls back to the default, exactly as the
|
|
680
|
+
// resolver does, rather than throwing inside a ship transaction.
|
|
681
|
+
const rawDecay = (config && config.ideaDescentDecay) == null
|
|
682
|
+
? NaN
|
|
683
|
+
: Number(config.ideaDescentDecay);
|
|
684
|
+
const decay = Number.isFinite(rawDecay) && rawDecay >= 0 && rawDecay < 1
|
|
685
|
+
? rawDecay
|
|
686
|
+
: IDEA_DESCENT_DECAY;
|
|
687
|
+
// 0 means "only the direct task pays" — a legal instance setting, and the
|
|
688
|
+
// cheapest possible answer, so it short-circuits before the walk.
|
|
689
|
+
if (!(decay > 0)) return { credited: 0, reason: 'decay_off', payments: [] };
|
|
690
|
+
|
|
691
|
+
// The walk lives in the ideas module and is reached through the switchboard:
|
|
692
|
+
// a module may not require a sibling (ADR 0083 rule 1), and a second copy of
|
|
693
|
+
// a breadth-first shortest-depth walk would drift into a different amount of
|
|
694
|
+
// money. resolveOptional, because an instance running without the ideas
|
|
695
|
+
// module has no descent to follow and must still ship tasks.
|
|
696
|
+
const port = resolveOptional && resolveOptional('ideas.descent');
|
|
697
|
+
if (!port || typeof port.ancestorsOfTask !== 'function') {
|
|
698
|
+
return { credited: 0, reason: 'no_descent_port', payments: [] };
|
|
699
|
+
}
|
|
700
|
+
// DON'T WALK FURTHER THAN THE MONEY REACHES. Past the depth at which even a
|
|
701
|
+
// 100%-complete idea's decayed share stops rounding to a credit, every
|
|
702
|
+
// remaining hop is a round trip that provably pays nobody. The cap is derived
|
|
703
|
+
// from the rate rather than picked: at the default half it is 4, and a
|
|
704
|
+
// shallower rate makes it smaller, not larger. Bounded by the walk's own
|
|
705
|
+
// DESCENT_MAX_DEPTH, which stays the backstop.
|
|
706
|
+
const maxDepth = descentPayoutMaxDepth(decay);
|
|
707
|
+
const ancestors = await port.ancestorsOfTask(taskId, { client, maxDepth });
|
|
708
|
+
if (!ancestors || ancestors.length === 0) {
|
|
709
|
+
return { credited: 0, reason: 'no_ancestors', payments: [] };
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// The shortest depth at which each ancestor TASK sits above this ship.
|
|
713
|
+
// ancestorsOfTask already returns nearest-first with each node once, so the
|
|
714
|
+
// first sighting is the shortest; the map is here so the lookup below cannot
|
|
715
|
+
// accidentally read a later one.
|
|
716
|
+
const depthByTask = new Map();
|
|
717
|
+
for (const a of ancestors) {
|
|
718
|
+
const key = String(a.id);
|
|
719
|
+
if (!depthByTask.has(key)) depthByTask.set(key, a.depth);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Which idea each ancestor task came from — ONE query, not one per ancestor.
|
|
723
|
+
// DISTINCT ON picks the EARLIEST lineage link per task, which is the same rule
|
|
724
|
+
// awardIdeaTaskShipCredit applies to the direct task (task 1003515 / ADR 0236:
|
|
725
|
+
// a task subsumed from several ideas keeps a row for each, so the read must
|
|
726
|
+
// choose, and it must choose the way the trail and inbox.sourceIdeaForTask do
|
|
727
|
+
// or the ledger and the money name different ideas).
|
|
728
|
+
const { rows } = await client.query(
|
|
729
|
+
`SELECT DISTINCT ON (l.task_id)
|
|
730
|
+
l.task_id,
|
|
731
|
+
l.idea_id,
|
|
732
|
+
i.captured_by,
|
|
733
|
+
COALESCE(g.grade, 'quick') AS grade,
|
|
734
|
+
g.completeness_score AS completeness,
|
|
735
|
+
g.developed_by AS developed_by
|
|
736
|
+
FROM ideas_task_lineage l
|
|
737
|
+
JOIN idea_inbox i ON i.id = l.idea_id
|
|
738
|
+
LEFT JOIN ideas_grades g ON g.idea_id = l.idea_id
|
|
739
|
+
WHERE l.task_id = ANY($1::bigint[])
|
|
740
|
+
ORDER BY l.task_id, l.created_at ASC, l.idea_id ASC`,
|
|
741
|
+
[[...depthByTask.keys()].map(Number)]
|
|
742
|
+
);
|
|
743
|
+
|
|
744
|
+
// Collapse ancestor tasks onto their ORIGINS: two ancestors can share one
|
|
745
|
+
// source idea, and that idea is owed one payment at the shallower of the two.
|
|
746
|
+
const skip = new Set((skipIdeaIds || []).map(String));
|
|
747
|
+
const byIdea = new Map();
|
|
748
|
+
for (const row of rows) {
|
|
749
|
+
if (row.captured_by == null) continue;
|
|
750
|
+
const ideaKey = String(row.idea_id);
|
|
751
|
+
if (skip.has(ideaKey)) continue; // already paid in full at depth 0
|
|
752
|
+
const depth = depthByTask.get(String(row.task_id));
|
|
753
|
+
if (!Number.isInteger(depth) || depth < 1) continue;
|
|
754
|
+
const prev = byIdea.get(ideaKey);
|
|
755
|
+
if (prev && prev.depth <= depth) continue;
|
|
756
|
+
byIdea.set(ideaKey, { row, depth });
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const payments = [];
|
|
760
|
+
let credited = 0;
|
|
761
|
+
// Nearest origins first, so a partially-booked run resumes in a stable order
|
|
762
|
+
// and the returned list reads the way the descent does.
|
|
763
|
+
const ordered = [...byIdea.values()].sort((a, b) => a.depth - b.depth || Number(a.row.idea_id) - Number(b.row.idea_id));
|
|
764
|
+
for (const { row, depth } of ordered) {
|
|
765
|
+
const { idea_id: ideaId, captured_by: authorId, grade, completeness, developed_by: developedBy } = row;
|
|
766
|
+
// The SAME grade factor the direct payout applies — a Full Idea earns in
|
|
767
|
+
// proportion to its completeness, a Quick or Spark idea the flat factor.
|
|
768
|
+
// Spelled through the shared helper so the two paths cannot disagree about
|
|
769
|
+
// what an idea's work is worth before depth is taken into account.
|
|
770
|
+
const factor = ideaGradeFactor(grade, completeness);
|
|
771
|
+
// ROUNDED ONCE, at the end. Rounding per hop would compound the error, and
|
|
772
|
+
// rounding to zero IS the floor the spec's open question asked about: a
|
|
773
|
+
// descendant stops paying when its decayed share no longer rounds to a
|
|
774
|
+
// credit, which needs no separately-chosen hop limit.
|
|
775
|
+
const delta = Math.round(IDEA_TASK_SHIP_BASE * factor * Math.pow(decay, depth));
|
|
776
|
+
if (delta <= 0) continue;
|
|
777
|
+
const payees = splitIdeaCredit(delta, { authorId, developedBy });
|
|
778
|
+
if (payees.length === 0) continue;
|
|
779
|
+
// THE SAME REASON KEY the direct payout uses — per (idea, task), not per
|
|
780
|
+
// (idea, task, depth). An origin is paid once for one ship however the
|
|
781
|
+
// descent is re-walked, and a re-run after a partial failure is safe.
|
|
782
|
+
const reason = `idea.credit.task_ship:${ideaId}:${taskId}`;
|
|
783
|
+
const gradeLabel = `${grade}${completeness != null ? ` ${Math.round(Number(completeness))}%` : ''}`;
|
|
784
|
+
const { credited: paid, payees: booked } = await bookIdeaSplit(client, {
|
|
785
|
+
payees,
|
|
786
|
+
reason,
|
|
787
|
+
taskId,
|
|
788
|
+
describe: (p) => `Task #${taskId} shipped ${depth} hop${depth === 1 ? '' : 's'} below idea #${ideaId} (${gradeLabel}) → ${p.delta}c to its ${p.role.replace(/_/g, ' ')} (ideator lane stream 1, decayed by depth, BV2.R09/ADR 0312)`,
|
|
789
|
+
});
|
|
790
|
+
credited += paid;
|
|
791
|
+
payments.push({ ideaId: Number(ideaId), depth, delta, credited: paid, payees: booked });
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
return {
|
|
795
|
+
credited,
|
|
796
|
+
reason: credited > 0 ? 'paid' : (payments.length > 0 ? 'already_paid' : 'nothing_to_pay'),
|
|
797
|
+
payments,
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
595
801
|
// Stream 3 (ADR 0172 / goal 1000067 IC5): a Full Idea that PASSES the Board
|
|
596
802
|
// Room's ratification vote pays a share of a 100-credit ceiling, scaled by the
|
|
597
803
|
// idea's R142 completeness — "70%-complete + passes = 70". The ideator lane, so
|
|
@@ -1059,6 +1265,7 @@ module.exports = {
|
|
|
1059
1265
|
maybeAwardIdeaPromotionBonus,
|
|
1060
1266
|
awardIdeaFullPassCredit,
|
|
1061
1267
|
awardIdeaTaskShipCredit,
|
|
1268
|
+
awardIdeaDescentShipCredit,
|
|
1062
1269
|
awardIdeaGovernancePassCredit,
|
|
1063
1270
|
// the spark-handoff split (task 1002923 / BV1.R147, ADR 0185)
|
|
1064
1271
|
splitIdeaCredit,
|
|
@@ -28,6 +28,28 @@ const REWARD_MARGIN_PCT = 20;
|
|
|
28
28
|
const REWARD_MODE_DEFAULT = 'cost-plus-and-estimate';
|
|
29
29
|
const REWARD_MODE_COST_PLUS_ONLY = 'cost-plus-only';
|
|
30
30
|
|
|
31
|
+
// The DEPTH DECAY on the ideator descent payout (task 1004088, BV2.R09; spec
|
|
32
|
+
// decision 16). A task shipping pays the idea it came from in full; a task
|
|
33
|
+
// shipping one hop BELOW that pays it this fraction, and so on down.
|
|
34
|
+
//
|
|
35
|
+
// WHY 0.5, AND WHY THE SERIES NEEDS NO CAP. At a half per hop an infinite
|
|
36
|
+
// descent is worth at most twice the direct task, so the payout converges on
|
|
37
|
+
// its own and nothing has to pick an arbitrary hop limit. A rate of 1 would not
|
|
38
|
+
// converge — a wide enough descent could pay an origin without bound — so the
|
|
39
|
+
// resolver refuses anything at or above 1 and falls back to the default.
|
|
40
|
+
//
|
|
41
|
+
// WHY IT LIVES HERE. The owner's ruling (interview Q1) is that the rate belongs
|
|
42
|
+
// to the project, not to the platform, so it is a per-instance dial and this is
|
|
43
|
+
// where per-instance reward policy already resolves. It is the SECOND such dial
|
|
44
|
+
// (the ignite floor in modules/ideas/routing.js is the first), and BV2.R05 is
|
|
45
|
+
// the task that gives the two of them one home; putting it in the existing
|
|
46
|
+
// resolver now makes that a move rather than a rewrite.
|
|
47
|
+
//
|
|
48
|
+
// 0 IS A LEGAL SETTING and means "only the direct task pays" — an instance that
|
|
49
|
+
// wants the old first-hop-only behaviour sets it and gets exactly that, with no
|
|
50
|
+
// code path to disable separately.
|
|
51
|
+
const IDEA_DESCENT_DECAY = 0.5;
|
|
52
|
+
|
|
31
53
|
// PURE. Resolves a raw branding `reward` block over the code defaults. Every field
|
|
32
54
|
// is validated rather than trusted: a malformed pack degrades to the defaults
|
|
33
55
|
// instead of poisoning an award.
|
|
@@ -39,12 +61,23 @@ function rewardPolicy(rawReward) {
|
|
|
39
61
|
const perTaskCredit = mode !== REWARD_MODE_COST_PLUS_ONLY;
|
|
40
62
|
// Default-on, opt-out-only: only an explicit `false` withholds the ideator lane.
|
|
41
63
|
const ideatorCredit = r.ideatorCredit !== false;
|
|
42
|
-
|
|
64
|
+
// Validated to [0, 1): see IDEA_DESCENT_DECAY above — a rate of 1 or more does
|
|
65
|
+
// not converge, and a negative one would pay a descent backwards.
|
|
66
|
+
// ABSENT IS THE DEFAULT, NOT ZERO — and Number(null) is 0, which is a legal
|
|
67
|
+
// rate meaning "only the direct task pays". Without this guard a pack that
|
|
68
|
+
// simply omits the key, or carries an explicit null, would silently switch
|
|
69
|
+
// the whole descent payout off and look exactly like a deliberate choice.
|
|
70
|
+
const rawDecay = r.ideaDescentDecay == null ? NaN : Number(r.ideaDescentDecay);
|
|
71
|
+
const ideaDescentDecay = Number.isFinite(rawDecay) && rawDecay >= 0 && rawDecay < 1
|
|
72
|
+
? rawDecay
|
|
73
|
+
: IDEA_DESCENT_DECAY;
|
|
74
|
+
return { marginPct, usdPeg, mode, perTaskCredit, ideatorCredit, ideaDescentDecay };
|
|
43
75
|
}
|
|
44
76
|
|
|
45
77
|
module.exports = {
|
|
46
78
|
REWARD_MARGIN_PCT,
|
|
47
79
|
REWARD_MODE_DEFAULT,
|
|
48
80
|
REWARD_MODE_COST_PLUS_ONLY,
|
|
81
|
+
IDEA_DESCENT_DECAY,
|
|
49
82
|
rewardPolicy,
|
|
50
83
|
};
|
|
@@ -28,6 +28,10 @@ module.exports = {
|
|
|
28
28
|
awardIdeaFullPassCredit: (...a) => credits.awardIdeaFullPassCredit(...a),
|
|
29
29
|
// ADR 0172 IC4 — stream 1: pay the idea's author per shipped task, grade-scaled (replaces the 25% slice at ship).
|
|
30
30
|
awardIdeaTaskShipCredit: (...a) => credits.awardIdeaTaskShipCredit(...a),
|
|
31
|
+
// BV2.R09 / ADR 0312 — the same stream, for the origins the shipping task
|
|
32
|
+
// DESCENDS from rather than the one it was promoted from, decayed by depth.
|
|
33
|
+
// Called right after the direct payout, which always wins for the same idea.
|
|
34
|
+
awardIdeaDescentShipCredit: (...a) => credits.awardIdeaDescentShipCredit(...a),
|
|
31
35
|
// ADR 0172 IC5 — stream 3: a board-ratified Full Idea pays up to 100, scaled by completeness.
|
|
32
36
|
// The board's close transaction resolves this port and calls this with its own
|
|
33
37
|
// `client` (docs/specs/board-room-pass-event-contract.md) — the port is how
|
|
@@ -39,6 +39,8 @@ 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
|
+
// The walk itself, published as the `ideas.descent` port below.
|
|
43
|
+
const descent = require('../descent');
|
|
42
44
|
// Full Idea capture (task 1002919 / BV1.R143): the template validator (R141)
|
|
43
45
|
// and the deterministic scorer (R142) — both pure, both walk the declaration.
|
|
44
46
|
const { validateFieldValues, templateFor, fieldOrder } = require('../templates');
|
|
@@ -61,10 +63,34 @@ const { TRIAGE_NOTE_MAX } = inbox;
|
|
|
61
63
|
// hook that runs both in the live server AND when a test rebuilds the GDS
|
|
62
64
|
// router) and hasProvider-guarded so re-instantiation never trips the seam's
|
|
63
65
|
// single-provider guard. Mirrors modules/economy/routes/reward.js.
|
|
66
|
+
// The `ideas.descent` kernel PORT (task 1004088, BV2.R09; ADR 0312 + ADR 0093
|
|
67
|
+
// §5). The ideator payout follows the task→task descent and decays by depth, so
|
|
68
|
+
// modules/economy/credits.js has to know how far a shipping task sits below the
|
|
69
|
+
// task an idea was promoted into.
|
|
70
|
+
//
|
|
71
|
+
// WHY A PORT AND NOT A SECOND SQL READ, which is what credits.js does for the
|
|
72
|
+
// lineage. A cross-table SQL read across the module wall is allowed and credits.js
|
|
73
|
+
// already relies on that; a cross-module require is not. But the descent is not a
|
|
74
|
+
// read, it is a WALK — breadth-first with a visited set, each node at its shortest
|
|
75
|
+
// depth — and a second copy of that in economy would be free to drift from this
|
|
76
|
+
// one. The payout decays by the depth the walk returns, so a drift between the two
|
|
77
|
+
// copies is not a rendering difference, it is a different amount of money. One
|
|
78
|
+
// implementation, reached through the switchboard.
|
|
79
|
+
//
|
|
80
|
+
// Registered here beside `ideas.capture` for the same reason: the route factory is
|
|
81
|
+
// the one boot hook that runs both in the live server and when a test rebuilds the
|
|
82
|
+
// GDS router. hasProvider-guarded so re-instantiation never trips the single-
|
|
83
|
+
// provider guard.
|
|
64
84
|
function registerSeams() {
|
|
65
85
|
if (!api.hasProvider('ideas.capture')) {
|
|
66
86
|
api.registerProvider('ideas.capture', inbox.captureIdea);
|
|
67
87
|
}
|
|
88
|
+
if (!api.hasProvider('ideas.descent')) {
|
|
89
|
+
api.registerProvider('ideas.descent', {
|
|
90
|
+
ancestorsOfTask: descent.ancestorsOfTask,
|
|
91
|
+
descendantsOfTask: descent.descendantsOfTask,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
68
94
|
}
|
|
69
95
|
|
|
70
96
|
// The author-facing shape of a ratification window this request just opened —
|
|
@@ -371,11 +371,26 @@ async function awardLandingCredit(client, { taskId, creditedBuilderId, creditsRe
|
|
|
371
371
|
const ideaBonus = reward.awardIdeaTaskShipCredit
|
|
372
372
|
? await reward.awardIdeaTaskShipCredit(client, { taskId })
|
|
373
373
|
: null;
|
|
374
|
+
// BV2.R09 (ADR 0312): the same stream, for the origins this task DESCENDS
|
|
375
|
+
// from. The direct payout above pays the idea whose lineage row names this
|
|
376
|
+
// task; this pays the ideas above it in the task→task descent, decayed by
|
|
377
|
+
// depth. Its skipIdeaIds carries whatever the direct payout just settled, so
|
|
378
|
+
// an idea that is both the direct source AND an ancestor's source is paid the
|
|
379
|
+
// full share once rather than the full share plus a decayed one.
|
|
380
|
+
// Guarded the same way — an older economy module without this export, or an
|
|
381
|
+
// instance with no ideas module, simply pays nothing extra.
|
|
382
|
+
const ideaDescent = reward.awardIdeaDescentShipCredit
|
|
383
|
+
? await reward.awardIdeaDescentShipCredit(client, {
|
|
384
|
+
taskId,
|
|
385
|
+
skipIdeaIds: ideaBonus && ideaBonus.ideaId != null ? [ideaBonus.ideaId] : [],
|
|
386
|
+
})
|
|
387
|
+
: null;
|
|
374
388
|
return {
|
|
375
389
|
creditsAwarded: credit.creditsAwarded,
|
|
376
390
|
creditsRaw: credit.raw,
|
|
377
391
|
creditsMultiplier: credit.multiplier,
|
|
378
392
|
ideaBonus,
|
|
393
|
+
ideaDescent,
|
|
379
394
|
};
|
|
380
395
|
}
|
|
381
396
|
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.902",
|
|
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.902",
|
|
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.902",
|
|
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.902'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
// tests/idea_descent_ship_credit.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1004088 (BV2.R09, goal 1000095) — the ideator payout follows the
|
|
4
|
+
// task→task descent and decays by depth. ADR 0312; spec
|
|
5
|
+
// docs/specs/bongos-v2-ideator-surface.md decisions 6, 7 and 16.
|
|
6
|
+
//
|
|
7
|
+
// WHY THIS FILE IS SHARP. This is money. The failure modes are all silent —
|
|
8
|
+
// nothing renders wrong, nobody sees an error, an origin is simply paid the
|
|
9
|
+
// wrong amount or paid twice:
|
|
10
|
+
//
|
|
11
|
+
// * PAID TWICE. A merge makes the descent a DAG, so one origin can sit above
|
|
12
|
+
// a shipping task by two routes of different length, and two ancestor tasks
|
|
13
|
+
// can share one source idea. Each of those is a duplicate payment that
|
|
14
|
+
// looks exactly like a correct one in the ledger.
|
|
15
|
+
// * FARMED. Spec decision 7 draws the line at descendant WORK: a separate
|
|
16
|
+
// thought that merely cites an origin must not pay it. The descent surface
|
|
17
|
+
// deliberately displays MORE than the payout counts, so the payout path has
|
|
18
|
+
// to be provably free of idea→idea relations.
|
|
19
|
+
// * DRIFTED. The direct payout and this one must agree about what an idea's
|
|
20
|
+
// work is worth before depth, or the same idea pays differently depending
|
|
21
|
+
// on which hop the ship happened at.
|
|
22
|
+
// * DIVERGED FROM TODAY. "Existing first-hop behaviour preserved" is a
|
|
23
|
+
// done-when, so the no-port and no-ancestor paths must be provably inert.
|
|
24
|
+
//
|
|
25
|
+
// DB-free: the client is a stub whose credit_log is a set of reasons and whose
|
|
26
|
+
// lineage SELECT answers from a fixture, and the descent port is registered
|
|
27
|
+
// with a stub walk — so the decay arithmetic, the de-duplication and the
|
|
28
|
+
// idempotency are proven without a Postgres. A real-Postgres suite would
|
|
29
|
+
// SELF-SKIP on a machine with no database, and a skipped case prints ok and
|
|
30
|
+
// counts as passed.
|
|
31
|
+
//
|
|
32
|
+
// Run: node tests/idea_descent_ship_credit.mjs
|
|
33
|
+
|
|
34
|
+
import { strict as assert } from 'node:assert';
|
|
35
|
+
import { createRequire } from 'node:module';
|
|
36
|
+
import fs from 'node:fs';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
import { fileURLToPath } from 'node:url';
|
|
39
|
+
|
|
40
|
+
const require = createRequire(import.meta.url);
|
|
41
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
42
|
+
const credits = require('../modules/economy/credits.js');
|
|
43
|
+
const api = require('../src/module-api.js');
|
|
44
|
+
const { rewardPolicy, IDEA_DESCENT_DECAY } = require('../modules/economy/reward-policy.js');
|
|
45
|
+
|
|
46
|
+
let passed = 0;
|
|
47
|
+
let failed = 0;
|
|
48
|
+
async function test(name, fn) {
|
|
49
|
+
try { await fn(); passed += 1; console.log(`ok ${name}`); }
|
|
50
|
+
catch (err) { failed += 1; console.log(`FAIL ${name}\n ${err && (err.message || JSON.stringify(err))}`); }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// the harness
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
// The `ideas.descent` port is SINGLE-PROVIDER and has no unregister (a port is
|
|
58
|
+
// a required capability, not a last-write-wins slot — src/module-seams.js). So
|
|
59
|
+
// the stub walk is registered exactly once, and each test varies what it
|
|
60
|
+
// returns rather than re-registering. The one case that needs the port ABSENT
|
|
61
|
+
// runs first, before the registration below.
|
|
62
|
+
let ancestorsFixture = [];
|
|
63
|
+
function registerStubDescentPort() {
|
|
64
|
+
api.registerProvider('ideas.descent', {
|
|
65
|
+
ancestorsOfTask: async () => ancestorsFixture,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
const withDescentPort = async (ancestors, fn) => {
|
|
69
|
+
ancestorsFixture = ancestors;
|
|
70
|
+
try { return await fn(); } finally { ancestorsFixture = []; }
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// `lineage` maps an ancestor task id to the row the DISTINCT ON read returns.
|
|
74
|
+
function stubClient(lineage = {}) {
|
|
75
|
+
const credited = [];
|
|
76
|
+
const log = [];
|
|
77
|
+
return {
|
|
78
|
+
credited,
|
|
79
|
+
log,
|
|
80
|
+
async query(sql, params) {
|
|
81
|
+
log.push({ sql, params });
|
|
82
|
+
if (/FROM ideas_task_lineage/i.test(sql)) {
|
|
83
|
+
const ids = (params[0] || []).map(String);
|
|
84
|
+
return { rows: ids.map((id) => lineage[id]).filter(Boolean) };
|
|
85
|
+
}
|
|
86
|
+
if (/INSERT INTO credit_log/i.test(sql)) {
|
|
87
|
+
// THE STUB MUST DEDUPE THE WAY THE REAL SQL DOES — on (builder_id,
|
|
88
|
+
// reason), not on reason alone. bookIdeaSplit books a SPLIT as two rows
|
|
89
|
+
// sharing one reason, so a reason-only stub refuses the second payee and
|
|
90
|
+
// reports a correct 70/30 split as a duplicate. That is a stub that
|
|
91
|
+
// manufactures a failure the database would not have, and it is exactly
|
|
92
|
+
// how a split-payee test gets written to the wrong expectation.
|
|
93
|
+
assert.match(sql, /builder_id = \$1 AND reason = \$4/,
|
|
94
|
+
'the real predicate is builder-scoped; this stub mirrors it, so pin it');
|
|
95
|
+
const reason = params[3];
|
|
96
|
+
const builderId = params[0];
|
|
97
|
+
if (credited.some((r) => r.reason === reason && String(r.builder_id) === String(builderId))) {
|
|
98
|
+
return { rowCount: 0, rows: [] };
|
|
99
|
+
}
|
|
100
|
+
credited.push({ builder_id: builderId, task_id: params[1], delta: params[2], reason });
|
|
101
|
+
return { rowCount: 1, rows: [] };
|
|
102
|
+
}
|
|
103
|
+
return { rows: [] };
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const src = (taskId, ideaId, authorId, extra = {}) => ({
|
|
109
|
+
task_id: String(taskId), idea_id: ideaId, captured_by: authorId,
|
|
110
|
+
grade: 'quick', completeness: null, developed_by: null, ...extra,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const ON = rewardPolicy({});
|
|
114
|
+
const BASE = credits.IDEA_TASK_SHIP_BASE;
|
|
115
|
+
const QUICK = credits.QUICK_IDEA_FACTOR;
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// 0. BEFORE the port exists — existing behaviour, preserved
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
// FIRST, and it has to be: a port is single-provider and cannot be unregistered,
|
|
122
|
+
// so the only honest way to test the absent-provider path is to test it before
|
|
123
|
+
// anything registers one.
|
|
124
|
+
await test('with NO descent port, nothing is paid and nothing is asked of the database', async () => {
|
|
125
|
+
// An instance running without the ideas module still ships tasks, and its
|
|
126
|
+
// direct payout is untouched. This is what "existing first-hop behaviour
|
|
127
|
+
// preserved" means when the module is absent.
|
|
128
|
+
assert.equal(api.hasProvider('ideas.descent'), false, 'nothing has registered the port yet');
|
|
129
|
+
const c = stubClient({});
|
|
130
|
+
const r = await credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON });
|
|
131
|
+
assert.equal(r.credited, 0);
|
|
132
|
+
assert.equal(r.reason, 'no_descent_port');
|
|
133
|
+
assert.equal(c.log.length, 0, 'no query is issued at all');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
registerStubDescentPort();
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// 1. the decay
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
await test('a task ONE hop below an idea pays it the halved share', async () => {
|
|
143
|
+
const c = stubClient({ 500: src(500, 7, 3) });
|
|
144
|
+
const r = await withDescentPort([{ id: 500, depth: 1, relation: 'split' }],
|
|
145
|
+
() => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
|
|
146
|
+
assert.equal(r.credited, Math.round(BASE * QUICK * 0.5), '5 × 0.6 × 0.5 = 2 (rounded once)');
|
|
147
|
+
assert.equal(r.payments[0].depth, 1);
|
|
148
|
+
assert.equal(c.credited[0].builder_id, 3);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await test('each further hop halves again, and the rate is the DIAL not a constant', async () => {
|
|
152
|
+
const anc = [{ id: 500, depth: 1 }, { id: 501, depth: 2 }, { id: 502, depth: 3 }];
|
|
153
|
+
// FULL-grade origins at 100%, so all three hops still round to a credit and
|
|
154
|
+
// the halving is visible as a sequence rather than truncated by the floor.
|
|
155
|
+
const full = { grade: 'full', completeness: 100 };
|
|
156
|
+
const lineage = {
|
|
157
|
+
500: src(500, 7, 3, full), 501: src(501, 8, 4, full), 502: src(502, 9, 5, full),
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const half = stubClient(lineage);
|
|
161
|
+
const rHalf = await withDescentPort(anc,
|
|
162
|
+
() => credits.awardIdeaDescentShipCredit(half, { taskId: 900, config: ON }));
|
|
163
|
+
assert.deepEqual(rHalf.payments.map((p) => p.delta), [
|
|
164
|
+
Math.round(BASE * 0.5), Math.round(BASE * 0.25), Math.round(BASE * 0.125),
|
|
165
|
+
], 'each hop is the one above it, halved');
|
|
166
|
+
|
|
167
|
+
// A project that wants a shallower descent to matter less says so, and the
|
|
168
|
+
// arithmetic follows the setting rather than a hard-coded half.
|
|
169
|
+
const steep = stubClient(lineage);
|
|
170
|
+
const rSteep = await withDescentPort(anc,
|
|
171
|
+
() => credits.awardIdeaDescentShipCredit(steep, { taskId: 900, config: rewardPolicy({ ideaDescentDecay: 0.1 }) }));
|
|
172
|
+
assert.ok(rSteep.credited < rHalf.credited, 'a steeper decay pays a descent less');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
await test('a descendant stops paying when its share no longer ROUNDS to a credit', async () => {
|
|
176
|
+
// The floor the spec left open (Q1) is arithmetic, not a hop limit: at the
|
|
177
|
+
// default rate a quick idea's share is under half a credit by depth 4.
|
|
178
|
+
const deep = [];
|
|
179
|
+
const lineage = {};
|
|
180
|
+
for (let d = 1; d <= 8; d += 1) {
|
|
181
|
+
deep.push({ id: 600 + d, depth: d });
|
|
182
|
+
lineage[600 + d] = src(600 + d, 100 + d, 3);
|
|
183
|
+
}
|
|
184
|
+
const c = stubClient(lineage);
|
|
185
|
+
const r = await withDescentPort(deep,
|
|
186
|
+
() => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
|
|
187
|
+
assert.ok(r.payments.length > 0 && r.payments.length < 8,
|
|
188
|
+
`the deep tail pays nothing and is not booked — paid ${r.payments.length} of 8`);
|
|
189
|
+
assert.ok(r.payments.every((p) => p.delta > 0), 'no zero-credit row is ever written');
|
|
190
|
+
const maxDepth = Math.max(...r.payments.map((p) => p.depth));
|
|
191
|
+
assert.ok(deep.some((a) => a.depth > maxDepth), 'and there were deeper ancestors it declined to pay');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await test('a descent of ANY depth pays, in total, no more than the direct task did', async () => {
|
|
195
|
+
// The property that makes a hop limit unnecessary (spec decision 6). Sum of
|
|
196
|
+
// (1/2)^d over every hop is 1, so even an unbounded chain of DISTINCT origins
|
|
197
|
+
// cannot, between them, take more out of one ship than the ship's own direct
|
|
198
|
+
// payout. Sixty-four hops, every one a different idea — the worst case.
|
|
199
|
+
const anc = [];
|
|
200
|
+
const lineage = {};
|
|
201
|
+
for (let d = 1; d <= 64; d += 1) {
|
|
202
|
+
anc.push({ id: 700 + d, depth: d });
|
|
203
|
+
lineage[700 + d] = src(700 + d, 200 + d, 3);
|
|
204
|
+
}
|
|
205
|
+
const c = stubClient(lineage);
|
|
206
|
+
const r = await withDescentPort(anc,
|
|
207
|
+
() => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
|
|
208
|
+
const direct = Math.round(BASE * QUICK);
|
|
209
|
+
assert.ok(r.credited <= direct,
|
|
210
|
+
`the whole 64-hop descent paid ${r.credited}, which must not exceed the direct share ${direct}`);
|
|
211
|
+
assert.ok(r.payments.length < 5, 'and the deep tail is not even booked');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
await test('the grade factor is the SAME one the direct payout applies', async () => {
|
|
215
|
+
// A full idea at 80% one hop down: 5 × 0.80 × 0.5. If the two paths kept
|
|
216
|
+
// separate copies of the clamp they could disagree, and the same idea would
|
|
217
|
+
// be worth different amounts depending on which hop shipped.
|
|
218
|
+
const c = stubClient({ 500: src(500, 7, 3, { grade: 'full', completeness: 80 }) });
|
|
219
|
+
const r = await withDescentPort([{ id: 500, depth: 1 }],
|
|
220
|
+
() => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
|
|
221
|
+
assert.equal(r.credited, Math.round(BASE * 0.8 * 0.5), '5 × 0.80 × 0.5 = 2');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
await test('a SPARK someone else developed splits the decayed share 70/30, like the direct one', async () => {
|
|
225
|
+
// R147 / ADR 0185: when someone other than the author developed the idea, both
|
|
226
|
+
// ideator streams split with them. The descent payout wires splitIdeaCredit up
|
|
227
|
+
// the same way, so the split has to be DRIVEN here — a fixture that always
|
|
228
|
+
// carries developed_by: null never enters that branch, and the branch that is
|
|
229
|
+
// never entered is the one that silently pays the wrong person.
|
|
230
|
+
const c = stubClient({ 500: src(500, 7, 3, { grade: 'full', completeness: 100, developed_by: 9 }) });
|
|
231
|
+
const r = await withDescentPort([{ id: 500, depth: 1 }],
|
|
232
|
+
() => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
|
|
233
|
+
const delta = Math.round(BASE * 1 * 0.5);
|
|
234
|
+
assert.equal(r.credited, delta, 'the TOTAL is the decayed share — the split divides it, never inflates it');
|
|
235
|
+
assert.equal(c.credited.length, 2, 'two ledger rows, one per payee');
|
|
236
|
+
const byBuilder = Object.fromEntries(c.credited.map((x) => [String(x.builder_id), x.delta]));
|
|
237
|
+
assert.ok(byBuilder['9'] >= byBuilder['3'], 'the developer takes the larger 70% share');
|
|
238
|
+
assert.equal(byBuilder['9'] + byBuilder['3'], delta, 'and the two shares conserve the total');
|
|
239
|
+
assert.equal(new Set(c.credited.map((x) => x.reason)).size, 1,
|
|
240
|
+
'both rows share ONE reason — which is why the dedup must be builder-scoped');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
await test('a SELF-developed idea collapses to one payee at the decayed rate', async () => {
|
|
244
|
+
// splitIdeaCredit collapses this deliberately: two rows for one builder would
|
|
245
|
+
// UNDER-pay under the per-builder idempotency predicate, not double-pay.
|
|
246
|
+
const c = stubClient({ 500: src(500, 7, 3, { grade: 'full', completeness: 100, developed_by: 3 }) });
|
|
247
|
+
const r = await withDescentPort([{ id: 500, depth: 1 }],
|
|
248
|
+
() => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
|
|
249
|
+
assert.equal(c.credited.length, 1, 'one payee, one row');
|
|
250
|
+
assert.equal(r.credited, Math.round(BASE * 1 * 0.5), 'paid the whole decayed share');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// 2. paid ONCE — the DAG cases
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
await test('one origin above a ship by TWO routes is paid ONCE, at the shorter', async () => {
|
|
258
|
+
// Ancestors 500 (depth 1) and 501 (depth 3) both trace to idea 7 — the shape a
|
|
259
|
+
// merge produces. Paying both would pay one origin twice for one ship.
|
|
260
|
+
const c = stubClient({ 500: src(500, 7, 3), 501: src(501, 7, 3) });
|
|
261
|
+
const r = await withDescentPort([{ id: 500, depth: 1 }, { id: 501, depth: 3 }],
|
|
262
|
+
() => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
|
|
263
|
+
assert.equal(r.payments.length, 1, 'one payment, not two');
|
|
264
|
+
assert.equal(r.payments[0].depth, 1, 'at the SHORTEST depth of the two');
|
|
265
|
+
assert.equal(c.credited.length, 1, 'and one ledger row');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
await test('the DIRECT payout wins over a decayed one for the same idea', async () => {
|
|
269
|
+
// Idea 7 is both this task's own source AND an ancestor's source. It must be
|
|
270
|
+
// paid the full direct share, not the full share plus a decayed one.
|
|
271
|
+
const c = stubClient({ 500: src(500, 7, 3) });
|
|
272
|
+
const r = await withDescentPort([{ id: 500, depth: 1 }],
|
|
273
|
+
() => credits.awardIdeaDescentShipCredit(c, { taskId: 900, skipIdeaIds: [7], config: ON }));
|
|
274
|
+
assert.equal(r.credited, 0);
|
|
275
|
+
assert.equal(r.payments.length, 0, 'the already-settled origin is skipped, not re-booked');
|
|
276
|
+
assert.equal(c.credited.length, 0);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
await test('re-running the same ship never pays an origin twice', async () => {
|
|
280
|
+
const lineage = { 500: src(500, 7, 3), 501: src(501, 8, 4) };
|
|
281
|
+
const c = stubClient(lineage);
|
|
282
|
+
const anc = [{ id: 500, depth: 1 }, { id: 501, depth: 1 }];
|
|
283
|
+
const first = await withDescentPort(anc, () => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
|
|
284
|
+
assert.ok(first.credited > 0);
|
|
285
|
+
const rows = c.credited.length;
|
|
286
|
+
const again = await withDescentPort(anc, () => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
|
|
287
|
+
assert.equal(again.credited, 0, 'the second run books nothing');
|
|
288
|
+
assert.equal(again.reason, 'already_paid');
|
|
289
|
+
assert.equal(c.credited.length, rows, 'and writes no further ledger rows');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
await test('the reason key is per (idea, task) — the SAME key the direct payout uses', async () => {
|
|
293
|
+
// Not per (idea, task, depth): if the depth rode in the key, re-walking a
|
|
294
|
+
// descent that had changed shape would pay the same origin again for one ship.
|
|
295
|
+
const c = stubClient({ 500: src(500, 7, 3) });
|
|
296
|
+
await withDescentPort([{ id: 500, depth: 2 }],
|
|
297
|
+
() => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
|
|
298
|
+
assert.equal(c.credited[0].reason, 'idea.credit.task_ship:7:900');
|
|
299
|
+
assert.doesNotMatch(c.credited[0].reason, /:2$|depth/, 'no depth in the key');
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
// 3. the farming boundary — spec decision 7
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
await test('the payout reads no idea-to-idea relation — a citation cannot farm an origin', async () => {
|
|
307
|
+
// The descent SURFACE follows a merge (modules/ideas/trail-descent.js) so a
|
|
308
|
+
// merged thought still sees the work it caused. The PAYOUT must not, or filing
|
|
309
|
+
// a thought that cites an old one would mint credit from someone else's work.
|
|
310
|
+
const c = stubClient({ 500: src(500, 7, 3) });
|
|
311
|
+
await withDescentPort([{ id: 500, depth: 1 }],
|
|
312
|
+
() => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
|
|
313
|
+
for (const q of c.log) {
|
|
314
|
+
assert.doesNotMatch(q.sql, /merged_into_idea_id/,
|
|
315
|
+
'a merge is an idea-to-idea relation and must not reach the money');
|
|
316
|
+
}
|
|
317
|
+
const SRC = fs.readFileSync(path.join(ROOT, 'modules/economy/credits.js'), 'utf8');
|
|
318
|
+
const fn = SRC.slice(SRC.indexOf('async function awardIdeaDescentShipCredit'));
|
|
319
|
+
const body = fn.slice(0, fn.indexOf('\n}\n'));
|
|
320
|
+
assert.doesNotMatch(body, /merged_into_idea_id|attachDescentToTrail|trail-descent/,
|
|
321
|
+
'nor may the function reach for the surface read that does follow merges');
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
await test('it walks the TASK edge table, not the idea lineage, to find its ancestors', async () => {
|
|
325
|
+
// The ancestors come from the port (ideas_task_descent, task→task); the
|
|
326
|
+
// lineage read only NAMES the idea behind each ancestor task. Reversing those
|
|
327
|
+
// two is how a citation graph would sneak in.
|
|
328
|
+
const SRC = fs.readFileSync(path.join(ROOT, 'modules/economy/credits.js'), 'utf8');
|
|
329
|
+
const fn = SRC.slice(SRC.indexOf('async function awardIdeaDescentShipCredit'));
|
|
330
|
+
const body = fn.slice(0, fn.indexOf('\n}\n'));
|
|
331
|
+
assert.match(body, /ancestorsOfTask/, 'the descent comes from the walk');
|
|
332
|
+
assert.match(body, /FROM ideas_task_lineage/, 'and the lineage read only resolves the origin');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
// 4. existing behaviour preserved
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
await test('a task with NO ancestors pays nothing and issues no lineage read', async () => {
|
|
340
|
+
const c = stubClient({});
|
|
341
|
+
const r = await withDescentPort([], () => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
|
|
342
|
+
assert.equal(r.credited, 0);
|
|
343
|
+
assert.equal(r.reason, 'no_ancestors');
|
|
344
|
+
assert.equal(c.log.length, 0);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
await test('the ideator lane being OFF suppresses the descent payout too', async () => {
|
|
348
|
+
const c = stubClient({ 500: src(500, 7, 3) });
|
|
349
|
+
const r = await withDescentPort([{ id: 500, depth: 1 }],
|
|
350
|
+
() => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: { ideatorCredit: false } }));
|
|
351
|
+
assert.equal(r.credited, 0);
|
|
352
|
+
assert.equal(r.reason, 'ideator_lane_off');
|
|
353
|
+
assert.equal(c.log.length, 0, 'the lane gate is checked before the walk, not after it');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
await test('a decay of 0 restores exactly the old first-hop-only behaviour', async () => {
|
|
357
|
+
const c = stubClient({ 500: src(500, 7, 3) });
|
|
358
|
+
const r = await withDescentPort([{ id: 500, depth: 1 }],
|
|
359
|
+
() => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: rewardPolicy({ ideaDescentDecay: 0 }) }));
|
|
360
|
+
assert.equal(r.credited, 0);
|
|
361
|
+
assert.equal(r.reason, 'decay_off');
|
|
362
|
+
assert.equal(c.log.length, 0, 'and it costs nothing — no walk, no read');
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
// 5. the dial, and the wiring
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
|
|
369
|
+
await test('the PORT METHOD re-applies the convergence bound, not just the resolver', async () => {
|
|
370
|
+
// awardIdeaDescentShipCredit is reachable through the reward port with a
|
|
371
|
+
// caller-supplied config that never passed through rewardPolicy(). A rate of
|
|
372
|
+
// 1.5 would make a deeper ancestor pay MORE than a nearer one — the exact
|
|
373
|
+
// property the comments promise cannot happen.
|
|
374
|
+
const c = stubClient({ 500: src(500, 7, 3), 501: src(501, 8, 4) });
|
|
375
|
+
const r = await withDescentPort([{ id: 500, depth: 1 }, { id: 501, depth: 2 }],
|
|
376
|
+
() => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: { ideatorCredit: true, ideaDescentDecay: 1.5 } }));
|
|
377
|
+
const deltas = r.payments.map((p) => p.delta);
|
|
378
|
+
for (let i = 1; i < deltas.length; i += 1) {
|
|
379
|
+
assert.ok(deltas[i] <= deltas[i - 1], `a deeper hop may never pay more — got ${JSON.stringify(deltas)}`);
|
|
380
|
+
}
|
|
381
|
+
assert.ok(r.credited <= Math.round(BASE * QUICK), 'and the total stays bounded by the direct share');
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
await test('the walk is capped at the depth the money can actually reach', async () => {
|
|
385
|
+
// Past the depth where even a 100%-complete idea rounds to nothing, every
|
|
386
|
+
// further hop is a round trip that provably pays nobody. The cap is derived
|
|
387
|
+
// from the rate, so a steeper decay walks LESS, never more.
|
|
388
|
+
let askedFor = null;
|
|
389
|
+
const c = stubClient({ 500: src(500, 7, 3) });
|
|
390
|
+
ancestorsFixture = [{ id: 500, depth: 1 }];
|
|
391
|
+
const port = api.resolve('ideas.descent');
|
|
392
|
+
const realWalk = port.ancestorsOfTask;
|
|
393
|
+
port.ancestorsOfTask = async (id, opts) => { askedFor = opts && opts.maxDepth; return realWalk(id, opts); };
|
|
394
|
+
try {
|
|
395
|
+
await credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON });
|
|
396
|
+
const atHalf = askedFor;
|
|
397
|
+
assert.ok(Number.isInteger(atHalf) && atHalf > 0 && atHalf < 64,
|
|
398
|
+
`the walk gets a derived cap, not the 64 backstop — got ${atHalf}`);
|
|
399
|
+
await credits.awardIdeaDescentShipCredit(c, { taskId: 901, config: rewardPolicy({ ideaDescentDecay: 0.1 }) });
|
|
400
|
+
assert.ok(askedFor < atHalf, `a steeper decay walks shallower — ${askedFor} vs ${atHalf}`);
|
|
401
|
+
} finally {
|
|
402
|
+
port.ancestorsOfTask = realWalk;
|
|
403
|
+
ancestorsFixture = [];
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
await test('the decay dial defaults to halving and refuses a non-converging rate', async () => {
|
|
408
|
+
assert.equal(rewardPolicy({}).ideaDescentDecay, IDEA_DESCENT_DECAY);
|
|
409
|
+
assert.equal(IDEA_DESCENT_DECAY, 0.5, 'spec decision 16: halving each hop');
|
|
410
|
+
assert.equal(rewardPolicy({ ideaDescentDecay: 0.25 }).ideaDescentDecay, 0.25, 'a project may set its own');
|
|
411
|
+
assert.equal(rewardPolicy({ ideaDescentDecay: 0 }).ideaDescentDecay, 0, '0 is legal — only the direct task pays');
|
|
412
|
+
for (const bad of [1, 1.5, -0.5, 'half', null, undefined, NaN]) {
|
|
413
|
+
assert.equal(rewardPolicy({ ideaDescentDecay: bad }).ideaDescentDecay, IDEA_DESCENT_DECAY,
|
|
414
|
+
`a rate of ${String(bad)} must fall back to the default, not poison an award`);
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
await test('the ship hook CALLS it, and hands it what the direct payout already settled', async () => {
|
|
419
|
+
// A payout nothing calls is dead code. The skipIdeaIds hand-off is what stops
|
|
420
|
+
// a direct source that is also an ancestor's source being paid twice, so the
|
|
421
|
+
// call site is pinned, not just the function.
|
|
422
|
+
const SHIP = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/db-ship.js'), 'utf8');
|
|
423
|
+
// ANCHORED ON THE CALL, not on the name. /reward\.awardIdeaDescentShipCredit/
|
|
424
|
+
// on its own still matches `reward.awardIdeaDescentShipCreditXX` — a typo'd
|
|
425
|
+
// call that resolves to undefined, takes the guard's else branch and skips
|
|
426
|
+
// the payout forever. Reverting the wiring has to red this, and with the bare
|
|
427
|
+
// name it did not. The open paren is what makes it assert a CALL.
|
|
428
|
+
assert.match(SHIP, /await reward\.awardIdeaDescentShipCredit\(/,
|
|
429
|
+
'the ship hook CALLS it through the reward port');
|
|
430
|
+
assert.match(SHIP, /skipIdeaIds:[\s\S]{0,120}ideaBonus[\s\S]{0,40}ideaId/,
|
|
431
|
+
'and passes the idea the direct payout just settled');
|
|
432
|
+
const PORT = fs.readFileSync(path.join(ROOT, 'modules/economy/reward.js'), 'utf8');
|
|
433
|
+
assert.match(PORT, /awardIdeaDescentShipCredit: \(\.\.\.a\) => credits\.awardIdeaDescentShipCredit\(/,
|
|
434
|
+
'the reward port publishes it, or the ship hook guard silently skips forever');
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
await test('ideas PROVIDES the port, and economy deliberately does NOT declare it as consumed', async () => {
|
|
438
|
+
// THE DECLARATION THAT WOULD BREAK BOOT, and the reason this is a test rather
|
|
439
|
+
// than a comment. `consumes` means REQUIRED: src/module-loader/loader.js
|
|
440
|
+
// verifyEnabledPorts THROWS and refuses to boot when an enabled module
|
|
441
|
+
// consumes a port no enabled module provides. The ideas module can be
|
|
442
|
+
// disabled; economy is default-on. So declaring ideas.descent in economy's
|
|
443
|
+
// consumes would turn "this instance does not use ideas" into "this instance
|
|
444
|
+
// does not start" — for a capability that is optional by construction and
|
|
445
|
+
// degrades to exactly today's first-hop payout.
|
|
446
|
+
//
|
|
447
|
+
// The next reader will see an undeclared port and want to tidy it up. This is
|
|
448
|
+
// the test that tells them why not.
|
|
449
|
+
const ideas = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/ideas/module.json'), 'utf8'));
|
|
450
|
+
const economy = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/economy/module.json'), 'utf8'));
|
|
451
|
+
assert.ok(ideas.provides.includes('ideas.descent'), 'ideas provides it');
|
|
452
|
+
assert.ok(!(economy.consumes || []).includes('ideas.descent'),
|
|
453
|
+
'economy must NOT require it — an instance with the ideas module off still has to boot');
|
|
454
|
+
|
|
455
|
+
const SRC = fs.readFileSync(path.join(ROOT, 'modules/economy/credits.js'), 'utf8');
|
|
456
|
+
assert.match(SRC, /resolveOptional\('ideas\.descent'\)/,
|
|
457
|
+
'which is only safe because it is resolved OPTIONALLY, never through resolve()');
|
|
458
|
+
assert.doesNotMatch(SRC, /[^t]resolve\('ideas\.descent'\)/,
|
|
459
|
+
'a hard resolve would throw on the request path instead of degrading');
|
|
460
|
+
|
|
461
|
+
const ROUTES = fs.readFileSync(path.join(ROOT, 'modules/ideas/routes/inbox.js'), 'utf8');
|
|
462
|
+
assert.match(ROUTES, /registerProvider\('ideas\.descent'/, 'and something actually registers it');
|
|
463
|
+
assert.match(ROUTES, /hasProvider\('ideas\.descent'\)/,
|
|
464
|
+
'guarded, or rebuilding the router trips the single-provider guard');
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
await test('the port publishes only names the walk actually exports', async () => {
|
|
468
|
+
// A guessed method name resolves to undefined and the payout silently pays
|
|
469
|
+
// nobody — the exact failure a port cannot surface at boot.
|
|
470
|
+
const descent = require('../modules/ideas/descent.js');
|
|
471
|
+
const ROUTES = fs.readFileSync(path.join(ROOT, 'modules/ideas/routes/inbox.js'), 'utf8');
|
|
472
|
+
const block = ROUTES.slice(ROUTES.indexOf("registerProvider('ideas.descent'"));
|
|
473
|
+
for (const name of [...block.slice(0, block.indexOf('}')).matchAll(/(\w+):\s*descent\.(\w+)/g)]) {
|
|
474
|
+
assert.equal(typeof descent[name[2]], 'function',
|
|
475
|
+
`descent.js must export ${name[2]} — the port publishes it`);
|
|
476
|
+
}
|
|
477
|
+
assert.equal(typeof descent.ancestorsOfTask, 'function', 'and the payout calls ancestorsOfTask by that name');
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
481
|
+
process.exit(failed === 0 ? 0 : 1);
|