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 `
+
+`;
+}
+
+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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
+
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();