diff --git a/endorsement-ring-guard/README.md b/endorsement-ring-guard/README.md new file mode 100644 index 0000000..98db638 --- /dev/null +++ b/endorsement-ring-guard/README.md @@ -0,0 +1,22 @@ +# Endorsement Ring Guard + +This module covers a narrow reputation scoring slice of SCIBASE issue #15. + +It evaluates endorsement events before they update public reputation scores, holding suspicious endorsements for review when they show reciprocal-ring, self-endorsement, same-institution concentration, missing-evidence, or invalid-weight risk. + +## What It Does + +- Accepts synthetic users, evidence records, and endorsement events. +- Blocks invalid endorsement weights. +- Holds self-endorsements and reciprocal endorsement pairs. +- Holds endorsements that lack accepted evidence. +- Detects concentrated endorsement clusters from one institution. +- Emits reputation deltas only for accepted endorsements. +- Produces reviewer actions and a deterministic audit digest. + +## Run + +```bash +node endorsement-ring-guard/test.js +node endorsement-ring-guard/demo.js +``` diff --git a/endorsement-ring-guard/acceptance-notes.md b/endorsement-ring-guard/acceptance-notes.md new file mode 100644 index 0000000..b391606 --- /dev/null +++ b/endorsement-ring-guard/acceptance-notes.md @@ -0,0 +1,16 @@ +# Acceptance Notes + +## Local Validation + +- `node endorsement-ring-guard/test.js` +- `node endorsement-ring-guard/demo.js` +- `node --check endorsement-ring-guard/index.js` +- `node --check endorsement-ring-guard/test.js` +- `node --check endorsement-ring-guard/demo.js` +- `ffprobe -v error -show_entries format=duration,size -show_entries stream=codec_name,width,height -of default=noprint_wrappers=1 endorsement-ring-guard/demo.mp4` + +## Reviewer Notes + +- The module is dependency-free and uses synthetic endorsement data only. +- It does not store private identity, payment, or institutional account data. +- It is scoped to endorsement integrity before public reputation scores, badges, or leaderboards are updated. diff --git a/endorsement-ring-guard/demo.js b/endorsement-ring-guard/demo.js new file mode 100644 index 0000000..03fd108 --- /dev/null +++ b/endorsement-ring-guard/demo.js @@ -0,0 +1,33 @@ +"use strict" + +const { analyzeEndorsements } = require("./index") + +const result = analyzeEndorsements({ + users: [ + { id: "ada", institution: "North Lab" }, + { id: "ben", institution: "North Lab" }, + { id: "cy", institution: "North Lab" }, + { id: "dee", institution: "East Institute" }, + { id: "eli", institution: "West Bio" }, + ], + evidence: [ + { id: "ev-review-1", type: "peer-review" }, + { id: "ev-code-1", type: "code-commit" }, + { id: "ev-repro-1", type: "reproducibility" }, + ], + endorsements: [ + { from: "ada", to: "eli", evidenceId: "ev-review-1", weight: 2, institution: "North Lab" }, + { from: "ben", to: "eli", evidenceId: "missing", weight: 2, institution: "North Lab" }, + { from: "cy", to: "eli", evidenceId: "ev-code-1", weight: 2, institution: "North Lab" }, + { from: "dee", to: "ada", evidenceId: "ev-repro-1", weight: 3, institution: "East Institute" }, + ], +}) + +console.log("Endorsement Ring Guard Demo") +console.log("===========================") +console.log(`status: ${result.status}`) +console.log(`accepted endorsements: ${result.acceptedCount}`) +console.log(`held endorsements: ${result.heldCount}`) +console.log(`top hold: ${result.holds[0].type}`) +console.log(`top action: ${result.reviewerActions[0]}`) +console.log(`digest: ${result.auditDigest.slice(0, 16)}...`) diff --git a/endorsement-ring-guard/demo.mp4 b/endorsement-ring-guard/demo.mp4 new file mode 100644 index 0000000..ed45d8f Binary files /dev/null and b/endorsement-ring-guard/demo.mp4 differ diff --git a/endorsement-ring-guard/demo.svg b/endorsement-ring-guard/demo.svg new file mode 100644 index 0000000..c93c116 --- /dev/null +++ b/endorsement-ring-guard/demo.svg @@ -0,0 +1,14 @@ + + + + Endorsement Ring Guard + Pre-score integrity checks for reputation endorsements + + NEEDS REVIEW + reciprocal ring / missing evidence / institution concentration + Held Signals + self endorsements cannot score + mutual endorsements require independent evidence + same-institution clusters are routed to review + audit digest attaches to leaderboard update + diff --git a/endorsement-ring-guard/index.js b/endorsement-ring-guard/index.js new file mode 100644 index 0000000..6418e61 --- /dev/null +++ b/endorsement-ring-guard/index.js @@ -0,0 +1,148 @@ +"use strict" + +const crypto = require("node:crypto") + +function stableStringify(value) { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]` + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(",")}}` + } + return JSON.stringify(value) +} + +function digest(value) { + return crypto.createHash("sha256").update(stableStringify(value)).digest("hex") +} + +function normalizeEndorsement(item) { + return { + from: item.from, + to: item.to, + projectId: item.projectId || null, + evidenceId: item.evidenceId || null, + relationship: item.relationship || "unknown", + institution: item.institution || null, + weight: Number(item.weight || 1), + } +} + +function pairKey(a, b) { + return [a, b].sort().join("::") +} + +function analyzeEndorsements(input) { + const endorsements = (input.endorsements || []).map(normalizeEndorsement) + const evidenceIds = new Set((input.evidence || []).map((item) => item.id)) + const users = new Map((input.users || []).map((user) => [user.id, user])) + const blockers = [] + const warnings = [] + const holds = [] + const reviewerActions = [] + + const byPair = new Map() + const inboundByUser = new Map() + + for (const endorsement of endorsements) { + if (!endorsement.from || !endorsement.to) { + blockers.push("endorsement is missing source or target user") + continue + } + if (endorsement.from === endorsement.to) { + holds.push({ + type: "self-endorsement", + user: endorsement.from, + reason: "self endorsements cannot contribute to reputation", + }) + } + if (!users.has(endorsement.from)) warnings.push(`unknown endorsing user: ${endorsement.from}`) + if (!users.has(endorsement.to)) warnings.push(`unknown endorsed user: ${endorsement.to}`) + if (!endorsement.evidenceId || !evidenceIds.has(endorsement.evidenceId)) { + holds.push({ + type: "missing-evidence", + from: endorsement.from, + to: endorsement.to, + reason: "endorsement lacks accepted review, code, dataset, or reproducibility evidence", + }) + } + if (endorsement.weight <= 0 || endorsement.weight > 5) { + blockers.push(`endorsement ${endorsement.from}->${endorsement.to} has invalid weight`) + } + + const pair = pairKey(endorsement.from, endorsement.to) + byPair.set(pair, [...(byPair.get(pair) || []), endorsement]) + inboundByUser.set(endorsement.to, [...(inboundByUser.get(endorsement.to) || []), endorsement]) + } + + for (const [pair, pairEndorsements] of byPair.entries()) { + const directions = new Set(pairEndorsements.map((item) => `${item.from}->${item.to}`)) + if (directions.size > 1) { + holds.push({ + type: "reciprocal-endorsement", + pair, + reason: "mutual endorsements require independent evidence before reputation credit", + }) + } + } + + for (const [target, inbound] of inboundByUser.entries()) { + const institutionCounts = inbound.reduce((counts, endorsement) => { + const institution = endorsement.institution || users.get(endorsement.from)?.institution || "unknown" + counts.set(institution, (counts.get(institution) || 0) + 1) + return counts + }, new Map()) + const dominant = [...institutionCounts.entries()].sort((a, b) => b[1] - a[1])[0] + if (dominant && inbound.length >= 3 && dominant[1] / inbound.length > 0.67) { + holds.push({ + type: "institution-concentration", + user: target, + institution: dominant[0], + reason: "endorsements are too concentrated in one institution", + }) + } + } + + const accepted = endorsements.filter((endorsement) => { + const hasHold = holds.some( + (hold) => + (hold.from === endorsement.from && hold.to === endorsement.to) || + hold.user === endorsement.from || + hold.user === endorsement.to || + hold.pair === pairKey(endorsement.from, endorsement.to), + ) + return !hasHold && endorsement.evidenceId && evidenceIds.has(endorsement.evidenceId) + }) + + const scoreByUser = new Map() + for (const endorsement of accepted) { + scoreByUser.set(endorsement.to, (scoreByUser.get(endorsement.to) || 0) + endorsement.weight) + } + + if (holds.length > 0) reviewerActions.push("Review held endorsements before updating reputation scores.") + if (warnings.length > 0) reviewerActions.push("Resolve warning metadata before publishing leaderboard changes.") + reviewerActions.push("Attach the audit digest to the reputation update packet.") + + const result = { + status: blockers.length > 0 ? "blocked" : holds.length > 0 || warnings.length > 0 ? "needs-review" : "ready", + acceptedCount: accepted.length, + heldCount: holds.length, + blockers, + warnings: [...new Set(warnings)], + holds, + reputationDeltas: [...scoreByUser.entries()] + .map(([userId, delta]) => ({ userId, delta })) + .sort((a, b) => b.delta - a.delta || a.userId.localeCompare(b.userId)), + reviewerActions, + } + + return { + ...result, + auditDigest: digest(result), + } +} + +module.exports = { + analyzeEndorsements, +} diff --git a/endorsement-ring-guard/requirements-map.md b/endorsement-ring-guard/requirements-map.md new file mode 100644 index 0000000..08d924e --- /dev/null +++ b/endorsement-ring-guard/requirements-map.md @@ -0,0 +1,14 @@ +# Requirements Map + +| Issue #15 requirement | Coverage in this module | +| --- | --- | +| Reputation scoring | Produces reputation deltas only after endorsement integrity checks. | +| Endorsements from other researchers | Validates endorsement source, target, evidence, and weight. | +| Peer review quality signals | Requires accepted evidence such as review, code, dataset, or reproducibility records. | +| Transparent reputation metrics | Emits holds, warnings, reviewer actions, and an audit digest. | +| Leaderboards and badges | Prevents questionable endorsement clusters from immediately affecting public rankings. | +| Scientific bounty completions and challenge performance | Keeps the scoring path extensible by treating evidence IDs as typed contribution records. | + +## Non-Overlap Note + +This submission is distinct from broad community reputation ledgers, peer-review assignment governance, review calibration, credit attestation, reputation transparency receipts, correction impact ledgers, abuse appeals, and mentorship ladders. It focuses specifically on pre-score endorsement integrity and ring detection. diff --git a/endorsement-ring-guard/test.js b/endorsement-ring-guard/test.js new file mode 100644 index 0000000..2a1b24a --- /dev/null +++ b/endorsement-ring-guard/test.js @@ -0,0 +1,82 @@ +"use strict" + +const assert = require("node:assert/strict") +const { analyzeEndorsements } = require("./index") + +const users = [ + { id: "ada", institution: "North Lab" }, + { id: "ben", institution: "North Lab" }, + { id: "cy", institution: "North Lab" }, + { id: "dee", institution: "East Institute" }, + { id: "eli", institution: "West Bio" }, +] + +const evidence = [ + { id: "ev-review-1", type: "peer-review" }, + { id: "ev-code-1", type: "code-commit" }, + { id: "ev-repro-1", type: "reproducibility" }, +] + +{ + const result = analyzeEndorsements({ + users, + evidence, + endorsements: [ + { from: "ada", to: "eli", evidenceId: "ev-review-1", weight: 2, institution: "North Lab" }, + { from: "dee", to: "eli", evidenceId: "ev-repro-1", weight: 3, institution: "East Institute" }, + ], + }) + + assert.equal(result.status, "ready") + assert.equal(result.acceptedCount, 2) + assert.equal(result.heldCount, 0) + assert.deepEqual(result.reputationDeltas, [{ userId: "eli", delta: 5 }]) + assert.match(result.auditDigest, /^[0-9a-f]{64}$/) +} + +{ + const result = analyzeEndorsements({ + users, + evidence, + endorsements: [ + { from: "ada", to: "ben", evidenceId: "ev-review-1", weight: 2, institution: "North Lab" }, + { from: "ben", to: "ada", evidenceId: "ev-code-1", weight: 2, institution: "North Lab" }, + { from: "cy", to: "cy", evidenceId: "ev-repro-1", weight: 1, institution: "North Lab" }, + ], + }) + + assert.equal(result.status, "needs-review") + assert.equal(result.acceptedCount, 0) + assert.ok(result.holds.some((hold) => hold.type === "reciprocal-endorsement")) + assert.ok(result.holds.some((hold) => hold.type === "self-endorsement")) +} + +{ + const result = analyzeEndorsements({ + users, + evidence, + endorsements: [ + { from: "ada", to: "eli", evidenceId: "ev-review-1", weight: 2, institution: "North Lab" }, + { from: "ben", to: "eli", evidenceId: "missing", weight: 2, institution: "North Lab" }, + { from: "cy", to: "eli", evidenceId: "ev-code-1", weight: 2, institution: "North Lab" }, + ], + }) + + assert.equal(result.status, "needs-review") + assert.ok(result.holds.some((hold) => hold.type === "missing-evidence")) + assert.ok(result.holds.some((hold) => hold.type === "institution-concentration")) + assert.equal(result.reputationDeltas.length, 0) +} + +{ + const result = analyzeEndorsements({ + users, + evidence, + endorsements: [{ from: "ada", to: "eli", evidenceId: "ev-review-1", weight: 9 }], + }) + + assert.equal(result.status, "blocked") + assert.ok(result.blockers.includes("endorsement ada->eli has invalid weight")) +} + +console.log("endorsement-ring-guard tests passed")