diff --git a/README.md b/README.md index d338cf68..4e16a884 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,4 @@ # deepevents.ai deepevents.ai main codebase + +- `credit-attestation-ledger/` adds contributor credit attestation and dispute routing for community reputation workflows. diff --git a/credit-attestation-ledger/README.md b/credit-attestation-ledger/README.md new file mode 100644 index 00000000..8fe2ad62 --- /dev/null +++ b/credit-attestation-ledger/README.md @@ -0,0 +1,43 @@ +# Credit Attestation Ledger + +This module adds a contributor-credit gate for the community and reputation layer. It validates CRediT-style contribution records, attached evidence, independent attestations, duplicate claims, and active disputes before profile credit or reputation points are released. + +It is self-contained and uses only Node built-ins. + +## What It Covers + +- CRediT role validation for contribution records +- Evidence checks for commits, uploads, reviews, and artifacts +- Attestation strength from project leads, reviewers, collaborators, and self claims +- Duplicate credit claim detection +- Open dispute routing for moderation +- Conflict checks for attestors +- Profile summaries and reputation deltas +- Signed ledger events and deterministic manifest digests + +## Run + +```sh +npm run check +npm test +npm run demo +``` + +The demo reads `data/sample-credit-input.json` and prints the credit queue. + +## Demo Artifact + +- `docs/demo.svg` +- `docs/demo.gif` +- `docs/demo.webm` + +## Main API + +```js +import { + evaluateCreditLedger, + renderCreditReport +} from "./src/credit-attestation-ledger.js"; +``` + +`evaluateCreditLedger(input)` returns dashboard metrics, ordered credit records, contributor profile summaries, signed events, and a manifest digest. diff --git a/credit-attestation-ledger/data/sample-credit-input.json b/credit-attestation-ledger/data/sample-credit-input.json new file mode 100644 index 00000000..cb41c271 --- /dev/null +++ b/credit-attestation-ledger/data/sample-credit-input.json @@ -0,0 +1,94 @@ +{ + "generatedAt": "2026-05-16T11:10:00.000Z", + "signingKey": "credit-demo-signing-key", + "knownConflicts": [ + { + "contributorId": "u-carol", + "attestorId": "u-pi-2", + "reason": "same lab funding line" + } + ], + "contributions": [ + { + "id": "CR-001", + "contributorId": "u-alex", + "contributorName": "Alex Rivera", + "projectId": "P-CELL-42", + "artifactId": "notebook-rerun-01", + "role": "software", + "timestamp": "2026-05-10T14:00:00.000Z", + "evidence": [ + {"type": "commit", "id": "a94df12", "summary": "rerun notebook pipeline"}, + {"type": "artifact", "id": "notebook-rerun-01", "summary": "clean execution output"} + ], + "attestations": [ + {"attestorId": "u-pi-1", "attestorRole": "lead", "statement": "Implemented the rerun script."}, + {"attestorId": "u-reviewer-1", "attestorRole": "reviewer", "statement": "Verified the submitted evidence."} + ], + "disputes": [] + }, + { + "id": "CR-002", + "contributorId": "u-bela", + "contributorName": "Bela Chen", + "projectId": "P-CELL-42", + "artifactId": "dataset-clean-02", + "role": "data-curation", + "timestamp": "2026-05-11T09:30:00.000Z", + "evidence": [], + "attestations": [ + {"attestorId": "u-bela", "attestorRole": "self", "statement": "Cleaned the dataset."} + ], + "disputes": [] + }, + { + "id": "CR-003", + "contributorId": "u-carol", + "contributorName": "Carol Singh", + "projectId": "P-META-7", + "artifactId": "review-report-3", + "role": "validation", + "timestamp": "2026-05-12T18:20:00.000Z", + "evidence": [ + {"type": "review", "id": "RV-88", "summary": "statistical validation notes"} + ], + "attestations": [ + {"attestorId": "u-pi-2", "attestorRole": "lead", "statement": "Reviewed the validation notes."} + ], + "attestorId": "u-pi-2", + "disputes": [ + {"id": "DSP-4", "status": "open", "reason": "role claimed by another reviewer"} + ] + }, + { + "id": "CR-004", + "contributorId": "u-bela", + "contributorName": "Bela Chen", + "projectId": "P-CELL-42", + "artifactId": "dataset-clean-02", + "role": "data-curation", + "timestamp": "2026-05-11T09:35:00.000Z", + "evidence": [ + {"type": "artifact", "id": "dataset-clean-02", "summary": "deduplicated data table"} + ], + "attestations": [ + {"attestorId": "u-reviewer-2", "attestorRole": "reviewer", "statement": "Confirmed curation work."} + ], + "disputes": [] + }, + { + "id": "CR-005", + "contributorId": "u-dev", + "contributorName": "Dev Novak", + "projectId": "P-META-7", + "artifactId": "figure-pack-9", + "role": "figure-polish", + "timestamp": "", + "evidence": [ + {"type": "artifact", "id": "figure-pack-9", "summary": "updated figures"} + ], + "attestations": [], + "disputes": [] + } + ] +} diff --git a/credit-attestation-ledger/docs/demo.gif b/credit-attestation-ledger/docs/demo.gif new file mode 100644 index 00000000..b521402c Binary files /dev/null and b/credit-attestation-ledger/docs/demo.gif differ diff --git a/credit-attestation-ledger/docs/demo.svg b/credit-attestation-ledger/docs/demo.svg new file mode 100644 index 00000000..35001fec --- /dev/null +++ b/credit-attestation-ledger/docs/demo.svg @@ -0,0 +1,35 @@ + + Credit attestation ledger demo + Dashboard preview showing verified, review, and disputed contributor credits. + + + Credit attestation ledger + Contributor credit checked before reputation points are released + + + Verified + 1 + + + Needs review + 3 + + + Disputed + 1 + + Moderation queue + + + CR-003 + open dispute blocks reputation credit + + + CR-002 + missing evidence and duplicate claim need review + + + CR-001 + verified software credit releases reputation + + diff --git a/credit-attestation-ledger/docs/demo.webm b/credit-attestation-ledger/docs/demo.webm new file mode 100644 index 00000000..ae2dff6e Binary files /dev/null and b/credit-attestation-ledger/docs/demo.webm differ diff --git a/credit-attestation-ledger/docs/requirement-map.md b/credit-attestation-ledger/docs/requirement-map.md new file mode 100644 index 00000000..d3df0021 --- /dev/null +++ b/credit-attestation-ledger/docs/requirement-map.md @@ -0,0 +1,36 @@ +# Requirement Map + +Maps this slice to issue #15, Community & User Reputation System. + +## Contributor Credits + +- Validates timestamped contribution records. +- Supports CRediT-style roles such as software, validation, data curation, and writing. +- Requires evidence records before visible profile credit is released. +- Produces contributor profile summaries with role counts and reputation deltas. + +## Peer Validation + +- Scores attestations from project leads, reviewers, collaborators, and self claims. +- Routes weak attestations to review instead of awarding automatic reputation. +- Detects conflicted attestations that need an independent reviewer. + +## Reputation Scoring + +- Releases reputation only for verified credit. +- Holds weak, duplicate, or unsupported claims for moderation. +- Blocks reputation changes while a dispute is active. + +## Community Timeline And Moderation + +- Detects duplicate credit claims for the same contributor, artifact, project, and role. +- Produces a moderation queue for unresolved disputes and review-needed credits. +- Signs ledger events for audit trails. + +## Local Verification + +```sh +npm run check +npm test +npm run demo +``` diff --git a/credit-attestation-ledger/package.json b/credit-attestation-ledger/package.json new file mode 100644 index 00000000..1c255713 --- /dev/null +++ b/credit-attestation-ledger/package.json @@ -0,0 +1,12 @@ +{ + "name": "credit-attestation-ledger", + "version": "1.0.0", + "description": "Contributor credit attestation ledger for community reputation workflows.", + "type": "module", + "scripts": { + "check": "node --check src/credit-attestation-ledger.js && node --check scripts/demo.js && node --check test/credit-attestation-ledger.test.js", + "test": "node --test", + "demo": "node scripts/demo.js" + }, + "license": "MIT" +} diff --git a/credit-attestation-ledger/scripts/demo.js b/credit-attestation-ledger/scripts/demo.js new file mode 100644 index 00000000..9d2e245b --- /dev/null +++ b/credit-attestation-ledger/scripts/demo.js @@ -0,0 +1,13 @@ +import path from "node:path"; +import {fileURLToPath} from "node:url"; +import { + evaluateCreditLedger, + readCreditInput, + renderCreditReport +} from "../src/credit-attestation-ledger.js"; + +const directory = path.dirname(fileURLToPath(import.meta.url)); +const inputPath = path.join(directory, "..", "data", "sample-credit-input.json"); +const result = evaluateCreditLedger(readCreditInput(inputPath)); + +console.log(renderCreditReport(result)); diff --git a/credit-attestation-ledger/src/credit-attestation-ledger.js b/credit-attestation-ledger/src/credit-attestation-ledger.js new file mode 100644 index 00000000..ac021b2c --- /dev/null +++ b/credit-attestation-ledger/src/credit-attestation-ledger.js @@ -0,0 +1,328 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; + +const CREDIT_ROLES = new Set([ + "conceptualization", + "data-curation", + "formal-analysis", + "funding-acquisition", + "investigation", + "methodology", + "project-administration", + "resources", + "software", + "supervision", + "validation", + "visualization", + "writing-original-draft", + "writing-review-editing" +]); + +const ROLE_SCORE = { + lead: 10, + reviewer: 7, + collaborator: 5, + self: 2 +}; + +const SEVERITY_SCORE = { + critical: 35, + high: 22, + medium: 12, + low: 5 +}; + +const stableStringify = value => { + if (Array.isArray(value)) { + return `[${value.map(item => stableStringify(item)).join(",")}]`; + } + + if (value && typeof value === "object") { + return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`; + } + + return JSON.stringify(value); +}; + +const digest = value => crypto.createHash("sha256").update(value).digest("hex"); + +const signLedgerEvent = (event, signingKey) => crypto + .createHmac("sha256", signingKey) + .update(stableStringify(event)) + .digest("hex"); + +const list = value => Array.isArray(value) ? value : []; + +const addFinding = (findings, severity, code, message, action) => { + findings.push({ + severity, + code, + score: SEVERITY_SCORE[severity], + message, + action + }); +}; + +const contributionKey = contribution => [ + contribution.contributorId, + contribution.projectId, + contribution.artifactId, + contribution.role +].join(":"); + +const evidenceCount = contribution => list(contribution.evidence).length; + +const attestationStrength = contribution => list(contribution.attestations) + .reduce((total, attestation) => total + (ROLE_SCORE[attestation.attestorRole] ?? 0), 0); + +const hasConflict = (contribution, knownConflicts) => knownConflicts + .some(conflict => conflict.contributorId === contribution.contributorId + && conflict.attestorId === contribution.attestorId); + +const buildContributionFindings = (contribution, duplicateCount, knownConflicts) => { + const findings = []; + + if (!CREDIT_ROLES.has(contribution.role)) { + addFinding( + findings, + "critical", + "unknown_credit_role", + `${contribution.id} uses unsupported credit role ${contribution.role}.`, + "Map the contribution to a supported CRediT role before profile display." + ); + } + + if (!contribution.timestamp) { + addFinding( + findings, + "medium", + "timestamp_missing", + `${contribution.id} is missing a timestamp.`, + "Record the timestamp so project timelines remain auditable." + ); + } + + if (evidenceCount(contribution) === 0) { + addFinding( + findings, + "high", + "evidence_missing", + `${contribution.id} has no artifact, commit, review, or upload evidence.`, + "Attach at least one evidence record." + ); + } + + if (attestationStrength(contribution) < 7) { + addFinding( + findings, + "medium", + "attestation_weak", + `${contribution.id} needs stronger independent attestation.`, + "Ask a project lead or reviewer to attest the contribution." + ); + } + + if (list(contribution.disputes).some(dispute => dispute.status !== "resolved")) { + addFinding( + findings, + "critical", + "active_credit_dispute", + `${contribution.id} has an unresolved credit dispute.`, + "Route the dispute to moderation before reputation credit is granted." + ); + } + + if (duplicateCount > 1) { + addFinding( + findings, + "high", + "duplicate_credit_claim", + `${contribution.id} overlaps another claim for the same contributor, artifact, project, and role.`, + "Merge or reject duplicate credit claims." + ); + } + + if (hasConflict(contribution, knownConflicts)) { + addFinding( + findings, + "medium", + "attestor_conflict", + `${contribution.id} includes an attestor conflict.`, + "Request an independent attestation." + ); + } + + return findings.sort((a, b) => b.score - a.score || a.code.localeCompare(b.code)); +}; + +const decideCreditState = findings => { + if (findings.some(finding => finding.code === "active_credit_dispute")) { + return "disputed"; + } + + if (findings.some(finding => finding.severity === "critical" || finding.severity === "high")) { + return "needs_review"; + } + + return "verified"; +}; + +const reputationDelta = (state, findings, contribution) => { + if (state === "disputed") { + return 0; + } + + const base = state === "verified" ? 12 : 4; + const roleBonus = contribution.role === "software" || contribution.role === "validation" ? 3 : 1; + const penalty = findings.reduce((total, finding) => total + Math.min(6, finding.score / 6), 0); + return Math.max(0, Math.round(base + roleBonus - penalty)); +}; + +const buildCreditRecord = (contribution, duplicateCount, knownConflicts, signingKey) => { + const findings = buildContributionFindings(contribution, duplicateCount, knownConflicts); + const state = decideCreditState(findings); + const event = { + type: "community.credit_attested", + contributionId: contribution.id, + contributorId: contribution.contributorId, + projectId: contribution.projectId, + artifactId: contribution.artifactId, + role: contribution.role, + state, + reputationDelta: reputationDelta(state, findings, contribution), + findingCount: findings.length + }; + + return { + contributionId: contribution.id, + contributorId: contribution.contributorId, + contributorName: contribution.contributorName, + projectId: contribution.projectId, + artifactId: contribution.artifactId, + role: contribution.role, + state, + evidenceDigest: digest(stableStringify(contribution.evidence ?? [])), + attestationStrength: attestationStrength(contribution), + reputationDelta: event.reputationDelta, + findings, + event: { + ...event, + signature: signLedgerEvent(event, signingKey) + } + }; +}; + +const buildDashboard = records => { + const dashboard = { + totalCredits: records.length, + verified: 0, + needsReview: 0, + disputed: 0, + reputationReleased: 0, + moderationQueue: 0 + }; + + for (const record of records) { + dashboard.verified += record.state === "verified" ? 1 : 0; + dashboard.needsReview += record.state === "needs_review" ? 1 : 0; + dashboard.disputed += record.state === "disputed" ? 1 : 0; + dashboard.reputationReleased += record.reputationDelta; + dashboard.moderationQueue += record.state === "verified" ? 0 : 1; + } + + return dashboard; +}; + +const buildContributorProfiles = records => { + const profiles = new Map(); + + for (const record of records) { + const profile = profiles.get(record.contributorId) ?? { + contributorId: record.contributorId, + contributorName: record.contributorName, + verifiedCredits: 0, + pendingCredits: 0, + disputedCredits: 0, + reputationDelta: 0, + roles: {} + }; + + profile.verifiedCredits += record.state === "verified" ? 1 : 0; + profile.pendingCredits += record.state === "needs_review" ? 1 : 0; + profile.disputedCredits += record.state === "disputed" ? 1 : 0; + profile.reputationDelta += record.reputationDelta; + profile.roles[record.role] = (profile.roles[record.role] ?? 0) + 1; + profiles.set(record.contributorId, profile); + } + + return [...profiles.values()].sort((a, b) => b.reputationDelta - a.reputationDelta || a.contributorName.localeCompare(b.contributorName)); +}; + +export const evaluateCreditLedger = input => { + const signingKey = input.signingKey ?? "local-credit-key"; + const duplicateCounts = new Map(); + + for (const contribution of list(input.contributions)) { + const key = contributionKey(contribution); + duplicateCounts.set(key, (duplicateCounts.get(key) ?? 0) + 1); + } + + const records = list(input.contributions) + .map(contribution => buildCreditRecord( + contribution, + duplicateCounts.get(contributionKey(contribution)) ?? 0, + list(input.knownConflicts), + signingKey + )) + .sort((a, b) => { + const stateRank = {disputed: 0, needs_review: 1, verified: 2}; + return stateRank[a.state] - stateRank[b.state] || a.contributionId.localeCompare(b.contributionId); + }); + + const manifest = { + generatedAt: input.generatedAt ?? new Date().toISOString(), + projectCount: new Set(records.map(record => record.projectId)).size, + creditCount: records.length, + recordDigests: records.map(record => digest(stableStringify({ + contributionId: record.contributionId, + state: record.state, + findings: record.findings.map(finding => finding.code) + }))) + }; + + return { + dashboard: buildDashboard(records), + records, + profiles: buildContributorProfiles(records), + manifest: { + ...manifest, + digest: digest(stableStringify(manifest)) + } + }; +}; + +export const renderCreditReport = result => { + const lines = [ + "Credit attestation ledger", + `Credits: ${result.dashboard.totalCredits}`, + `Verified: ${result.dashboard.verified}`, + `Needs review: ${result.dashboard.needsReview}`, + `Disputed: ${result.dashboard.disputed}`, + `Moderation queue: ${result.dashboard.moderationQueue}`, + "", + "Credit queue:" + ]; + + for (const record of result.records) { + lines.push(`- ${record.contributionId} (${record.contributorName}): ${record.role}, ${record.state}, reputation +${record.reputationDelta}`); + for (const finding of record.findings.slice(0, 2)) { + lines.push(` - ${finding.severity}: ${finding.message}`); + } + } + + lines.push(""); + lines.push(`Manifest digest: ${result.manifest.digest}`); + return lines.join("\n"); +}; + +export const readCreditInput = filePath => JSON.parse(fs.readFileSync(filePath, "utf8")); diff --git a/credit-attestation-ledger/test/credit-attestation-ledger.test.js b/credit-attestation-ledger/test/credit-attestation-ledger.test.js new file mode 100644 index 00000000..dbae7940 --- /dev/null +++ b/credit-attestation-ledger/test/credit-attestation-ledger.test.js @@ -0,0 +1,65 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + evaluateCreditLedger, + renderCreditReport +} from "../src/credit-attestation-ledger.js"; +import sampleInput from "../data/sample-credit-input.json" with {type: "json"}; + +test("builds dashboard counts for verified, review, and disputed credits", () => { + const result = evaluateCreditLedger(sampleInput); + + assert.equal(result.dashboard.totalCredits, 5); + assert.equal(result.dashboard.verified, 1); + assert.equal(result.dashboard.needsReview, 3); + assert.equal(result.dashboard.disputed, 1); + assert.equal(result.dashboard.moderationQueue, 4); +}); + +test("releases reputation only for well-attested credit", () => { + const result = evaluateCreditLedger(sampleInput); + const alex = result.records.find(record => record.contributionId === "CR-001"); + + assert.equal(alex.state, "verified"); + assert.equal(alex.reputationDelta, 15); + assert.equal(alex.attestationStrength, 17); + assert.equal(alex.findings.length, 0); +}); + +test("routes weak and duplicate credits to review", () => { + const result = evaluateCreditLedger(sampleInput); + const bela = result.records.find(record => record.contributionId === "CR-002"); + + assert.equal(bela.state, "needs_review"); + assert.ok(bela.findings.some(finding => finding.code === "evidence_missing")); + assert.ok(bela.findings.some(finding => finding.code === "duplicate_credit_claim")); +}); + +test("marks unresolved disputes as disputed", () => { + const result = evaluateCreditLedger(sampleInput); + const carol = result.records.find(record => record.contributionId === "CR-003"); + + assert.equal(carol.state, "disputed"); + assert.equal(carol.reputationDelta, 0); + assert.ok(carol.findings.some(finding => finding.code === "active_credit_dispute")); + assert.ok(carol.findings.some(finding => finding.code === "attestor_conflict")); +}); + +test("creates stable manifest and signatures", () => { + const first = evaluateCreditLedger(sampleInput); + const second = evaluateCreditLedger(sampleInput); + + assert.equal(first.manifest.digest, second.manifest.digest); + assert.deepEqual( + first.records.map(record => record.event.signature), + second.records.map(record => record.event.signature) + ); +}); + +test("renders a reviewer report", () => { + const report = renderCreditReport(evaluateCreditLedger(sampleInput)); + + assert.match(report, /Credit attestation ledger/); + assert.match(report, /CR-003/); + assert.match(report, /Manifest digest/); +});