diff --git a/community-reputation-ledger/README.md b/community-reputation-ledger/README.md new file mode 100644 index 0000000..8a701b9 --- /dev/null +++ b/community-reputation-ledger/README.md @@ -0,0 +1,85 @@ +# Community Reputation Ledger + +Self-contained community and reputation milestone for [SCIBASE.AI issue #15](https://github.com/SCIBASE-AI/SCIBASE.AI/issues/15). + +The issue asks for structured peer reviews, inline comments, contributor credit, CRediT taxonomy support, transparent reputation scoring, leaderboards, badges, and incentive tiers. This module provides a deterministic ledger that reviewers can run locally without databases, accounts, or external services. + +## What It Adds + +- Discipline-specific peer review templates for biology, physics, social sciences, and general research. +- Public, semi-private, anonymous, and double-blind review modes with reviewer anonymization. +- Inline comments targeting documents, datasets, code, notebooks, anchors, and line ranges. +- Timestamped contributor credit records with validated CRediT roles and stable hashes. +- Git-style contributor graph aggregation by researcher and project. +- Researcher profile views with review history, comment history, visible citation credits, badges, tiers, and profile hashes. +- Project timeline views that combine contributions, reviews, and comments into hashable chronological events. +- Citation page views that surface visible contributor credit by project for author/citation pages. +- Transparent researcher score components for citations, forks, endorsements, peer review, reproducibility, bounties, contribution credit, and moderation penalties. +- Moderation signals for self-endorsements, reciprocal endorsements, thin reviews, and flagged researcher metrics. +- Governance audit for review quality, reputation score changes, open appeals, required actions, and appeal SLA due dates. +- Leaderboards by domain, region, and institution. +- Badge and incentive tier assignment for trusted reviewers, reproducibility, challenge completion, and open-science leadership. +- Sample community fixture, tests, requirement map, CLI demo, and short demo GIF. + +## Run + +```bash +cd community-reputation-ledger +npm run check +npm test +npm run demo +``` + +Expected demo shape: + +```json +{ + "reviewTemplates": 4, + "reviews": 2, + "comments": 2, + "creditedContributions": 5, + "researcherProfiles": 3, + "projectTimelines": [ + { + "projectId": "project-flood-microbiome", + "eventCount": 5 + } + ], + "citationPages": [ + { + "projectId": "project-flood-microbiome", + "creditCount": 3 + } + ], + "topBiologyResearcher": { + "researcherId": "u-ada", + "tier": "open-science-champion" + }, + "moderation": { + "status": "review" + }, + "governance": { + "status": "needs-governance-review", + "requiredActions": 1, + "openAppeals": 1, + "firstAppealDueBy": "2026-05-19T08:00:00.000Z" + }, + "packetHash": "..." +} +``` + +## Demo Artifact + +See [docs/demo.gif](docs/demo.gif) for a short visual walkthrough. The SVG source is included at [docs/demo.svg](docs/demo.svg). + +## Files + +- `src/community-reputation-ledger.js` - peer reviews, comments, contribution ledger, contributor graph, researcher profiles, project timelines, citation pages, reputation scores, leaderboards, moderation, and governance audit. +- `data/sample-community.json` - reviewable community fixture. +- `test/community-reputation-ledger.test.js` - dependency-free Node tests. +- `scripts/demo.js` - CLI demo. +- `docs/issue-15-requirement-map.md` - maps the implementation to bounty requirements. + +## AI-Assisted Disclosure + +This contribution was produced with AI assistance and manually verified with the local commands above. diff --git a/community-reputation-ledger/data/sample-community.json b/community-reputation-ledger/data/sample-community.json new file mode 100644 index 0000000..1405527 --- /dev/null +++ b/community-reputation-ledger/data/sample-community.json @@ -0,0 +1,203 @@ +{ + "researchers": [ + { + "id": "u-ada", + "displayName": "Ada Chen", + "domain": "biology", + "region": "North America", + "institution": "Northstar Lab" + }, + { + "id": "u-ravi", + "displayName": "Ravi Patel", + "domain": "biology", + "region": "Europe", + "institution": "Open Bio Institute" + }, + { + "id": "u-maya", + "displayName": "Maya Okafor", + "domain": "social-sciences", + "region": "Africa", + "institution": "Civic Methods Center" + } + ], + "projects": [ + { + "id": "project-flood-microbiome", + "title": "Coastal flooding microbiome atlas", + "domain": "biology", + "visibility": "public" + }, + { + "id": "project-civic-survey", + "title": "Civic survey reproducibility pack", + "domain": "social-sciences", + "visibility": "public" + } + ], + "reviews": [ + { + "id": "review-1", + "projectId": "project-flood-microbiome", + "reviewerId": "u-ravi", + "discipline": "biology", + "visibility": "public", + "scores": { + "clarity": 4.5, + "methodology": 4, + "data-quality": 5, + "reproducibility": 4, + "novelty": 3.5 + }, + "recommendation": "accept-with-minor-revisions", + "summary": "Strong open dataset with clear reproducibility hooks.", + "strengths": ["Raw data and notebooks are easy to trace", "Protocol metadata is complete"], + "concerns": ["Clarify batch effects in the methods appendix"], + "createdAt": "2026-05-03T10:00:00Z" + }, + { + "id": "review-2", + "projectId": "project-civic-survey", + "reviewerId": "u-ada", + "discipline": "social-sciences", + "visibility": "double-blind", + "scores": { + "clarity": 4, + "ethics": 5, + "sampling": 3.5, + "reproducibility": 4.5, + "impact": 4 + }, + "recommendation": "revise", + "summary": "Promising survey pack with strong ethics disclosure.", + "strengths": ["Consent workflow is explicit", "Analysis scripts are included"], + "concerns": ["Sampling limitations need clearer framing"], + "createdAt": "2026-05-04T12:00:00Z" + } + ], + "comments": [ + { + "id": "comment-1", + "projectId": "project-flood-microbiome", + "authorId": "u-ravi", + "target": { + "kind": "dataset", + "path": "data/samples.csv", + "anchor": "column:collection_site" + }, + "mode": "public", + "body": "Please document whether collection_site was GPS rounded.", + "createdAt": "2026-05-03T10:15:00Z" + }, + { + "id": "comment-2", + "projectId": "project-civic-survey", + "authorId": "u-ada", + "target": { + "kind": "notebook", + "path": "notebooks/weighting.ipynb", + "lineStart": 42 + }, + "mode": "anonymous", + "body": "The weighting cell should include a seed for deterministic reruns.", + "createdAt": "2026-05-04T12:20:00Z" + } + ], + "contributions": [ + { + "id": "contrib-1", + "projectId": "project-flood-microbiome", + "contributorId": "u-ada", + "type": "dataset", + "roles": ["data-curation", "investigation"], + "impact": 1.4, + "timestamp": "2026-05-01T09:00:00Z" + }, + { + "id": "contrib-2", + "projectId": "project-flood-microbiome", + "contributorId": "u-ada", + "type": "code", + "roles": ["software", "formal-analysis"], + "impact": 1.2, + "timestamp": "2026-05-02T09:00:00Z" + }, + { + "id": "contrib-3", + "projectId": "project-flood-microbiome", + "contributorId": "u-ravi", + "type": "review", + "roles": ["validation", "writing-review-editing"], + "impact": 1.1, + "timestamp": "2026-05-03T10:00:00Z" + }, + { + "id": "contrib-4", + "projectId": "project-civic-survey", + "contributorId": "u-maya", + "type": "authorship", + "roles": ["conceptualization", "methodology", "writing-original-draft"], + "impact": 1.5, + "timestamp": "2026-05-04T08:00:00Z" + }, + { + "id": "contrib-5", + "projectId": "project-civic-survey", + "contributorId": "u-ada", + "type": "review", + "roles": ["validation", "writing-review-editing"], + "impact": 1, + "timestamp": "2026-05-04T12:00:00Z" + } + ], + "endorsements": [ + { "from": "u-ravi", "to": "u-ada", "weight": 5, "status": "active" }, + { "from": "u-maya", "to": "u-ada", "weight": 4, "status": "active" }, + { "from": "u-ada", "to": "u-ada", "weight": 5, "status": "active" }, + { "from": "u-ada", "to": "u-ravi", "weight": 4, "status": "active" } + ], + "metrics": { + "researchers": { + "u-ada": { + "previousReputation": 122, + "previousTier": "open-science-champion", + "citations": 34, + "forks": 8, + "reproducibilityBadges": 2, + "bountyCompletions": 1, + "flags": [] + }, + "u-ravi": { + "previousReputation": 48, + "previousTier": "active-collaborator", + "citations": 12, + "forks": 3, + "reproducibilityBadges": 1, + "bountyCompletions": 0, + "flags": [] + }, + "u-maya": { + "previousReputation": 44, + "previousTier": "emerging-contributor", + "citations": 18, + "forks": 2, + "reproducibilityBadges": 0, + "bountyCompletions": 0, + "flags": [{ "id": "flag-1", "severity": "low", "reason": "late review disclosure" }] + } + } + }, + "appeals": [ + { + "id": "appeal-1", + "researcherId": "u-maya", + "targetType": "metric-flag", + "targetId": "flag-1", + "status": "open", + "openedAt": "2026-05-05T08:00:00Z", + "reviewerGroup": "community-governance", + "requestedChange": "Review whether the late disclosure flag should expire after the corrected disclosure was added." + } + ] +} diff --git a/community-reputation-ledger/docs/demo.gif b/community-reputation-ledger/docs/demo.gif new file mode 100644 index 0000000..bcda284 Binary files /dev/null and b/community-reputation-ledger/docs/demo.gif differ diff --git a/community-reputation-ledger/docs/demo.mp4 b/community-reputation-ledger/docs/demo.mp4 new file mode 100644 index 0000000..839e43f Binary files /dev/null and b/community-reputation-ledger/docs/demo.mp4 differ diff --git a/community-reputation-ledger/docs/demo.svg b/community-reputation-ledger/docs/demo.svg new file mode 100644 index 0000000..fbab838 --- /dev/null +++ b/community-reputation-ledger/docs/demo.svg @@ -0,0 +1,33 @@ + + Community Reputation Ledger Demo + Visual demo for peer review, contribution credit, reputation scoring, badges, and leaderboards. + + + Community Reputation Ledger + Peer review · CRediT attribution · transparent scores · badges + + Review Modes + 4 + public to double-blind + + CRediT Roles + 14 + validated contribution types + + Leaderboards + 3 + domain · region · institution + + Transparent Score + citations + forks + endorsements + reviews + reproducibility + credit - penalties + Self-endorsements are ignored before badge and tier assignment. + diff --git a/community-reputation-ledger/docs/issue-15-requirement-map.md b/community-reputation-ledger/docs/issue-15-requirement-map.md new file mode 100644 index 0000000..6196621 --- /dev/null +++ b/community-reputation-ledger/docs/issue-15-requirement-map.md @@ -0,0 +1,30 @@ +# Issue #15 Requirement Map + +This module implements a deterministic community and reputation milestone for SCIBASE issue #15. It focuses on structured peer review, inline comments, contributor credit, transparent scoring, leaderboards, badges, and incentive tiers. + +| Issue requirement | Implementation | +| --- | --- | +| Structured peer reviews | `selectReviewTemplate()` and `createPeerReview()` support discipline-specific criteria, scores, narrative fields, recommendations, and review history hashes. | +| Public, semi-private, anonymous, and double-blind modes | `createPeerReview()` normalizes review visibility and anonymizes reviewer identity for anonymous and double-blind reviews. | +| Inline comments on documents, datasets, code blocks, and notebooks | `createInlineComment()` stores target kind, path, anchors, line ranges, visibility mode, status, and comment hashes. | +| Review history on reviewer profiles and project timelines | `buildResearcherProfiles()` exposes per-researcher review/comment history, while `buildProjectTimelines()` combines reviews, comments, and contributions into chronological project events. | +| Timestamped contributor credits | `buildContributionLedger()` creates timestamped contribution records with stable hashes and citation visibility. | +| CRediT taxonomy support | Contribution roles are validated against the CRediT role list exported as `CREDIT_ROLES`. | +| Git-style contributor graphs | `buildContributorGraph()` aggregates contributor/project edges, role counts, contribution counts, and credit totals. | +| Visible credit on researcher profiles and citation pages | `buildResearcherProfiles()` returns visible citation credits for each researcher, and `buildCitationPages()` produces project-level credit lists and citation text. | +| Transparent reputation metrics | `scoreResearcher()` exposes score components for citations, forks, endorsements, peer review, reproducibility badges, bounty completions, contribution credit, and penalties. | +| Abuse-resistant moderation hooks | `buildModerationSignals()` flags self-endorsements, reciprocal endorsements, thin review narratives, and flagged researcher metrics before scores are trusted. | +| Trustworthy score governance | `buildGovernanceReport()` audits review quality, reputation score deltas, open appeals, appeal due dates, and required governance actions before reputation changes are published. | +| Leaderboards by domain, region, and institution | `buildLeaderboards()` groups researchers by a requested dimension and ranks them deterministically. | +| Badge and incentive tiers | `assignBadges()` and `assignTier()` surface Trusted Reviewer, Reproducibility Verified, Challenge Finisher, and Open Science Champion outcomes through `scoreResearcher()`. | +| Reviewer demo | `npm run demo` prints review counts, contribution counts, top-ranked researcher, incentive tiers, and packet hash. | + +## Verification + +```bash +npm run check +npm test +npm run demo +``` + +The module is dependency-free and isolated under `community-reputation-ledger/`. diff --git a/community-reputation-ledger/package.json b/community-reputation-ledger/package.json new file mode 100644 index 0000000..ca52700 --- /dev/null +++ b/community-reputation-ledger/package.json @@ -0,0 +1,12 @@ +{ + "name": "scibase-community-reputation-ledger", + "version": "0.1.0", + "private": true, + "description": "Community review, contribution credit, and reputation scoring module for SCIBASE issue #15.", + "type": "commonjs", + "scripts": { + "check": "node --check src/community-reputation-ledger.js && node --check scripts/demo.js && node --check test/community-reputation-ledger.test.js", + "demo": "node scripts/demo.js", + "test": "node test/community-reputation-ledger.test.js" + } +} diff --git a/community-reputation-ledger/scripts/demo.js b/community-reputation-ledger/scripts/demo.js new file mode 100644 index 0000000..8377e76 --- /dev/null +++ b/community-reputation-ledger/scripts/demo.js @@ -0,0 +1,39 @@ +"use strict"; + +const community = require("../data/sample-community.json"); +const { buildCommunityReputationPacket } = require("../src/community-reputation-ledger"); + +const packet = buildCommunityReputationPacket(community); +const topDomain = packet.leaderboards.domain.find((leaderboard) => leaderboard.group === "biology"); + +console.log( + JSON.stringify( + { + reviewTemplates: packet.reviewTemplates.length, + reviews: packet.reviews.length, + comments: packet.comments.length, + creditedContributions: packet.contributionLedger.length, + researcherProfiles: packet.researcherProfiles.length, + projectTimelines: packet.projectTimelines.map((timeline) => ({ + projectId: timeline.projectId, + eventCount: timeline.eventCount, + })), + citationPages: packet.citationPages.map((page) => ({ + projectId: page.projectId, + creditCount: page.credits.length, + })), + topBiologyResearcher: topDomain.entries[0], + moderation: packet.moderation, + governance: { + status: packet.governance.status, + requiredActions: packet.governance.requiredActions.length, + openAppeals: packet.governance.appeals.filter((appeal) => appeal.status === "open").length, + firstAppealDueBy: packet.governance.appeals[0] ? packet.governance.appeals[0].dueBy : null, + }, + incentiveTiers: packet.incentiveTiers, + packetHash: packet.packetHash, + }, + null, + 2, + ), +); diff --git a/community-reputation-ledger/src/community-reputation-ledger.js b/community-reputation-ledger/src/community-reputation-ledger.js new file mode 100644 index 0000000..ee2ea2a --- /dev/null +++ b/community-reputation-ledger/src/community-reputation-ledger.js @@ -0,0 +1,736 @@ +"use strict"; + +const crypto = require("crypto"); + +const REVIEW_VISIBILITY = new Set(["public", "semi-private", "anonymous", "double-blind"]); +const CREDIT_ROLES = [ + "conceptualization", + "data-curation", + "formal-analysis", + "funding-acquisition", + "investigation", + "methodology", + "project-administration", + "resources", + "software", + "supervision", + "validation", + "visualization", + "writing-original-draft", + "writing-review-editing", +]; + +const REVIEW_TEMPLATES = { + biology: ["clarity", "methodology", "data-quality", "reproducibility", "novelty"], + physics: ["clarity", "rigor", "assumptions", "reproducibility", "novelty"], + "social-sciences": ["clarity", "ethics", "sampling", "reproducibility", "impact"], + general: ["clarity", "rigor", "novelty", "reproducibility"], +}; + +const CONTRIBUTION_WEIGHTS = { + authorship: 12, + protocol: 7, + dataset: 9, + curation: 6, + code: 8, + analysis: 8, + review: 5, + comment: 2, + issue: 4, + reproducibility: 10, + bounty: 12, +}; + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +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 hashRecord(value) { + return crypto.createHash("sha256").update(stableStringify(value)).digest("hex").slice(0, 20); +} + +function normalizeScore(value) { + const score = Number(value); + if (!Number.isFinite(score)) return 0; + return Math.max(0, Math.min(5, score)); +} + +function average(values) { + const usable = values.filter((value) => Number.isFinite(value)); + return usable.length ? usable.reduce((sum, value) => sum + value, 0) / usable.length : 0; +} + +function normalizeCommunity(input) { + if (!input || typeof input !== "object") throw new TypeError("community data must be an object"); + return { + researchers: asArray(input.researchers), + projects: asArray(input.projects), + reviews: asArray(input.reviews), + comments: asArray(input.comments), + contributions: asArray(input.contributions), + endorsements: asArray(input.endorsements), + appeals: asArray(input.appeals), + metrics: input.metrics || {}, + }; +} + +function selectReviewTemplate(discipline = "general") { + const criteria = REVIEW_TEMPLATES[discipline] || REVIEW_TEMPLATES.general; + return { + discipline: REVIEW_TEMPLATES[discipline] ? discipline : "general", + criteria, + maxScore: 5, + requiredNarrativeFields: ["summary", "strengths", "concerns", "recommendation"], + }; +} + +function createPeerReview(reviewInput) { + const template = selectReviewTemplate(reviewInput.discipline); + const visibility = REVIEW_VISIBILITY.has(reviewInput.visibility) ? reviewInput.visibility : "public"; + const scores = Object.fromEntries( + template.criteria.map((criterion) => [criterion, normalizeScore((reviewInput.scores || {})[criterion])]), + ); + const scoreAverage = Number(average(Object.values(scores)).toFixed(4)); + const reviewerVisible = visibility === "public" || visibility === "semi-private"; + + return { + id: reviewInput.id || `review-${hashRecord(reviewInput)}`, + projectId: reviewInput.projectId, + reviewerId: reviewerVisible ? reviewInput.reviewerId : null, + reviewerAlias: + reviewerVisible ? reviewInput.reviewerId : `anonymous-${hashRecord({ reviewerId: reviewInput.reviewerId }).slice(0, 8)}`, + discipline: template.discipline, + visibility, + scores, + scoreAverage, + recommendation: reviewInput.recommendation || "revise", + narrative: { + summary: reviewInput.summary || "", + strengths: asArray(reviewInput.strengths), + concerns: asArray(reviewInput.concerns), + }, + createdAt: reviewInput.createdAt || new Date().toISOString(), + reviewHash: hashRecord({ + projectId: reviewInput.projectId, + reviewerId: reviewInput.reviewerId, + scores, + recommendation: reviewInput.recommendation || "revise", + createdAt: reviewInput.createdAt || null, + }), + }; +} + +function createInlineComment(commentInput) { + const target = commentInput.target || {}; + const mode = REVIEW_VISIBILITY.has(commentInput.mode) ? commentInput.mode : "public"; + const authorVisible = mode === "public" || mode === "semi-private"; + return { + id: commentInput.id || `comment-${hashRecord(commentInput)}`, + projectId: commentInput.projectId, + authorId: authorVisible ? commentInput.authorId : null, + authorAlias: + authorVisible ? commentInput.authorId : `anonymous-${hashRecord({ authorId: commentInput.authorId }).slice(0, 8)}`, + target: { + kind: target.kind || "document", + path: target.path || "manuscript/main.md", + anchor: target.anchor || null, + lineStart: target.lineStart || null, + lineEnd: target.lineEnd || target.lineStart || null, + }, + mode, + status: commentInput.status || "open", + body: commentInput.body || "", + createdAt: commentInput.createdAt || new Date().toISOString(), + commentHash: hashRecord({ + projectId: commentInput.projectId, + authorId: commentInput.authorId, + target, + body: commentInput.body || "", + }), + }; +} + +function buildContributionLedger(communityInput) { + const community = normalizeCommunity(communityInput); + return community.contributions.map((contribution) => { + const roles = asArray(contribution.roles).filter((role) => CREDIT_ROLES.includes(role)); + const baseWeight = CONTRIBUTION_WEIGHTS[contribution.type] || 1; + const credit = Number((baseWeight * Math.max(1, roles.length || 1) * Number(contribution.impact || 1)).toFixed(4)); + + return { + id: contribution.id || `contribution-${hashRecord(contribution)}`, + projectId: contribution.projectId, + contributorId: contribution.contributorId, + type: contribution.type, + roles, + timestamp: contribution.timestamp || new Date().toISOString(), + credit, + citationVisible: contribution.citationVisible !== false, + contributionHash: hashRecord({ contribution, roles, credit }), + }; + }); +} + +function buildContributorGraph(communityInput) { + const ledger = buildContributionLedger(communityInput); + const byContributor = new Map(); + const projectEdges = new Map(); + + for (const entry of ledger) { + if (!byContributor.has(entry.contributorId)) { + byContributor.set(entry.contributorId, { + contributorId: entry.contributorId, + totalCredit: 0, + contributionCount: 0, + roleCounts: {}, + projects: new Set(), + }); + } + const contributor = byContributor.get(entry.contributorId); + contributor.totalCredit = Number((contributor.totalCredit + entry.credit).toFixed(4)); + contributor.contributionCount += 1; + contributor.projects.add(entry.projectId); + for (const role of entry.roles) { + contributor.roleCounts[role] = (contributor.roleCounts[role] || 0) + 1; + } + + const edgeId = `${entry.contributorId}->${entry.projectId}`; + projectEdges.set(edgeId, { + contributorId: entry.contributorId, + projectId: entry.projectId, + weight: Number(((projectEdges.get(edgeId) || {}).weight || 0) + entry.credit), + }); + } + + return { + contributors: Array.from(byContributor.values()).map((contributor) => ({ + ...contributor, + projects: Array.from(contributor.projects).sort(), + })), + edges: Array.from(projectEdges.values()).map((edge) => ({ + ...edge, + weight: Number(edge.weight.toFixed(4)), + })), + }; +} + +function scoreResearcher(communityInput, researcherId) { + const community = normalizeCommunity(communityInput); + const researcher = community.researchers.find((candidate) => candidate.id === researcherId) || { id: researcherId }; + const ledger = buildContributionLedger(community); + const researcherReviews = community.reviews + .map(createPeerReview) + .filter((review) => review.reviewerId === researcherId || review.reviewerAlias.endsWith(hashRecord({ reviewerId: researcherId }).slice(0, 8))); + const researcherContributions = ledger.filter((entry) => entry.contributorId === researcherId); + const validEndorsements = community.endorsements.filter( + (endorsement) => endorsement.to === researcherId && endorsement.from !== researcherId && endorsement.status !== "revoked", + ); + const metric = (community.metrics.researchers || {})[researcherId] || {}; + const penalties = asArray(metric.flags).reduce((total, flag) => total + (flag.severity === "high" ? 12 : 4), 0); + + const components = { + citationImpact: Number(metric.citations || 0) * 0.8, + forkImpact: Number(metric.forks || 0) * 2.2, + endorsementImpact: validEndorsements.reduce((sum, endorsement) => sum + normalizeScore(endorsement.weight || 1), 0) * 1.5, + peerReviewImpact: + researcherReviews.length * 3 + average(researcherReviews.map((review) => review.scoreAverage)) * 4, + reproducibilityImpact: Number(metric.reproducibilityBadges || 0) * 9, + bountyImpact: Number(metric.bountyCompletions || 0) * 12, + contributionImpact: researcherContributions.reduce((sum, entry) => sum + entry.credit, 0) * 0.7, + penaltyImpact: -penalties, + }; + const total = Number(Math.max(0, Object.values(components).reduce((sum, value) => sum + value, 0)).toFixed(4)); + + return { + researcherId, + displayName: researcher.displayName || researcherId, + domain: researcher.domain || "general", + region: researcher.region || "global", + institution: researcher.institution || "independent", + components, + total, + tier: assignTier(total), + badges: assignBadges({ total, components, metric, reviews: researcherReviews, contributions: researcherContributions }), + transparencyHash: hashRecord({ researcherId, components, total }), + }; +} + +function assignTier(score) { + if (score >= 120) return "open-science-champion"; + if (score >= 80) return "trusted-reviewer"; + if (score >= 45) return "active-collaborator"; + return "emerging-contributor"; +} + +function assignBadges({ total, components, metric, reviews, contributions }) { + const badges = []; + if (reviews.length >= 2 || components.peerReviewImpact >= 15) badges.push("Trusted Reviewer"); + if (Number(metric.reproducibilityBadges || 0) > 0) badges.push("Reproducibility Verified"); + if (components.contributionImpact >= 25) badges.push("CRediT Power Contributor"); + if (Number(metric.bountyCompletions || 0) > 0) badges.push("Challenge Finisher"); + if (contributions.some((entry) => entry.roles.includes("data-curation"))) badges.push("Data Steward"); + if (total >= 120) badges.push("Open Science Champion"); + return badges; +} + +function buildLeaderboards(communityInput, dimension = "domain") { + const community = normalizeCommunity(communityInput); + const scores = community.researchers.map((researcher) => scoreResearcher(community, researcher.id)); + const groups = new Map(); + for (const score of scores) { + const group = score[dimension] || "global"; + if (!groups.has(group)) groups.set(group, []); + groups.get(group).push(score); + } + + return Array.from(groups.entries()).map(([group, entries]) => ({ + dimension, + group, + entries: entries + .sort((left, right) => right.total - left.total || left.displayName.localeCompare(right.displayName)) + .map((entry, index) => ({ + rank: index + 1, + researcherId: entry.researcherId, + displayName: entry.displayName, + total: entry.total, + tier: entry.tier, + badges: entry.badges, + })), + })); +} + +function buildResearcherProfiles(communityInput) { + const community = normalizeCommunity(communityInput); + const contributionLedger = buildContributionLedger(community); + const comments = community.comments.map(createInlineComment); + const reviews = community.reviews.map(createPeerReview); + + return community.researchers.map((researcher) => { + const score = scoreResearcher(community, researcher.id); + const profileContributions = contributionLedger.filter((entry) => entry.contributorId === researcher.id); + const profileReviews = reviews.filter( + (review) => + review.reviewerId === researcher.id || + review.reviewerAlias.endsWith(hashRecord({ reviewerId: researcher.id }).slice(0, 8)), + ); + const profileComments = comments.filter( + (comment) => + comment.authorId === researcher.id || + comment.authorAlias.endsWith(hashRecord({ authorId: researcher.id }).slice(0, 8)), + ); + + return { + researcherId: researcher.id, + displayName: researcher.displayName || researcher.id, + domain: researcher.domain || "general", + institution: researcher.institution || "independent", + tier: score.tier, + badges: score.badges, + reviewHistory: profileReviews.map((review) => ({ + reviewId: review.id, + projectId: review.projectId, + visibility: review.visibility, + recommendation: review.recommendation, + scoreAverage: review.scoreAverage, + createdAt: review.createdAt, + })), + commentHistory: profileComments.map((comment) => ({ + commentId: comment.id, + projectId: comment.projectId, + target: comment.target, + mode: comment.mode, + status: comment.status, + createdAt: comment.createdAt, + })), + creditSummary: { + totalCredit: Number(profileContributions.reduce((sum, entry) => sum + entry.credit, 0).toFixed(4)), + visibleCitationCredits: profileContributions + .filter((entry) => entry.citationVisible) + .map((entry) => ({ + contributionId: entry.id, + projectId: entry.projectId, + roles: entry.roles, + type: entry.type, + credit: entry.credit, + timestamp: entry.timestamp, + })), + }, + profileHash: hashRecord({ researcherId: researcher.id, score, profileContributions, profileReviews, profileComments }), + }; + }); +} + +function buildProjectTimelines(communityInput) { + const community = normalizeCommunity(communityInput); + const contributionLedger = buildContributionLedger(community); + const reviews = community.reviews.map(createPeerReview); + const comments = community.comments.map(createInlineComment); + + return community.projects.map((project) => { + const events = [ + ...contributionLedger + .filter((entry) => entry.projectId === project.id) + .map((entry) => ({ + type: "contribution", + id: entry.id, + actorId: entry.contributorId, + timestamp: entry.timestamp, + summary: `${entry.type} contribution credited`, + hash: entry.contributionHash, + })), + ...reviews + .filter((review) => review.projectId === project.id) + .map((review) => ({ + type: "review", + id: review.id, + actorId: review.reviewerId || review.reviewerAlias, + timestamp: review.createdAt, + summary: `${review.visibility} review ${review.recommendation}`, + hash: review.reviewHash, + })), + ...comments + .filter((comment) => comment.projectId === project.id) + .map((comment) => ({ + type: "comment", + id: comment.id, + actorId: comment.authorId || comment.authorAlias, + timestamp: comment.createdAt, + summary: `${comment.mode} comment on ${comment.target.kind}`, + hash: comment.commentHash, + })), + ].sort((left, right) => String(left.timestamp).localeCompare(String(right.timestamp))); + + return { + projectId: project.id, + title: project.title || project.id, + visibility: project.visibility || "private", + eventCount: events.length, + events, + timelineHash: hashRecord({ projectId: project.id, events }), + }; + }); +} + +function buildCitationPages(communityInput) { + const community = normalizeCommunity(communityInput); + const profiles = buildResearcherProfiles(community); + const profileById = new Map(profiles.map((profile) => [profile.researcherId, profile])); + + return community.projects.map((project) => { + const credits = profiles.flatMap((profile) => + profile.creditSummary.visibleCitationCredits + .filter((credit) => credit.projectId === project.id) + .map((credit) => ({ + researcherId: profile.researcherId, + displayName: profile.displayName, + roles: credit.roles, + type: credit.type, + credit: credit.credit, + tier: profileById.get(profile.researcherId).tier, + })), + ); + + return { + projectId: project.id, + title: project.title || project.id, + credits: credits.sort((left, right) => right.credit - left.credit || left.displayName.localeCompare(right.displayName)), + citationText: credits + .map((credit) => `${credit.displayName} (${credit.roles.join(", ") || credit.type})`) + .join("; "), + citationHash: hashRecord({ projectId: project.id, credits }), + }; + }); +} + +function buildModerationSignals(communityInput) { + const community = normalizeCommunity(communityInput); + const signals = []; + const activeEndorsements = community.endorsements.filter((endorsement) => endorsement.status !== "revoked"); + const activePairs = new Set(activeEndorsements.map((endorsement) => `${endorsement.from}->${endorsement.to}`)); + + for (const endorsement of activeEndorsements) { + if (endorsement.from === endorsement.to) { + signals.push({ + type: "self-endorsement", + severity: "medium", + researcherId: endorsement.to, + message: "Self-endorsements are ignored by reputation scoring.", + }); + } else if (activePairs.has(`${endorsement.to}->${endorsement.from}`)) { + signals.push({ + type: "reciprocal-endorsement", + severity: "low", + researcherId: endorsement.to, + relatedResearcherId: endorsement.from, + message: "Reciprocal endorsements should be reviewed for collusion risk.", + }); + } + } + + for (const review of community.reviews.map(createPeerReview)) { + const narrativeLength = [ + review.narrative.summary, + ...review.narrative.strengths, + ...review.narrative.concerns, + ].join(" ").trim().length; + if (narrativeLength < 40) { + signals.push({ + type: "thin-review", + severity: "low", + reviewId: review.id, + message: "Structured score exists but the narrative is short.", + }); + } + } + + for (const [researcherId, metric] of Object.entries(community.metrics.researchers || {})) { + for (const flag of asArray(metric.flags)) { + signals.push({ + type: "metric-flag", + severity: flag.severity === "high" ? "high" : "medium", + researcherId, + flagId: flag.id || null, + message: flag.reason || "Researcher metric flag requires review.", + }); + } + } + + return { + status: signals.some((signal) => signal.severity === "high") ? "needs-action" : signals.length ? "review" : "clear", + signals, + moderationHash: hashRecord(signals), + }; +} + +function addDays(timestamp, days) { + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) return null; + date.setUTCDate(date.getUTCDate() + days); + return date.toISOString(); +} + +function countWords(value) { + return String(value || "") + .trim() + .split(/\s+/) + .filter(Boolean).length; +} + +function buildReviewQualityAudits(communityInput) { + const community = normalizeCommunity(communityInput); + return community.reviews.map((reviewInput) => { + const review = createPeerReview(reviewInput); + const missingScores = Object.entries(review.scores) + .filter(([, score]) => score === 0) + .map(([criterion]) => criterion); + const narrativeWordCount = [ + review.narrative.summary, + ...review.narrative.strengths, + ...review.narrative.concerns, + ].reduce((sum, entry) => sum + countWords(entry), 0); + const missingNarrative = []; + if (countWords(review.narrative.summary) < 6) missingNarrative.push("summary"); + if (review.narrative.strengths.length === 0) missingNarrative.push("strengths"); + if (review.narrative.concerns.length === 0) missingNarrative.push("concerns"); + + const scoreSpread = Number( + (Math.max(...Object.values(review.scores)) - Math.min(...Object.values(review.scores))).toFixed(4), + ); + const qualityScore = Math.max( + 0, + 100 - missingScores.length * 18 - missingNarrative.length * 14 - (narrativeWordCount < 30 ? 12 : 0), + ); + + return { + reviewId: review.id, + projectId: review.projectId, + reviewerAlias: review.reviewerAlias, + visibility: review.visibility, + missingScores, + missingNarrative, + narrativeWordCount, + scoreSpread, + qualityScore, + status: qualityScore >= 80 ? "accepted" : qualityScore >= 60 ? "needs-editor-check" : "needs-revision", + auditHash: hashRecord({ review, missingScores, missingNarrative, narrativeWordCount, qualityScore }), + }; + }); +} + +function buildAppealDocket(communityInput) { + const community = normalizeCommunity(communityInput); + return community.appeals.map((appeal) => ({ + id: appeal.id || `appeal-${hashRecord(appeal)}`, + researcherId: appeal.researcherId, + targetType: appeal.targetType || "reputation-score", + targetId: appeal.targetId || appeal.researcherId, + status: appeal.status || "open", + openedAt: appeal.openedAt || null, + dueBy: appeal.dueBy || (appeal.openedAt ? addDays(appeal.openedAt, 14) : null), + reviewerGroup: appeal.reviewerGroup || "community-governance", + requestedChange: appeal.requestedChange || "", + evidenceHash: hashRecord({ + researcherId: appeal.researcherId, + targetType: appeal.targetType || "reputation-score", + targetId: appeal.targetId || appeal.researcherId, + requestedChange: appeal.requestedChange || "", + openedAt: appeal.openedAt || null, + }), + })); +} + +function buildReputationChangeLedger(communityInput) { + const community = normalizeCommunity(communityInput); + const moderation = buildModerationSignals(community); + return community.researchers.map((researcher) => { + const score = scoreResearcher(community, researcher.id); + const metric = (community.metrics.researchers || {})[researcher.id] || {}; + const previousTotal = Number(metric.previousReputation || 0); + const delta = Number((score.total - previousTotal).toFixed(4)); + const researcherSignals = moderation.signals.filter((signal) => signal.researcherId === researcher.id); + const highRisk = researcherSignals.some((signal) => signal.severity === "high"); + const largeDelta = Math.abs(delta) >= 35; + + return { + researcherId: researcher.id, + displayName: score.displayName, + previousTotal, + currentTotal: score.total, + delta, + previousTier: metric.previousTier || null, + currentTier: score.tier, + status: highRisk || largeDelta ? "review-required" : "published", + reason: highRisk + ? "High-severity moderation signal is attached to this researcher." + : largeDelta + ? "Large reputation delta requires governance review before promotion surfaces update." + : "Transparent score change can be published.", + moderationSignalCount: researcherSignals.length, + changeHash: hashRecord({ researcherId: researcher.id, components: score.components, previousTotal, delta }), + }; + }); +} + +function buildGovernanceReport(communityInput) { + const reviewQuality = buildReviewQualityAudits(communityInput); + const reputationChanges = buildReputationChangeLedger(communityInput); + const appeals = buildAppealDocket(communityInput); + + const requiredActions = [ + ...reviewQuality + .filter((audit) => audit.status !== "accepted") + .map((audit) => ({ + type: "review-quality", + severity: audit.status === "needs-revision" ? "high" : "medium", + targetId: audit.reviewId, + message: "Review needs additional scores or narrative before it should affect reputation.", + })), + ...reputationChanges + .filter((change) => change.status === "review-required") + .map((change) => ({ + type: "reputation-change", + severity: "medium", + targetId: change.researcherId, + message: change.reason, + })), + ...appeals + .filter((appeal) => appeal.status === "open") + .map((appeal) => ({ + type: "appeal", + severity: "medium", + targetId: appeal.id, + message: `Resolve appeal by ${appeal.dueBy || "the governance SLA"}.`, + })), + ]; + + return { + status: requiredActions.some((action) => action.severity === "high") + ? "blocked" + : requiredActions.length + ? "needs-governance-review" + : "clear", + reviewQuality, + reputationChanges, + appeals, + requiredActions, + governanceHash: hashRecord({ reviewQuality, reputationChanges, appeals, requiredActions }), + }; +} + +function buildCommunityReputationPacket(communityInput) { + const community = normalizeCommunity(communityInput); + const reviews = community.reviews.map(createPeerReview); + const comments = community.comments.map(createInlineComment); + const contributionLedger = buildContributionLedger(community); + const contributorGraph = buildContributorGraph(community); + const reputationScores = community.researchers.map((researcher) => scoreResearcher(community, researcher.id)); + const researcherProfiles = buildResearcherProfiles(community); + const projectTimelines = buildProjectTimelines(community); + const citationPages = buildCitationPages(community); + const moderation = buildModerationSignals(community); + const governance = buildGovernanceReport(community); + + return { + reviewTemplates: Object.keys(REVIEW_TEMPLATES).map(selectReviewTemplate), + reviews, + comments, + contributionLedger, + contributorGraph, + researcherProfiles, + projectTimelines, + citationPages, + reputationScores, + leaderboards: { + domain: buildLeaderboards(community, "domain"), + region: buildLeaderboards(community, "region"), + institution: buildLeaderboards(community, "institution"), + }, + moderation, + governance, + incentiveTiers: ["emerging-contributor", "active-collaborator", "trusted-reviewer", "open-science-champion"], + packetHash: hashRecord({ + reviews, + comments, + contributionLedger, + researcherProfiles, + projectTimelines, + citationPages, + reputationScores, + moderation, + governance, + }), + }; +} + +module.exports = { + CREDIT_ROLES, + REVIEW_TEMPLATES, + REVIEW_VISIBILITY, + buildCommunityReputationPacket, + buildContributionLedger, + buildContributorGraph, + buildCitationPages, + buildGovernanceReport, + buildLeaderboards, + buildModerationSignals, + buildProjectTimelines, + buildReputationChangeLedger, + buildResearcherProfiles, + buildReviewQualityAudits, + createInlineComment, + createPeerReview, + hashRecord, + scoreResearcher, + selectReviewTemplate, +}; diff --git a/community-reputation-ledger/test/community-reputation-ledger.test.js b/community-reputation-ledger/test/community-reputation-ledger.test.js new file mode 100644 index 0000000..88f6cc6 --- /dev/null +++ b/community-reputation-ledger/test/community-reputation-ledger.test.js @@ -0,0 +1,160 @@ +"use strict"; + +const assert = require("assert"); +const community = require("../data/sample-community.json"); +const { + buildCommunityReputationPacket, + buildCitationPages, + buildContributionLedger, + buildContributorGraph, + buildGovernanceReport, + buildLeaderboards, + buildModerationSignals, + buildProjectTimelines, + buildReputationChangeLedger, + buildResearcherProfiles, + buildReviewQualityAudits, + createInlineComment, + createPeerReview, + scoreResearcher, + selectReviewTemplate, +} = require("../src/community-reputation-ledger"); + +function testReviewTemplatesAndPrivacy() { + const biology = selectReviewTemplate("biology"); + const doubleBlind = createPeerReview(community.reviews[1]); + + assert.ok(biology.criteria.includes("data-quality")); + assert.strictEqual(doubleBlind.reviewerId, null); + assert.ok(doubleBlind.reviewerAlias.startsWith("anonymous-")); + assert.strictEqual(doubleBlind.scoreAverage, 4.2); +} + +function testInlineComments() { + const comment = createInlineComment(community.comments[1]); + + assert.strictEqual(comment.target.kind, "notebook"); + assert.strictEqual(comment.target.lineStart, 42); + assert.strictEqual(comment.mode, "anonymous"); + assert.strictEqual(comment.authorId, null); + assert.ok(comment.authorAlias.startsWith("anonymous-")); + assert.ok(comment.commentHash); +} + +function testContributionLedgerAndGraph() { + const ledger = buildContributionLedger(community); + const graph = buildContributorGraph(community); + const ada = graph.contributors.find((contributor) => contributor.contributorId === "u-ada"); + + assert.strictEqual(ledger.length, 5); + assert.ok(ledger.every((entry) => entry.roles.length > 0)); + assert.ok(ada.totalCredit > 40); + assert.ok(ada.roleCounts["data-curation"]); + assert.ok(graph.edges.some((edge) => edge.contributorId === "u-ada" && edge.projectId === "project-flood-microbiome")); +} + +function testTransparentReputationScoring() { + const ada = scoreResearcher(community, "u-ada"); + const ravi = scoreResearcher(community, "u-ravi"); + const withoutSelfEndorsement = { + ...community, + endorsements: community.endorsements.filter((endorsement) => endorsement.from !== endorsement.to), + }; + const adaNoSelf = scoreResearcher(withoutSelfEndorsement, "u-ada"); + + assert.strictEqual(ada.components.endorsementImpact, adaNoSelf.components.endorsementImpact); + assert.ok(ada.total > ravi.total); + assert.ok(ada.badges.includes("Reproducibility Verified")); + assert.ok(ada.transparencyHash); +} + +function testModerationSignals() { + const moderation = buildModerationSignals(community); + const risky = buildModerationSignals({ + ...community, + metrics: { + researchers: { + ...community.metrics.researchers, + "u-ravi": { + ...community.metrics.researchers["u-ravi"], + flags: [{ id: "flag-high", severity: "high", reason: "review ring investigation" }], + }, + }, + }, + }); + + assert.strictEqual(moderation.status, "review"); + assert.ok(moderation.signals.some((signal) => signal.type === "self-endorsement")); + assert.ok(moderation.signals.some((signal) => signal.type === "reciprocal-endorsement")); + assert.strictEqual(risky.status, "needs-action"); + assert.ok(risky.moderationHash.length >= 12); +} + +function testGovernanceReport() { + const reviewQuality = buildReviewQualityAudits(community); + const reputationChanges = buildReputationChangeLedger(community); + const governance = buildGovernanceReport(community); + + assert.strictEqual(reviewQuality.length, community.reviews.length); + assert.ok(reviewQuality.every((audit) => audit.status === "accepted")); + assert.ok(reviewQuality.every((audit) => audit.auditHash.length >= 12)); + + const adaChange = reputationChanges.find((change) => change.researcherId === "u-ada"); + assert.strictEqual(adaChange.previousTotal, 122); + assert.strictEqual(adaChange.currentTier, "open-science-champion"); + assert.strictEqual(adaChange.status, "published"); + assert.ok(adaChange.changeHash.length >= 12); + + assert.strictEqual(governance.status, "needs-governance-review"); + assert.strictEqual(governance.appeals[0].dueBy, "2026-05-19T08:00:00.000Z"); + assert.ok(governance.requiredActions.some((action) => action.type === "appeal")); + assert.ok(governance.governanceHash.length >= 12); +} + +function testLeaderboardsAndPacket() { + const leaderboards = buildLeaderboards(community, "domain"); + const biology = leaderboards.find((leaderboard) => leaderboard.group === "biology"); + const packet = buildCommunityReputationPacket(community); + + assert.strictEqual(biology.entries[0].researcherId, "u-ada"); + assert.strictEqual(packet.reviews.length, community.reviews.length); + assert.strictEqual(packet.comments.length, community.comments.length); + assert.strictEqual(packet.moderation.status, "review"); + assert.strictEqual(packet.governance.status, "needs-governance-review"); + assert.ok(packet.incentiveTiers.includes("trusted-reviewer")); + assert.ok(packet.packetHash.length >= 12); +} + +function testProfilesTimelinesAndCitationPages() { + const profiles = buildResearcherProfiles(community); + const timelines = buildProjectTimelines(community); + const citationPages = buildCitationPages(community); + const ada = profiles.find((profile) => profile.researcherId === "u-ada"); + const floodTimeline = timelines.find((timeline) => timeline.projectId === "project-flood-microbiome"); + const floodCitation = citationPages.find((page) => page.projectId === "project-flood-microbiome"); + + assert.ok(ada.reviewHistory.some((review) => review.projectId === "project-civic-survey")); + assert.ok(ada.commentHistory.some((comment) => comment.projectId === "project-civic-survey")); + assert.ok(ada.creditSummary.visibleCitationCredits.length >= 2); + assert.ok(ada.profileHash.length >= 12); + + assert.strictEqual(floodTimeline.eventCount, floodTimeline.events.length); + assert.ok(floodTimeline.events.some((event) => event.type === "review")); + assert.ok(floodTimeline.events.some((event) => event.type === "comment")); + assert.ok(floodTimeline.timelineHash.length >= 12); + + assert.ok(floodCitation.credits.some((credit) => credit.researcherId === "u-ada")); + assert.ok(floodCitation.citationText.includes("Ada Chen")); + assert.ok(floodCitation.citationHash.length >= 12); +} + +testReviewTemplatesAndPrivacy(); +testInlineComments(); +testContributionLedgerAndGraph(); +testTransparentReputationScoring(); +testModerationSignals(); +testGovernanceReport(); +testLeaderboardsAndPacket(); +testProfilesTimelinesAndCitationPages(); + +console.log("community-reputation-ledger tests passed");