diff --git a/README.md b/README.md index d338cf6..867af59 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,6 @@ # deepevents.ai deepevents.ai main codebase + +## Community tooling additions + +- `reputation-transparency-receipts`: reviewer-safe reputation receipts for structured peer reviews, CRediT contribution attribution, blinded visibility, leaderboards, tiers, and moderation evidence. diff --git a/reputation-transparency-receipts/README.md b/reputation-transparency-receipts/README.md new file mode 100644 index 0000000..85f5e4f --- /dev/null +++ b/reputation-transparency-receipts/README.md @@ -0,0 +1,26 @@ +# Reputation Transparency Receipts + +This module is a self-contained milestone for SCIBASE issue #15, Community and User Reputation System. It focuses on transparent reputation evidence rather than a broad social feed implementation. + +It models structured peer-review receipts, CRediT-style contribution records, anonymous and semi-private review visibility, endorsement quality, reproducibility badges, bounty completions, leaderboards, tiers, and moderator findings. The goal is to make every reputation point explainable from a reviewer-safe receipt. + +## What It Covers + +- Structured peer review scores for clarity, rigor, novelty, and reproducibility. +- Public, semi-private, anonymous, and double-blind review receipts. +- Inline comment anchors against manuscripts, datasets, code, or notebooks. +- CRediT-style contribution attribution with verified artifact receipts. +- Transparent reputation signal breakdowns and incentive tiers. +- Domain, institution, and global leaderboards. +- Abuse and privacy findings for self endorsement, thin high-score reviews, reciprocal endorsement rings, and anonymous identity leaks. +- Deterministic evidence digests for audit trails and project timelines. + +## Run It + +```bash +npm run check +npm test +npm run demo +``` + +All logic is dependency-free and uses only Node.js built-ins. diff --git a/reputation-transparency-receipts/data/sample-community-input.json b/reputation-transparency-receipts/data/sample-community-input.json new file mode 100644 index 0000000..947f1df --- /dev/null +++ b/reputation-transparency-receipts/data/sample-community-input.json @@ -0,0 +1,132 @@ +{ + "generatedAt": "2026-05-16T15:20:00.000Z", + "community": "SCIBASE beta contributor community", + "users": [ + { + "id": "usr-ada", + "handle": "Ada Chen", + "domain": "biology", + "institution": "North Lab", + "lastActiveAt": "2026-05-15T12:00:00.000Z" + }, + { + "id": "usr-ben", + "handle": "Ben Ortiz", + "domain": "biology", + "institution": "North Lab", + "lastActiveAt": "2026-05-01T09:00:00.000Z" + }, + { + "id": "usr-cora", + "handle": "Cora Singh", + "domain": "physics", + "institution": "River Institute", + "lastActiveAt": "2026-04-28T18:00:00.000Z" + } + ], + "reviews": [ + { + "id": "rev-manuscript-1", + "reviewerId": "usr-ada", + "projectId": "proj-organoid-map", + "mode": "public", + "scores": { + "clarity": 4.4, + "rigor": 4.7, + "novelty": 4.1, + "reproducibility": 4.6 + }, + "evidenceLinks": ["claim-2", "dataset-v3", "notebook-run-17"], + "commentAnchors": ["methods:line-42", "results:figure-2"], + "createdAt": "2026-05-12T08:00:00.000Z" + }, + { + "id": "rev-doubleblind-7", + "reviewerId": "usr-ben", + "projectId": "proj-neurochip", + "mode": "double_blind", + "visibleTo": ["editor-board"], + "scores": { + "clarity": 4.9, + "rigor": 4.8, + "novelty": 4.8, + "reproducibility": 4.7 + }, + "evidenceLinks": ["protocol-9"], + "commentAnchors": ["discussion:limitation-1"], + "createdAt": "2026-05-11T17:30:00.000Z" + } + ], + "contributions": [ + { + "id": "cred-001", + "userId": "usr-ada", + "projectId": "proj-organoid-map", + "creditRole": "data_curation", + "artifactIds": ["dataset-v3", "schema-v2"], + "verifiedBy": "editor-board", + "createdAt": "2026-05-08T10:00:00.000Z" + }, + { + "id": "cred-002", + "userId": "usr-cora", + "projectId": "proj-neurochip", + "creditRole": "software", + "artifactIds": ["analysis-cli", "notebook-run-17"], + "verifiedBy": "editor-board", + "createdAt": "2026-05-09T10:00:00.000Z" + }, + { + "id": "cred-003", + "userId": "usr-ben", + "projectId": "proj-neurochip", + "creditRole": "peer_review", + "artifactIds": ["rev-doubleblind-7"], + "createdAt": "2026-05-11T17:45:00.000Z" + } + ], + "endorsements": [ + { + "id": "end-001", + "fromUserId": "usr-ben", + "toUserId": "usr-ada", + "projectId": "proj-organoid-map", + "reason": "High quality methods review", + "createdAt": "2026-05-13T10:00:00.000Z" + }, + { + "id": "end-002", + "fromUserId": "usr-cora", + "toUserId": "usr-ada", + "projectId": "proj-organoid-map", + "reason": "Reusable dataset curation", + "createdAt": "2026-05-13T11:00:00.000Z" + }, + { + "id": "end-003", + "fromUserId": "usr-ben", + "toUserId": "usr-ben", + "projectId": "proj-neurochip", + "reason": "Self boost", + "createdAt": "2026-05-13T12:00:00.000Z" + } + ], + "reproducibilityBadges": [ + { + "id": "badge-organoid-replay", + "userId": "usr-cora", + "projectId": "proj-organoid-map", + "verified": true, + "verifiedAt": "2026-05-14T15:00:00.000Z" + } + ], + "bountyCompletions": [ + { + "id": "bounty-peer-review-template", + "userId": "usr-ada", + "projectId": "proj-organoid-map", + "amount": 150, + "verified": true + } + ] +} diff --git a/reputation-transparency-receipts/docs/demo.mp4 b/reputation-transparency-receipts/docs/demo.mp4 new file mode 100644 index 0000000..2defbf5 Binary files /dev/null and b/reputation-transparency-receipts/docs/demo.mp4 differ diff --git a/reputation-transparency-receipts/docs/demo.svg b/reputation-transparency-receipts/docs/demo.svg new file mode 100644 index 0000000..976510e --- /dev/null +++ b/reputation-transparency-receipts/docs/demo.svg @@ -0,0 +1,24 @@ + + Reputation transparency receipt demo + A static walkthrough of the reputation transparency receipts module output. + + + Reputation Transparency Receipts + SCIBASE Community and User Reputation System milestone + + Review receipts + Public and blinded modes + Evidence anchors preserved + + Credit receipts + CRediT roles and artifacts + Verified contribution history + + Moderation queue + Self-endorsement guard + Thin review warnings + + Deterministic output + Global, domain, and institution leaderboards expose the exact signal breakdown behind each reputation tier. + Every receipt contributes to a stable evidence digest for audit and reviewer handoff. + diff --git a/reputation-transparency-receipts/docs/requirement-map.md b/reputation-transparency-receipts/docs/requirement-map.md new file mode 100644 index 0000000..ea5af44 --- /dev/null +++ b/reputation-transparency-receipts/docs/requirement-map.md @@ -0,0 +1,16 @@ +# Requirement Map + +SCIBASE issue #15 asks for peer reviews and comments, contributor credits, reputation scoring, leaderboards, badges, and incentive tiers. + +| Requirement | Implementation | +| --- | --- | +| Structured peer reviews | `reviewReceipts` preserve per-review scores, evidence links, mode, and comment anchors. | +| Public, semi-private, anonymous, double-blind modes | `visibilityReceipt` redacts anonymous identities and restricts semi-private visibility. | +| Inline commenting | `commentAnchors` link review feedback to manuscript, dataset, code, or notebook locations. | +| Contributor credits | `contributionCredits` groups CRediT-style roles, verified artifacts, projects, and receipts per user. | +| Project timelines | `timeline` combines review, credit, and reputation tier events. | +| Reputation scoring | `reputationReports` expose contribution, review, endorsement, reproducibility, bounty, recency, and penalty signals. | +| Leaderboards | `leaderboards` emits global, domain, and institution rankings. | +| Badges and incentive tiers | `reputationTier` assigns community member, verified contributor, trusted reviewer, and open science champion tiers. | +| Abuse resistance | `moderationFindings` flags self endorsements, thin high-score reviews, endorsement rings, and anonymous identity leaks. | +| Auditability | `evidenceDigest` is deterministic for reviewer-ready receipts and score evidence. | diff --git a/reputation-transparency-receipts/package.json b/reputation-transparency-receipts/package.json new file mode 100644 index 0000000..f0624b4 --- /dev/null +++ b/reputation-transparency-receipts/package.json @@ -0,0 +1,11 @@ +{ + "name": "reputation-transparency-receipts", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "check": "node --check src/reputation-transparency-receipts.js && node --check scripts/demo.js && node --check test/reputation-transparency-receipts.test.js", + "test": "node --test test/reputation-transparency-receipts.test.js", + "demo": "node scripts/demo.js" + } +} diff --git a/reputation-transparency-receipts/scripts/demo.js b/reputation-transparency-receipts/scripts/demo.js new file mode 100644 index 0000000..47d3216 --- /dev/null +++ b/reputation-transparency-receipts/scripts/demo.js @@ -0,0 +1,18 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { analyzeReputationTransparencyReceipts } from "../src/reputation-transparency-receipts.js"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const input = JSON.parse(readFileSync(join(root, "data", "sample-community-input.json"), "utf8")); +const report = analyzeReputationTransparencyReceipts(input); + +console.log(`${report.community} reputation transparency receipts`); +console.log(`Evidence digest: ${report.evidenceDigest}`); +console.log(`Review receipts: ${report.summary.reviewReceipts}`); +console.log(`Contribution receipts: ${report.summary.contributionReceipts}`); +console.log(`Moderation findings: ${report.summary.findings}`); +console.log("Global leaderboard:"); +for (const entry of report.leaderboards.global) { + console.log(`- ${entry.handle}: ${entry.score} (${entry.tier})`); +} diff --git a/reputation-transparency-receipts/src/reputation-transparency-receipts.js b/reputation-transparency-receipts/src/reputation-transparency-receipts.js new file mode 100644 index 0000000..0e53b4b --- /dev/null +++ b/reputation-transparency-receipts/src/reputation-transparency-receipts.js @@ -0,0 +1,352 @@ +import { createHash } from "node:crypto"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function normalize(value) { + if (Array.isArray(value)) return value.map(normalize); + if (value && typeof value === "object") { + return Object.fromEntries(Object.keys(value).sort().map((key) => [key, normalize(value[key])])); + } + return value; +} + +function stableHash(value) { + return createHash("sha256").update(JSON.stringify(normalize(value))).digest("hex").slice(0, 16); +} + +function clamp(value, min = 0, max = 100) { + return Math.max(min, Math.min(max, value)); +} + +function daysSince(date, now) { + return Math.max(0, Math.ceil((new Date(now).getTime() - new Date(date).getTime()) / DAY_MS)); +} + +function userMap(users) { + return new Map(asArray(users).map((user) => [user.id, user])); +} + +function averageScore(scores = {}) { + const values = ["clarity", "rigor", "novelty", "reproducibility"] + .map((key) => Number(scores[key])) + .filter(Number.isFinite); + if (values.length === 0) return 0; + return values.reduce((sum, value) => sum + value, 0) / values.length; +} + +function visibilityReceipt(review, users) { + const reviewer = users.get(review.reviewerId); + const base = { + id: review.id, + projectId: review.projectId, + mode: review.mode ?? "public", + score: Number(averageScore(review.scores).toFixed(2)), + evidenceCount: asArray(review.evidenceLinks).length, + commentAnchors: asArray(review.commentAnchors).slice().sort() + }; + + if (base.mode === "anonymous" || base.mode === "double_blind") { + return { + ...base, + reviewer: `anonymous-${stableHash({ reviewId: review.id, reviewerId: review.reviewerId })}`, + visibleTo: asArray(review.visibleTo).slice().sort() + }; + } + + if (base.mode === "semi_private") { + return { + ...base, + reviewer: reviewer?.handle ?? review.reviewerId, + visibleTo: [...new Set([review.reviewerId, ...asArray(review.visibleTo)])].sort() + }; + } + + return { + ...base, + reviewer: reviewer?.handle ?? review.reviewerId, + visibleTo: ["public"] + }; +} + +function creditWeight(role) { + return { + conceptualization: 14, + data_curation: 12, + software: 12, + formal_analysis: 10, + validation: 10, + peer_review: 8, + visualization: 7, + writing_review: 7, + supervision: 5 + }[role] ?? 4; +} + +function contributionReceipts(contributions) { + const byUser = new Map(); + + for (const contribution of asArray(contributions)) { + if (!byUser.has(contribution.userId)) { + byUser.set(contribution.userId, { + userId: contribution.userId, + roles: {}, + projectIds: new Set(), + verifiedArtifacts: 0, + creditScore: 0, + receipts: [] + }); + } + + const receipt = byUser.get(contribution.userId); + const role = contribution.creditRole ?? "other"; + const verified = Boolean(contribution.verifiedBy); + receipt.roles[role] = (receipt.roles[role] ?? 0) + 1; + receipt.projectIds.add(contribution.projectId); + receipt.verifiedArtifacts += verified ? asArray(contribution.artifactIds).length : 0; + receipt.creditScore += creditWeight(role) + (verified ? 4 : 0); + receipt.receipts.push({ + id: contribution.id, + projectId: contribution.projectId, + role, + verified, + artifactIds: asArray(contribution.artifactIds).slice().sort(), + createdAt: contribution.createdAt + }); + } + + return [...byUser.values()].map((receipt) => ({ + ...receipt, + projectIds: [...receipt.projectIds].sort(), + creditScore: clamp(receipt.creditScore), + receipts: receipt.receipts.sort((a, b) => a.id.localeCompare(b.id)) + })); +} + +function findModerationFindings(input) { + const findings = []; + const endorsementPairs = new Map(); + + for (const endorsement of asArray(input.endorsements)) { + if (endorsement.fromUserId === endorsement.toUserId) { + findings.push({ + id: stableHash({ kind: "self_endorsement", endorsement }), + kind: "self_endorsement", + severity: "high", + subject: endorsement.toUserId, + message: "Self endorsements are excluded from reputation scoring", + evidence: [endorsement.id] + }); + } + + const pair = [endorsement.fromUserId, endorsement.toUserId].sort().join(":"); + endorsementPairs.set(pair, (endorsementPairs.get(pair) ?? 0) + 1); + } + + for (const [pair, count] of endorsementPairs.entries()) { + if (count >= 3) { + findings.push({ + id: stableHash({ kind: "endorsement_ring", pair, count }), + kind: "endorsement_ring", + severity: "medium", + subject: pair, + message: "Repeated reciprocal endorsements need moderator review", + evidence: { count } + }); + } + } + + for (const review of asArray(input.reviews)) { + if (averageScore(review.scores) >= 4.5 && asArray(review.evidenceLinks).length < 2) { + findings.push({ + id: stableHash({ kind: "thin_high_score_review", reviewId: review.id }), + kind: "thin_high_score_review", + severity: "medium", + subject: review.reviewerId, + message: "High review score has too little evidence attached", + evidence: [review.id] + }); + } + + if ((review.mode === "anonymous" || review.mode === "double_blind") && review.publicReviewerName) { + findings.push({ + id: stableHash({ kind: "anonymous_identity_leak", reviewId: review.id }), + kind: "anonymous_identity_leak", + severity: "critical", + subject: review.reviewerId, + message: "Anonymous review includes a public reviewer name", + evidence: [review.id] + }); + } + } + + return findings.sort((a, b) => { + const severity = { critical: 0, high: 1, medium: 2, low: 3 }; + const bySeverity = (severity[a.severity] ?? 9) - (severity[b.severity] ?? 9); + return bySeverity || a.id.localeCompare(b.id); + }); +} + +function reputationTier(score) { + if (score >= 85) return "open_science_champion"; + if (score >= 70) return "trusted_reviewer"; + if (score >= 50) return "verified_contributor"; + return "community_member"; +} + +function buildReputationReports(input, now, creditReceipts, reviewReceipts, moderationFindings) { + const users = userMap(input.users); + const validEndorsements = asArray(input.endorsements).filter((endorsement) => endorsement.fromUserId !== endorsement.toUserId); + const penaltiesByUser = new Map(); + + for (const finding of moderationFindings) { + if (finding.kind === "anonymous_identity_leak") { + penaltiesByUser.set(finding.subject, (penaltiesByUser.get(finding.subject) ?? 0) + 15); + } else if (finding.kind === "self_endorsement" || finding.kind === "thin_high_score_review") { + penaltiesByUser.set(finding.subject, (penaltiesByUser.get(finding.subject) ?? 0) + 8); + } + } + + return asArray(input.users).map((user) => { + const credit = creditReceipts.find((item) => item.userId === user.id); + const userReviews = reviewReceipts.filter((review) => asArray(input.reviews).find((raw) => raw.id === review.id)?.reviewerId === user.id); + const avgReviewScore = userReviews.length + ? userReviews.reduce((sum, review) => sum + review.score, 0) / userReviews.length + : 0; + const endorsements = validEndorsements.filter((endorsement) => endorsement.toUserId === user.id); + const badges = asArray(input.reproducibilityBadges).filter((badge) => badge.userId === user.id && badge.verified); + const bountyCompletions = asArray(input.bountyCompletions).filter((completion) => completion.userId === user.id && completion.verified); + const recencyBoost = Math.max(0, 8 - daysSince(user.lastActiveAt ?? now, now) / 30); + const penalty = penaltiesByUser.get(user.id) ?? 0; + + const signals = { + contributionCredit: credit?.creditScore ?? 0, + reviewQuality: clamp(avgReviewScore * 14), + endorsements: clamp(endorsements.length * 6, 0, 18), + reproducibility: clamp(badges.length * 9, 0, 18), + bountyCompletions: clamp(bountyCompletions.length * 8, 0, 16), + recency: Number(recencyBoost.toFixed(2)), + penalties: penalty + }; + const score = clamp( + signals.contributionCredit * 0.6 + + signals.reviewQuality * 0.45 + + signals.endorsements + + signals.reproducibility + + signals.bountyCompletions + + signals.recency - + signals.penalties + ); + + return { + userId: user.id, + handle: user.handle, + domain: user.domain, + institution: user.institution, + score: Number(score.toFixed(2)), + tier: reputationTier(score), + signals, + publicReceipts: [ + ...(credit?.receipts.map((receipt) => receipt.id) ?? []), + ...userReviews.map((review) => review.id), + ...badges.map((badge) => badge.id) + ].sort() + }; + }).sort((a, b) => b.score - a.score || a.handle.localeCompare(b.handle)); +} + +function leaderboards(reputationReports) { + const byDomain = new Map(); + const byInstitution = new Map(); + + for (const report of reputationReports) { + if (!byDomain.has(report.domain)) byDomain.set(report.domain, []); + byDomain.get(report.domain).push(report); + if (!byInstitution.has(report.institution)) byInstitution.set(report.institution, []); + byInstitution.get(report.institution).push(report); + } + + const shape = (entries) => Object.fromEntries([...entries.entries()].sort().map(([key, reports]) => [ + key, + reports.slice().sort((a, b) => b.score - a.score).slice(0, 5).map((report) => ({ + userId: report.userId, + handle: report.handle, + score: report.score, + tier: report.tier + })) + ])); + + return { + global: reputationReports.slice(0, 10).map((report) => ({ + userId: report.userId, + handle: report.handle, + score: report.score, + tier: report.tier + })), + byDomain: shape(byDomain), + byInstitution: shape(byInstitution) + }; +} + +function timeline(input, reviewReceipts, creditReceipts, reports) { + const reviewEvents = reviewReceipts.map((review) => ({ + at: asArray(input.reviews).find((raw) => raw.id === review.id)?.createdAt, + type: "review_receipt", + subject: review.id, + projectId: review.projectId + })); + const creditEvents = creditReceipts.flatMap((credit) => credit.receipts.map((receipt) => ({ + at: receipt.createdAt, + type: "credit_receipt", + subject: receipt.id, + projectId: receipt.projectId + }))); + const tierEvents = reports.map((report) => ({ + at: input.generatedAt, + type: "reputation_tier", + subject: report.userId, + tier: report.tier + })); + + return [...reviewEvents, ...creditEvents, ...tierEvents] + .filter((event) => event.at) + .sort((a, b) => `${a.at}:${a.type}:${a.subject}`.localeCompare(`${b.at}:${b.type}:${b.subject}`)); +} + +export function analyzeReputationTransparencyReceipts(input, options = {}) { + const now = options.now ?? input.generatedAt ?? new Date().toISOString(); + const users = userMap(input.users); + const reviewReceipts = asArray(input.reviews).map((review) => visibilityReceipt(review, users)); + const creditReceipts = contributionReceipts(input.contributions); + const moderationFindings = findModerationFindings(input); + const reputationReports = buildReputationReports(input, now, creditReceipts, reviewReceipts, moderationFindings); + const board = leaderboards(reputationReports); + const events = timeline(input, reviewReceipts, creditReceipts, reputationReports); + const summary = { + users: asArray(input.users).length, + reviewReceipts: reviewReceipts.length, + contributionReceipts: creditReceipts.reduce((sum, receipt) => sum + receipt.receipts.length, 0), + findings: moderationFindings.length, + criticalFindings: moderationFindings.filter((finding) => finding.severity === "critical").length, + trustedReviewers: reputationReports.filter((report) => report.tier === "trusted_reviewer" || report.tier === "open_science_champion").length + }; + const result = { + generatedAt: now, + community: input.community ?? "SCIBASE research community", + summary, + reviewReceipts, + contributionCredits: creditReceipts, + reputationReports, + leaderboards: board, + moderationFindings, + timeline: events + }; + + return { + ...result, + evidenceDigest: stableHash(result) + }; +} diff --git a/reputation-transparency-receipts/test/reputation-transparency-receipts.test.js b/reputation-transparency-receipts/test/reputation-transparency-receipts.test.js new file mode 100644 index 0000000..4e2b7ab --- /dev/null +++ b/reputation-transparency-receipts/test/reputation-transparency-receipts.test.js @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, it } from "node:test"; +import { analyzeReputationTransparencyReceipts } from "../src/reputation-transparency-receipts.js"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const sample = JSON.parse(readFileSync(join(root, "data", "sample-community-input.json"), "utf8")); + +describe("analyzeReputationTransparencyReceipts", () => { + it("redacts double-blind reviewer identity while keeping visibility receipts", () => { + const report = analyzeReputationTransparencyReceipts(sample); + const blinded = report.reviewReceipts.find((receipt) => receipt.id === "rev-doubleblind-7"); + const publicReview = report.reviewReceipts.find((receipt) => receipt.id === "rev-manuscript-1"); + + assert.match(blinded.reviewer, /^anonymous-/); + assert.deepEqual(blinded.visibleTo, ["editor-board"]); + assert.equal(publicReview.reviewer, "Ada Chen"); + assert.deepEqual(publicReview.visibleTo, ["public"]); + }); + + it("builds transparent reputation reports with tiers and signal breakdowns", () => { + const report = analyzeReputationTransparencyReceipts(sample); + const ada = report.reputationReports.find((item) => item.userId === "usr-ada"); + const cora = report.reputationReports.find((item) => item.userId === "usr-cora"); + + assert.ok(ada.score > 60); + assert.equal(ada.signals.bountyCompletions, 8); + assert.equal(cora.signals.reproducibility, 9); + assert.equal(report.leaderboards.byDomain.biology[0].userId, "usr-ada"); + }); + + it("flags self endorsements and thin high-score reviews for moderation", () => { + const report = analyzeReputationTransparencyReceipts(sample); + const kinds = new Set(report.moderationFindings.map((finding) => finding.kind)); + + assert.equal(kinds.has("self_endorsement"), true); + assert.equal(kinds.has("thin_high_score_review"), true); + assert.equal(report.summary.findings, 2); + }); + + it("is deterministic for audit evidence", () => { + const first = analyzeReputationTransparencyReceipts(sample); + const second = analyzeReputationTransparencyReceipts(sample); + + assert.equal(first.evidenceDigest, second.evidenceDigest); + assert.deepEqual(first.timeline.map((event) => event.subject), second.timeline.map((event) => event.subject)); + }); +});