From 6a44b1060031587ab34b4e8316361be814cf6ca2 Mon Sep 17 00:00:00 2001 From: shaiananvari8 <228813044+shaiananvari8@users.noreply.github.com> Date: Wed, 27 May 2026 16:56:46 -0500 Subject: [PATCH] Add researcher reputation anomaly guard --- researcher-reputation-anomaly-guard/README.md | 32 ++ .../acceptance-notes.md | 18 ++ researcher-reputation-anomaly-guard/demo.js | 87 ++++++ researcher-reputation-anomaly-guard/index.js | 291 ++++++++++++++++++ .../make-demo-video.js | 158 ++++++++++ .../package.json | 13 + .../reports/demo.avi | Bin 0 -> 2765416 bytes .../reports/reputation-anomaly-packet.json | 143 +++++++++ .../reports/reputation-anomaly-report.md | 29 ++ .../reports/summary.svg | 23 ++ .../requirements-map.md | 13 + .../sample-data.js | 221 +++++++++++++ researcher-reputation-anomaly-guard/test.js | 60 ++++ 13 files changed, 1088 insertions(+) create mode 100644 researcher-reputation-anomaly-guard/README.md create mode 100644 researcher-reputation-anomaly-guard/acceptance-notes.md create mode 100644 researcher-reputation-anomaly-guard/demo.js create mode 100644 researcher-reputation-anomaly-guard/index.js create mode 100644 researcher-reputation-anomaly-guard/make-demo-video.js create mode 100644 researcher-reputation-anomaly-guard/package.json create mode 100644 researcher-reputation-anomaly-guard/reports/demo.avi create mode 100644 researcher-reputation-anomaly-guard/reports/reputation-anomaly-packet.json create mode 100644 researcher-reputation-anomaly-guard/reports/reputation-anomaly-report.md create mode 100644 researcher-reputation-anomaly-guard/reports/summary.svg create mode 100644 researcher-reputation-anomaly-guard/requirements-map.md create mode 100644 researcher-reputation-anomaly-guard/sample-data.js create mode 100644 researcher-reputation-anomaly-guard/test.js diff --git a/researcher-reputation-anomaly-guard/README.md b/researcher-reputation-anomaly-guard/README.md new file mode 100644 index 00000000..7382588b --- /dev/null +++ b/researcher-reputation-anomaly-guard/README.md @@ -0,0 +1,32 @@ +# Researcher Reputation Anomaly Guard + +Synthetic, dependency-free reviewer module for SCIBASE issue #11, User & Project Management. + +The guard evaluates whether public researcher reputation badges should be shown, held, or sent to manual review. It focuses on metric abuse rather than profile sync: + +- download bursts from concentrated network clusters +- self-controlled or weakly verified fork rings +- reciprocal endorsement clusters and new same-affiliation endorsers +- reproducibility-score inflation from self or lab-controlled runs +- private or anonymous-review profile exposure risks +- stale metric evidence windows + +## Run + +```bash +npm run check +npm test +npm run demo +npm run demo:video +``` + +The demo writes reviewer artifacts under `reports/`: + +- `reputation-anomaly-packet.json` +- `reputation-anomaly-report.md` +- `summary.svg` +- `demo.avi` + +## Safety + +All scenarios are synthetic. The module does not call ORCID, SAML, OAuth, analytics providers, profile services, GitHub, identity providers, payment providers, or production project data. diff --git a/researcher-reputation-anomaly-guard/acceptance-notes.md b/researcher-reputation-anomaly-guard/acceptance-notes.md new file mode 100644 index 00000000..c4c40b7c --- /dev/null +++ b/researcher-reputation-anomaly-guard/acceptance-notes.md @@ -0,0 +1,18 @@ +# Acceptance Notes + +Reviewer acceptance checklist: + +- The risky sample holds public reputation badges for concentrated download bursts, fork rings, reciprocal endorsements, weak reproducibility evidence, and private-profile exposure. +- The clean sample remains ready and keeps reputation badges visible. +- The stale sample triggers manual review without blocking the profile. +- Audit digests are deterministic for repeated runs. +- Demo artifacts are generated from synthetic data only. + +Local validation commands: + +```bash +npm run check +npm test +npm run demo +npm run demo:video +``` diff --git a/researcher-reputation-anomaly-guard/demo.js b/researcher-reputation-anomaly-guard/demo.js new file mode 100644 index 00000000..811dea37 --- /dev/null +++ b/researcher-reputation-anomaly-guard/demo.js @@ -0,0 +1,87 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { assessResearcherReputation } = require("./index"); +const { riskySnapshot, cleanSnapshot, staleSnapshot } = require("./sample-data"); + +const reportsDir = path.join(__dirname, "reports"); +fs.mkdirSync(reportsDir, { recursive: true }); + +const packet = { + risky: assessResearcherReputation(riskySnapshot), + clean: assessResearcherReputation(cleanSnapshot), + stale: assessResearcherReputation(staleSnapshot) +}; + +fs.writeFileSync( + path.join(reportsDir, "reputation-anomaly-packet.json"), + `${JSON.stringify(packet, null, 2)}\n` +); +fs.writeFileSync(path.join(reportsDir, "reputation-anomaly-report.md"), renderMarkdown(packet)); +fs.writeFileSync(path.join(reportsDir, "summary.svg"), renderSvg(packet)); + +console.log(`Wrote ${path.relative(process.cwd(), reportsDir)}`); +console.log(`Risky status: ${packet.risky.status}`); +console.log(`Audit digest: ${packet.risky.auditDigest}`); + +function renderMarkdown(packet) { + const lines = [ + "# Researcher Reputation Anomaly Guard Report", + "", + "Synthetic reviewer packet for SCIBASE issue #11.", + "", + "## Scenario Summary", + "", + "| Scenario | Status | Held Researchers | Blockers | Warnings | Digest |", + "| --- | --- | ---: | ---: | ---: | --- |" + ]; + + for (const [name, result] of Object.entries(packet)) { + lines.push( + `| ${name} | ${result.status} | ${result.summary.heldResearchers} | ${result.summary.blockerCount} | ${result.summary.warningCount} | ${result.auditDigest.slice(0, 12)} |` + ); + } + + lines.push("", "## Risky Profile Actions", ""); + for (const action of packet.risky.researchers[0].actions) { + lines.push(`- ${action}`); + } + + lines.push("", "## Non-Overlap Notes", ""); + lines.push( + "This module evaluates metric abuse and public reputation badge safety. It does not sync ORCID/publication state, implement broad RBAC, issue automation credentials, manage anonymous review escrow, provision projects, or change live identity-provider state." + ); + + return `${lines.join("\n")}\n`; +} + +function renderSvg(packet) { + const risky = packet.risky.summary; + const clean = packet.clean.summary; + const stale = packet.stale.summary; + return ` + + + + Reputation anomaly guard + Synthetic badge-safety review for SCIBASE user profiles + ${bar(56, 148, "Risky profile", risky.blockerCount, risky.warningCount, "#c2410c")} + ${bar(56, 218, "Clean profile", clean.blockerCount, clean.warningCount, "#15803d")} + ${bar(56, 288, "Stale profile", stale.blockerCount, stale.warningCount, "#b7791f")} + Digest ${packet.risky.auditDigest.slice(0, 16)} + +`; +} + +function bar(x, y, label, blockers, warnings, color) { + const blockerWidth = blockers * 42; + const warningWidth = warnings * 42; + return [ + ` ${label}`, + ` `, + ` `, + ` `, + ` ${blockers} blockers, ${warnings} warnings` + ].join("\n"); +} diff --git a/researcher-reputation-anomaly-guard/index.js b/researcher-reputation-anomaly-guard/index.js new file mode 100644 index 00000000..673d0745 --- /dev/null +++ b/researcher-reputation-anomaly-guard/index.js @@ -0,0 +1,291 @@ +"use strict"; + +const crypto = require("crypto"); + +const DEFAULTS = { + burstWindowDays: 7, + baselineWindowDays: 30, + downloadBurstMultiplier: 3, + maxSameNetworkRatio: 0.55, + minInstitutionDiversity: 3, + maxMetricAgeDays: 45, + maxSelfControlledForkRatio: 0.35, + maxReciprocalEndorsementRatio: 0.4, + maxLabControlledRunRatio: 0.35 +}; + +function assessResearcherReputation(snapshot, options = {}) { + const settings = { ...DEFAULTS, ...options }; + const now = new Date(snapshot.generatedAt || "2026-05-27T00:00:00Z"); + const researchers = (snapshot.researchers || []).map((researcher) => + assessResearcher(researcher, now, settings) + ); + const summary = summarize(researchers); + const payload = { + generatedAt: snapshot.generatedAt || now.toISOString(), + status: summary.status, + summary, + researchers + }; + + return { + ...payload, + auditDigest: digestFor(payload) + }; +} + +function assessResearcher(researcher, now, settings) { + const blockers = []; + const warnings = []; + const actions = []; + + checkDownloadAnomalies(researcher, now, settings, blockers, warnings, actions); + checkForkAnomalies(researcher, settings, blockers, warnings, actions); + checkEndorsementAnomalies(researcher, settings, blockers, warnings, actions); + checkReproducibilityInflation(researcher, settings, blockers, warnings, actions); + checkPrivacyExposure(researcher, blockers, warnings, actions); + checkMetricFreshness(researcher, now, settings, blockers, warnings, actions); + + const reputationScore = Math.max(0, 100 - blockers.length * 18 - warnings.length * 7); + const status = blockers.length > 0 ? "hold_reputation_badges" : warnings.length > 0 ? "manual_review" : "ready"; + + return { + researcherId: researcher.id, + displayName: researcher.displayName, + status, + reputationScore, + blockers, + warnings, + actions: uniqueActions(actions) + }; +} + +function checkDownloadAnomalies(researcher, now, settings, blockers, warnings, actions) { + const events = researcher.metrics?.downloads || []; + const recentCutoff = daysAgo(now, settings.burstWindowDays); + const baselineCutoff = daysAgo(now, settings.burstWindowDays + settings.baselineWindowDays); + const recent = events.filter((event) => new Date(event.date) >= recentCutoff); + const baseline = events.filter((event) => { + const eventDate = new Date(event.date); + return eventDate >= baselineCutoff && eventDate < recentCutoff; + }); + const recentTotal = sum(recent, "count"); + const baselineDaily = Math.max(1, sum(baseline, "count") / Math.max(1, settings.baselineWindowDays)); + const recentDaily = recentTotal / Math.max(1, settings.burstWindowDays); + const maxSameNetworkRatio = Math.max(0, ...recent.map((event) => event.sameNetworkRatio || 0)); + const recentInstitutions = new Set(recent.flatMap((event) => event.institutions || [])).size; + + if (recent.length > 0 && recentDaily >= baselineDaily * settings.downloadBurstMultiplier) { + blockers.push({ + code: "download_burst", + message: `Recent downloads are ${round(recentDaily / baselineDaily)}x above baseline.` + }); + actions.push("Hold download-derived badges until traffic provenance is reviewed."); + } + + if (recentTotal >= 50 && maxSameNetworkRatio > settings.maxSameNetworkRatio) { + blockers.push({ + code: "concentrated_download_source", + message: `${Math.round(maxSameNetworkRatio * 100)}% of recent downloads came from one network cluster.` + }); + actions.push("Require bot and same-network traffic review before publishing metrics."); + } + + if (recentTotal >= 50 && recentInstitutions < settings.minInstitutionDiversity) { + warnings.push({ + code: "low_download_diversity", + message: `Only ${recentInstitutions} institutions appear in the recent download window.` + }); + actions.push("Show metric as provisional until institution diversity improves."); + } +} + +function checkForkAnomalies(researcher, settings, blockers, warnings, actions) { + const forks = researcher.metrics?.forks || []; + if (forks.length === 0) return; + + const selfControlled = forks.filter((fork) => fork.selfControlled || fork.samePaymentAdmin || fork.identityConfidence < 0.6); + const noDownstreamWork = forks.filter((fork) => !fork.hasDownstreamCommit); + const selfRatio = selfControlled.length / forks.length; + + if (selfRatio > settings.maxSelfControlledForkRatio) { + blockers.push({ + code: "fork_ring_suspected", + message: `${selfControlled.length} of ${forks.length} forks look self-controlled or weakly verified.` + }); + actions.push("Exclude suspect forks from reputation scoring and queue identity review."); + } + + if (noDownstreamWork.length / forks.length > 0.6) { + warnings.push({ + code: "thin_fork_activity", + message: `${noDownstreamWork.length} forks have no downstream commit evidence.` + }); + actions.push("Down-rank fork-count badges until reuse evidence is available."); + } +} + +function checkEndorsementAnomalies(researcher, settings, blockers, warnings, actions) { + const endorsements = researcher.metrics?.endorsements || []; + if (endorsements.length === 0) return; + + const reciprocal = endorsements.filter((endorsement) => endorsement.reciprocal); + const newSharedAffiliation = endorsements.filter( + (endorsement) => endorsement.sharedAffiliation && endorsement.endorserAccountAgeDays < 30 + ); + const unverified = endorsements.filter((endorsement) => !endorsement.verifiedIdentity); + + if (reciprocal.length / endorsements.length > settings.maxReciprocalEndorsementRatio) { + blockers.push({ + code: "reciprocal_endorsement_cluster", + message: `${reciprocal.length} of ${endorsements.length} endorsements are reciprocal.` + }); + actions.push("Hold endorsement badges and require independent reviewer validation."); + } + + if (newSharedAffiliation.length >= 2) { + warnings.push({ + code: "new_affiliation_endorsements", + message: `${newSharedAffiliation.length} endorsements came from new accounts at the same affiliation.` + }); + actions.push("Mark endorsement signal as provisional."); + } + + if (unverified.length > 0) { + warnings.push({ + code: "unverified_endorsers", + message: `${unverified.length} endorsers lack verified identity evidence.` + }); + } +} + +function checkReproducibilityInflation(researcher, settings, blockers, warnings, actions) { + const runs = researcher.metrics?.reproducibilityRuns || []; + if (runs.length === 0) return; + + const highScoreRuns = runs.filter((run) => run.score >= 0.95); + const labControlledRuns = highScoreRuns.filter((run) => run.runnerRelation === "self" || run.runnerRelation === "same_lab"); + const weakEvidenceRuns = highScoreRuns.filter( + (run) => !run.environmentPinned || !run.datasetHashMatch || !run.notebookOutputFresh || run.rerunStatus !== "passed" + ); + + if (highScoreRuns.length > 0 && labControlledRuns.length / highScoreRuns.length > settings.maxLabControlledRunRatio) { + blockers.push({ + code: "reproducibility_score_inflation", + message: `${labControlledRuns.length} high-score reproducibility runs are self or lab controlled.` + }); + actions.push("Require independent reproducibility evidence before showing the score badge."); + } + + if (weakEvidenceRuns.length > 0) { + blockers.push({ + code: "weak_reproducibility_evidence", + message: `${weakEvidenceRuns.length} high-score runs have missing environment, data, or output freshness evidence.` + }); + actions.push("Remove weak runs from score calculation."); + } +} + +function checkPrivacyExposure(researcher, blockers, warnings, actions) { + const profileMode = researcher.profileMode || "public"; + const publicBadges = researcher.publicBadges || []; + const exposesMetricBadge = publicBadges.some((badge) => + ["downloads", "forks", "endorsements", "reproducibility"].includes(badge) + ); + + if (profileMode !== "public" && exposesMetricBadge) { + blockers.push({ + code: "private_profile_metric_exposure", + message: `Profile mode is ${profileMode}, but public metric badges are enabled.` + }); + actions.push("Hide reputation badges until profile visibility is reconciled."); + } + + if (researcher.anonymousReviewParticipant && publicBadges.includes("endorsements")) { + warnings.push({ + code: "anonymous_review_linkage_risk", + message: "Endorsement badges could reveal anonymous-review participation patterns." + }); + actions.push("Aggregate endorsement counts before showing them on anonymous-review profiles."); + } +} + +function checkMetricFreshness(researcher, now, settings, blockers, warnings, actions) { + const checkedAt = researcher.metrics?.checkedAt; + if (!checkedAt) { + warnings.push({ + code: "missing_metric_check_time", + message: "Metric snapshot does not include a checkedAt timestamp." + }); + actions.push("Refresh metric evidence before publication."); + return; + } + + const ageDays = (now - new Date(checkedAt)) / (1000 * 60 * 60 * 24); + if (ageDays > settings.maxMetricAgeDays) { + warnings.push({ + code: "stale_metric_window", + message: `Metric evidence is ${Math.round(ageDays)} days old.` + }); + actions.push("Refresh metric windows before rendering public badges."); + } +} + +function summarize(researchers) { + const blockerCount = sum(researchers, "blockers.length"); + const warningCount = sum(researchers, "warnings.length"); + const heldResearchers = researchers.filter((researcher) => researcher.status === "hold_reputation_badges").length; + const reviewResearchers = researchers.filter((researcher) => researcher.status === "manual_review").length; + const status = heldResearchers > 0 ? "hold_reputation_badges" : reviewResearchers > 0 ? "manual_review" : "ready"; + + return { + status, + researcherCount: researchers.length, + heldResearchers, + reviewResearchers, + blockerCount, + warningCount + }; +} + +function digestFor(payload) { + return crypto.createHash("sha256").update(stableStringify(payload)).digest("hex"); +} + +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 sum(items, path) { + return items.reduce((total, item) => total + Number(getPath(item, path) || 0), 0); +} + +function getPath(item, path) { + return path.split(".").reduce((current, part) => (current == null ? undefined : current[part]), item); +} + +function daysAgo(now, days) { + return new Date(now.getTime() - days * 24 * 60 * 60 * 1000); +} + +function uniqueActions(actions) { + return [...new Set(actions)]; +} + +function round(value) { + return Math.round(value * 10) / 10; +} + +module.exports = { + assessResearcherReputation, + DEFAULTS +}; diff --git a/researcher-reputation-anomaly-guard/make-demo-video.js b/researcher-reputation-anomaly-guard/make-demo-video.js new file mode 100644 index 00000000..f3a36f84 --- /dev/null +++ b/researcher-reputation-anomaly-guard/make-demo-video.js @@ -0,0 +1,158 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const width = 320; +const height = 180; +const fps = 2; +const frameCount = 16; +const rowSize = Math.ceil((width * 3) / 4) * 4; +const frameSize = rowSize * height; +const reportsDir = path.join(__dirname, "reports"); +const outputPath = path.join(reportsDir, "demo.avi"); + +fs.mkdirSync(reportsDir, { recursive: true }); + +const frames = []; +for (let i = 0; i < frameCount; i += 1) { + frames.push(makeFrame(i)); +} + +const moviChunks = []; +const indexEntries = []; +let offset = 4; +for (const frame of frames) { + const chunk = riffChunk("00db", frame); + moviChunks.push(chunk); + indexEntries.push(indexEntry("00db", 0x10, offset, frame.length)); + offset += chunk.length; +} + +const hdrl = listChunk("hdrl", [ + riffChunk("avih", aviHeader()), + listChunk("strl", [ + riffChunk("strh", streamHeader()), + riffChunk("strf", bitmapInfoHeader()) + ]) +]); +const movi = listChunk("movi", moviChunks); +const idx1 = riffChunk("idx1", Buffer.concat(indexEntries)); +const riff = riffChunk("RIFF", Buffer.concat([Buffer.from("AVI "), hdrl, movi, idx1])); + +fs.writeFileSync(outputPath, riff); +console.log(`Wrote ${path.relative(process.cwd(), outputPath)} (${riff.length} bytes)`); + +function makeFrame(frameIndex) { + const frame = Buffer.alloc(frameSize, 0xff); + const progress = (frameIndex + 1) / frameCount; + const holdWidth = Math.round(240 * progress); + const reviewWidth = Math.round(120 * Math.max(0, 1 - progress / 1.2)); + + fillRect(frame, 0, 0, width, height, [248, 250, 252]); + fillRect(frame, 18, 18, 284, 144, [255, 255, 255]); + strokeRect(frame, 18, 18, 284, 144, [210, 218, 228]); + fillRect(frame, 38, 58, 240, 18, [232, 237, 244]); + fillRect(frame, 38, 58, holdWidth, 18, [194, 65, 12]); + fillRect(frame, 38, 98, 240, 18, [232, 237, 244]); + fillRect(frame, 38, 98, reviewWidth, 18, [246, 196, 83]); + fillRect(frame, 38, 136, 240, 8, [21, 128, 61]); + fillRect(frame, 38, 136, Math.round(240 * progress), 8, [21, 128, 61]); + return frame; +} + +function fillRect(frame, x, y, w, h, rgb) { + for (let py = y; py < y + h; py += 1) { + if (py < 0 || py >= height) continue; + for (let px = x; px < x + w; px += 1) { + if (px < 0 || px >= width) continue; + setPixel(frame, px, py, rgb); + } + } +} + +function strokeRect(frame, x, y, w, h, rgb) { + fillRect(frame, x, y, w, 1, rgb); + fillRect(frame, x, y + h - 1, w, 1, rgb); + fillRect(frame, x, y, 1, h, rgb); + fillRect(frame, x + w - 1, y, 1, h, rgb); +} + +function setPixel(frame, x, y, rgb) { + const bottomUpY = height - y - 1; + const index = bottomUpY * rowSize + x * 3; + frame[index] = rgb[2]; + frame[index + 1] = rgb[1]; + frame[index + 2] = rgb[0]; +} + +function aviHeader() { + const buffer = Buffer.alloc(56); + buffer.writeUInt32LE(Math.round(1000000 / fps), 0); + buffer.writeUInt32LE(frameSize * fps, 4); + buffer.writeUInt32LE(0, 8); + buffer.writeUInt32LE(0x10, 12); + buffer.writeUInt32LE(frameCount, 16); + buffer.writeUInt32LE(0, 20); + buffer.writeUInt32LE(1, 24); + buffer.writeUInt32LE(frameSize, 28); + buffer.writeUInt32LE(width, 32); + buffer.writeUInt32LE(height, 36); + return buffer; +} + +function streamHeader() { + const buffer = Buffer.alloc(56); + buffer.write("vids", 0, 4, "ascii"); + buffer.write("DIB ", 4, 4, "ascii"); + buffer.writeUInt32LE(0, 8); + buffer.writeUInt32LE(0, 12); + buffer.writeUInt32LE(0, 16); + buffer.writeUInt32LE(1, 20); + buffer.writeUInt32LE(fps, 24); + buffer.writeUInt32LE(0, 28); + buffer.writeUInt32LE(frameCount, 32); + buffer.writeUInt32LE(frameSize, 36); + buffer.writeInt32LE(-1, 40); + buffer.writeUInt32LE(0, 44); + buffer.writeInt16LE(0, 48); + buffer.writeInt16LE(0, 50); + buffer.writeInt16LE(width, 52); + buffer.writeInt16LE(height, 54); + return buffer; +} + +function bitmapInfoHeader() { + const buffer = Buffer.alloc(40); + buffer.writeUInt32LE(40, 0); + buffer.writeInt32LE(width, 4); + buffer.writeInt32LE(height, 8); + buffer.writeUInt16LE(1, 12); + buffer.writeUInt16LE(24, 14); + buffer.writeUInt32LE(0, 16); + buffer.writeUInt32LE(frameSize, 20); + return buffer; +} + +function indexEntry(id, flags, chunkOffset, size) { + const buffer = Buffer.alloc(16); + buffer.write(id, 0, 4, "ascii"); + buffer.writeUInt32LE(flags, 4); + buffer.writeUInt32LE(chunkOffset, 8); + buffer.writeUInt32LE(size, 12); + return buffer; +} + +function riffChunk(id, payload) { + const size = payload.length; + const pad = size % 2 === 1 ? 1 : 0; + const buffer = Buffer.alloc(8 + size + pad); + buffer.write(id, 0, 4, "ascii"); + buffer.writeUInt32LE(size, 4); + payload.copy(buffer, 8); + return buffer; +} + +function listChunk(type, chunks) { + return riffChunk("LIST", Buffer.concat([Buffer.from(type, "ascii"), ...chunks])); +} diff --git a/researcher-reputation-anomaly-guard/package.json b/researcher-reputation-anomaly-guard/package.json new file mode 100644 index 00000000..bbfa062e --- /dev/null +++ b/researcher-reputation-anomaly-guard/package.json @@ -0,0 +1,13 @@ +{ + "name": "researcher-reputation-anomaly-guard", + "version": "1.0.0", + "description": "Synthetic reputation anomaly guard for SCIBASE user and project management.", + "main": "index.js", + "scripts": { + "check": "node --check index.js && node --check sample-data.js && node --check test.js && node --check demo.js && node --check make-demo-video.js", + "test": "node test.js", + "demo": "node demo.js", + "demo:video": "node make-demo-video.js" + }, + "license": "MIT" +} diff --git a/researcher-reputation-anomaly-guard/reports/demo.avi b/researcher-reputation-anomaly-guard/reports/demo.avi new file mode 100644 index 0000000000000000000000000000000000000000..5cd3b6007b6efe5ba8ca4090d91028d9af8788f6 GIT binary patch literal 2765416 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 + + + + Reputation anomaly guard + Synthetic badge-safety review for SCIBASE user profiles + Risky profile + + + + 7 blockers, 5 warnings + Clean profile + + + + 0 blockers, 0 warnings + Stale profile + + + + 0 blockers, 1 warnings + Digest 9e0a152011f4be2e + diff --git a/researcher-reputation-anomaly-guard/requirements-map.md b/researcher-reputation-anomaly-guard/requirements-map.md new file mode 100644 index 00000000..c42cc09c --- /dev/null +++ b/researcher-reputation-anomaly-guard/requirements-map.md @@ -0,0 +1,13 @@ +# Requirements Map + +| Issue #11 requirement | Coverage | +| --- | --- | +| Researcher profile citation and reputation metrics | Evaluates whether downloads, forks, endorsements, and reproducibility metrics can safely contribute to public profile scores. | +| Public vs private profile modes | Blocks public badges when profile visibility or anonymous-review participation would expose private activity. | +| Activity feed and recent project signals | Detects bursty or self-controlled activity before it appears as public reputation. | +| Role and object-level access context | Includes protected profile modes and anonymous-review contexts in badge decisions. | +| Project-level audit log | Emits deterministic reviewer actions and an audit digest for profile-metric review packets. | + +## Non-Overlap + +This is not a broad RBAC ledger, project provisioning module, profile sync/freshness module, anonymous-review escrow, data-room consent ledger, automation credential guard, visibility transition guard, or permission inheritance drift guard. It focuses narrowly on reputation metric abuse and badge-publication safety. diff --git a/researcher-reputation-anomaly-guard/sample-data.js b/researcher-reputation-anomaly-guard/sample-data.js new file mode 100644 index 00000000..037d6cc7 --- /dev/null +++ b/researcher-reputation-anomaly-guard/sample-data.js @@ -0,0 +1,221 @@ +"use strict"; + +const riskySnapshot = { + generatedAt: "2026-05-27T22:00:00Z", + researchers: [ + { + id: "researcher-risky", + displayName: "Dr. Mira Vale", + profileMode: "institutional", + publicBadges: ["downloads", "forks", "endorsements", "reproducibility"], + anonymousReviewParticipant: true, + metrics: { + checkedAt: "2026-05-20T12:00:00Z", + downloads: [ + { + date: "2026-05-26", + count: 140, + institutions: ["North Valley Lab"], + sameNetworkRatio: 0.72 + }, + { + date: "2026-05-25", + count: 110, + institutions: ["North Valley Lab", "North Valley Lab"], + sameNetworkRatio: 0.68 + }, + { + date: "2026-04-30", + count: 12, + institutions: ["State Biohub", "Open Climate Lab"], + sameNetworkRatio: 0.18 + }, + { + date: "2026-04-22", + count: 18, + institutions: ["State Biohub", "Metro Genomics"], + sameNetworkRatio: 0.21 + } + ], + forks: [ + { + projectId: "single-cell-atlas", + forkedBy: "mvale-lab-bot", + hasDownstreamCommit: false, + selfControlled: true, + samePaymentAdmin: true, + identityConfidence: 0.35 + }, + { + projectId: "single-cell-atlas", + forkedBy: "mvale-student-1", + hasDownstreamCommit: false, + selfControlled: false, + samePaymentAdmin: false, + identityConfidence: 0.52 + }, + { + projectId: "single-cell-atlas", + forkedBy: "external-reuse-lab", + hasDownstreamCommit: true, + selfControlled: false, + samePaymentAdmin: false, + identityConfidence: 0.93 + } + ], + endorsements: [ + { + from: "mvale-student-1", + to: "researcher-risky", + reciprocal: true, + sharedAffiliation: true, + endorserAccountAgeDays: 12, + verifiedIdentity: false + }, + { + from: "mvale-student-2", + to: "researcher-risky", + reciprocal: true, + sharedAffiliation: true, + endorserAccountAgeDays: 18, + verifiedIdentity: false + }, + { + from: "external-reviewer", + to: "researcher-risky", + reciprocal: false, + sharedAffiliation: false, + endorserAccountAgeDays: 540, + verifiedIdentity: true + } + ], + reproducibilityRuns: [ + { + projectId: "single-cell-atlas", + score: 0.98, + runnerId: "mvale-lab-runner", + runnerRelation: "same_lab", + rerunStatus: "passed", + environmentPinned: false, + datasetHashMatch: true, + notebookOutputFresh: false + }, + { + projectId: "single-cell-atlas", + score: 0.97, + runnerId: "mvale", + runnerRelation: "self", + rerunStatus: "passed", + environmentPinned: true, + datasetHashMatch: true, + notebookOutputFresh: true + } + ] + } + } + ] +}; + +const cleanSnapshot = { + generatedAt: "2026-05-27T22:00:00Z", + researchers: [ + { + id: "researcher-clean", + displayName: "Dr. Rowan Chen", + profileMode: "public", + publicBadges: ["downloads", "forks", "reproducibility"], + anonymousReviewParticipant: false, + metrics: { + checkedAt: "2026-05-25T12:00:00Z", + downloads: [ + { + date: "2026-05-26", + count: 24, + institutions: ["Metro Genomics", "Coastal Lab", "North Valley Lab", "Open Climate Lab"], + sameNetworkRatio: 0.18 + }, + { + date: "2026-04-25", + count: 95, + institutions: ["Metro Genomics", "Coastal Lab", "State Biohub", "Open Climate Lab"], + sameNetworkRatio: 0.2 + } + ], + forks: [ + { + projectId: "climate-assay", + forkedBy: "coastal-lab", + hasDownstreamCommit: true, + selfControlled: false, + samePaymentAdmin: false, + identityConfidence: 0.91 + }, + { + projectId: "climate-assay", + forkedBy: "state-biohub", + hasDownstreamCommit: true, + selfControlled: false, + samePaymentAdmin: false, + identityConfidence: 0.88 + } + ], + endorsements: [ + { + from: "external-reviewer-a", + to: "researcher-clean", + reciprocal: false, + sharedAffiliation: false, + endorserAccountAgeDays: 440, + verifiedIdentity: true + }, + { + from: "external-reviewer-b", + to: "researcher-clean", + reciprocal: false, + sharedAffiliation: false, + endorserAccountAgeDays: 620, + verifiedIdentity: true + } + ], + reproducibilityRuns: [ + { + projectId: "climate-assay", + score: 0.96, + runnerId: "external-runner", + runnerRelation: "independent", + rerunStatus: "passed", + environmentPinned: true, + datasetHashMatch: true, + notebookOutputFresh: true + } + ] + } + } + ] +}; + +const staleSnapshot = { + generatedAt: "2026-05-27T22:00:00Z", + researchers: [ + { + id: "researcher-stale", + displayName: "Dr. Imani Stone", + profileMode: "public", + publicBadges: ["downloads"], + anonymousReviewParticipant: false, + metrics: { + checkedAt: "2026-02-20T12:00:00Z", + downloads: [], + forks: [], + endorsements: [], + reproducibilityRuns: [] + } + } + ] +}; + +module.exports = { + riskySnapshot, + cleanSnapshot, + staleSnapshot +}; diff --git a/researcher-reputation-anomaly-guard/test.js b/researcher-reputation-anomaly-guard/test.js new file mode 100644 index 00000000..4938d1f5 --- /dev/null +++ b/researcher-reputation-anomaly-guard/test.js @@ -0,0 +1,60 @@ +"use strict"; + +const assert = require("assert"); +const { assessResearcherReputation } = require("./index"); +const { riskySnapshot, cleanSnapshot, staleSnapshot } = require("./sample-data"); + +function testRiskySnapshotHoldsBadges() { + const result = assessResearcherReputation(riskySnapshot); + const researcher = result.researchers[0]; + const blockerCodes = researcher.blockers.map((blocker) => blocker.code); + + assert.strictEqual(result.status, "hold_reputation_badges"); + assert.strictEqual(researcher.status, "hold_reputation_badges"); + assert(blockerCodes.includes("download_burst")); + assert(blockerCodes.includes("concentrated_download_source")); + assert(blockerCodes.includes("fork_ring_suspected")); + assert(blockerCodes.includes("reciprocal_endorsement_cluster")); + assert(blockerCodes.includes("reproducibility_score_inflation")); + assert(blockerCodes.includes("weak_reproducibility_evidence")); + assert(blockerCodes.includes("private_profile_metric_exposure")); +} + +function testCleanSnapshotIsReady() { + const result = assessResearcherReputation(cleanSnapshot); + const researcher = result.researchers[0]; + + assert.strictEqual(result.status, "ready"); + assert.strictEqual(researcher.status, "ready"); + assert.strictEqual(researcher.blockers.length, 0); + assert.strictEqual(researcher.warnings.length, 0); + assert(researcher.reputationScore >= 95); +} + +function testStaleSnapshotNeedsReviewOnly() { + const result = assessResearcherReputation(staleSnapshot); + const researcher = result.researchers[0]; + + assert.strictEqual(result.status, "manual_review"); + assert.strictEqual(researcher.status, "manual_review"); + assert.strictEqual(researcher.blockers.length, 0); + assert.strictEqual(researcher.warnings[0].code, "stale_metric_window"); +} + +function testDigestIsDeterministic() { + const first = assessResearcherReputation(riskySnapshot); + const second = assessResearcherReputation(riskySnapshot); + + assert.match(first.auditDigest, /^[a-f0-9]{64}$/); + assert.strictEqual(first.auditDigest, second.auditDigest); +} + +function run() { + testRiskySnapshotHoldsBadges(); + testCleanSnapshotIsReady(); + testStaleSnapshotNeedsReviewOnly(); + testDigestIsDeterministic(); + console.log("4 tests passed"); +} + +run();