From 35af2eb9554d3283bc39fa5b2ae33a15a9a35d40 Mon Sep 17 00:00:00 2001 From: Seowoo Han Date: Wed, 20 May 2026 19:32:39 +0900 Subject: [PATCH] Add endorsement ring guard --- endorsement-ring-guard/README.md | 22 +++ endorsement-ring-guard/acceptance-notes.md | 16 +++ endorsement-ring-guard/demo.js | 33 +++++ endorsement-ring-guard/demo.mp4 | Bin 0 -> 39927 bytes endorsement-ring-guard/demo.svg | 14 ++ endorsement-ring-guard/index.js | 148 +++++++++++++++++++++ endorsement-ring-guard/requirements-map.md | 14 ++ endorsement-ring-guard/test.js | 82 ++++++++++++ 8 files changed, 329 insertions(+) create mode 100644 endorsement-ring-guard/README.md create mode 100644 endorsement-ring-guard/acceptance-notes.md create mode 100644 endorsement-ring-guard/demo.js create mode 100644 endorsement-ring-guard/demo.mp4 create mode 100644 endorsement-ring-guard/demo.svg create mode 100644 endorsement-ring-guard/index.js create mode 100644 endorsement-ring-guard/requirements-map.md create mode 100644 endorsement-ring-guard/test.js 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 0000000000000000000000000000000000000000..ed45d8f7f1ba4c9d15d31213c8618ae1adc3f97f GIT binary patch literal 39927 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 n^G5m~j?U_rDnM53V*d + + + 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")