diff --git a/reputation-sybil-cluster-guard/README.md b/reputation-sybil-cluster-guard/README.md new file mode 100644 index 00000000..f16b0dc0 --- /dev/null +++ b/reputation-sybil-cluster-guard/README.md @@ -0,0 +1,35 @@ +# Reputation Sybil Cluster Guard + +This slice adds a dependency-free guard for SCIBASE's Community & User Reputation System. It audits synthetic reputation events for sybil-cluster patterns before points affect leaderboards, bounty eligibility, trust badges, or payout review. + +The scope is distinct from existing submissions for probation reinstatement, leaderboard eligibility/privacy, reputation review abuse, identity impersonation, badge renewal, credit-ledger reputation, and completion reputation. + +## What It Checks + +- Shared payout handles across multiple accounts. +- Recycled device fingerprints across high-velocity accounts. +- Burst endorsements shortly after signup. +- Suspicious invitation chains. +- Cross-project high-score review rings. +- High reputation deltas while risk signals are present. +- Missing quarantine, leaderboard suppression, payout pause, or manual review controls. + +## Reviewer Output + +Running the demo creates: + +- `reports/sybil-cluster-report.json` +- `reports/sybil-cluster-report.md` +- `reports/summary.svg` +- `reports/demo-script.txt` +- `reports/demo.mp4` + +All data is synthetic. The guard uses no credentials, private user records, payment processors, wallets, social media accounts, or external services. + +## Commands + +```bash +npm test +npm run demo +npm run demo:video +``` diff --git a/reputation-sybil-cluster-guard/demo.js b/reputation-sybil-cluster-guard/demo.js new file mode 100644 index 00000000..a6a5ec76 --- /dev/null +++ b/reputation-sybil-cluster-guard/demo.js @@ -0,0 +1,24 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { auditReputationSybilCluster, buildReviewerMarkdown, buildSummarySvg } from "./index.js"; +import { riskyReputationPacket } from "./sample-data.js"; + +const reportsDir = new URL("./reports/", import.meta.url); +await mkdir(reportsDir, { recursive: true }); + +const report = auditReputationSybilCluster(riskyReputationPacket); +await writeFile(new URL("sybil-cluster-report.json", reportsDir), `${JSON.stringify(report, null, 2)}\n`); +await writeFile(new URL("sybil-cluster-report.md", reportsDir), buildReviewerMarkdown(report)); +await writeFile(new URL("summary.svg", reportsDir), buildSummarySvg(report)); +await writeFile( + new URL("demo-script.txt", reportsDir), + [ + "Demo: Reputation Sybil Cluster Guard", + `Cluster: ${report.clusterId}`, + `Decision: ${report.decision}`, + `Risk score: ${report.riskScore}/100`, + `Blockers: ${report.summary.blockCount}`, + "Action: quarantine cluster before reputation points affect rankings, bounties, or payout eligibility.", + ].join("\n"), +); + +console.log(JSON.stringify(report.summary, null, 2)); diff --git a/reputation-sybil-cluster-guard/demo_video.py b/reputation-sybil-cluster-guard/demo_video.py new file mode 100644 index 00000000..168a17d9 --- /dev/null +++ b/reputation-sybil-cluster-guard/demo_video.py @@ -0,0 +1,45 @@ +from pathlib import Path + +import imageio.v3 as iio +import numpy as np +from PIL import Image, ImageDraw, ImageFont + + +ROOT = Path(__file__).resolve().parent +REPORTS = ROOT / "reports" +REPORTS.mkdir(exist_ok=True) + + +def font(size): + for name in ("arial.ttf", "segoeui.ttf"): + try: + return ImageFont.truetype(name, size) + except OSError: + pass + return ImageFont.load_default() + + +slides = [ + ("Reputation Sybil Cluster Guard", "Synthetic trust-and-safety control for SCIBASE #15"), + ("Decision", "quarantine-cluster · risk score 100/100"), + ("Signals", "shared payout · recycled device · review ring"), + ("Action", "Pause reputation, rankings, and payout eligibility until review"), +] + +frames = [] +for title, subtitle in slides: + image = Image.new("RGB", (960, 544), "#111827") + draw = ImageDraw.Draw(image) + draw.rectangle((48, 58, 912, 486), outline="#374151", width=3) + draw.text((82, 136), title, fill="#f9fafb", font=font(42)) + draw.text((82, 220), subtitle, fill="#d1d5db", font=font(24)) + draw.rectangle((82, 350, 700, 392), fill="#dc2626") + draw.text((82, 424), "Synthetic data only. No social media, wallets, or private user records.", fill="#9ca3af", font=font(20)) + frames.extend([image] * 14) + +gif_path = REPORTS / "demo.gif" +mp4_path = REPORTS / "demo.mp4" +frames[0].save(gif_path, save_all=True, append_images=frames[1:], duration=120, loop=0) +iio.imwrite(mp4_path, [np.asarray(frame) for frame in frames], fps=8, codec="libx264") +print(f"wrote {gif_path}") +print(f"wrote {mp4_path}") diff --git a/reputation-sybil-cluster-guard/index.js b/reputation-sybil-cluster-guard/index.js new file mode 100644 index 00000000..7ea07a87 --- /dev/null +++ b/reputation-sybil-cluster-guard/index.js @@ -0,0 +1,197 @@ +const SEVERITY = { block: 3, warn: 2 }; + +function finding(code, severity, message, evidence, remediation) { + return { code, severity, message, evidence, remediation }; +} + +function groupsBy(items, key) { + const groups = new Map(); + for (const item of items) { + const value = item[key]; + if (!value) continue; + groups.set(value, [...(groups.get(value) || []), item]); + } + return groups; +} + +export function auditReputationSybilCluster(packet) { + if (!packet || typeof packet !== "object") { + throw new TypeError("reputation packet must be an object"); + } + + const users = packet.users || []; + const endorsements = packet.endorsements || []; + const reviews = packet.reviews || []; + const controls = packet.controls || {}; + const findings = []; + + for (const [payoutHandle, members] of groupsBy(users, "payoutHandle")) { + if (members.length > 1) { + findings.push( + finding( + "SHARED_PAYOUT_HANDLE", + "block", + "Multiple reputation accounts share the same payout handle.", + { payoutHandle, userIds: members.map((member) => member.userId) }, + "Pause payout eligibility and require identity review before reputation points affect bounty access.", + ), + ); + } + } + + for (const [fingerprint, members] of groupsBy(users, "deviceFingerprint")) { + if (members.length >= 3) { + findings.push( + finding( + "RECYCLED_DEVICE_FINGERPRINT", + "block", + "Three or more accounts share a device fingerprint inside the same review window.", + { fingerprint, userIds: members.map((member) => member.userId) }, + "Quarantine the cluster and suppress leaderboard movement until reviewed.", + ), + ); + } + } + + const burstEndorsements = endorsements.filter((endorsement) => endorsement.createdMinutesAfterSignup <= 30); + if (burstEndorsements.length >= 3) { + findings.push( + finding( + "BURST_ENDORSEMENT_RING", + "warn", + "Several endorsements were created immediately after signup.", + { count: burstEndorsements.length, edges: burstEndorsements.map(({ from, to }) => `${from}->${to}`) }, + "Delay endorsement credit until accounts age past the configured trust window.", + ), + ); + } + + const invitationCounts = groupsBy(users, "invitedBy"); + for (const [inviter, members] of invitationCounts) { + if (inviter !== "seed" && members.length >= 2) { + findings.push( + finding( + "SUSPICIOUS_INVITATION_CHAIN", + "warn", + "A non-seed account invited multiple high-velocity reputation accounts.", + { inviter, invitees: members.map((member) => member.userId) }, + "Require manual review before granting invitation-derived reputation boosts.", + ), + ); + } + } + + const reciprocalReviewEdges = new Set(reviews.map((review) => `${review.reviewer}:${review.submitter}`)); + const ringEdges = reviews.filter((review) => reciprocalReviewEdges.has(`${review.submitter}:${review.reviewer}`) || review.score >= 5); + if (ringEdges.length >= 3) { + findings.push( + finding( + "SELF_REVIEW_RING_RISK", + "block", + "Reviewer and submitter relationships form a high-score ring across bounty projects.", + { edges: ringEdges.map(({ reviewer, submitter, projectId }) => `${reviewer}->${submitter}@${projectId}`) }, + "Suppress review-derived reputation and route the cluster to trust-and-safety review.", + ), + ); + } + + const totalReputationDelta = users.reduce((total, user) => total + Number(user.reputationDelta || 0), 0); + if (totalReputationDelta >= 100 && findings.length > 0) { + findings.push( + finding( + "HIGH_REPUTATION_DELTA_UNDER_RISK", + "warn", + "The cluster gained enough reputation to affect rankings or bounty eligibility while risk signals are present.", + { totalReputationDelta }, + "Hold reputation deltas until the cluster review is resolved.", + ), + ); + } + + if (findings.some((item) => item.severity === "block") && (!controls.quarantineApplied || !controls.leaderboardSuppressed || !controls.payoutEligibilityPaused)) { + findings.push( + finding( + "MISSING_CLUSTER_QUARANTINE", + "block", + "Blocking sybil signals exist but reputation, ranking, or payout controls are not fully paused.", + controls, + "Apply quarantine, suppress leaderboard rank, pause payout eligibility, and open a manual review ticket.", + ), + ); + } + + const blockCount = findings.filter((item) => item.severity === "block").length; + const warnCount = findings.filter((item) => item.severity === "warn").length; + return { + clusterId: packet.clusterId, + reviewWindow: packet.reviewWindow, + decision: blockCount > 0 ? "quarantine-cluster" : warnCount > 0 ? "manual-review" : "reputation-clear", + riskScore: Math.min(100, blockCount * 25 + warnCount * 10), + summary: { + blockCount, + warnCount, + findingCount: findings.length, + userCount: users.length, + endorsementCount: endorsements.length, + reviewCount: reviews.length, + }, + findings: findings.sort((a, b) => SEVERITY[b.severity] - SEVERITY[a.severity] || a.code.localeCompare(b.code)), + }; +} + +export function buildReviewerMarkdown(report) { + const lines = [ + `# Reputation Sybil Cluster Guard: ${report.clusterId}`, + "", + `Review window: **${report.reviewWindow}**`, + `Decision: **${report.decision}**`, + `Risk score: **${report.riskScore}/100**`, + "", + `Findings: ${report.summary.blockCount} blockers, ${report.summary.warnCount} warnings.`, + "", + ]; + + for (const item of report.findings) { + lines.push(`## ${item.severity.toUpperCase()}: ${item.code}`); + lines.push(item.message); + lines.push(""); + lines.push(`Evidence: \`${JSON.stringify(item.evidence)}\``); + lines.push(""); + lines.push(`Remediation: ${item.remediation}`); + lines.push(""); + } + + if (report.findings.length === 0) lines.push("No sybil-cluster reputation issues were detected.\n"); + return lines.join("\n"); +} + +export function buildSummarySvg(report) { + return ` + + Reputation Sybil Cluster Guard + ${escapeXml(report.clusterId)} · ${escapeXml(report.decision)} + + + ${report.riskScore}/100 + + + ${report.summary.blockCount} + blocking signals + + + + ${report.summary.userCount} + accounts reviewed + + + + ${report.summary.endorsementCount} + endorsement edges + + +`; +} + +function escapeXml(value) { + return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +} diff --git a/reputation-sybil-cluster-guard/package.json b/reputation-sybil-cluster-guard/package.json new file mode 100644 index 00000000..51ab3741 --- /dev/null +++ b/reputation-sybil-cluster-guard/package.json @@ -0,0 +1,12 @@ +{ + "name": "reputation-sybil-cluster-guard", + "version": "1.0.0", + "description": "Synthetic sybil-cluster reputation guard for SCIBASE community reputation workflows.", + "type": "module", + "private": true, + "scripts": { + "test": "node test.js", + "demo": "node demo.js", + "demo:video": "python demo_video.py" + } +} diff --git a/reputation-sybil-cluster-guard/reports/demo-script.txt b/reputation-sybil-cluster-guard/reports/demo-script.txt new file mode 100644 index 00000000..f44ad801 --- /dev/null +++ b/reputation-sybil-cluster-guard/reports/demo-script.txt @@ -0,0 +1,6 @@ +Demo: Reputation Sybil Cluster Guard +Cluster: cluster-rho-17 +Decision: quarantine-cluster +Risk score: 100/100 +Blockers: 4 +Action: quarantine cluster before reputation points affect rankings, bounties, or payout eligibility. \ No newline at end of file diff --git a/reputation-sybil-cluster-guard/reports/demo.gif b/reputation-sybil-cluster-guard/reports/demo.gif new file mode 100644 index 00000000..e7ed2ba2 Binary files /dev/null and b/reputation-sybil-cluster-guard/reports/demo.gif differ diff --git a/reputation-sybil-cluster-guard/reports/demo.mp4 b/reputation-sybil-cluster-guard/reports/demo.mp4 new file mode 100644 index 00000000..cdfa5f84 Binary files /dev/null and b/reputation-sybil-cluster-guard/reports/demo.mp4 differ diff --git a/reputation-sybil-cluster-guard/reports/summary.svg b/reputation-sybil-cluster-guard/reports/summary.svg new file mode 100644 index 00000000..ff044525 --- /dev/null +++ b/reputation-sybil-cluster-guard/reports/summary.svg @@ -0,0 +1,23 @@ + + + Reputation Sybil Cluster Guard + cluster-rho-17 · quarantine-cluster + + + 100/100 + + + 4 + blocking signals + + + + 4 + accounts reviewed + + + + 4 + endorsement edges + + diff --git a/reputation-sybil-cluster-guard/reports/sybil-cluster-report.json b/reputation-sybil-cluster-guard/reports/sybil-cluster-report.json new file mode 100644 index 00000000..9e067014 --- /dev/null +++ b/reputation-sybil-cluster-guard/reports/sybil-cluster-report.json @@ -0,0 +1,105 @@ +{ + "clusterId": "cluster-rho-17", + "reviewWindow": "2026-06", + "decision": "quarantine-cluster", + "riskScore": 100, + "summary": { + "blockCount": 4, + "warnCount": 3, + "findingCount": 7, + "userCount": 4, + "endorsementCount": 4, + "reviewCount": 3 + }, + "findings": [ + { + "code": "MISSING_CLUSTER_QUARANTINE", + "severity": "block", + "message": "Blocking sybil signals exist but reputation, ranking, or payout controls are not fully paused.", + "evidence": { + "quarantineApplied": false, + "manualReviewTicket": null, + "leaderboardSuppressed": false, + "payoutEligibilityPaused": false + }, + "remediation": "Apply quarantine, suppress leaderboard rank, pause payout eligibility, and open a manual review ticket." + }, + { + "code": "RECYCLED_DEVICE_FINGERPRINT", + "severity": "block", + "message": "Three or more accounts share a device fingerprint inside the same review window.", + "evidence": { + "fingerprint": "fp-77", + "userIds": [ + "u-104", + "u-105", + "u-106" + ] + }, + "remediation": "Quarantine the cluster and suppress leaderboard movement until reviewed." + }, + { + "code": "SELF_REVIEW_RING_RISK", + "severity": "block", + "message": "Reviewer and submitter relationships form a high-score ring across bounty projects.", + "evidence": { + "edges": [ + "u-104->u-105@bounty-1", + "u-105->u-106@bounty-1", + "u-106->u-104@bounty-2" + ] + }, + "remediation": "Suppress review-derived reputation and route the cluster to trust-and-safety review." + }, + { + "code": "SHARED_PAYOUT_HANDLE", + "severity": "block", + "message": "Multiple reputation accounts share the same payout handle.", + "evidence": { + "payoutHandle": "wallet-alpha", + "userIds": [ + "u-104", + "u-105" + ] + }, + "remediation": "Pause payout eligibility and require identity review before reputation points affect bounty access." + }, + { + "code": "BURST_ENDORSEMENT_RING", + "severity": "warn", + "message": "Several endorsements were created immediately after signup.", + "evidence": { + "count": 4, + "edges": [ + "u-104->u-105", + "u-105->u-106", + "u-106->u-104", + "u-107->u-104" + ] + }, + "remediation": "Delay endorsement credit until accounts age past the configured trust window." + }, + { + "code": "HIGH_REPUTATION_DELTA_UNDER_RISK", + "severity": "warn", + "message": "The cluster gained enough reputation to affect rankings or bounty eligibility while risk signals are present.", + "evidence": { + "totalReputationDelta": 119 + }, + "remediation": "Hold reputation deltas until the cluster review is resolved." + }, + { + "code": "SUSPICIOUS_INVITATION_CHAIN", + "severity": "warn", + "message": "A non-seed account invited multiple high-velocity reputation accounts.", + "evidence": { + "inviter": "u-101", + "invitees": [ + "u-104", + "u-105" + ] + }, + "remediation": "Require manual review before granting invitation-derived reputation boosts." + } + ] +} diff --git a/reputation-sybil-cluster-guard/reports/sybil-cluster-report.md b/reputation-sybil-cluster-guard/reports/sybil-cluster-report.md new file mode 100644 index 00000000..e959cd6f --- /dev/null +++ b/reputation-sybil-cluster-guard/reports/sybil-cluster-report.md @@ -0,0 +1,56 @@ +# Reputation Sybil Cluster Guard: cluster-rho-17 + +Review window: **2026-06** +Decision: **quarantine-cluster** +Risk score: **100/100** + +Findings: 4 blockers, 3 warnings. + +## BLOCK: MISSING_CLUSTER_QUARANTINE +Blocking sybil signals exist but reputation, ranking, or payout controls are not fully paused. + +Evidence: `{"quarantineApplied":false,"manualReviewTicket":null,"leaderboardSuppressed":false,"payoutEligibilityPaused":false}` + +Remediation: Apply quarantine, suppress leaderboard rank, pause payout eligibility, and open a manual review ticket. + +## BLOCK: RECYCLED_DEVICE_FINGERPRINT +Three or more accounts share a device fingerprint inside the same review window. + +Evidence: `{"fingerprint":"fp-77","userIds":["u-104","u-105","u-106"]}` + +Remediation: Quarantine the cluster and suppress leaderboard movement until reviewed. + +## BLOCK: SELF_REVIEW_RING_RISK +Reviewer and submitter relationships form a high-score ring across bounty projects. + +Evidence: `{"edges":["u-104->u-105@bounty-1","u-105->u-106@bounty-1","u-106->u-104@bounty-2"]}` + +Remediation: Suppress review-derived reputation and route the cluster to trust-and-safety review. + +## BLOCK: SHARED_PAYOUT_HANDLE +Multiple reputation accounts share the same payout handle. + +Evidence: `{"payoutHandle":"wallet-alpha","userIds":["u-104","u-105"]}` + +Remediation: Pause payout eligibility and require identity review before reputation points affect bounty access. + +## WARN: BURST_ENDORSEMENT_RING +Several endorsements were created immediately after signup. + +Evidence: `{"count":4,"edges":["u-104->u-105","u-105->u-106","u-106->u-104","u-107->u-104"]}` + +Remediation: Delay endorsement credit until accounts age past the configured trust window. + +## WARN: HIGH_REPUTATION_DELTA_UNDER_RISK +The cluster gained enough reputation to affect rankings or bounty eligibility while risk signals are present. + +Evidence: `{"totalReputationDelta":119}` + +Remediation: Hold reputation deltas until the cluster review is resolved. + +## WARN: SUSPICIOUS_INVITATION_CHAIN +A non-seed account invited multiple high-velocity reputation accounts. + +Evidence: `{"inviter":"u-101","invitees":["u-104","u-105"]}` + +Remediation: Require manual review before granting invitation-derived reputation boosts. diff --git a/reputation-sybil-cluster-guard/requirements-map.md b/reputation-sybil-cluster-guard/requirements-map.md new file mode 100644 index 00000000..7a39922f --- /dev/null +++ b/reputation-sybil-cluster-guard/requirements-map.md @@ -0,0 +1,27 @@ +# Requirements Map + +Issue #15 describes a Community & User Reputation System with reputation, trust, rewards, and safeguards around user contributions. + +This contribution adds a distinct trust-and-safety control: sybil-cluster auditing before reputation affects rankings or bounty eligibility. + +| Issue capability | Implementation | +| --- | --- | +| Community reputation | Reviews reputation deltas and endorsement edges before points are trusted. | +| User trust safeguards | Detects shared payout handles, recycled device fingerprints, suspicious invitations, and review rings. | +| Reward/bounty eligibility | Emits a `quarantine-cluster` decision when reputation should not affect bounty access or payout eligibility. | +| Reviewer transparency | Generates JSON, Markdown, SVG, and MP4 artifacts from synthetic data. | +| Privacy-safe demo | Uses synthetic data only; no private user records, credentials, wallets, or social accounts. | + +## Non-Overlap + +This is not: + +- Probation reinstatement. +- Leaderboard eligibility or cohort privacy. +- Reputation review abuse. +- Identity impersonation. +- Badge renewal. +- Credit-ledger reputation. +- Completion reputation. + +It specifically focuses on clustered sybil signals that can inflate reputation before bounty eligibility or ranking decisions. diff --git a/reputation-sybil-cluster-guard/sample-data.js b/reputation-sybil-cluster-guard/sample-data.js new file mode 100644 index 00000000..282123f4 --- /dev/null +++ b/reputation-sybil-cluster-guard/sample-data.js @@ -0,0 +1,50 @@ +export const riskyReputationPacket = { + reviewWindow: "2026-06", + clusterId: "cluster-rho-17", + users: [ + { userId: "u-104", payoutHandle: "wallet-alpha", deviceFingerprint: "fp-77", invitedBy: "u-101", reputationDelta: 38 }, + { userId: "u-105", payoutHandle: "wallet-alpha", deviceFingerprint: "fp-77", invitedBy: "u-101", reputationDelta: 41 }, + { userId: "u-106", payoutHandle: "wallet-beta", deviceFingerprint: "fp-77", invitedBy: "u-105", reputationDelta: 36 }, + { userId: "u-107", payoutHandle: "wallet-gamma", deviceFingerprint: "fp-88", invitedBy: "u-106", reputationDelta: 4 }, + ], + endorsements: [ + { from: "u-104", to: "u-105", projectId: "bounty-1", createdMinutesAfterSignup: 9 }, + { from: "u-105", to: "u-106", projectId: "bounty-1", createdMinutesAfterSignup: 11 }, + { from: "u-106", to: "u-104", projectId: "bounty-2", createdMinutesAfterSignup: 13 }, + { from: "u-107", to: "u-104", projectId: "bounty-2", createdMinutesAfterSignup: 15 }, + ], + reviews: [ + { reviewer: "u-104", submitter: "u-105", projectId: "bounty-1", score: 5 }, + { reviewer: "u-105", submitter: "u-106", projectId: "bounty-1", score: 5 }, + { reviewer: "u-106", submitter: "u-104", projectId: "bounty-2", score: 5 }, + ], + controls: { + quarantineApplied: false, + manualReviewTicket: null, + leaderboardSuppressed: false, + payoutEligibilityPaused: false, + }, +}; + +export const cleanReputationPacket = { + reviewWindow: "2026-06", + clusterId: "cluster-eta-04", + users: [ + { userId: "u-201", payoutHandle: "wallet-201", deviceFingerprint: "fp-201", invitedBy: "seed", reputationDelta: 12 }, + { userId: "u-202", payoutHandle: "wallet-202", deviceFingerprint: "fp-202", invitedBy: "seed", reputationDelta: 9 }, + { userId: "u-203", payoutHandle: "wallet-203", deviceFingerprint: "fp-203", invitedBy: "u-201", reputationDelta: 6 }, + ], + endorsements: [ + { from: "u-201", to: "u-202", projectId: "bounty-7", createdMinutesAfterSignup: 1440 }, + { from: "u-202", to: "u-203", projectId: "bounty-8", createdMinutesAfterSignup: 2880 }, + ], + reviews: [ + { reviewer: "u-201", submitter: "u-203", projectId: "bounty-8", score: 4 }, + ], + controls: { + quarantineApplied: true, + manualReviewTicket: "REP-2048", + leaderboardSuppressed: true, + payoutEligibilityPaused: true, + }, +}; diff --git a/reputation-sybil-cluster-guard/test.js b/reputation-sybil-cluster-guard/test.js new file mode 100644 index 00000000..fc7bbd37 --- /dev/null +++ b/reputation-sybil-cluster-guard/test.js @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import { auditReputationSybilCluster, buildReviewerMarkdown, buildSummarySvg } from "./index.js"; +import { cleanReputationPacket, riskyReputationPacket } from "./sample-data.js"; + +const risky = auditReputationSybilCluster(riskyReputationPacket); +assert.equal(risky.decision, "quarantine-cluster"); +assert.ok(risky.riskScore >= 90); +assert.ok(risky.findings.some((finding) => finding.code === "SHARED_PAYOUT_HANDLE")); +assert.ok(risky.findings.some((finding) => finding.code === "RECYCLED_DEVICE_FINGERPRINT")); +assert.ok(risky.findings.some((finding) => finding.code === "SELF_REVIEW_RING_RISK")); +assert.ok(risky.findings.some((finding) => finding.code === "MISSING_CLUSTER_QUARANTINE")); + +const clean = auditReputationSybilCluster(cleanReputationPacket); +assert.equal(clean.decision, "reputation-clear"); +assert.equal(clean.riskScore, 0); +assert.equal(clean.summary.findingCount, 0); + +assert.throws(() => auditReputationSybilCluster(null), /reputation packet must be an object/); +assert.match(buildReviewerMarkdown(risky), /quarantine-cluster/); +assert.match(buildSummarySvg(risky), /Reputation Sybil Cluster Guard/); + +console.log("reputation sybil cluster guard tests passed");