diff --git a/mechanisms/m015-contribution-weighted-rewards/datasets/README.md b/mechanisms/m015-contribution-weighted-rewards/datasets/README.md new file mode 100644 index 0000000..e9de2c0 --- /dev/null +++ b/mechanisms/m015-contribution-weighted-rewards/datasets/README.md @@ -0,0 +1,27 @@ +# m015 datasets (replay fixtures) + +These fixtures are **deterministic inputs** for generating non-zero m015 KPI outputs **without MCP**. + +## Files +- `schema.json` -- JSON schema for replay datasets +- `fixtures/v0_sample.json` -- single distribution period with 4 participants and 1 stability commitment +- `fixtures/v0_stability_sample.json` -- stability tier scenarios (committed, matured, early exit, cap overflow) + +## How they are used +A replay runner (e.g., in `regen-heartbeat`) can read a fixture file and compute: +- Per-participant activity scores using `computeActivityScore` from `reference-impl/m015_score.js` +- Stability tier allocation using `computeStabilityAllocation` from `reference-impl/m015_score.js` +- Pro-rata distribution using `computeDistribution` from `reference-impl/m015_score.js` +- Aggregated KPIs using `computeM015KPI` from `reference-impl/m015_kpi.js` + +Key metrics produced: +- `total_distributed_uregen` -- total rewards distributed (stability + activity) +- `activity_pool_uregen` -- pool available for activity-based distribution +- `stability_allocation_uregen` -- stability tier payout (capped at 30% of inflow) +- `participant_count` -- participants with non-zero activity scores +- `gini_coefficient` -- inequality measure of reward distribution + +## Units +All token amounts are in **uregen** (1 REGEN = 1,000,000 uregen) and represented as integers. + +These datasets are **reference-only** and do not imply enforcement or on-chain actions. diff --git a/mechanisms/m015-contribution-weighted-rewards/datasets/schema.json b/mechanisms/m015-contribution-weighted-rewards/datasets/schema.json new file mode 100644 index 0000000..888b80d --- /dev/null +++ b/mechanisms/m015-contribution-weighted-rewards/datasets/schema.json @@ -0,0 +1,231 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "m015 replay dataset", + "type": "object", + "required": [ + "community_pool_inflow_uregen", + "periods_per_year", + "max_stability_share", + "stability_commitments", + "participants" + ], + "properties": { + "description": { + "type": "string", + "description": "Human-readable description of the dataset" + }, + "epoch": { + "type": "string", + "description": "ISO week identifier (e.g. 2026-W07)" + }, + "community_pool_inflow_uregen": { + "type": "integer", + "minimum": 0, + "description": "Community Pool inflow for this period in uregen" + }, + "periods_per_year": { + "type": "integer", + "minimum": 1, + "description": "Number of distribution periods per year (default 52 for weekly epochs)" + }, + "max_stability_share": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Maximum fraction of inflow allocated to stability tier (default 0.30)" + }, + "stability_commitments": { + "type": "array", + "description": "Active stability tier commitments for the period", + "items": { + "type": "object", + "required": ["holder_address", "committed_amount_uregen", "lock_period_months"], + "properties": { + "holder_address": { + "type": "string", + "description": "Bech32 address of the commitment holder" + }, + "committed_amount_uregen": { + "type": "integer", + "minimum": 0, + "description": "Amount of uregen locked in stability commitment" + }, + "lock_period_months": { + "type": "integer", + "minimum": 6, + "maximum": 24, + "description": "Lock period in months (min 6, max 24)" + }, + "status": { + "type": "string", + "enum": ["uncommitted", "committed", "matured", "early_exit"], + "description": "Lifecycle state of the commitment" + }, + "committed_at": { + "type": "string", + "format": "date-time", + "description": "ISO-8601 timestamp when commitment was made" + }, + "matured_at": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO-8601 timestamp when commitment matured (null if not matured)" + }, + "exited_at": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO-8601 timestamp of early exit (null if not exited)" + }, + "accrued_rewards_uregen": { + "type": "integer", + "minimum": 0, + "description": "Total rewards accrued to date in uregen" + }, + "forfeited_rewards_uregen": { + "type": "integer", + "minimum": 0, + "description": "Rewards forfeited due to early exit (50% penalty)" + }, + "annual_return_rate": { + "type": "number", + "minimum": 0, + "description": "Annual return rate (default 0.06)" + } + } + } + }, + "participants": { + "type": "array", + "description": "Participants with activity data for the period", + "items": { + "type": "object", + "required": ["address", "activities"], + "properties": { + "address": { + "type": "string", + "description": "Bech32 address of the participant" + }, + "label": { + "type": "string", + "description": "Human-readable label for the participant" + }, + "activities": { + "type": "object", + "required": [ + "credit_purchase_value", + "credit_retirement_value", + "platform_facilitation_value", + "governance_votes_cast", + "proposals" + ], + "properties": { + "credit_purchase_value": { + "type": "integer", + "minimum": 0, + "description": "Total credit purchase value in uregen for this period" + }, + "credit_retirement_value": { + "type": "integer", + "minimum": 0, + "description": "Total credit retirement value in uregen for this period" + }, + "platform_facilitation_value": { + "type": "integer", + "minimum": 0, + "description": "Total platform facilitation value in uregen for this period" + }, + "governance_votes_cast": { + "type": "integer", + "minimum": 0, + "description": "Number of governance votes cast in this period" + }, + "proposals": { + "type": "array", + "description": "Proposals submitted in this period", + "items": { + "type": "object", + "required": ["passed", "reached_quorum"], + "properties": { + "id": { + "type": "string", + "description": "Proposal identifier" + }, + "passed": { + "type": "boolean", + "description": "Whether the proposal passed" + }, + "reached_quorum": { + "type": "boolean", + "description": "Whether the proposal reached quorum" + } + } + } + } + } + }, + "computed": { + "type": "object", + "description": "Pre-computed values for validation (optional, present in fixture files)", + "properties": { + "total_score": { + "type": "number", + "minimum": 0 + }, + "activity_reward_uregen": { + "type": "integer", + "minimum": 0 + }, + "activity_share": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "stability_reward_uregen": { + "type": "integer", + "minimum": 0 + }, + "total_reward_uregen": { + "type": "integer", + "minimum": 0 + } + } + } + } + } + }, + "summary": { + "type": "object", + "description": "Pre-computed summary for validation (optional, present in fixture files)", + "properties": { + "stability_allocation_uregen": { + "type": "integer", + "minimum": 0 + }, + "activity_pool_uregen": { + "type": "integer", + "minimum": 0 + }, + "total_distributed_uregen": { + "type": "integer", + "minimum": 0 + }, + "participant_count": { + "type": "integer", + "minimum": 0 + }, + "stability_commitments_count": { + "type": "integer", + "minimum": 0 + }, + "stability_cap_utilization": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "notes": { + "type": "string" + } + } + } + } +} diff --git a/mechanisms/m015-contribution-weighted-rewards/reference-impl/README.md b/mechanisms/m015-contribution-weighted-rewards/reference-impl/README.md new file mode 100644 index 0000000..94169a2 --- /dev/null +++ b/mechanisms/m015-contribution-weighted-rewards/reference-impl/README.md @@ -0,0 +1,80 @@ +# m015 reference implementation (v0) + +This folder provides a **canonical computation** for m015 outputs so that different agents/runners +produce consistent numbers. + +## Modules + +### Activity scoring and distribution (`m015_score.js`) +- `computeActivityScore({ activities })` -- weighted score from five activity types +- `computeStabilityAllocation({ community_pool_inflow, stability_commitments, ... })` -- stability tier payout with 30% cap +- `computeDistribution({ activity_pool_amount, participants })` -- pro-rata reward distribution with remainder handling + +### KPI computation (`m015_kpi.js`) +- `computeM015KPI({ community_pool_inflow_uregen, stability_commitments, participants, ... })` -- aggregated KPI block +- `giniCoefficient(values)` -- Gini inequality measure for reward distribution + +## Inputs + +### Activity scoring +- `activities.credit_purchase_value` (number, uregen) -- weight 0.30 +- `activities.credit_retirement_value` (number, uregen) -- weight 0.30 +- `activities.platform_facilitation_value` (number, uregen) -- weight 0.20 +- `activities.governance_votes_cast` (number) -- weight 0.10 +- `activities.proposals[]` (array of `{ passed, reached_quorum }`) -- weight 0.10, anti-gaming rules apply + +### Stability allocation +- `community_pool_inflow` (number, uregen) -- Community Pool inflow for the period +- `stability_commitments[]` (array of `{ committed_amount_uregen }`) -- active commitments +- `periods_per_year` (number, default 52) -- weekly epochs +- `max_stability_share` (number, default 0.30) -- 30% cap on stability tier + +## Outputs + +### Activity score +- `total_score` -- weighted sum of all activity contributions +- `breakdown` -- per-activity detail (raw_value, weight, weighted_value) + +### Stability allocation +- `stability_allocation` -- uregen allocated to stability tier (capped) +- `activity_pool` -- uregen remaining for activity distribution + +### Distribution +- Per participant: `address`, `reward` (uregen), `share` (0-1) +- Remainder from `Math.floor()` truncation assigned to largest-share participant + +### KPI block +- `total_distributed_uregen` -- stability + activity distributions +- `stability_allocation_uregen`, `activity_pool_uregen` +- `stability_utilization` -- fraction of 30% cap used +- `participant_count` -- participants with score > 0 +- `gini_coefficient` -- inequality measure (0 = equal, 1 = max inequality) +- `top_earner_share` -- share of rewards going to highest scorer +- `revenue_constraint_satisfied` -- boolean: total payout <= inflow +- `stability_cap_satisfied` -- boolean: stability <= 30% cap + +## Self-test + +```bash +node m015_score.js +node m015_kpi.js +``` + +Each script reads all test vectors from `test_vectors/` and validates computed outputs against expected values. + +## Test vectors + +| Vector | Scenario | +|--------|----------| +| `vector_v0_sample` | 4 participants with diverse activity profiles, 1 stability commitment | +| `vector_v0_early_exit` | Stability tier with early exit penalty (50% forfeiture), 3 participants | +| `vector_v0_cap_overflow` | Stability obligations exceed 30% cap, demonstrating cap enforcement | +| `vector_v0_zero_activity` | All participants have zero activity, no stability commitments | + +Each vector has a `.input.json` and `.expected.json` pair. + +## Design notes +- All monetary values are integers in **uregen** (1 REGEN = 1,000,000 uregen). +- `Math.floor()` truncation is intentional for all reward computations, matching on-chain integer arithmetic. +- The remainder from floor truncation is assigned to the largest-share participant to ensure `sum(rewards) == activity_pool`. +- Stability allocation uses `Math.min(Math.floor(rawAllocation), Math.floor(cap))` to prevent over-allocation. diff --git a/mechanisms/m015-contribution-weighted-rewards/reference-impl/m015_kpi.js b/mechanisms/m015-contribution-weighted-rewards/reference-impl/m015_kpi.js new file mode 100644 index 0000000..e2e2235 --- /dev/null +++ b/mechanisms/m015-contribution-weighted-rewards/reference-impl/m015_kpi.js @@ -0,0 +1,237 @@ +/** + * m015 — Contribution-Weighted Rewards: KPI computation. + * + * Aggregates distribution results into a KPI block conforming to + * schemas/m015_kpi.schema.json. + * + * KPI outputs: + * total_distributed_uregen — sum of stability + activity distributions + * stability_allocation_uregen — stability tier payout for the period + * activity_pool_uregen — remaining pool after stability allocation + * stability_utilization — ratio of stability allocation to cap + * participant_count — participants with activity score > 0 + * gini_coefficient — inequality measure of reward distribution + * top_earner_share — share of rewards going to highest scorer + * revenue_constraint_satisfied — total payout <= community pool inflow + * stability_cap_satisfied — stability allocation <= 30% cap + * + * All monetary values are in uregen (1 REGEN = 1,000,000 uregen). + * + * @module m015_kpi + */ + +import { + computeActivityScore, + computeStabilityAllocation, + computeDistribution, +} from "./m015_score.js"; + +/** + * Compute the Gini coefficient for a set of values. + * Returns 0 for perfectly equal distributions, approaches 1 for maximum inequality. + * + * @param {number[]} values - array of non-negative numeric values + * @returns {number} Gini coefficient in [0, 1] + */ +export function giniCoefficient(values) { + const n = values.length; + if (n === 0) return 0; + const sum = values.reduce((s, v) => s + v, 0); + if (sum === 0) return 0; + + const sorted = [...values].sort((a, b) => a - b); + let weightedSum = 0; + for (let i = 0; i < n; i++) { + weightedSum += (i + 1) * sorted[i]; + } + // Gini = (2 * sum(rank_i * x_i)) / (n * sum) - (n + 1) / n + return Number(((2 * weightedSum) / (n * sum) - (n + 1) / n).toFixed(6)); +} + +/** + * Compute m015 KPI from a distribution period input. + * + * @param {Object} opts + * @param {number} opts.community_pool_inflow_uregen - inflow for this period + * @param {number} [opts.periods_per_year=52] - distribution periods per year + * @param {number} [opts.max_stability_share=0.30] - cap on stability allocation + * @param {Array} opts.stability_commitments - active stability commitments + * @param {Array} opts.participants - participant records with { address, activities } + * @returns {Object} KPI block + */ +export function computeM015KPI({ + community_pool_inflow_uregen, + periods_per_year = 52, + max_stability_share = 0.30, + stability_commitments = [], + participants = [], +}) { + // 1. Stability allocation + const { stability_allocation, activity_pool } = computeStabilityAllocation({ + community_pool_inflow: community_pool_inflow_uregen, + stability_commitments, + periods_per_year, + max_stability_share, + }); + + // 2. Score each participant + const scoredParticipants = participants.map((p) => { + const result = computeActivityScore({ activities: p.activities }); + return { address: p.address, ...result }; + }); + + // 3. Distribution + const distribution = computeDistribution({ + activity_pool_amount: activity_pool, + participants: scoredParticipants, + }); + + // 4. KPI metrics + const totalActivityDistributed = distribution.reduce((s, d) => s + d.reward, 0); + const total_distributed_uregen = stability_allocation + totalActivityDistributed; + + const activeParticipants = scoredParticipants.filter((p) => p.total_score > 0); + const participant_count = activeParticipants.length; + + const stabilityCap = Math.floor(community_pool_inflow_uregen * max_stability_share); + const stability_utilization = stabilityCap > 0 + ? Number((stability_allocation / stabilityCap).toFixed(6)) + : 0; + + const rewards = distribution.map((d) => d.reward); + const gini = giniCoefficient(rewards); + + const topShare = distribution.length > 0 + ? Math.max(...distribution.map((d) => d.share)) + : 0; + + const revenue_constraint_satisfied = total_distributed_uregen <= community_pool_inflow_uregen; + const stability_cap_satisfied = stability_allocation <= stabilityCap; + + return { + total_distributed_uregen, + stability_allocation_uregen: stability_allocation, + activity_pool_uregen: activity_pool, + stability_utilization, + participant_count, + gini_coefficient: gini, + top_earner_share: topShare, + revenue_constraint_satisfied, + stability_cap_satisfied, + }; +} + +// --------------------------------------------------------------------------- +// Self-test harness (runs when executed directly) +// --------------------------------------------------------------------------- +import { readFileSync, readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +function selfTest() { + const vectorDir = join(__dirname, "test_vectors"); + const inputFiles = readdirSync(vectorDir) + .filter((f) => f.endsWith(".input.json")) + .sort(); + + let passed = 0; + let failed = 0; + + for (const inputFile of inputFiles) { + const baseName = inputFile.replace(".input.json", ""); + const expectedFile = baseName + ".expected.json"; + const inputPath = join(vectorDir, inputFile); + const expectedPath = join(vectorDir, expectedFile); + + console.log(`--- ${baseName} ---`); + + const input = JSON.parse(readFileSync(inputPath, "utf8")); + const expected = JSON.parse(readFileSync(expectedPath, "utf8")); + + const kpi = computeM015KPI({ + community_pool_inflow_uregen: input.community_pool_inflow_uregen, + periods_per_year: input.periods_per_year, + max_stability_share: input.max_stability_share, + stability_commitments: input.stability_commitments, + participants: input.participants, + }); + + console.log(` total_distributed: ${kpi.total_distributed_uregen}`); + console.log(` stability_alloc: ${kpi.stability_allocation_uregen}`); + console.log(` activity_pool: ${kpi.activity_pool_uregen}`); + console.log(` stability_util: ${kpi.stability_utilization}`); + console.log(` participant_count: ${kpi.participant_count}`); + console.log(` gini_coefficient: ${kpi.gini_coefficient}`); + console.log(` top_earner_share: ${kpi.top_earner_share}`); + console.log(` revenue_ok: ${kpi.revenue_constraint_satisfied}`); + console.log(` stability_cap_ok: ${kpi.stability_cap_satisfied}`); + + // Validate KPI outputs against expected values + let vectorFailed = false; + + if (kpi.total_distributed_uregen !== expected.total_distributed_uregen) { + console.error(` FAIL: total_distributed expected ${expected.total_distributed_uregen}, got ${kpi.total_distributed_uregen}`); + vectorFailed = true; + } + + if (kpi.stability_allocation_uregen !== expected.stability_allocation_uregen) { + console.error(` FAIL: stability_allocation expected ${expected.stability_allocation_uregen}, got ${kpi.stability_allocation_uregen}`); + vectorFailed = true; + } + + if (kpi.activity_pool_uregen !== expected.activity_pool_uregen) { + console.error(` FAIL: activity_pool expected ${expected.activity_pool_uregen}, got ${kpi.activity_pool_uregen}`); + vectorFailed = true; + } + + if (kpi.stability_utilization !== expected.stability_utilization) { + console.error(` FAIL: stability_utilization expected ${expected.stability_utilization}, got ${kpi.stability_utilization}`); + vectorFailed = true; + } + + if (kpi.participant_count !== expected.participant_count) { + console.error(` FAIL: participant_count expected ${expected.participant_count}, got ${kpi.participant_count}`); + vectorFailed = true; + } + + if (Math.abs(kpi.gini_coefficient - expected.gini_coefficient) > 0.000001) { + console.error(` FAIL: gini_coefficient expected ${expected.gini_coefficient}, got ${kpi.gini_coefficient}`); + vectorFailed = true; + } + + if (Math.abs(kpi.top_earner_share - expected.top_earner_share) > 0.000001) { + console.error(` FAIL: top_earner_share expected ${expected.top_earner_share}, got ${kpi.top_earner_share}`); + vectorFailed = true; + } + + // Security invariants + if (!kpi.revenue_constraint_satisfied) { + console.error(` FAIL: revenue constraint violated — total ${kpi.total_distributed_uregen} > inflow ${input.community_pool_inflow_uregen}`); + vectorFailed = true; + } + + if (!kpi.stability_cap_satisfied) { + console.error(` FAIL: stability cap violated`); + vectorFailed = true; + } + + if (vectorFailed) { + failed++; + } else { + passed++; + console.log(` PASS`); + } + console.log(); + } + + console.log(`m015_kpi self-test: ${passed} passed, ${failed} failed`); + if (failed > 0) process.exit(1); +} + +// Run self-test if executed directly +if (process.argv[1] === __filename) { + selfTest(); +} diff --git a/mechanisms/m015-contribution-weighted-rewards/reference-impl/m015_score.js b/mechanisms/m015-contribution-weighted-rewards/reference-impl/m015_score.js index 8254c53..42e0f1f 100644 --- a/mechanisms/m015-contribution-weighted-rewards/reference-impl/m015_score.js +++ b/mechanisms/m015-contribution-weighted-rewards/reference-impl/m015_score.js @@ -167,17 +167,14 @@ export function computeDistribution({ activity_pool_amount, participants }) { // --------------------------------------------------------------------------- // Self-test harness (runs when executed directly) // --------------------------------------------------------------------------- -import { readFileSync } from "node:fs"; +import { readFileSync, readdirSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -function selfTest() { - const inputPath = join(__dirname, "test_vectors", "vector_v0_sample.input.json"); - const expectedPath = join(__dirname, "test_vectors", "vector_v0_sample.expected.json"); - +function runVector(inputPath, expectedPath) { const input = JSON.parse(readFileSync(inputPath, "utf8")); const expected = JSON.parse(readFileSync(expectedPath, "utf8")); @@ -189,17 +186,15 @@ function selfTest() { max_stability_share: input.max_stability_share, }); - console.log(`Stability allocation: ${stability_allocation} uregen`); - console.log(`Activity pool: ${activity_pool} uregen`); + console.log(` Stability allocation: ${stability_allocation} uregen`); + console.log(` Activity pool: ${activity_pool} uregen`); // Check stability allocation if (stability_allocation !== expected.stability_allocation_uregen) { - console.error(`FAIL: stability_allocation expected ${expected.stability_allocation_uregen}, got ${stability_allocation}`); - process.exit(1); + throw new Error(`stability_allocation expected ${expected.stability_allocation_uregen}, got ${stability_allocation}`); } if (activity_pool !== expected.activity_pool_uregen) { - console.error(`FAIL: activity_pool expected ${expected.activity_pool_uregen}, got ${activity_pool}`); - process.exit(1); + throw new Error(`activity_pool expected ${expected.activity_pool_uregen}, got ${activity_pool}`); } // 2. Compute activity scores for each participant @@ -207,19 +202,17 @@ function selfTest() { for (const p of input.participants) { const result = computeActivityScore({ activities: p.activities }); scoredParticipants.push({ address: p.address, ...result }); - console.log(`Score ${p.address}: ${result.total_score}`); + console.log(` Score ${p.address}: ${result.total_score}`); } // Check individual scores for (const exp of expected.participant_scores) { const actual = scoredParticipants.find(p => p.address === exp.address); if (!actual) { - console.error(`FAIL: participant ${exp.address} not found`); - process.exit(1); + throw new Error(`participant ${exp.address} not found`); } if (Math.abs(actual.total_score - exp.total_score) > 0.001) { - console.error(`FAIL: ${exp.address} score expected ${exp.total_score}, got ${actual.total_score}`); - process.exit(1); + throw new Error(`${exp.address} score expected ${exp.total_score}, got ${actual.total_score}`); } } @@ -229,45 +222,69 @@ function selfTest() { participants: scoredParticipants, }); - console.log("\nDistribution:"); + console.log(" Distribution:"); for (const d of dist) { - console.log(` ${d.address}: ${d.reward} uregen (${(d.share * 100).toFixed(2)}%)`); + console.log(` ${d.address}: ${d.reward} uregen (${(d.share * 100).toFixed(2)}%)`); } - // Check distribution totals + // Check distribution totals (skip for zero-activity: all rewards 0, pool is unallocated) const totalDistributed = dist.reduce((s, d) => s + d.reward, 0); - if (totalDistributed !== activity_pool) { - console.error(`FAIL: total distributed ${totalDistributed} != activity_pool ${activity_pool}`); - process.exit(1); + const allZero = scoredParticipants.every(p => p.total_score === 0); + if (!allZero && totalDistributed !== activity_pool) { + throw new Error(`total distributed ${totalDistributed} != activity_pool ${activity_pool}`); } // Check individual rewards for (const exp of expected.distribution) { const actual = dist.find(d => d.address === exp.address); if (!actual) { - console.error(`FAIL: distribution for ${exp.address} not found`); - process.exit(1); + throw new Error(`distribution for ${exp.address} not found`); } if (Math.abs(actual.reward - exp.reward) > 1) { - console.error(`FAIL: ${exp.address} reward expected ${exp.reward}, got ${actual.reward}`); - process.exit(1); + throw new Error(`${exp.address} reward expected ${exp.reward}, got ${actual.reward}`); } } // Check security invariants const totalPayout = stability_allocation + totalDistributed; if (totalPayout > input.community_pool_inflow_uregen) { - console.error(`FAIL: total payout ${totalPayout} > inflow ${input.community_pool_inflow_uregen}`); - process.exit(1); + throw new Error(`total payout ${totalPayout} > inflow ${input.community_pool_inflow_uregen}`); } const stabilityCap = input.community_pool_inflow_uregen * input.max_stability_share; if (stability_allocation > stabilityCap) { - console.error(`FAIL: stability_allocation ${stability_allocation} > cap ${stabilityCap}`); - process.exit(1); + throw new Error(`stability_allocation ${stability_allocation} > cap ${stabilityCap}`); + } +} + +function selfTest() { + const vectorDir = join(__dirname, "test_vectors"); + const inputFiles = readdirSync(vectorDir) + .filter(f => f.endsWith(".input.json")) + .sort(); + + let passed = 0; + let failed = 0; + + for (const inputFile of inputFiles) { + const baseName = inputFile.replace(".input.json", ""); + const expectedFile = baseName + ".expected.json"; + const inputPath = join(vectorDir, inputFile); + const expectedPath = join(vectorDir, expectedFile); + + console.log(`--- ${baseName} ---`); + try { + runVector(inputPath, expectedPath); + passed++; + console.log(" PASS\n"); + } catch (err) { + failed++; + console.error(` FAIL: ${err.message}\n`); + } } - console.log("\nm015_score self-test: PASS"); + console.log(`m015_score self-test: ${passed} passed, ${failed} failed`); + if (failed > 0) process.exit(1); } // Run self-test if executed directly diff --git a/mechanisms/m015-contribution-weighted-rewards/reference-impl/test_vectors/vector_v0_cap_overflow.expected.json b/mechanisms/m015-contribution-weighted-rewards/reference-impl/test_vectors/vector_v0_cap_overflow.expected.json new file mode 100644 index 0000000..c878f2c --- /dev/null +++ b/mechanisms/m015-contribution-weighted-rewards/reference-impl/test_vectors/vector_v0_cap_overflow.expected.json @@ -0,0 +1,32 @@ +{ + "description": "Expected outputs for vector_v0_cap_overflow — raw stability allocation (173,076,923) exceeds 30% cap (150,000,000), so capped. Computed by m015_score.js reference implementation.", + "stability_allocation_uregen": 150000000, + "activity_pool_uregen": 350000000, + "total_distributed_uregen": 500000000, + "stability_utilization": 1, + "participant_count": 2, + "gini_coefficient": 0.152174, + "top_earner_share": 0.652174, + "participant_scores": [ + { + "address": "regen1whale0example", + "total_score": 15000000.2 + }, + { + "address": "regen1small0example", + "total_score": 8000000.5 + } + ], + "distribution": [ + { + "address": "regen1whale0example", + "reward": 228260866, + "share": 0.652174 + }, + { + "address": "regen1small0example", + "reward": 121739134, + "share": 0.347826 + } + ] +} diff --git a/mechanisms/m015-contribution-weighted-rewards/reference-impl/test_vectors/vector_v0_cap_overflow.input.json b/mechanisms/m015-contribution-weighted-rewards/reference-impl/test_vectors/vector_v0_cap_overflow.input.json new file mode 100644 index 0000000..ddae99c --- /dev/null +++ b/mechanisms/m015-contribution-weighted-rewards/reference-impl/test_vectors/vector_v0_cap_overflow.input.json @@ -0,0 +1,40 @@ +{ + "description": "Stability obligations exceed 30% cap — demonstrates cap enforcement. 150B uregen committed at 6% annual = 173M/period, but cap is 30% of 500M = 150M.", + "community_pool_inflow_uregen": 500000000, + "periods_per_year": 52, + "max_stability_share": 0.30, + "stability_commitments": [ + { + "holder_address": "regen1whale0example", + "committed_amount_uregen": 150000000000, + "lock_period_months": 24, + "status": "committed" + } + ], + "participants": [ + { + "address": "regen1whale0example", + "label": "large holder with modest activity", + "activities": { + "credit_purchase_value": 40000000, + "credit_retirement_value": 10000000, + "platform_facilitation_value": 0, + "governance_votes_cast": 2, + "proposals": [] + } + }, + { + "address": "regen1small0example", + "label": "small active participant", + "activities": { + "credit_purchase_value": 15000000, + "credit_retirement_value": 5000000, + "platform_facilitation_value": 10000000, + "governance_votes_cast": 4, + "proposals": [ + { "passed": true, "reached_quorum": true } + ] + } + } + ] +} diff --git a/mechanisms/m015-contribution-weighted-rewards/reference-impl/test_vectors/vector_v0_early_exit.expected.json b/mechanisms/m015-contribution-weighted-rewards/reference-impl/test_vectors/vector_v0_early_exit.expected.json new file mode 100644 index 0000000..f4b0733 --- /dev/null +++ b/mechanisms/m015-contribution-weighted-rewards/reference-impl/test_vectors/vector_v0_early_exit.expected.json @@ -0,0 +1,41 @@ +{ + "description": "Expected outputs for vector_v0_early_exit — stability allocation includes all commitments (early exit penalty is metadata, not allocation reduction). Computed by m015_score.js reference implementation.", + "stability_allocation_uregen": 1961538, + "activity_pool_uregen": 1998038462, + "total_distributed_uregen": 2000000000, + "stability_utilization": 0.003269, + "participant_count": 3, + "gini_coefficient": 0.347222, + "top_earner_share": 0.625, + "participant_scores": [ + { + "address": "regen1staker1example", + "total_score": 90000000.6 + }, + { + "address": "regen1staker2example", + "total_score": 39000000.2 + }, + { + "address": "regen1staker3example", + "total_score": 15000001.15 + } + ], + "distribution": [ + { + "address": "regen1staker1example", + "reward": 1248774031, + "share": 0.625 + }, + { + "address": "regen1staker2example", + "reward": 541135412, + "share": 0.270833 + }, + { + "address": "regen1staker3example", + "reward": 208129019, + "share": 0.104167 + } + ] +} diff --git a/mechanisms/m015-contribution-weighted-rewards/reference-impl/test_vectors/vector_v0_early_exit.input.json b/mechanisms/m015-contribution-weighted-rewards/reference-impl/test_vectors/vector_v0_early_exit.input.json new file mode 100644 index 0000000..1618af0 --- /dev/null +++ b/mechanisms/m015-contribution-weighted-rewards/reference-impl/test_vectors/vector_v0_early_exit.input.json @@ -0,0 +1,68 @@ +{ + "description": "Stability tier with early exit penalty — one commitment triggers 50% forfeiture. 3 participants with varying activity profiles.", + "community_pool_inflow_uregen": 2000000000, + "periods_per_year": 52, + "max_stability_share": 0.30, + "stability_commitments": [ + { + "holder_address": "regen1staker1example", + "committed_amount_uregen": 1000000000, + "lock_period_months": 12, + "status": "committed" + }, + { + "holder_address": "regen1staker2example", + "committed_amount_uregen": 500000000, + "lock_period_months": 6, + "status": "early_exit", + "accrued_rewards_uregen": 1442307, + "forfeited_rewards_uregen": 721153 + }, + { + "holder_address": "regen1staker3example", + "committed_amount_uregen": 200000000, + "lock_period_months": 24, + "status": "committed" + } + ], + "participants": [ + { + "address": "regen1staker1example", + "label": "active buyer and retirer", + "activities": { + "credit_purchase_value": 200000000, + "credit_retirement_value": 100000000, + "platform_facilitation_value": 0, + "governance_votes_cast": 5, + "proposals": [ + { "passed": true, "reached_quorum": true } + ] + } + }, + { + "address": "regen1staker2example", + "label": "platform operator with early exit", + "activities": { + "credit_purchase_value": 50000000, + "credit_retirement_value": 0, + "platform_facilitation_value": 120000000, + "governance_votes_cast": 2, + "proposals": [] + } + }, + { + "address": "regen1staker3example", + "label": "governance-focused participant", + "activities": { + "credit_purchase_value": 30000000, + "credit_retirement_value": 20000000, + "platform_facilitation_value": 0, + "governance_votes_cast": 10, + "proposals": [ + { "passed": true, "reached_quorum": true }, + { "passed": false, "reached_quorum": true } + ] + } + } + ] +} diff --git a/mechanisms/m015-contribution-weighted-rewards/reference-impl/test_vectors/vector_v0_sample.expected.json b/mechanisms/m015-contribution-weighted-rewards/reference-impl/test_vectors/vector_v0_sample.expected.json index ceb1990..c83b3cd 100644 --- a/mechanisms/m015-contribution-weighted-rewards/reference-impl/test_vectors/vector_v0_sample.expected.json +++ b/mechanisms/m015-contribution-weighted-rewards/reference-impl/test_vectors/vector_v0_sample.expected.json @@ -2,6 +2,11 @@ "description": "Expected outputs for vector_v0_sample — computed by m015_score.js reference implementation", "stability_allocation_uregen": 576923, "activity_pool_uregen": 999423077, + "total_distributed_uregen": 1000000000, + "stability_utilization": 0.001923, + "participant_count": 4, + "gini_coefficient": 0.491611, + "top_earner_share": 0.604027, "participant_scores": [ { "address": "regen1alice0example", diff --git a/mechanisms/m015-contribution-weighted-rewards/reference-impl/test_vectors/vector_v0_zero_activity.expected.json b/mechanisms/m015-contribution-weighted-rewards/reference-impl/test_vectors/vector_v0_zero_activity.expected.json new file mode 100644 index 0000000..85fafcf --- /dev/null +++ b/mechanisms/m015-contribution-weighted-rewards/reference-impl/test_vectors/vector_v0_zero_activity.expected.json @@ -0,0 +1,41 @@ +{ + "description": "Expected outputs for vector_v0_zero_activity — zero scores mean zero distribution. Activity pool equals full inflow. Computed by m015_score.js reference implementation.", + "stability_allocation_uregen": 0, + "activity_pool_uregen": 1000000000, + "total_distributed_uregen": 0, + "stability_utilization": 0, + "participant_count": 0, + "gini_coefficient": 0, + "top_earner_share": 0, + "participant_scores": [ + { + "address": "regen1zero1example", + "total_score": 0 + }, + { + "address": "regen1zero2example", + "total_score": 0 + }, + { + "address": "regen1zero3example", + "total_score": 0 + } + ], + "distribution": [ + { + "address": "regen1zero1example", + "reward": 0, + "share": 0 + }, + { + "address": "regen1zero2example", + "reward": 0, + "share": 0 + }, + { + "address": "regen1zero3example", + "reward": 0, + "share": 0 + } + ] +} diff --git a/mechanisms/m015-contribution-weighted-rewards/reference-impl/test_vectors/vector_v0_zero_activity.input.json b/mechanisms/m015-contribution-weighted-rewards/reference-impl/test_vectors/vector_v0_zero_activity.input.json new file mode 100644 index 0000000..cd0ad36 --- /dev/null +++ b/mechanisms/m015-contribution-weighted-rewards/reference-impl/test_vectors/vector_v0_zero_activity.input.json @@ -0,0 +1,42 @@ +{ + "description": "Edge case: all participants have zero activity. No stability commitments. All rewards should be 0.", + "community_pool_inflow_uregen": 1000000000, + "periods_per_year": 52, + "max_stability_share": 0.30, + "stability_commitments": [], + "participants": [ + { + "address": "regen1zero1example", + "label": "inactive participant 1", + "activities": { + "credit_purchase_value": 0, + "credit_retirement_value": 0, + "platform_facilitation_value": 0, + "governance_votes_cast": 0, + "proposals": [] + } + }, + { + "address": "regen1zero2example", + "label": "inactive participant 2", + "activities": { + "credit_purchase_value": 0, + "credit_retirement_value": 0, + "platform_facilitation_value": 0, + "governance_votes_cast": 0, + "proposals": [] + } + }, + { + "address": "regen1zero3example", + "label": "inactive participant 3", + "activities": { + "credit_purchase_value": 0, + "credit_retirement_value": 0, + "platform_facilitation_value": 0, + "governance_votes_cast": 0, + "proposals": [] + } + } + ] +}