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 00000000..5cd3b600 Binary files /dev/null and b/researcher-reputation-anomaly-guard/reports/demo.avi differ diff --git a/researcher-reputation-anomaly-guard/reports/reputation-anomaly-packet.json b/researcher-reputation-anomaly-guard/reports/reputation-anomaly-packet.json new file mode 100644 index 00000000..4b1ad16b --- /dev/null +++ b/researcher-reputation-anomaly-guard/reports/reputation-anomaly-packet.json @@ -0,0 +1,143 @@ +{ + "risky": { + "generatedAt": "2026-05-27T22:00:00Z", + "status": "hold_reputation_badges", + "summary": { + "status": "hold_reputation_badges", + "researcherCount": 1, + "heldResearchers": 1, + "reviewResearchers": 0, + "blockerCount": 7, + "warningCount": 5 + }, + "researchers": [ + { + "researcherId": "researcher-risky", + "displayName": "Dr. Mira Vale", + "status": "hold_reputation_badges", + "reputationScore": 0, + "blockers": [ + { + "code": "download_burst", + "message": "Recent downloads are 35.7x above baseline." + }, + { + "code": "concentrated_download_source", + "message": "72% of recent downloads came from one network cluster." + }, + { + "code": "fork_ring_suspected", + "message": "2 of 3 forks look self-controlled or weakly verified." + }, + { + "code": "reciprocal_endorsement_cluster", + "message": "2 of 3 endorsements are reciprocal." + }, + { + "code": "reproducibility_score_inflation", + "message": "2 high-score reproducibility runs are self or lab controlled." + }, + { + "code": "weak_reproducibility_evidence", + "message": "1 high-score runs have missing environment, data, or output freshness evidence." + }, + { + "code": "private_profile_metric_exposure", + "message": "Profile mode is institutional, but public metric badges are enabled." + } + ], + "warnings": [ + { + "code": "low_download_diversity", + "message": "Only 1 institutions appear in the recent download window." + }, + { + "code": "thin_fork_activity", + "message": "2 forks have no downstream commit evidence." + }, + { + "code": "new_affiliation_endorsements", + "message": "2 endorsements came from new accounts at the same affiliation." + }, + { + "code": "unverified_endorsers", + "message": "2 endorsers lack verified identity evidence." + }, + { + "code": "anonymous_review_linkage_risk", + "message": "Endorsement badges could reveal anonymous-review participation patterns." + } + ], + "actions": [ + "Hold download-derived badges until traffic provenance is reviewed.", + "Require bot and same-network traffic review before publishing metrics.", + "Show metric as provisional until institution diversity improves.", + "Exclude suspect forks from reputation scoring and queue identity review.", + "Down-rank fork-count badges until reuse evidence is available.", + "Hold endorsement badges and require independent reviewer validation.", + "Mark endorsement signal as provisional.", + "Require independent reproducibility evidence before showing the score badge.", + "Remove weak runs from score calculation.", + "Hide reputation badges until profile visibility is reconciled.", + "Aggregate endorsement counts before showing them on anonymous-review profiles." + ] + } + ], + "auditDigest": "9e0a152011f4be2e67389f701d6b73e630a21a387f6e7d44bd73ac13c5c2a3a0" + }, + "clean": { + "generatedAt": "2026-05-27T22:00:00Z", + "status": "ready", + "summary": { + "status": "ready", + "researcherCount": 1, + "heldResearchers": 0, + "reviewResearchers": 0, + "blockerCount": 0, + "warningCount": 0 + }, + "researchers": [ + { + "researcherId": "researcher-clean", + "displayName": "Dr. Rowan Chen", + "status": "ready", + "reputationScore": 100, + "blockers": [], + "warnings": [], + "actions": [] + } + ], + "auditDigest": "72e09f87a8cf9883952e870e4fe819a1e0966a579102ffda19fb9c79c09b1740" + }, + "stale": { + "generatedAt": "2026-05-27T22:00:00Z", + "status": "manual_review", + "summary": { + "status": "manual_review", + "researcherCount": 1, + "heldResearchers": 0, + "reviewResearchers": 1, + "blockerCount": 0, + "warningCount": 1 + }, + "researchers": [ + { + "researcherId": "researcher-stale", + "displayName": "Dr. Imani Stone", + "status": "manual_review", + "reputationScore": 93, + "blockers": [], + "warnings": [ + { + "code": "stale_metric_window", + "message": "Metric evidence is 96 days old." + } + ], + "actions": [ + "Refresh metric windows before rendering public badges." + ] + } + ], + "auditDigest": "db3aaba107f9589a3f89ca0927dfbac3df3a912b36570a7148d86f7e8b3acda5" + } +} diff --git a/researcher-reputation-anomaly-guard/reports/reputation-anomaly-report.md b/researcher-reputation-anomaly-guard/reports/reputation-anomaly-report.md new file mode 100644 index 00000000..66b1b7ca --- /dev/null +++ b/researcher-reputation-anomaly-guard/reports/reputation-anomaly-report.md @@ -0,0 +1,29 @@ +# Researcher Reputation Anomaly Guard Report + +Synthetic reviewer packet for SCIBASE issue #11. + +## Scenario Summary + +| Scenario | Status | Held Researchers | Blockers | Warnings | Digest | +| --- | --- | ---: | ---: | ---: | --- | +| risky | hold_reputation_badges | 1 | 7 | 5 | 9e0a152011f4 | +| clean | ready | 0 | 0 | 0 | 72e09f87a8cf | +| stale | manual_review | 0 | 0 | 1 | db3aaba107f9 | + +## Risky Profile Actions + +- Hold download-derived badges until traffic provenance is reviewed. +- Require bot and same-network traffic review before publishing metrics. +- Show metric as provisional until institution diversity improves. +- Exclude suspect forks from reputation scoring and queue identity review. +- Down-rank fork-count badges until reuse evidence is available. +- Hold endorsement badges and require independent reviewer validation. +- Mark endorsement signal as provisional. +- Require independent reproducibility evidence before showing the score badge. +- Remove weak runs from score calculation. +- Hide reputation badges until profile visibility is reconciled. +- Aggregate endorsement counts before showing them on anonymous-review profiles. + +## Non-Overlap Notes + +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. diff --git a/researcher-reputation-anomaly-guard/reports/summary.svg b/researcher-reputation-anomaly-guard/reports/summary.svg new file mode 100644 index 00000000..6bb9b8b1 --- /dev/null +++ b/researcher-reputation-anomaly-guard/reports/summary.svg @@ -0,0 +1,23 @@ + + + + + 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();