Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions endorsement-ring-guard/README.md
Original file line number Diff line number Diff line change
@@ -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
```
16 changes: 16 additions & 0 deletions endorsement-ring-guard/acceptance-notes.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 33 additions & 0 deletions endorsement-ring-guard/demo.js
Original file line number Diff line number Diff line change
@@ -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)}...`)
Binary file added endorsement-ring-guard/demo.mp4
Binary file not shown.
14 changes: 14 additions & 0 deletions endorsement-ring-guard/demo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
148 changes: 148 additions & 0 deletions endorsement-ring-guard/index.js
Original file line number Diff line number Diff line change
@@ -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,
}
14 changes: 14 additions & 0 deletions endorsement-ring-guard/requirements-map.md
Original file line number Diff line number Diff line change
@@ -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.
82 changes: 82 additions & 0 deletions endorsement-ring-guard/test.js
Original file line number Diff line number Diff line change
@@ -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")