diff --git a/challenge-withdrawal-reimbursement-guard/README.md b/challenge-withdrawal-reimbursement-guard/README.md new file mode 100644 index 00000000..e80a3c5b --- /dev/null +++ b/challenge-withdrawal-reimbursement-guard/README.md @@ -0,0 +1,44 @@ +# Challenge Withdrawal Reimbursement Guard + +This self-contained module adds a deterministic closeout guard for sponsor withdrawals, cancellations, and material scope reductions in the SCIBASE Scientific Bounty System. It is scoped to issue #18 and focuses on solver fairness after teams have already started work. + +The guard does not call external APIs, payment processors, live payout systems, private data stores, or credentialed services. Fixtures are synthetic and every check runs with Node built-ins. + +## What It Checks + +- Sponsor cancellation authority and reviewer-ready withdrawal reasons. +- Equal direct notice to every started solver. +- Appeal-window and solver-cost claim instructions in each notice. +- Dispute-hold duration before sponsor refund or challenge closeout. +- Milestone-progress evidence and hashed work artifacts. +- Cost claim evidence for documented solver spend. +- Receipt hashes for non-refundable solver costs. +- Sponsor-funded reimbursement reserve sufficiency. +- Recorded reimbursement decisions against deterministic recommendations. +- IP return and data-destruction attestations before closeout. + +## Local Validation + +```sh +npm --prefix challenge-withdrawal-reimbursement-guard run check +npm --prefix challenge-withdrawal-reimbursement-guard test +npm --prefix challenge-withdrawal-reimbursement-guard run demo +npm --prefix challenge-withdrawal-reimbursement-guard run make-demo-video +npm --prefix challenge-withdrawal-reimbursement-guard run verify-video +``` + +## Generated Artifacts + +Running the demo writes: + +- `reports/clean-withdrawal-report.json` +- `reports/risky-withdrawal-report.json` +- `reports/risky-withdrawal-handoff.md` +- `reports/withdrawal-dashboard.svg` +- `reports/demo.mp4` + +The risky packet intentionally demonstrates release blockers: missing cancellation authority, thin withdrawal reason, unequal solver notice, missing appeal and cost-claim instructions, missing milestone evidence, missing cost evidence, missing non-refundable receipts, incomplete IP/data return attestations, short dispute hold, missing appeal deadline, reimbursement shortfalls, and an underfunded reimbursement reserve. + +## Issue Fit + +This is a distinct Scientific Bounty System slice. It complements the existing intake, rubric readiness, scoring, arbitration, award transparency, appeals, escrow, payout eligibility, sponsor compliance, debrief feedback, onboarding clock parity, communication parity, accessibility/localization, deadline fairness, and amendment-consent work by focusing specifically on cancellation closeout fairness after solvers have already incurred work and costs. diff --git a/challenge-withdrawal-reimbursement-guard/demo.js b/challenge-withdrawal-reimbursement-guard/demo.js new file mode 100644 index 00000000..51636e4b --- /dev/null +++ b/challenge-withdrawal-reimbursement-guard/demo.js @@ -0,0 +1,110 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { evaluateChallengeWithdrawal } = require("./index"); +const { cleanPacket, riskyPacket } = require("./sample-data"); + +const reportsDir = path.join(__dirname, "reports"); +fs.mkdirSync(reportsDir, { recursive: true }); + +const clean = evaluateChallengeWithdrawal(cleanPacket); +const risky = evaluateChallengeWithdrawal(riskyPacket); + +function writeJson(name, value) { + fs.writeFileSync(path.join(reportsDir, name), `${JSON.stringify(value, null, 2)}\n`); +} + +function escapeXml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function findingTable(report) { + return report.findings + .slice(0, 12) + .map((finding) => `| ${finding.severity} | ${finding.code} | ${finding.action} |`) + .join("\n"); +} + +function writeHandoff(report) { + const lines = [ + "# Challenge Withdrawal Reimbursement Handoff", + "", + `Decision: ${report.summary.decision}`, + `Affected solvers: ${report.summary.affectedSolvers}`, + `Recommended reimbursement: $${report.summary.recommendedReimbursementUsd.toFixed(2)}`, + `Funded shortfall: $${report.summary.fundedShortfallUsd.toFixed(2)}`, + `Audit digest: ${report.summary.auditDigest}`, + "", + "## Priority Findings", + "", + "| Severity | Code | Remediation |", + "| --- | --- | --- |", + findingTable(report), + "", + "## Team Recommendations", + "", + ...report.challenges.flatMap((challenge) => [ + `### ${challenge.id}`, + "", + "| Team | Recommended | Recorded | Shortfall | Action |", + "| --- | ---: | ---: | ---: | --- |", + ...challenge.recommendations.map((item) => ( + `| ${item.teamId} | $${item.recommendedUsd.toFixed(2)} | $${item.recordedUsd.toFixed(2)} | $${item.shortfallUsd.toFixed(2)} | ${item.action} |` + )), + "" + ]) + ]; + fs.writeFileSync(path.join(reportsDir, "risky-withdrawal-handoff.md"), `${lines.join("\n")}\n`); +} + +function writeSvg(cleanReport, riskyReport) { + const width = 960; + const height = 540; + const cleanBar = Math.round((cleanReport.summary.recommendedReimbursementUsd / 2600) * 300); + const riskyBar = Math.round((riskyReport.summary.recommendedReimbursementUsd / 2600) * 300); + const shortfallBar = Math.round((riskyReport.summary.fundedShortfallUsd / 2600) * 300); + const rows = riskyReport.findings.slice(0, 8).map((finding, index) => { + const y = 250 + index * 26; + const color = finding.severity === "critical" ? "#991b1b" : finding.severity === "high" ? "#dc2626" : "#d97706"; + return `${escapeXml(finding.code)}`; + }).join("\n"); + + const svg = ` + + + + Challenge withdrawal reimbursement guard + Deterministic closeout review for sponsor cancellations after solvers have started work. + Clean closeout + + + $${cleanReport.summary.recommendedReimbursementUsd.toFixed(2)} + Risky closeout + + + $${riskyReport.summary.recommendedReimbursementUsd.toFixed(2)} + Reserve shortfall + + + $${riskyReport.summary.fundedShortfallUsd.toFixed(2)} + Top risky findings + ${rows} + Decision: ${escapeXml(riskyReport.summary.decision)} | ${riskyReport.summary.auditDigest.slice(0, 28)}... + +`; + fs.writeFileSync(path.join(reportsDir, "withdrawal-dashboard.svg"), svg); +} + +writeJson("clean-withdrawal-report.json", clean); +writeJson("risky-withdrawal-report.json", risky); +writeHandoff(risky); +writeSvg(clean, risky); + +console.log("Wrote challenge withdrawal reimbursement guard reports:"); +console.log(`- ${path.join(reportsDir, "clean-withdrawal-report.json")}`); +console.log(`- ${path.join(reportsDir, "risky-withdrawal-report.json")}`); +console.log(`- ${path.join(reportsDir, "risky-withdrawal-handoff.md")}`); +console.log(`- ${path.join(reportsDir, "withdrawal-dashboard.svg")}`); diff --git a/challenge-withdrawal-reimbursement-guard/index.js b/challenge-withdrawal-reimbursement-guard/index.js new file mode 100644 index 00000000..0987ae0b --- /dev/null +++ b/challenge-withdrawal-reimbursement-guard/index.js @@ -0,0 +1,426 @@ +const crypto = require("node:crypto"); + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function stableJson(value) { + if (Array.isArray(value)) { + return `[${value.map(stableJson).join(",")}]`; + } + if (value && typeof value === "object") { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`; + } + return JSON.stringify(value); +} + +function sha256(value) { + return crypto.createHash("sha256").update(stableJson(value)).digest("hex"); +} + +function toDate(value) { + const parsed = new Date(value || ""); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function hoursBetween(laterValue, earlierValue) { + const later = toDate(laterValue); + const earlier = toDate(earlierValue); + if (!later || !earlier) { + return null; + } + return Math.round((later.getTime() - earlier.getTime()) / (60 * 60 * 1000)); +} + +function daysBetween(laterValue, earlierValue) { + const later = toDate(laterValue); + const earlier = toDate(earlierValue); + if (!later || !earlier) { + return null; + } + return Math.floor((later.getTime() - earlier.getTime()) / (24 * 60 * 60 * 1000)); +} + +function roundMoney(value) { + return Math.round((Number(value) || 0) * 100) / 100; +} + +function severityRank(severity) { + return { critical: 4, high: 3, medium: 2, low: 1 }[severity] || 0; +} + +function addFinding(findings, severity, code, message, refs, action) { + findings.push({ + severity, + code, + message, + refs: asArray(refs), + action + }); +} + +function normalizeString(value) { + return String(value || "").trim().toLowerCase(); +} + +function noticeByTeam(challenge, teamId) { + return asArray(challenge.communications && challenge.communications.solverNotices) + .find((notice) => normalizeString(notice.teamId) === normalizeString(teamId)); +} + +function reimbursementByTeam(challenge, teamId) { + return asArray(challenge.reimbursements) + .find((payment) => normalizeString(payment.teamId) === normalizeString(teamId)); +} + +function solverStartedBeforeCancellation(solver, cancellation) { + const startedAt = toDate(solver.startedAt || solver.acceptedAt); + const requestedAt = toDate(cancellation && cancellation.requestedAt); + if (!startedAt || !requestedAt) { + return false; + } + return startedAt.getTime() <= requestedAt.getTime(); +} + +function evidenceMissing(record) { + return !record || !record.evidenceHash || String(record.evidenceHash).length < 12; +} + +function receiptMissing(record) { + return !record || !record.receiptHash || String(record.receiptHash).length < 12; +} + +function sumAmounts(records) { + return roundMoney(asArray(records).reduce((total, record) => total + (Number(record.amountUsd) || 0), 0)); +} + +function calculateTeamRecommendation(challenge, solver, policy) { + const bountyAmount = Number(challenge.bounty && challenge.bounty.amountUsd) || 0; + const reimbursementCap = Math.min( + Number(challenge.bounty && challenge.bounty.reimbursementCapUsd) || Number.POSITIVE_INFINITY, + bountyAmount * Number(policy.maxReimbursementPercent || 0.35) + ); + const documentedCosts = sumAmounts(asArray(solver.costClaims).filter((claim) => !evidenceMissing(claim))); + const irreversibleCosts = sumAmounts(asArray(solver.costClaims).filter((claim) => claim.nonRefundable === true && !evidenceMissing(claim))); + const progressPercent = Math.max(0, Math.min(100, Number(solver.milestoneProgressPercent) || 0)); + const milestoneCredit = roundMoney(bountyAmount * (progressPercent / 100) * Number(policy.milestoneCreditRate || 0.18)); + const rawRecommendation = roundMoney(irreversibleCosts + milestoneCredit); + const recommendedUsd = roundMoney(Math.min(rawRecommendation, reimbursementCap)); + + return { + teamId: solver.teamId, + documentedCostsUsd: documentedCosts, + irreversibleCostsUsd: irreversibleCosts, + milestoneCreditUsd: milestoneCredit, + reimbursementCapUsd: roundMoney(reimbursementCap), + recommendedUsd + }; +} + +function evaluateChallenge(challenge, policy, reviewDate, findings) { + const cancellation = challenge.cancellation || {}; + const bountyAmount = Number(challenge.bounty && challenge.bounty.amountUsd) || 0; + const materialReduction = Number(cancellation.materialReductionPercent) || 0; + const isWithdrawal = ["withdrawn", "cancelled", "canceled", "reduced"].includes(normalizeString(challenge.status)) + || ["withdrawal", "cancellation", "material_reduction"].includes(normalizeString(cancellation.type)) + || materialReduction >= Number(policy.materialReductionThresholdPercent || 25); + const startedSolvers = asArray(challenge.solvers) + .filter((solver) => solverStartedBeforeCancellation(solver, cancellation)); + const recommendations = []; + + if (!isWithdrawal) { + return { + id: challenge.id, + status: challenge.status || "active", + action: "not_a_withdrawal", + affectedSolvers: 0, + recommendedReimbursementUsd: 0, + fundedShortfallUsd: 0, + recommendations + }; + } + + if (!cancellation.authorizedBy || challenge.sponsor && challenge.sponsor.cancellationAuthority !== true) { + addFinding( + findings, + "critical", + "CANCELLATION_AUTHORITY_MISSING", + `${challenge.id || "Challenge"} does not show a sponsor-authorized cancellation decision.`, + [challenge.id || "challenge"], + "verify_sponsor_cancellation_authority_before_closeout" + ); + } + + if (!cancellation.reason || String(cancellation.reason).length < 20) { + addFinding( + findings, + "medium", + "WITHDRAWAL_REASON_TOO_THIN", + `${challenge.id || "Challenge"} has no reviewer-ready reason for withdrawal or material reduction.`, + [challenge.id || "challenge"], + "record_specific_withdrawal_reason_for_audit" + ); + } + + if (startedSolvers.length === 0 && materialReduction >= Number(policy.materialReductionThresholdPercent || 25)) { + addFinding( + findings, + "medium", + "MATERIAL_REDUCTION_WITHOUT_SOLVER_SCAN", + `${challenge.id || "Challenge"} is materially reduced but has no solver-start scan attached.`, + [challenge.id || "challenge"], + "attach_solver_start_scan_before_scope_reduction" + ); + } + + const directNotices = startedSolvers.map((solver) => noticeByTeam(challenge, solver.teamId)).filter(Boolean); + const earliestNotice = directNotices + .map((notice) => notice.sentAt) + .filter(Boolean) + .sort()[0] || (challenge.communications && challenge.communications.sponsorNoticeAt); + + for (const solver of startedSolvers) { + const refs = [challenge.id || "challenge", solver.teamId || "solver"]; + const notice = noticeByTeam(challenge, solver.teamId); + + if (!notice || !notice.sentAt) { + addFinding( + findings, + "high", + "SOLVER_WITHDRAWAL_NOTICE_MISSING", + `${solver.teamId || "Solver"} has no direct withdrawal notice.`, + refs, + "send_equal_withdrawal_notice_to_all_started_solvers" + ); + } else { + const lag = hoursBetween(notice.sentAt, earliestNotice); + if (lag !== null && lag > Number(policy.equalNoticeToleranceHours || 12)) { + addFinding( + findings, + "high", + "NOTICE_PARITY_GAP", + `${solver.teamId || "Solver"} received withdrawal notice ${lag} hours after the first notified solver.`, + refs, + "restart_closeout_clock_after_equal_solver_notice" + ); + } + if (notice.includesAppealWindow !== true) { + addFinding( + findings, + "medium", + "APPEAL_WINDOW_NOT_IN_NOTICE", + `${solver.teamId || "Solver"} notice omits the appeal window.`, + refs, + "include_appeal_deadline_in_solver_notice" + ); + } + if (notice.includesCostClaimLink !== true) { + addFinding( + findings, + "high", + "COST_CLAIM_PATH_NOT_IN_NOTICE", + `${solver.teamId || "Solver"} notice omits the cost-claim path.`, + refs, + "include_solver_cost_claim_path_in_notice" + ); + } + } + + if (!solver.milestoneProgressPercent && asArray(solver.submittedMilestones).length === 0) { + addFinding( + findings, + "medium", + "MILESTONE_PROGRESS_EVIDENCE_MISSING", + `${solver.teamId || "Solver"} has no milestone-progress evidence for reimbursement review.`, + refs, + "attach_milestone_progress_evidence_before_reimbursement_decision" + ); + } + + for (const milestone of asArray(solver.submittedMilestones)) { + if (evidenceMissing(milestone)) { + addFinding( + findings, + "high", + "MILESTONE_EVIDENCE_HASH_MISSING", + `${solver.teamId || "Solver"} milestone ${milestone.id || "unknown"} lacks an evidence hash.`, + refs, + "hash_milestone_evidence_before_solver_cost_review" + ); + } + } + + for (const claim of asArray(solver.costClaims)) { + if (evidenceMissing(claim)) { + addFinding( + findings, + "high", + "COST_CLAIM_EVIDENCE_MISSING", + `${solver.teamId || "Solver"} cost claim ${claim.id || "unknown"} lacks evidence.`, + refs, + "attach_cost_claim_evidence_before_reimbursement" + ); + } + if (claim.nonRefundable === true && policy.irreversibleSpendRequiresReceipt !== false && receiptMissing(claim)) { + addFinding( + findings, + "high", + "NONREFUNDABLE_RECEIPT_MISSING", + `${solver.teamId || "Solver"} non-refundable cost claim ${claim.id || "unknown"} lacks a receipt hash.`, + refs, + "attach_receipt_hash_for_nonrefundable_solver_spend" + ); + } + } + + if (!solver.ipReturn || !solver.ipReturn.returnedAt || !solver.ipReturn.dataDestroyedAt || !solver.ipReturn.attestationHash) { + addFinding( + findings, + "medium", + "IP_DATA_RETURN_ATTESTATION_MISSING", + `${solver.teamId || "Solver"} has no complete IP return and data-destruction attestation.`, + refs, + "collect_ip_return_and_data_destruction_attestation" + ); + } + + const recommendation = calculateTeamRecommendation(challenge, solver, policy); + const recorded = reimbursementByTeam(challenge, solver.teamId); + const recordedAmount = Number(recorded && recorded.amountUsd) || 0; + recommendation.recordedUsd = roundMoney(recordedAmount); + recommendation.shortfallUsd = roundMoney(Math.max(0, recommendation.recommendedUsd - recordedAmount)); + recommendation.action = recommendation.shortfallUsd > 0 ? "hold_and_reimburse_solver_costs" : "release_reimbursement_closeout"; + recommendations.push(recommendation); + + if (recommendation.recommendedUsd > 0 && !recorded) { + addFinding( + findings, + "high", + "REIMBURSEMENT_DECISION_MISSING", + `${solver.teamId || "Solver"} has an eligible reimbursement recommendation but no recorded decision.`, + refs, + "record_solver_reimbursement_decision_before_challenge_closeout" + ); + } else if (recommendation.shortfallUsd > 0) { + addFinding( + findings, + "high", + "REIMBURSEMENT_SHORTFALL", + `${solver.teamId || "Solver"} reimbursement is short by $${recommendation.shortfallUsd.toFixed(2)}.`, + refs, + "fund_reimbursement_shortfall_or_escalate_dispute" + ); + } + } + + const holdDays = daysBetween(cancellation.disputeHoldUntil, cancellation.requestedAt); + if (holdDays === null || holdDays < Number(policy.disputeHoldDays || 7)) { + addFinding( + findings, + "critical", + "DISPUTE_HOLD_WINDOW_TOO_SHORT", + `${challenge.id || "Challenge"} has a dispute hold of ${holdDays === null ? "none" : `${holdDays} days`}.`, + [challenge.id || "challenge"], + "extend_dispute_hold_before_refund_or_closeout" + ); + } + + if (!cancellation.appealDeadline) { + addFinding( + findings, + "high", + "APPEAL_DEADLINE_MISSING", + `${challenge.id || "Challenge"} has no solver appeal deadline.`, + [challenge.id || "challenge"], + "set_solver_appeal_deadline_before_withdrawal_closeout" + ); + } + + const totalRecommended = roundMoney(recommendations.reduce((total, item) => total + item.recommendedUsd, 0)); + const reserve = Number(challenge.sponsor && challenge.sponsor.reimbursementReserveUsd) || 0; + const fundedShortfall = roundMoney(Math.max(0, totalRecommended - reserve)); + + if (policy.requireSponsorFundedReserve !== false && fundedShortfall > 0) { + addFinding( + findings, + "critical", + "REIMBURSEMENT_RESERVE_SHORTFALL", + `${challenge.id || "Challenge"} reserve is short by $${fundedShortfall.toFixed(2)} for solver reimbursement.`, + [challenge.id || "challenge"], + "fund_solver_reimbursement_reserve_before_releasing_sponsor_refund" + ); + } + + const closeoutAction = recommendations.some((item) => item.shortfallUsd > 0) || fundedShortfall > 0 + ? "hold_for_solver_reimbursement" + : "release_withdrawal_closeout"; + + return { + id: challenge.id, + status: challenge.status || "withdrawn", + bountyAmountUsd: roundMoney(bountyAmount), + action: closeoutAction, + affectedSolvers: startedSolvers.length, + recommendedReimbursementUsd: totalRecommended, + fundedShortfallUsd: fundedShortfall, + recommendations + }; +} + +function evaluateChallengeWithdrawal(packet) { + const findings = []; + const reviewDate = packet.reviewDate || new Date().toISOString().slice(0, 10); + const policy = { + equalNoticeToleranceHours: 12, + disputeHoldDays: 7, + maxReimbursementPercent: 0.35, + materialReductionThresholdPercent: 25, + milestoneCreditRate: 0.18, + irreversibleSpendRequiresReceipt: true, + requireSponsorFundedReserve: true, + ...(packet.policy || {}) + }; + const challengeSummaries = asArray(packet.challenges).map((challenge) => ( + evaluateChallenge(challenge, policy, reviewDate, findings) + )); + const criticalFindings = findings.filter((finding) => finding.severity === "critical").length; + const highOrCriticalFindings = findings.filter((finding) => severityRank(finding.severity) >= severityRank("high")).length; + const recommendedReimbursementUsd = roundMoney(challengeSummaries.reduce((total, item) => total + item.recommendedReimbursementUsd, 0)); + const fundedShortfallUsd = roundMoney(challengeSummaries.reduce((total, item) => total + item.fundedShortfallUsd, 0)); + const affectedSolvers = challengeSummaries.reduce((total, item) => total + item.affectedSolvers, 0); + let decision = "release_withdrawal_closeout"; + + if (criticalFindings > 0 || fundedShortfallUsd > 0) { + decision = "escalate_withdrawal_dispute"; + } else if (highOrCriticalFindings > 0) { + decision = "hold_for_reimbursement_review"; + } + + const auditSubject = { + reviewDate, + policy, + challengeSummaries, + findingCodes: findings.map((finding) => finding.code).sort() + }; + + return { + summary: { + decision, + challengeCount: challengeSummaries.length, + affectedSolvers, + recommendedReimbursementUsd, + fundedShortfallUsd, + findingCount: findings.length, + criticalFindings, + highOrCriticalFindings, + auditDigest: `sha256:${sha256(auditSubject)}` + }, + challenges: challengeSummaries, + findings: findings.sort((a, b) => severityRank(b.severity) - severityRank(a.severity) || a.code.localeCompare(b.code)) + }; +} + +module.exports = { + evaluateChallengeWithdrawal, + sha256 +}; diff --git a/challenge-withdrawal-reimbursement-guard/make-demo-video.js b/challenge-withdrawal-reimbursement-guard/make-demo-video.js new file mode 100644 index 00000000..67f755db --- /dev/null +++ b/challenge-withdrawal-reimbursement-guard/make-demo-video.js @@ -0,0 +1,98 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const { evaluateChallengeWithdrawal } = require("./index"); +const { cleanPacket, riskyPacket } = require("./sample-data"); + +const reportsDir = path.join(__dirname, "reports"); +const framesDir = path.join(reportsDir, "frames"); +fs.mkdirSync(framesDir, { recursive: true }); + +const clean = evaluateChallengeWithdrawal(cleanPacket); +const risky = evaluateChallengeWithdrawal(riskyPacket); +const width = 960; +const height = 540; +const frames = 72; +const fps = 18; + +function setPixel(buffer, x, y, r, g, b) { + if (x < 0 || y < 0 || x >= width || y >= height) { + return; + } + const offset = (y * width + x) * 3; + buffer[offset] = r; + buffer[offset + 1] = g; + buffer[offset + 2] = b; +} + +function fillRect(buffer, x, y, w, h, r, g, b) { + for (let row = y; row < y + h; row += 1) { + for (let col = x; col < x + w; col += 1) { + setPixel(buffer, col, row, r, g, b); + } + } +} + +function drawColumns(buffer, x, baseline, count, color) { + for (let index = 0; index < count; index += 1) { + const barHeight = 26 + (index % 6) * 17; + fillRect(buffer, x + index * 26, baseline - barHeight, 18, barHeight, color[0], color[1], color[2]); + } +} + +function writeFrame(index, progress) { + const buffer = Buffer.alloc(width * height * 3, 248); + fillRect(buffer, 0, 0, width, height, 248, 250, 252); + fillRect(buffer, 48, 44, 864, 452, 255, 255, 255); + fillRect(buffer, 48, 44, 864, 8, 15, 23, 42); + + const cleanWidth = Math.floor(300 * Math.min(1, progress * 1.5) * (clean.summary.recommendedReimbursementUsd / 2600)); + const riskyWidth = Math.floor(300 * Math.max(0, (progress - 0.08) * 1.4) * (risky.summary.recommendedReimbursementUsd / 2600)); + const shortfallWidth = Math.floor(300 * Math.max(0, (progress - 0.18) * 1.3) * (risky.summary.fundedShortfallUsd / 2600)); + + fillRect(buffer, 96, 126, 300, 42, 226, 232, 240); + fillRect(buffer, 96, 126, cleanWidth, 42, 16, 185, 129); + fillRect(buffer, 96, 222, 300, 42, 226, 232, 240); + fillRect(buffer, 96, 222, riskyWidth, 42, 239, 68, 68); + fillRect(buffer, 96, 318, 300, 42, 226, 232, 240); + fillRect(buffer, 96, 318, shortfallWidth, 42, 245, 158, 11); + + for (let i = 0; i < risky.summary.affectedSolvers; i += 1) { + fillRect(buffer, 112 + i * 78, 404, 52, 52, 99, 102, 241); + fillRect(buffer, 122 + i * 78, 416, 32, 8, 255, 255, 255); + fillRect(buffer, 122 + i * 78, 434, 32, 8, 255, 255, 255); + } + + drawColumns(buffer, 536, 408, Math.min(12, risky.summary.findingCount), [220, 38, 38]); + drawColumns(buffer, 536, 210, clean.summary.findingCount, [16, 185, 129]); + fillRect(buffer, 536, 436, Math.floor(310 * progress), 14, 37, 99, 235); + + const header = Buffer.from(`P6\n${width} ${height}\n255\n`, "ascii"); + fs.writeFileSync(path.join(framesDir, `frame-${String(index).padStart(3, "0")}.ppm`), Buffer.concat([header, buffer])); +} + +for (let index = 0; index < frames; index += 1) { + writeFrame(index, index / (frames - 1)); +} + +const output = path.join(reportsDir, "demo.mp4"); +const result = spawnSync(process.env.FFMPEG_PATH || "ffmpeg", [ + "-y", + "-framerate", + String(fps), + "-i", + path.join(framesDir, "frame-%03d.ppm"), + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + output +], { stdio: "inherit" }); + +fs.rmSync(framesDir, { recursive: true, force: true }); + +if (result.status !== 0) { + process.exit(result.status || 1); +} + +console.log(`Wrote ${output}`); diff --git a/challenge-withdrawal-reimbursement-guard/package.json b/challenge-withdrawal-reimbursement-guard/package.json new file mode 100644 index 00000000..45f377ee --- /dev/null +++ b/challenge-withdrawal-reimbursement-guard/package.json @@ -0,0 +1,15 @@ +{ + "name": "challenge-withdrawal-reimbursement-guard", + "version": "1.0.0", + "description": "Dependency-free challenge withdrawal and solver-cost reimbursement guard for SCIBASE scientific bounty workflows.", + "main": "index.js", + "scripts": { + "check": "node test.js", + "test": "node test.js", + "demo": "node demo.js", + "make-demo-video": "node make-demo-video.js", + "verify-video": "node verify-video.js" + }, + "license": "MIT", + "private": true +} diff --git a/challenge-withdrawal-reimbursement-guard/reports/clean-withdrawal-report.json b/challenge-withdrawal-reimbursement-guard/reports/clean-withdrawal-report.json new file mode 100644 index 00000000..a4a2adb7 --- /dev/null +++ b/challenge-withdrawal-reimbursement-guard/reports/clean-withdrawal-report.json @@ -0,0 +1,49 @@ +{ + "summary": { + "decision": "release_withdrawal_closeout", + "challengeCount": 1, + "affectedSolvers": 2, + "recommendedReimbursementUsd": 1076, + "fundedShortfallUsd": 0, + "findingCount": 0, + "criticalFindings": 0, + "highOrCriticalFindings": 0, + "auditDigest": "sha256:11141c6fc5ef8c4eb1259853baad8680c7925329d6be3f4dfa10c347fb95d4c4" + }, + "challenges": [ + { + "id": "BNTY-WITHDRAW-ALPHA", + "status": "withdrawn", + "bountyAmountUsd": 6000, + "action": "release_withdrawal_closeout", + "affectedSolvers": 2, + "recommendedReimbursementUsd": 1076, + "fundedShortfallUsd": 0, + "recommendations": [ + { + "teamId": "team-curie", + "documentedCostsUsd": 180, + "irreversibleCostsUsd": 180, + "milestoneCreditUsd": 453.6, + "reimbursementCapUsd": 2100, + "recommendedUsd": 633.6, + "recordedUsd": 633.6, + "shortfallUsd": 0, + "action": "release_reimbursement_closeout" + }, + { + "teamId": "team-noether", + "documentedCostsUsd": 140, + "irreversibleCostsUsd": 140, + "milestoneCreditUsd": 302.4, + "reimbursementCapUsd": 2100, + "recommendedUsd": 442.4, + "recordedUsd": 442.4, + "shortfallUsd": 0, + "action": "release_reimbursement_closeout" + } + ] + } + ], + "findings": [] +} diff --git a/challenge-withdrawal-reimbursement-guard/reports/demo.mp4 b/challenge-withdrawal-reimbursement-guard/reports/demo.mp4 new file mode 100644 index 00000000..2711fe84 Binary files /dev/null and b/challenge-withdrawal-reimbursement-guard/reports/demo.mp4 differ diff --git a/challenge-withdrawal-reimbursement-guard/reports/risky-withdrawal-handoff.md b/challenge-withdrawal-reimbursement-guard/reports/risky-withdrawal-handoff.md new file mode 100644 index 00000000..3bd0796f --- /dev/null +++ b/challenge-withdrawal-reimbursement-guard/reports/risky-withdrawal-handoff.md @@ -0,0 +1,35 @@ +# Challenge Withdrawal Reimbursement Handoff + +Decision: escalate_withdrawal_dispute +Affected solvers: 3 +Recommended reimbursement: $2586.00 +Funded shortfall: $2286.00 +Audit digest: sha256:af101fe0baa90544d8acc9069ccfa1524ccfc6c133806eeff1bb088088a48cb1 + +## Priority Findings + +| Severity | Code | Remediation | +| --- | --- | --- | +| critical | CANCELLATION_AUTHORITY_MISSING | verify_sponsor_cancellation_authority_before_closeout | +| critical | DISPUTE_HOLD_WINDOW_TOO_SHORT | extend_dispute_hold_before_refund_or_closeout | +| critical | REIMBURSEMENT_RESERVE_SHORTFALL | fund_solver_reimbursement_reserve_before_releasing_sponsor_refund | +| high | APPEAL_DEADLINE_MISSING | set_solver_appeal_deadline_before_withdrawal_closeout | +| high | COST_CLAIM_EVIDENCE_MISSING | attach_cost_claim_evidence_before_reimbursement | +| high | COST_CLAIM_PATH_NOT_IN_NOTICE | include_solver_cost_claim_path_in_notice | +| high | MILESTONE_EVIDENCE_HASH_MISSING | hash_milestone_evidence_before_solver_cost_review | +| high | NONREFUNDABLE_RECEIPT_MISSING | attach_receipt_hash_for_nonrefundable_solver_spend | +| high | NONREFUNDABLE_RECEIPT_MISSING | attach_receipt_hash_for_nonrefundable_solver_spend | +| high | NONREFUNDABLE_RECEIPT_MISSING | attach_receipt_hash_for_nonrefundable_solver_spend | +| high | NOTICE_PARITY_GAP | restart_closeout_clock_after_equal_solver_notice | +| high | REIMBURSEMENT_DECISION_MISSING | record_solver_reimbursement_decision_before_challenge_closeout | + +## Team Recommendations + +### BNTY-WITHDRAW-RISK + +| Team | Recommended | Recorded | Shortfall | Action | +| --- | ---: | ---: | ---: | --- | +| team-lovelace | $1449.00 | $500.00 | $949.00 | hold_and_reimburse_solver_costs | +| team-hopper | $634.00 | $0.00 | $634.00 | hold_and_reimburse_solver_costs | +| team-johnson | $503.00 | $0.00 | $503.00 | hold_and_reimburse_solver_costs | + diff --git a/challenge-withdrawal-reimbursement-guard/reports/risky-withdrawal-report.json b/challenge-withdrawal-reimbursement-guard/reports/risky-withdrawal-report.json new file mode 100644 index 00000000..1c304c62 --- /dev/null +++ b/challenge-withdrawal-reimbursement-guard/reports/risky-withdrawal-report.json @@ -0,0 +1,266 @@ +{ + "summary": { + "decision": "escalate_withdrawal_dispute", + "challengeCount": 1, + "affectedSolvers": 3, + "recommendedReimbursementUsd": 2586, + "fundedShortfallUsd": 2286, + "findingCount": 21, + "criticalFindings": 3, + "highOrCriticalFindings": 15, + "auditDigest": "sha256:af101fe0baa90544d8acc9069ccfa1524ccfc6c133806eeff1bb088088a48cb1" + }, + "challenges": [ + { + "id": "BNTY-WITHDRAW-RISK", + "status": "canceled", + "bountyAmountUsd": 9000, + "action": "hold_for_solver_reimbursement", + "affectedSolvers": 3, + "recommendedReimbursementUsd": 2586, + "fundedShortfallUsd": 2286, + "recommendations": [ + { + "teamId": "team-lovelace", + "documentedCostsUsd": 720, + "irreversibleCostsUsd": 720, + "milestoneCreditUsd": 729, + "reimbursementCapUsd": 2600, + "recommendedUsd": 1449, + "recordedUsd": 500, + "shortfallUsd": 949, + "action": "hold_and_reimburse_solver_costs" + }, + { + "teamId": "team-hopper", + "documentedCostsUsd": 310, + "irreversibleCostsUsd": 310, + "milestoneCreditUsd": 324, + "reimbursementCapUsd": 2600, + "recommendedUsd": 634, + "recordedUsd": 0, + "shortfallUsd": 634, + "action": "hold_and_reimburse_solver_costs" + }, + { + "teamId": "team-johnson", + "documentedCostsUsd": 260, + "irreversibleCostsUsd": 260, + "milestoneCreditUsd": 243, + "reimbursementCapUsd": 2600, + "recommendedUsd": 503, + "recordedUsd": 0, + "shortfallUsd": 503, + "action": "hold_and_reimburse_solver_costs" + } + ] + } + ], + "findings": [ + { + "severity": "critical", + "code": "CANCELLATION_AUTHORITY_MISSING", + "message": "BNTY-WITHDRAW-RISK does not show a sponsor-authorized cancellation decision.", + "refs": [ + "BNTY-WITHDRAW-RISK" + ], + "action": "verify_sponsor_cancellation_authority_before_closeout" + }, + { + "severity": "critical", + "code": "DISPUTE_HOLD_WINDOW_TOO_SHORT", + "message": "BNTY-WITHDRAW-RISK has a dispute hold of 4 days.", + "refs": [ + "BNTY-WITHDRAW-RISK" + ], + "action": "extend_dispute_hold_before_refund_or_closeout" + }, + { + "severity": "critical", + "code": "REIMBURSEMENT_RESERVE_SHORTFALL", + "message": "BNTY-WITHDRAW-RISK reserve is short by $2286.00 for solver reimbursement.", + "refs": [ + "BNTY-WITHDRAW-RISK" + ], + "action": "fund_solver_reimbursement_reserve_before_releasing_sponsor_refund" + }, + { + "severity": "high", + "code": "APPEAL_DEADLINE_MISSING", + "message": "BNTY-WITHDRAW-RISK has no solver appeal deadline.", + "refs": [ + "BNTY-WITHDRAW-RISK" + ], + "action": "set_solver_appeal_deadline_before_withdrawal_closeout" + }, + { + "severity": "high", + "code": "COST_CLAIM_EVIDENCE_MISSING", + "message": "team-lovelace cost claim annotation-vendor lacks evidence.", + "refs": [ + "BNTY-WITHDRAW-RISK", + "team-lovelace" + ], + "action": "attach_cost_claim_evidence_before_reimbursement" + }, + { + "severity": "high", + "code": "COST_CLAIM_PATH_NOT_IN_NOTICE", + "message": "team-lovelace notice omits the cost-claim path.", + "refs": [ + "BNTY-WITHDRAW-RISK", + "team-lovelace" + ], + "action": "include_solver_cost_claim_path_in_notice" + }, + { + "severity": "high", + "code": "MILESTONE_EVIDENCE_HASH_MISSING", + "message": "team-lovelace milestone baseline-model lacks an evidence hash.", + "refs": [ + "BNTY-WITHDRAW-RISK", + "team-lovelace" + ], + "action": "hash_milestone_evidence_before_solver_cost_review" + }, + { + "severity": "high", + "code": "NONREFUNDABLE_RECEIPT_MISSING", + "message": "team-lovelace non-refundable cost claim gpu-reservation lacks a receipt hash.", + "refs": [ + "BNTY-WITHDRAW-RISK", + "team-lovelace" + ], + "action": "attach_receipt_hash_for_nonrefundable_solver_spend" + }, + { + "severity": "high", + "code": "NONREFUNDABLE_RECEIPT_MISSING", + "message": "team-lovelace non-refundable cost claim annotation-vendor lacks a receipt hash.", + "refs": [ + "BNTY-WITHDRAW-RISK", + "team-lovelace" + ], + "action": "attach_receipt_hash_for_nonrefundable_solver_spend" + }, + { + "severity": "high", + "code": "NONREFUNDABLE_RECEIPT_MISSING", + "message": "team-hopper non-refundable cost claim secure-workspace lacks a receipt hash.", + "refs": [ + "BNTY-WITHDRAW-RISK", + "team-hopper" + ], + "action": "attach_receipt_hash_for_nonrefundable_solver_spend" + }, + { + "severity": "high", + "code": "NOTICE_PARITY_GAP", + "message": "team-hopper received withdrawal notice 20 hours after the first notified solver.", + "refs": [ + "BNTY-WITHDRAW-RISK", + "team-hopper" + ], + "action": "restart_closeout_clock_after_equal_solver_notice" + }, + { + "severity": "high", + "code": "REIMBURSEMENT_DECISION_MISSING", + "message": "team-hopper has an eligible reimbursement recommendation but no recorded decision.", + "refs": [ + "BNTY-WITHDRAW-RISK", + "team-hopper" + ], + "action": "record_solver_reimbursement_decision_before_challenge_closeout" + }, + { + "severity": "high", + "code": "REIMBURSEMENT_DECISION_MISSING", + "message": "team-johnson has an eligible reimbursement recommendation but no recorded decision.", + "refs": [ + "BNTY-WITHDRAW-RISK", + "team-johnson" + ], + "action": "record_solver_reimbursement_decision_before_challenge_closeout" + }, + { + "severity": "high", + "code": "REIMBURSEMENT_SHORTFALL", + "message": "team-lovelace reimbursement is short by $949.00.", + "refs": [ + "BNTY-WITHDRAW-RISK", + "team-lovelace" + ], + "action": "fund_reimbursement_shortfall_or_escalate_dispute" + }, + { + "severity": "high", + "code": "SOLVER_WITHDRAWAL_NOTICE_MISSING", + "message": "team-johnson has no direct withdrawal notice.", + "refs": [ + "BNTY-WITHDRAW-RISK", + "team-johnson" + ], + "action": "send_equal_withdrawal_notice_to_all_started_solvers" + }, + { + "severity": "medium", + "code": "APPEAL_WINDOW_NOT_IN_NOTICE", + "message": "team-lovelace notice omits the appeal window.", + "refs": [ + "BNTY-WITHDRAW-RISK", + "team-lovelace" + ], + "action": "include_appeal_deadline_in_solver_notice" + }, + { + "severity": "medium", + "code": "APPEAL_WINDOW_NOT_IN_NOTICE", + "message": "team-hopper notice omits the appeal window.", + "refs": [ + "BNTY-WITHDRAW-RISK", + "team-hopper" + ], + "action": "include_appeal_deadline_in_solver_notice" + }, + { + "severity": "medium", + "code": "IP_DATA_RETURN_ATTESTATION_MISSING", + "message": "team-lovelace has no complete IP return and data-destruction attestation.", + "refs": [ + "BNTY-WITHDRAW-RISK", + "team-lovelace" + ], + "action": "collect_ip_return_and_data_destruction_attestation" + }, + { + "severity": "medium", + "code": "IP_DATA_RETURN_ATTESTATION_MISSING", + "message": "team-hopper has no complete IP return and data-destruction attestation.", + "refs": [ + "BNTY-WITHDRAW-RISK", + "team-hopper" + ], + "action": "collect_ip_return_and_data_destruction_attestation" + }, + { + "severity": "medium", + "code": "IP_DATA_RETURN_ATTESTATION_MISSING", + "message": "team-johnson has no complete IP return and data-destruction attestation.", + "refs": [ + "BNTY-WITHDRAW-RISK", + "team-johnson" + ], + "action": "collect_ip_return_and_data_destruction_attestation" + }, + { + "severity": "medium", + "code": "WITHDRAWAL_REASON_TOO_THIN", + "message": "BNTY-WITHDRAW-RISK has no reviewer-ready reason for withdrawal or material reduction.", + "refs": [ + "BNTY-WITHDRAW-RISK" + ], + "action": "record_specific_withdrawal_reason_for_audit" + } + ] +} diff --git a/challenge-withdrawal-reimbursement-guard/reports/withdrawal-dashboard.svg b/challenge-withdrawal-reimbursement-guard/reports/withdrawal-dashboard.svg new file mode 100644 index 00000000..fe101681 --- /dev/null +++ b/challenge-withdrawal-reimbursement-guard/reports/withdrawal-dashboard.svg @@ -0,0 +1,29 @@ + + + + + Challenge withdrawal reimbursement guard + Deterministic closeout review for sponsor cancellations after solvers have started work. + Clean closeout + + + $1076.00 + Risky closeout + + + $2586.00 + Reserve shortfall + + + $2286.00 + Top risky findings + CANCELLATION_AUTHORITY_MISSING +DISPUTE_HOLD_WINDOW_TOO_SHORT +REIMBURSEMENT_RESERVE_SHORTFALL +APPEAL_DEADLINE_MISSING +COST_CLAIM_EVIDENCE_MISSING +COST_CLAIM_PATH_NOT_IN_NOTICE +MILESTONE_EVIDENCE_HASH_MISSING +NONREFUNDABLE_RECEIPT_MISSING + Decision: escalate_withdrawal_dispute | sha256:af101fe0baa90544d8acc... + diff --git a/challenge-withdrawal-reimbursement-guard/sample-data.js b/challenge-withdrawal-reimbursement-guard/sample-data.js new file mode 100644 index 00000000..f2482b72 --- /dev/null +++ b/challenge-withdrawal-reimbursement-guard/sample-data.js @@ -0,0 +1,274 @@ +const cleanPacket = { + reviewDate: "2026-06-01", + policy: { + equalNoticeToleranceHours: 12, + disputeHoldDays: 10, + maxReimbursementPercent: 0.35, + materialReductionThresholdPercent: 25, + milestoneCreditRate: 0.18, + irreversibleSpendRequiresReceipt: true, + requireSponsorFundedReserve: true + }, + challenges: [ + { + id: "BNTY-WITHDRAW-ALPHA", + title: "Federated reproducibility benchmark", + status: "withdrawn", + sponsor: { + id: "sponsor-lab-a", + cancellationAuthority: true, + reimbursementReserveUsd: 2200 + }, + bounty: { + amountUsd: 6000, + reimbursementCapUsd: 2100 + }, + cancellation: { + type: "withdrawal", + requestedAt: "2026-05-28T15:00:00Z", + effectiveAt: "2026-06-07T15:00:00Z", + reason: "Sponsor lost access to the proprietary validation corpus and withdrew before finalist scoring.", + authorizedBy: "sponsor-owner-17", + materialReductionPercent: 100, + appealDeadline: "2026-06-06T15:00:00Z", + disputeHoldUntil: "2026-06-08T15:00:00Z" + }, + communications: { + sponsorNoticeAt: "2026-05-28T15:10:00Z", + solverNotices: [ + { + teamId: "team-curie", + sentAt: "2026-05-28T15:15:00Z", + channel: "platform", + includesAppealWindow: true, + includesCostClaimLink: true + }, + { + teamId: "team-noether", + sentAt: "2026-05-28T16:00:00Z", + channel: "platform", + includesAppealWindow: true, + includesCostClaimLink: true + } + ] + }, + solvers: [ + { + teamId: "team-curie", + acceptedAt: "2026-05-20T12:00:00Z", + startedAt: "2026-05-21T09:00:00Z", + milestoneProgressPercent: 42, + submittedMilestones: [ + { id: "design-review", percent: 25, evidenceHash: "sha256:curie-design-review-0a1b2c3d4e" }, + { id: "baseline-run", percent: 17, evidenceHash: "sha256:curie-baseline-run-0a1b2c3d4e" } + ], + costClaims: [ + { + id: "cloud-prep", + amountUsd: 180, + category: "compute", + incurredAt: "2026-05-25", + nonRefundable: true, + evidenceHash: "sha256:curie-cloud-evidence-0a1b2c3d4e", + receiptHash: "sha256:curie-cloud-receipt-0a1b2c3d4e" + } + ], + ipReturn: { + returnedAt: "2026-05-30T12:00:00Z", + dataDestroyedAt: "2026-05-30T12:15:00Z", + attestationHash: "sha256:curie-return-attestation-0a1b2c3d4e" + } + }, + { + teamId: "team-noether", + acceptedAt: "2026-05-22T13:30:00Z", + startedAt: "2026-05-23T10:00:00Z", + milestoneProgressPercent: 28, + submittedMilestones: [ + { id: "replication-plan", percent: 28, evidenceHash: "sha256:noether-plan-evidence-0a1b2c3d4e" } + ], + costClaims: [ + { + id: "dataset-access", + amountUsd: 140, + category: "data", + incurredAt: "2026-05-24", + nonRefundable: true, + evidenceHash: "sha256:noether-data-evidence-0a1b2c3d4e", + receiptHash: "sha256:noether-data-receipt-0a1b2c3d4e" + } + ], + ipReturn: { + returnedAt: "2026-05-30T13:00:00Z", + dataDestroyedAt: "2026-05-30T13:20:00Z", + attestationHash: "sha256:noether-return-attestation-0a1b2c3d4e" + } + } + ], + reimbursements: [ + { + teamId: "team-curie", + amountUsd: 633.60, + approvedBy: "bounty-ops", + approvedAt: "2026-05-31T10:00:00Z" + }, + { + teamId: "team-noether", + amountUsd: 442.40, + approvedBy: "bounty-ops", + approvedAt: "2026-05-31T10:05:00Z" + } + ] + } + ] +}; + +const riskyPacket = { + reviewDate: "2026-06-01", + policy: { + equalNoticeToleranceHours: 8, + disputeHoldDays: 10, + maxReimbursementPercent: 0.35, + materialReductionThresholdPercent: 25, + milestoneCreditRate: 0.18, + irreversibleSpendRequiresReceipt: true, + requireSponsorFundedReserve: true + }, + challenges: [ + { + id: "BNTY-WITHDRAW-RISK", + title: "Private clinical signal challenge", + status: "canceled", + sponsor: { + id: "sponsor-clinic-x", + cancellationAuthority: false, + reimbursementReserveUsd: 300 + }, + bounty: { + amountUsd: 9000, + reimbursementCapUsd: 2600 + }, + cancellation: { + type: "cancellation", + requestedAt: "2026-05-29T11:00:00Z", + effectiveAt: "2026-05-30T11:00:00Z", + reason: "Budget changed", + materialReductionPercent: 100, + disputeHoldUntil: "2026-06-02T11:00:00Z" + }, + communications: { + sponsorNoticeAt: "2026-05-29T12:00:00Z", + solverNotices: [ + { + teamId: "team-lovelace", + sentAt: "2026-05-29T13:00:00Z", + channel: "email", + includesAppealWindow: false, + includesCostClaimLink: false + }, + { + teamId: "team-hopper", + sentAt: "2026-05-30T09:00:00Z", + channel: "email", + includesAppealWindow: false, + includesCostClaimLink: true + } + ] + }, + solvers: [ + { + teamId: "team-lovelace", + acceptedAt: "2026-05-20T12:00:00Z", + startedAt: "2026-05-21T08:00:00Z", + milestoneProgressPercent: 45, + submittedMilestones: [ + { id: "baseline-model", percent: 35, evidenceHash: "" }, + { id: "validation-report", percent: 10, evidenceHash: "sha256:lovelace-report-evidence-0a1b2c3d4e" } + ], + costClaims: [ + { + id: "gpu-reservation", + amountUsd: 720, + category: "compute", + incurredAt: "2026-05-24", + nonRefundable: true, + evidenceHash: "sha256:lovelace-gpu-evidence-0a1b2c3d4e", + receiptHash: "" + }, + { + id: "annotation-vendor", + amountUsd: 420, + category: "annotation", + incurredAt: "2026-05-25", + nonRefundable: true, + evidenceHash: "", + receiptHash: "" + } + ], + ipReturn: { + returnedAt: "", + dataDestroyedAt: "", + attestationHash: "" + } + }, + { + teamId: "team-hopper", + acceptedAt: "2026-05-22T10:00:00Z", + startedAt: "2026-05-23T09:00:00Z", + milestoneProgressPercent: 20, + submittedMilestones: [], + costClaims: [ + { + id: "secure-workspace", + amountUsd: 310, + category: "workspace", + incurredAt: "2026-05-24", + nonRefundable: true, + evidenceHash: "sha256:hopper-workspace-evidence-0a1b2c3d4e", + receiptHash: "" + } + ], + ipReturn: null + }, + { + teamId: "team-johnson", + acceptedAt: "2026-05-23T10:00:00Z", + startedAt: "2026-05-24T09:00:00Z", + milestoneProgressPercent: 15, + submittedMilestones: [ + { id: "error-analysis", percent: 15, evidenceHash: "sha256:johnson-error-analysis-0a1b2c3d4e" } + ], + costClaims: [ + { + id: "dataset-license", + amountUsd: 260, + category: "data", + incurredAt: "2026-05-25", + nonRefundable: true, + evidenceHash: "sha256:johnson-license-evidence-0a1b2c3d4e", + receiptHash: "sha256:johnson-license-receipt-0a1b2c3d4e" + } + ], + ipReturn: { + returnedAt: "2026-05-31T12:00:00Z", + dataDestroyedAt: "", + attestationHash: "" + } + } + ], + reimbursements: [ + { + teamId: "team-lovelace", + amountUsd: 500, + approvedBy: "sponsor-admin", + approvedAt: "2026-05-30T12:00:00Z" + } + ] + } + ] +}; + +module.exports = { + cleanPacket, + riskyPacket +}; diff --git a/challenge-withdrawal-reimbursement-guard/test.js b/challenge-withdrawal-reimbursement-guard/test.js new file mode 100644 index 00000000..c376b33a --- /dev/null +++ b/challenge-withdrawal-reimbursement-guard/test.js @@ -0,0 +1,44 @@ +const assert = require("node:assert/strict"); +const { evaluateChallengeWithdrawal, sha256 } = require("./index"); +const { cleanPacket, riskyPacket } = require("./sample-data"); + +const clean = evaluateChallengeWithdrawal(cleanPacket); +assert.equal(clean.summary.decision, "release_withdrawal_closeout"); +assert.equal(clean.summary.findingCount, 0); +assert.equal(clean.summary.challengeCount, 1); +assert.equal(clean.summary.affectedSolvers, 2); +assert.equal(clean.summary.recommendedReimbursementUsd, 1076); +assert.equal(clean.summary.fundedShortfallUsd, 0); +assert.ok(clean.summary.auditDigest.startsWith("sha256:")); +assert.equal(clean.challenges[0].action, "release_withdrawal_closeout"); + +const risky = evaluateChallengeWithdrawal(riskyPacket); +assert.equal(risky.summary.decision, "escalate_withdrawal_dispute"); +assert.equal(risky.summary.challengeCount, 1); +assert.equal(risky.summary.affectedSolvers, 3); +assert.equal(risky.summary.recommendedReimbursementUsd, 2586); +assert.equal(risky.summary.fundedShortfallUsd, 2286); +assert.ok(risky.summary.findingCount >= 17); +assert.ok(risky.summary.criticalFindings >= 3); +assert.ok(risky.summary.highOrCriticalFindings >= 12); + +const findingCodes = new Set(risky.findings.map((finding) => finding.code)); +assert.ok(findingCodes.has("CANCELLATION_AUTHORITY_MISSING")); +assert.ok(findingCodes.has("SOLVER_WITHDRAWAL_NOTICE_MISSING")); +assert.ok(findingCodes.has("NOTICE_PARITY_GAP")); +assert.ok(findingCodes.has("COST_CLAIM_PATH_NOT_IN_NOTICE")); +assert.ok(findingCodes.has("MILESTONE_EVIDENCE_HASH_MISSING")); +assert.ok(findingCodes.has("COST_CLAIM_EVIDENCE_MISSING")); +assert.ok(findingCodes.has("NONREFUNDABLE_RECEIPT_MISSING")); +assert.ok(findingCodes.has("REIMBURSEMENT_SHORTFALL")); +assert.ok(findingCodes.has("REIMBURSEMENT_DECISION_MISSING")); +assert.ok(findingCodes.has("DISPUTE_HOLD_WINDOW_TOO_SHORT")); +assert.ok(findingCodes.has("REIMBURSEMENT_RESERVE_SHORTFALL")); +assert.ok(findingCodes.has("IP_DATA_RETURN_ATTESTATION_MISSING")); + +const firstDigest = evaluateChallengeWithdrawal(riskyPacket).summary.auditDigest; +const secondDigest = evaluateChallengeWithdrawal(riskyPacket).summary.auditDigest; +assert.equal(firstDigest, secondDigest); +assert.equal(sha256({ b: 2, a: 1 }), sha256({ a: 1, b: 2 })); + +console.log("challenge withdrawal reimbursement guard tests passed"); diff --git a/challenge-withdrawal-reimbursement-guard/verify-video.js b/challenge-withdrawal-reimbursement-guard/verify-video.js new file mode 100644 index 00000000..39af983c --- /dev/null +++ b/challenge-withdrawal-reimbursement-guard/verify-video.js @@ -0,0 +1,37 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const videoPath = path.join(__dirname, "reports", "demo.mp4"); +assert.ok(fs.existsSync(videoPath), "reports/demo.mp4 must exist"); +assert.ok(fs.statSync(videoPath).size > 5000, "reports/demo.mp4 should not be empty"); + +const probe = spawnSync(process.env.FFPROBE_PATH || "ffprobe", [ + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=codec_name,width,height,r_frame_rate:format=duration", + "-of", + "json", + videoPath +], { encoding: "utf8" }); + +if (probe.status !== 0) { + process.stderr.write(probe.stderr || "ffprobe failed\n"); + process.exit(probe.status || 1); +} + +const metadata = JSON.parse(probe.stdout); +const stream = metadata.streams && metadata.streams[0]; +assert.equal(stream.codec_name, "h264"); +assert.equal(stream.width, 960); +assert.equal(stream.height, 540); +assert.equal(stream.r_frame_rate, "18/1"); + +const duration = Number(metadata.format && metadata.format.duration); +assert.ok(duration >= 3.9 && duration <= 4.2, `unexpected duration ${duration}`); + +console.log(`demo.mp4 verified: ${stream.codec_name}, ${stream.width}x${stream.height}, ${duration.toFixed(3)}s, ${stream.r_frame_rate}`);