diff --git a/scientific-bounty-appeals-ledger/README.md b/scientific-bounty-appeals-ledger/README.md new file mode 100644 index 0000000..807703c --- /dev/null +++ b/scientific-bounty-appeals-ledger/README.md @@ -0,0 +1,38 @@ +# Scientific Bounty Appeals Ledger + +This is a focused Scientific Bounty System module for SCIBASE issue #18. It handles the post-evaluation dispute layer: appeal eligibility, evidence-lock checks, reviewer conflict detection, response SLAs, payout holds, and IP transfer guards. + +The slice is intentionally separate from challenge intake, rubric scoring, payout routing engines, and solver workspace privacy. It answers a narrower operational question: after a sponsor or solver contests a result, what must stay locked, who needs to respond, what money can be released, and what IP transfer must wait? + +## What It Does + +- Validates appeal windows, accepted reason categories, appellant roles, and known submissions. +- Verifies locked evidence snapshots cover contested submission artifacts. +- Flags reviewer conflicts, including sponsor-affiliated reviewers on contested submissions. +- Tracks reviewer response SLAs and escalation state. +- Holds awarded payout amounts while eligible appeals are open. +- Blocks IP transfer while payout is held by an eligible appeal. +- Builds a sponsor feedback packet, arbitration dashboard, audit trail, and stable digest. + +## Files + +- `src/appeals-ledger.js` - deterministic appeals ledger engine. +- `data/sample-appeals.json` - sample challenge with awards, reviewers, evidence snapshots, and appeals. +- `test/appeals-ledger.test.js` - dependency-free assertions for eligibility, evidence locks, conflicts, payout holds, IP guards, and stable digest behavior. +- `scripts/demo.js` - local CLI demo. +- `docs/requirement-map.md` - mapping to issue #18 requirements. +- `docs/demo.svg` and `docs/demo.mp4` - short visual demo artifacts. + +## Run + +```bash +npm run check +npm test +npm run demo +``` + +Expected demo output includes the challenge title, tracked appeal count, payout hold status, held amount, top action, IP guard count, and stable digest. + +## Scope Notes + +The implementation is dependency-free and credential-free. It models the trust and dispute workflow that sits after challenge evaluation and before payout/IP release, so it can be embedded into a broader bounty marketplace later. diff --git a/scientific-bounty-appeals-ledger/data/sample-appeals.json b/scientific-bounty-appeals-ledger/data/sample-appeals.json new file mode 100644 index 0000000..dd7d42f --- /dev/null +++ b/scientific-bounty-appeals-ledger/data/sample-appeals.json @@ -0,0 +1,153 @@ +{ + "challengeId": "climate-forecasting-2026", + "title": "Regional climate forecasting benchmark", + "asOf": "2026-05-15T04:00:00Z", + "sponsor": { + "id": "sponsor-climate-lab", + "name": "Open Climate Lab" + }, + "appealPolicy": { + "appealWindowHours": 72, + "reviewerResponseHours": 24, + "acceptedReasons": [ + "missing-evidence", + "rubric-miscalculation", + "conflict-of-interest", + "payout-split-dispute" + ] + }, + "ipPolicy": { + "defaultTransfer": "after-payout", + "holdDuringAppeal": true + }, + "submissions": [ + { + "id": "sub-aurora", + "teamId": "team-aurora", + "title": "Aurora probabilistic forecast model", + "decision": "awarded", + "awardAmount": 60000, + "decisionAt": "2026-05-13T10:00:00Z", + "solverPayoutRoutes": [ + { + "solverId": "solver-1", + "share": 0.65 + }, + { + "solverId": "solver-2", + "share": 0.35 + } + ], + "artifactIds": [ + "artifact-aurora-model", + "artifact-aurora-report", + "artifact-aurora-dataset" + ] + }, + { + "id": "sub-boreal", + "teamId": "team-boreal", + "title": "Boreal analog ensemble", + "decision": "honorable-mention", + "awardAmount": 10000, + "decisionAt": "2026-05-12T12:00:00Z", + "solverPayoutRoutes": [ + { + "solverId": "solver-3", + "share": 1 + } + ], + "artifactIds": [ + "artifact-boreal-model", + "artifact-boreal-report" + ] + } + ], + "reviewers": [ + { + "id": "reviewer-stat", + "name": "Statistical reviewer", + "affiliations": [ + "Independent" + ], + "reviewedSubmissionIds": [ + "sub-aurora", + "sub-boreal" + ] + }, + { + "id": "reviewer-sponsor", + "name": "Sponsor domain reviewer", + "affiliations": [ + "Open Climate Lab" + ], + "reviewedSubmissionIds": [ + "sub-aurora" + ] + }, + { + "id": "reviewer-external", + "name": "External replication reviewer", + "affiliations": [ + "University Center for Forecasting" + ], + "reviewedSubmissionIds": [] + } + ], + "evidenceSnapshots": [ + { + "id": "snapshot-aurora-decision", + "submissionId": "sub-aurora", + "lockedAt": "2026-05-13T10:05:00Z", + "artifactHashes": { + "artifact-aurora-model": "sha256:4c9dc6f02f0c9f8f9a0010e7c8c0221d", + "artifact-aurora-report": "sha256:9a78aa4dcb8a9b605e63b10ec31e4f62", + "artifact-aurora-dataset": "sha256:f0796207ec9d81a43ff84dc148d873ab" + } + }, + { + "id": "snapshot-boreal-decision", + "submissionId": "sub-boreal", + "lockedAt": "2026-05-12T12:10:00Z", + "artifactHashes": { + "artifact-boreal-model": "sha256:e2e87d98509bb0186ef49b6b37a31a30", + "artifact-boreal-report": "sha256:d33a2ad6f1fb4f8421ec13fb935af884" + } + } + ], + "appeals": [ + { + "id": "appeal-aurora-conflict", + "submissionId": "sub-aurora", + "filedBy": { + "role": "competing-solver", + "teamId": "team-boreal" + }, + "reason": "conflict-of-interest", + "filedAt": "2026-05-13T18:00:00Z", + "reviewerIds": [ + "reviewer-sponsor", + "reviewer-external" + ], + "status": "open", + "summary": "A sponsor-affiliated reviewer participated in scoring the winning submission." + }, + { + "id": "appeal-boreal-split", + "submissionId": "sub-boreal", + "filedBy": { + "role": "solver", + "teamId": "team-boreal" + }, + "reason": "payout-split-dispute", + "filedAt": "2026-05-13T08:00:00Z", + "resolvedAt": "2026-05-14T02:00:00Z", + "reviewerIds": [ + "reviewer-stat" + ], + "status": "resolved", + "resolution": "upheld-original-route", + "summary": "Team requested an alternate split, but submitted payout route was confirmed." + } + ] +} diff --git a/scientific-bounty-appeals-ledger/docs/demo.mp4 b/scientific-bounty-appeals-ledger/docs/demo.mp4 new file mode 100644 index 0000000..f48b085 Binary files /dev/null and b/scientific-bounty-appeals-ledger/docs/demo.mp4 differ diff --git a/scientific-bounty-appeals-ledger/docs/demo.svg b/scientific-bounty-appeals-ledger/docs/demo.svg new file mode 100644 index 0000000..e84874b --- /dev/null +++ b/scientific-bounty-appeals-ledger/docs/demo.svg @@ -0,0 +1,16 @@ + + + + Bounty Appeals Ledger + Dispute handling for evidence, payout, and IP guards + + US$60k held + Eligible open appeal + + Conflict review + Sponsor-affiliated reviewer + + Top action: assign an independent reviewer. + + IP transfer remains blocked while the eligible appeal is open. + diff --git a/scientific-bounty-appeals-ledger/docs/requirement-map.md b/scientific-bounty-appeals-ledger/docs/requirement-map.md new file mode 100644 index 0000000..50407c1 --- /dev/null +++ b/scientific-bounty-appeals-ledger/docs/requirement-map.md @@ -0,0 +1,16 @@ +# Requirement Map + +This module contributes a focused dispute-handling slice for SCIBASE issue #18: Scientific Bounty System. + +| Issue #18 requirement | Evidence in this module | +| --- | --- | +| Platform-mediated arbitration system | `buildAppealsLedger` creates a deterministic ledger for post-evaluation appeals, eligibility, reviewer conflicts, SLA state, and next actions. | +| Automated checklists for deliverables | `evaluateEvidenceLock` checks whether contested submissions have complete locked artifact hashes before arbitration proceeds. | +| Optional third-party reviewers or peer validators | `evaluateReviewerConflicts` identifies when a sponsor-affiliated reviewer must be replaced by an independent reviewer. | +| Feedback loop between submitters and sponsors | `buildSponsorFeedbackPacket` summarizes open appeals, overdue responses, conflict reviews, and requested sponsor actions. | +| Escrowed prize funds | `summarizePayouts` separates held and releasable award amounts while an eligible appeal remains open. | +| Partial payments or honorable mentions | The sample challenge covers both a winning award and an honorable-mention payout route. | +| Payout routing | `evaluatePayoutHold` keeps awarded submission routes intact while holding/releasing funds based on appeal state. | +| IP transfer upon payout | `evaluateIpGuard` blocks IP transfer while payout is held by an eligible appeal. | +| Auditability | `buildAuditTrail` emits decision, appeal-window, response-due, payout-hold, and payout-summary events with a stable digest. | +| Reviewer demo | `npm run demo` prints appeal count, payout hold amount, top action, IP guard count, and digest; `docs/demo.mp4` is a short visual demo artifact. | diff --git a/scientific-bounty-appeals-ledger/package.json b/scientific-bounty-appeals-ledger/package.json new file mode 100644 index 0000000..96cc70f --- /dev/null +++ b/scientific-bounty-appeals-ledger/package.json @@ -0,0 +1,18 @@ +{ + "name": "scientific-bounty-appeals-ledger", + "version": "1.0.0", + "private": true, + "description": "Dependency-free appeals and dispute ledger for scientific bounty systems.", + "scripts": { + "check": "node --check src/appeals-ledger.js && node --check scripts/demo.js && node --check test/appeals-ledger.test.js", + "demo": "node scripts/demo.js", + "test": "node test/appeals-ledger.test.js" + }, + "keywords": [ + "scientific-bounties", + "appeals", + "arbitration", + "payouts" + ], + "license": "MIT" +} diff --git a/scientific-bounty-appeals-ledger/scripts/demo.js b/scientific-bounty-appeals-ledger/scripts/demo.js new file mode 100644 index 0000000..530fe30 --- /dev/null +++ b/scientific-bounty-appeals-ledger/scripts/demo.js @@ -0,0 +1,16 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { buildAppealsLedger } = require("../src/appeals-ledger"); + +const samplePath = path.join(__dirname, "..", "data", "sample-appeals.json"); +const challenge = JSON.parse(fs.readFileSync(samplePath, "utf8")); +const ledger = buildAppealsLedger(challenge); + +console.log(`Challenge: ${ledger.title}`); +console.log(`Appeals tracked: ${ledger.appeals.length}`); +console.log(`Payout status: ${ledger.payout.status}`); +console.log(`Held amount: US$${ledger.payout.heldAmount.toLocaleString("en-US")}`); +console.log(`Open appeals: ${ledger.dashboard.openAppealCount}`); +console.log(`Top action: ${ledger.dashboard.highPriorityItems[0].nextAction}`); +console.log(`IP guards: ${ledger.ipTransferGuards.length}`); +console.log(`Digest: ${ledger.digest}`); diff --git a/scientific-bounty-appeals-ledger/src/appeals-ledger.js b/scientific-bounty-appeals-ledger/src/appeals-ledger.js new file mode 100644 index 0000000..fc7e550 --- /dev/null +++ b/scientific-bounty-appeals-ledger/src/appeals-ledger.js @@ -0,0 +1,396 @@ +const crypto = require("node:crypto"); + +function buildAppealsLedger(challenge) { + const validation = validateChallenge(challenge); + const submissions = indexById(challenge.submissions || []); + const reviewers = indexById(challenge.reviewers || []); + const snapshots = indexBySubmission(challenge.evidenceSnapshots || []); + const appeals = (challenge.appeals || []).map((appeal) => + evaluateAppeal(appeal, challenge, submissions, reviewers, snapshots) + ); + const payout = summarizePayouts(challenge, appeals); + const auditTrail = buildAuditTrail(challenge, appeals, payout); + const dashboard = buildArbitrationDashboard(challenge, appeals, payout); + + const ledger = { + challengeId: challenge.challengeId, + title: challenge.title, + asOf: challenge.asOf, + validation, + appeals, + payout, + ipTransferGuards: buildIpTransferGuards(challenge, appeals), + sponsorFeedbackPacket: buildSponsorFeedbackPacket(challenge, appeals), + dashboard, + auditTrail + }; + + ledger.digest = stableDigest(ledger); + return ledger; +} + +function validateChallenge(challenge) { + const required = [ + ["challengeId", challenge.challengeId], + ["title", challenge.title], + ["asOf", challenge.asOf], + ["appealPolicy.appealWindowHours", challenge.appealPolicy && challenge.appealPolicy.appealWindowHours], + ["submissions", (challenge.submissions || []).length], + ["reviewers", (challenge.reviewers || []).length], + ["evidenceSnapshots", (challenge.evidenceSnapshots || []).length], + ["appeals", (challenge.appeals || []).length] + ]; + const missing = required.filter(([, value]) => !value).map(([field]) => field); + return { + status: missing.length === 0 ? "passed" : "incomplete", + score: Math.max(0, 100 - missing.length * 12), + missing + }; +} + +function evaluateAppeal(appeal, challenge, submissions, reviewers, snapshots) { + const submission = submissions.get(appeal.submissionId); + const policy = challenge.appealPolicy || {}; + const asOf = new Date(challenge.asOf); + const filedAt = new Date(appeal.filedAt); + const decisionAt = submission ? new Date(submission.decisionAt) : null; + const appealWindowEndsAt = decisionAt ? addHours(decisionAt, policy.appealWindowHours || 0) : null; + const responseDueAt = addHours(filedAt, policy.reviewerResponseHours || 0); + const eligibility = evaluateEligibility(appeal, submission, policy, filedAt, appealWindowEndsAt); + const evidenceLock = evaluateEvidenceLock(submission, snapshots.get(appeal.submissionId)); + const reviewerConflicts = evaluateReviewerConflicts(appeal, challenge, submission, reviewers); + const sla = evaluateSla(appeal, asOf, responseDueAt); + const payoutHold = evaluatePayoutHold(appeal, submission, eligibility); + const ipGuard = evaluateIpGuard(challenge, appeal, payoutHold); + + return { + id: appeal.id, + submissionId: appeal.submissionId, + status: appeal.status, + reason: appeal.reason, + summary: appeal.summary, + eligibility, + evidenceLock, + reviewerConflicts, + sla, + payoutHold, + ipGuard, + nextAction: chooseNextAction(appeal, eligibility, evidenceLock, reviewerConflicts, sla, payoutHold) + }; +} + +function evaluateEligibility(appeal, submission, policy, filedAt, appealWindowEndsAt) { + const acceptedReason = (policy.acceptedReasons || []).includes(appeal.reason); + const withinWindow = Boolean(appealWindowEndsAt && filedAt <= appealWindowEndsAt); + const knownSubmission = Boolean(submission); + const allowedRole = ["solver", "competing-solver", "sponsor", "reviewer"].includes(appeal.filedBy && appeal.filedBy.role); + const failures = []; + + if (!knownSubmission) failures.push("unknown-submission"); + if (!acceptedReason) failures.push("unsupported-reason"); + if (!withinWindow) failures.push("outside-appeal-window"); + if (!allowedRole) failures.push("unsupported-appellant-role"); + + return { + status: failures.length === 0 ? "eligible" : "ineligible", + acceptedReason, + withinWindow, + appealWindowEndsAt: appealWindowEndsAt ? appealWindowEndsAt.toISOString() : null, + failures + }; +} + +function evaluateEvidenceLock(submission, snapshot) { + if (!submission || !snapshot) { + return { + status: "missing-lock", + coverage: 0, + missingArtifactIds: submission ? submission.artifactIds.slice() : [] + }; + } + + const hashes = snapshot.artifactHashes || {}; + const missingArtifactIds = (submission.artifactIds || []).filter((artifactId) => !hashes[artifactId]); + const coverage = submission.artifactIds.length === 0 + ? 100 + : Math.round(((submission.artifactIds.length - missingArtifactIds.length) / submission.artifactIds.length) * 100); + + return { + status: missingArtifactIds.length === 0 ? "locked" : "partial-lock", + snapshotId: snapshot.id, + lockedAt: snapshot.lockedAt, + coverage, + missingArtifactIds + }; +} + +function evaluateReviewerConflicts(appeal, challenge, submission, reviewers) { + const sponsorName = challenge.sponsor && challenge.sponsor.name; + const conflicts = (appeal.reviewerIds || []).flatMap((reviewerId) => { + const reviewer = reviewers.get(reviewerId); + if (!reviewer) { + return [{ + reviewerId, + type: "unknown-reviewer", + message: "Reviewer id is not registered in the challenge reviewer roster." + }]; + } + + const items = []; + if (sponsorName && (reviewer.affiliations || []).includes(sponsorName)) { + items.push({ + reviewerId, + type: "sponsor-affiliation", + message: `${reviewer.name} is affiliated with the sponsor.` + }); + } + if (submission && (reviewer.reviewedSubmissionIds || []).includes(submission.id) && appeal.reason === "conflict-of-interest") { + items.push({ + reviewerId, + type: "reviewed-contested-submission", + message: `${reviewer.name} reviewed the contested submission.` + }); + } + return items; + }); + + return { + status: conflicts.length === 0 ? "clear" : "conflict-review-needed", + conflicts + }; +} + +function evaluateSla(appeal, asOf, responseDueAt) { + if (appeal.status === "resolved") { + return { + status: "resolved", + responseDueAt: responseDueAt.toISOString(), + hoursOverdue: 0 + }; + } + + const hoursOverdue = Math.max(0, Math.ceil((asOf - responseDueAt) / 36e5)); + return { + status: hoursOverdue > 0 ? "overdue" : "within-sla", + responseDueAt: responseDueAt.toISOString(), + hoursOverdue + }; +} + +function evaluatePayoutHold(appeal, submission, eligibility) { + if (!submission || eligibility.status !== "eligible") { + return { + status: "no-hold", + holdAmount: 0, + reason: "Appeal is not eligible for a payout hold." + }; + } + if (appeal.status === "resolved") { + return { + status: "released", + holdAmount: 0, + releaseAmount: submission.awardAmount, + reason: "Appeal is resolved; payout may follow the recorded route." + }; + } + return { + status: "hold", + holdAmount: submission.awardAmount, + releaseAmount: 0, + reason: "Eligible open appeal keeps the award in hold until arbitration closes." + }; +} + +function evaluateIpGuard(challenge, appeal, payoutHold) { + const holdDuringAppeal = Boolean(challenge.ipPolicy && challenge.ipPolicy.holdDuringAppeal); + if (holdDuringAppeal && payoutHold.status === "hold") { + return { + status: "blocked", + message: "IP transfer is blocked while an eligible appeal holds payout." + }; + } + if (appeal.status === "resolved") { + return { + status: "ready-after-payout", + message: "IP transfer can follow the payout outcome." + }; + } + return { + status: "not-required", + message: "No IP transfer guard is required for this appeal state." + }; +} + +function summarizePayouts(challenge, appeals) { + const heldBySubmission = new Map(); + appeals.forEach((appeal) => { + if (appeal.payoutHold.status === "hold") { + heldBySubmission.set(appeal.submissionId, appeal.payoutHold.holdAmount); + } + }); + + const submissions = challenge.submissions || []; + const totalAwarded = submissions.reduce((sum, submission) => sum + (submission.awardAmount || 0), 0); + const heldAmount = [...heldBySubmission.values()].reduce((sum, amount) => sum + amount, 0); + + return { + totalAwarded, + heldAmount, + releasableAmount: totalAwarded - heldAmount, + heldSubmissions: [...heldBySubmission.keys()].sort(), + status: heldAmount > 0 ? "partial-hold" : "all-releasable" + }; +} + +function buildIpTransferGuards(challenge, appeals) { + return appeals + .filter((appeal) => appeal.ipGuard.status !== "not-required") + .map((appeal) => ({ + appealId: appeal.id, + submissionId: appeal.submissionId, + status: appeal.ipGuard.status, + message: appeal.ipGuard.message + })); +} + +function buildSponsorFeedbackPacket(challenge, appeals) { + return { + sponsorId: challenge.sponsor && challenge.sponsor.id, + openAppeals: appeals.filter((appeal) => appeal.status !== "resolved").length, + overdueResponses: appeals.filter((appeal) => appeal.sla.status === "overdue").length, + conflictReviews: appeals.filter((appeal) => appeal.reviewerConflicts.status !== "clear").length, + requestedSponsorActions: appeals + .filter((appeal) => appeal.nextAction.owner === "sponsor") + .map((appeal) => appeal.nextAction.message) + }; +} + +function buildArbitrationDashboard(challenge, appeals, payout) { + return { + challengeId: challenge.challengeId, + appealCount: appeals.length, + openAppealCount: appeals.filter((appeal) => appeal.status !== "resolved").length, + payoutStatus: payout.status, + highPriorityItems: appeals + .filter((appeal) => appeal.sla.status === "overdue" || appeal.reviewerConflicts.status !== "clear") + .map((appeal) => ({ + appealId: appeal.id, + reason: appeal.reason, + nextAction: appeal.nextAction.message + })) + }; +} + +function buildAuditTrail(challenge, appeals, payout) { + const events = []; + (challenge.submissions || []).forEach((submission) => { + events.push({ + at: submission.decisionAt, + type: "decision-recorded", + submissionId: submission.id, + amount: submission.awardAmount + }); + }); + appeals.forEach((appeal) => { + events.push({ + at: appeal.eligibility.appealWindowEndsAt, + type: "appeal-window-ends", + appealId: appeal.id + }); + events.push({ + at: appeal.sla.responseDueAt, + type: "reviewer-response-due", + appealId: appeal.id, + status: appeal.sla.status + }); + if (appeal.payoutHold.status === "hold") { + events.push({ + at: challenge.asOf, + type: "payout-held", + appealId: appeal.id, + submissionId: appeal.submissionId, + amount: appeal.payoutHold.holdAmount + }); + } + }); + events.push({ + at: challenge.asOf, + type: "payout-summary", + heldAmount: payout.heldAmount, + releasableAmount: payout.releasableAmount + }); + + return events.sort((a, b) => `${a.at}:${a.type}`.localeCompare(`${b.at}:${b.type}`)); +} + +function chooseNextAction(appeal, eligibility, evidenceLock, reviewerConflicts, sla, payoutHold) { + if (eligibility.status !== "eligible") { + return { + owner: "arbitrator", + message: `Reject or request correction: ${eligibility.failures.join(", ")}.` + }; + } + if (evidenceLock.status !== "locked") { + return { + owner: "arbitrator", + message: "Lock missing evidence artifacts before continuing arbitration." + }; + } + if (reviewerConflicts.status !== "clear") { + return { + owner: "sponsor", + message: "Assign an independent reviewer to resolve conflict-of-interest concerns." + }; + } + if (sla.status === "overdue") { + return { + owner: "arbitrator", + message: "Escalate overdue reviewer response." + }; + } + if (payoutHold.status === "hold") { + return { + owner: "arbitrator", + message: "Keep payout and IP transfer on hold until appeal resolution." + }; + } + return { + owner: "platform", + message: "Release payout according to the recorded route." + }; +} + +function addHours(date, hours) { + return new Date(date.getTime() + hours * 36e5); +} + +function indexById(items) { + return new Map(items.map((item) => [item.id, item])); +} + +function indexBySubmission(items) { + return new Map(items.map((item) => [item.submissionId, item])); +} + +function stableDigest(value) { + return crypto.createHash("sha256").update(stableStringify(value)).digest("hex"); +} + +function stableStringify(value) { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(",")}]`; + } + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +module.exports = { + buildAppealsLedger, + validateChallenge, + stableDigest +}; diff --git a/scientific-bounty-appeals-ledger/test/appeals-ledger.test.js b/scientific-bounty-appeals-ledger/test/appeals-ledger.test.js new file mode 100644 index 0000000..ead66d9 --- /dev/null +++ b/scientific-bounty-appeals-ledger/test/appeals-ledger.test.js @@ -0,0 +1,46 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { buildAppealsLedger, validateChallenge } = require("../src/appeals-ledger"); + +const samplePath = path.join(__dirname, "..", "data", "sample-appeals.json"); +const challenge = JSON.parse(fs.readFileSync(samplePath, "utf8")); +const ledger = buildAppealsLedger(challenge); + +assert.equal(ledger.validation.status, "passed"); +assert.equal(ledger.appeals.length, 2); +assert.equal(ledger.payout.status, "partial-hold"); +assert.equal(ledger.payout.heldAmount, 60000); +assert.equal(ledger.payout.releasableAmount, 10000); + +const conflictAppeal = ledger.appeals.find((appeal) => appeal.id === "appeal-aurora-conflict"); +assert.equal(conflictAppeal.eligibility.status, "eligible"); +assert.equal(conflictAppeal.evidenceLock.status, "locked"); +assert.equal(conflictAppeal.reviewerConflicts.status, "conflict-review-needed"); +assert.equal(conflictAppeal.payoutHold.status, "hold"); +assert.equal(conflictAppeal.ipGuard.status, "blocked"); +assert.equal(conflictAppeal.nextAction.owner, "sponsor"); + +const resolvedAppeal = ledger.appeals.find((appeal) => appeal.id === "appeal-boreal-split"); +assert.equal(resolvedAppeal.sla.status, "resolved"); +assert.equal(resolvedAppeal.payoutHold.status, "released"); +assert.equal(resolvedAppeal.ipGuard.status, "ready-after-payout"); + +assert.equal(ledger.sponsorFeedbackPacket.openAppeals, 1); +assert.equal(ledger.sponsorFeedbackPacket.conflictReviews, 1); +assert.ok(ledger.dashboard.highPriorityItems[0].nextAction.includes("independent reviewer")); +assert.ok(ledger.auditTrail.some((event) => event.type === "payout-held")); +assert.equal(ledger.digest, buildAppealsLedger(challenge).digest); + +const incomplete = validateChallenge({ challengeId: "draft" }); +assert.equal(incomplete.status, "incomplete"); +assert.ok(incomplete.missing.includes("appealPolicy.appealWindowHours")); + +const degradedChallenge = JSON.parse(JSON.stringify(challenge)); +degradedChallenge.evidenceSnapshots[0].artifactHashes = {}; +const degradedLedger = buildAppealsLedger(degradedChallenge); +const degradedAppeal = degradedLedger.appeals.find((appeal) => appeal.id === "appeal-aurora-conflict"); +assert.equal(degradedAppeal.evidenceLock.status, "partial-lock"); +assert.equal(degradedAppeal.nextAction.owner, "arbitrator"); + +console.log("scientific-bounty-appeals-ledger tests passed");