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 `
+`;
+}
+
+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 @@
+
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");