From 4f0e4bf2e252708d30c012c66185e6c89256ade1 Mon Sep 17 00:00:00 2001 From: "tho.nguyen" <91511523+haki203@users.noreply.github.com> Date: Fri, 22 May 2026 22:08:49 +0700 Subject: [PATCH] Add challenge solver withdrawal continuity guard --- .../README.md | 33 ++ .../demo.js | 135 ++++++++ .../index.js | 304 ++++++++++++++++++ .../reports/coordinator-packet.md | 75 +++++ .../reports/demo.mp4 | Bin 0 -> 6577 bytes .../reports/summary.svg | 23 ++ .../reports/withdrawal-continuity-packet.json | 178 ++++++++++ .../requirements-map.md | 30 ++ .../sample-data.js | 89 +++++ .../test.js | 130 ++++++++ 10 files changed, 997 insertions(+) create mode 100644 challenge-solver-withdrawal-continuity-guard/README.md create mode 100644 challenge-solver-withdrawal-continuity-guard/demo.js create mode 100644 challenge-solver-withdrawal-continuity-guard/index.js create mode 100644 challenge-solver-withdrawal-continuity-guard/reports/coordinator-packet.md create mode 100644 challenge-solver-withdrawal-continuity-guard/reports/demo.mp4 create mode 100644 challenge-solver-withdrawal-continuity-guard/reports/summary.svg create mode 100644 challenge-solver-withdrawal-continuity-guard/reports/withdrawal-continuity-packet.json create mode 100644 challenge-solver-withdrawal-continuity-guard/requirements-map.md create mode 100644 challenge-solver-withdrawal-continuity-guard/sample-data.js create mode 100644 challenge-solver-withdrawal-continuity-guard/test.js diff --git a/challenge-solver-withdrawal-continuity-guard/README.md b/challenge-solver-withdrawal-continuity-guard/README.md new file mode 100644 index 00000000..1dbfcfa8 --- /dev/null +++ b/challenge-solver-withdrawal-continuity-guard/README.md @@ -0,0 +1,33 @@ +# Solver Withdrawal Continuity Guard + +Self-contained Scientific Bounty System slice for issue #18. It evaluates active solver/team withdrawals or replacements after a private challenge workspace has opened, before milestone review, sponsor scoring, payout, or IP handoff continues. + +## What It Checks + +- Dated withdrawal notice records. +- Workspace access revocation after a solver leaves. +- Solver IP return, retention, or destruction confirmation. +- Team quorum and eligible replacement readiness. +- Artifact custody for work owned by withdrawing solvers. +- Near-term milestone risk after team membership changes. +- Sponsor and reviewer notification coverage. + +## Outputs + +- `reports/withdrawal-continuity-packet.json`: structured decisions, findings, summaries, and actions. +- `reports/coordinator-packet.md`: readable challenge coordinator packet. +- `reports/summary.svg`: visual decision summary. +- `reports/demo.mp4`: short demo artifact for Algora review. + +## Local Verification + +```bash +node --test challenge-solver-withdrawal-continuity-guard/test.js +node challenge-solver-withdrawal-continuity-guard/demo.js +node --check challenge-solver-withdrawal-continuity-guard/index.js +node --check challenge-solver-withdrawal-continuity-guard/sample-data.js +node --check challenge-solver-withdrawal-continuity-guard/demo.js +node --check challenge-solver-withdrawal-continuity-guard/test.js +``` + +The module is dependency-free, uses synthetic data only, and makes no network calls. Set `FFMPEG_PATH` to an ffmpeg binary before running `demo.js` if regenerating `reports/demo.mp4`. diff --git a/challenge-solver-withdrawal-continuity-guard/demo.js b/challenge-solver-withdrawal-continuity-guard/demo.js new file mode 100644 index 00000000..c8ee9102 --- /dev/null +++ b/challenge-solver-withdrawal-continuity-guard/demo.js @@ -0,0 +1,135 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const {spawnSync} = require('node:child_process'); + +const { + evaluateSolverWithdrawalContinuity, + buildCoordinatorPacket, +} = require('./index'); +const {scenarios} = require('./sample-data'); + +const reportsDir = path.join(__dirname, 'reports'); +const framesDir = path.join(reportsDir, 'frames'); +fs.mkdirSync(reportsDir, {recursive: true}); + +const evaluations = scenarios.map((scenario) => ({ + scenario: scenario.name, + ...evaluateSolverWithdrawalContinuity(scenario), +})); + +const decisionCounts = evaluations.reduce((counts, item) => { + counts[item.decision] = (counts[item.decision] || 0) + 1; + return counts; +}, {}); +const findings = evaluations.reduce((sum, item) => sum + item.findings.length, 0); +const affectedTeams = evaluations.reduce((sum, item) => sum + item.summary.affectedTeams, 0); +const readyReplacements = evaluations.reduce((sum, item) => sum + item.summary.replacementsReady, 0); + +const packetJson = JSON.stringify(evaluations, null, 2); +const coordinatorPacket = evaluations.map(buildCoordinatorPacket).join('\n---\n'); +const svg = ` + + Solver Withdrawal Continuity Guard + Challenge continuity packet for solver withdrawals, replacements, and artifact custody + + + Workspace Hold + ${decisionCounts['hold-workspace-continuity'] || 0} + + + + Coordinator Review + ${decisionCounts['coordinator-review'] || 0} + + + + Continuity Ready + ${decisionCounts['continuity-ready'] || 0} + + Affected teams: ${affectedTeams} | Ready replacements: ${readyReplacements} | Findings: ${findings} + Checks: notice, access revocation, IP return, team quorum, artifact custody, milestone risk, notifications + Synthetic data only. No credentials, private challenge files, payout actions, KYC, bank, or live workspace calls. + +`; + +fs.writeFileSync(path.join(reportsDir, 'withdrawal-continuity-packet.json'), `${packetJson}\n`); +fs.writeFileSync(path.join(reportsDir, 'coordinator-packet.md'), coordinatorPacket); +fs.writeFileSync(path.join(reportsDir, 'summary.svg'), svg); + +function fillRect(buffer, width, x0, y0, rectWidth, rectHeight, color) { + const [r, g, b] = color; + const x1 = Math.min(width, x0 + rectWidth); + const y1 = Math.min(Math.floor(buffer.length / (width * 3)), y0 + rectHeight); + for (let y = Math.max(0, y0); y < y1; y += 1) { + for (let x = Math.max(0, x0); x < x1; x += 1) { + const offset = (y * width + x) * 3; + buffer[offset] = r; + buffer[offset + 1] = g; + buffer[offset + 2] = b; + } + } +} + +function writePpmFrame(filePath, frameIndex, frameCount) { + const width = 640; + const height = 360; + const buffer = Buffer.alloc(width * height * 3); + for (let i = 0; i < width * height; i += 1) { + buffer[i * 3] = 17; + buffer[i * 3 + 1] = 24; + buffer[i * 3 + 2] = 39; + } + const progress = frameIndex / Math.max(1, frameCount - 1); + fillRect(buffer, width, 42, 42, 556, 44, [248, 250, 252]); + fillRect(buffer, width, 42, 112, 160, 118, [127, 29, 29]); + fillRect(buffer, width, 240, 112, 160, 118, [146, 64, 14]); + fillRect(buffer, width, 438, 112, 160, 118, [22, 101, 52]); + fillRect(buffer, width, 42, 266, Math.round(556 * progress), 28, [191, 219, 254]); + fillRect(buffer, width, 42 + Math.round(492 * progress), 310, 64, 26, [248, 250, 252]); + const header = Buffer.from(`P6\n${width} ${height}\n255\n`); + fs.writeFileSync(filePath, Buffer.concat([header, buffer])); +} + +function createDemoVideo() { + const ffmpegPath = process.env.FFMPEG_PATH; + if (!ffmpegPath) { + console.log('FFMPEG_PATH not set; skipped MP4 generation.'); + return; + } + + fs.rmSync(framesDir, {recursive: true, force: true}); + fs.mkdirSync(framesDir, {recursive: true}); + const frameCount = 72; + for (let index = 0; index < frameCount; index += 1) { + writePpmFrame(path.join(framesDir, `frame-${String(index).padStart(3, '0')}.ppm`), index, frameCount); + } + + const output = path.join(reportsDir, 'demo.mp4'); + const result = spawnSync(ffmpegPath, [ + '-y', + '-framerate', + '24', + '-i', + path.join(framesDir, 'frame-%03d.ppm'), + '-c:v', + 'libx264', + '-pix_fmt', + 'yuv420p', + '-movflags', + '+faststart', + output, + ], {encoding: 'utf8'}); + + fs.rmSync(framesDir, {recursive: true, force: true}); + if (result.status !== 0) { + throw new Error(result.stderr || 'ffmpeg failed to generate demo.mp4'); + } +} + +createDemoVideo(); + +console.log(`Wrote ${evaluations.length} solver withdrawal evaluations to ${reportsDir}`); +console.log(`Decision counts: ${JSON.stringify(decisionCounts)}`); +console.log(`Affected teams: ${affectedTeams}`); +console.log(`Ready replacements: ${readyReplacements}`); +console.log(`Findings: ${findings}`); diff --git a/challenge-solver-withdrawal-continuity-guard/index.js b/challenge-solver-withdrawal-continuity-guard/index.js new file mode 100644 index 00000000..7a543289 --- /dev/null +++ b/challenge-solver-withdrawal-continuity-guard/index.js @@ -0,0 +1,304 @@ +function list(value) { + return Array.isArray(value) ? value : []; +} + +function hasText(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +function normalize(value) { + return String(value || '').trim().toLowerCase(); +} + +function fullDaysBetween(older, newer) { + const olderTime = Date.parse(older); + const newerTime = Date.parse(newer); + if (!Number.isFinite(olderTime) || !Number.isFinite(newerTime)) { + return Infinity; + } + return Math.floor((newerTime - olderTime) / 86400000); +} + +function addFinding(findings, requiredActions, finding, action) { + findings.push(finding); + if (action) { + requiredActions.push(action); + } +} + +function reviewerAction(type, target, reason) { + return {type, target, reason}; +} + +function collectTeams(input) { + return list(input.teams).map((team) => ({ + ...team, + members: list(team.members), + artifacts: list(team.artifacts), + notifications: list(team.notifications), + })); +} + +function withdrawingMembers(team) { + return team.members.filter((member) => normalize(member.status) === 'withdrawing'); +} + +function continuityMembers(team) { + return team.members.filter((member) => { + const status = normalize(member.status); + if (status === 'active') { + return true; + } + return status === 'replacement' && member.replacementEligible === true && member.ndaAccepted === true; + }); +} + +function readyReplacements(team) { + return team.members.filter((member) => ( + normalize(member.status) === 'replacement' + && member.replacementEligible === true + && member.ndaAccepted === true + )); +} + +function notificationAudiences(team) { + return new Set(team.notifications.filter((notice) => hasText(notice.sentAt)).map((notice) => normalize(notice.audience))); +} + +function countSeverities(findings) { + return findings.reduce((counts, finding) => { + counts[finding.severity] = (counts[finding.severity] || 0) + 1; + return counts; + }, {critical: 0, high: 0, medium: 0}); +} + +function decide(findings) { + if (findings.some((finding) => finding.severity === 'critical')) { + return 'hold-workspace-continuity'; + } + if (findings.length > 0) { + return 'coordinator-review'; + } + return 'continuity-ready'; +} + +function evaluateSolverWithdrawalContinuity(input) { + const generatedAt = input.generatedAt || new Date(0).toISOString(); + const teams = collectTeams(input); + const findings = []; + const requiredActions = []; + let replacementsReady = 0; + const affectedTeams = new Set(); + + for (const team of teams) { + const teamId = team.id || 'unknown-team'; + const withdrawing = withdrawingMembers(team); + const withdrawingIds = new Set(withdrawing.map((member) => member.id)); + replacementsReady += readyReplacements(team).length; + + for (const member of withdrawing) { + const memberId = member.id || 'unknown-solver'; + affectedTeams.add(teamId); + if (!hasText(member.noticeAt)) { + addFinding( + findings, + requiredActions, + { + type: 'missing-withdrawal-notice', + severity: 'critical', + target: memberId, + teamId, + message: `${memberId} is withdrawing without a dated notice record.`, + }, + reviewerAction( + 'record_withdrawal_notice', + memberId, + 'challenge coordinators need a dated notice before changing solver team state' + ) + ); + } + + const accessRevoked = hasText(member.accessRevokedAt); + const accessAfterRevocation = accessRevoked + && Date.parse(member.lastWorkspaceAccessAt) > Date.parse(member.accessRevokedAt); + if (!accessRevoked || accessAfterRevocation) { + addFinding( + findings, + requiredActions, + { + type: 'access-revocation-missing', + severity: 'critical', + target: memberId, + teamId, + message: `${memberId} still needs workspace access revocation evidence.`, + }, + reviewerAction( + 'revoke_workspace_access', + memberId, + 'private challenge data and submission workspaces must be protected after withdrawal' + ) + ); + } + + if (member.ipReturnConfirmed !== true) { + addFinding( + findings, + requiredActions, + { + type: 'ip-return-missing', + severity: 'high', + target: memberId, + teamId, + message: `${memberId} lacks IP return, retention, or destruction confirmation.`, + }, + reviewerAction( + 'confirm_ip_return_duties', + memberId, + 'solver-owned and sponsor-confidential materials need explicit custody status' + ) + ); + } + } + + if (withdrawing.length > 0 && continuityMembers(team).length < (team.minActiveMembers || 1)) { + addFinding( + findings, + requiredActions, + { + type: 'team-quorum-broken', + severity: 'critical', + target: teamId, + activeContinuityMembers: continuityMembers(team).length, + minActiveMembers: team.minActiveMembers || 1, + message: `${teamId} falls below required active solver quorum after withdrawal.`, + }, + reviewerAction( + 'restore_team_quorum', + teamId, + 'challenge fairness requires enough active or eligible replacement solvers before work continues' + ) + ); + affectedTeams.add(teamId); + } + + const orphanedArtifacts = team.artifacts.filter((artifact) => ( + withdrawingIds.has(artifact.ownerId) && artifact.custodyConfirmed !== true + )); + if (orphanedArtifacts.length > 0) { + addFinding( + findings, + requiredActions, + { + type: 'artifact-custody-gap', + severity: 'high', + target: teamId, + artifactIds: orphanedArtifacts.map((artifact) => artifact.id || 'unknown-artifact'), + message: `${teamId} has withdrawing-solver artifacts without custody confirmation.`, + }, + reviewerAction( + 'confirm_artifact_custody', + teamId, + 'submission artifacts need owner handoff before sponsor review or scoring continues' + ) + ); + affectedTeams.add(teamId); + } + + if (withdrawing.length > 0 && fullDaysBetween(generatedAt, team.currentMilestoneDueAt) <= 7) { + addFinding( + findings, + requiredActions, + { + type: 'milestone-continuity-risk', + severity: 'medium', + target: teamId, + dueAt: team.currentMilestoneDueAt, + message: `${teamId} has a milestone due within 7 days of a withdrawal event.`, + }, + reviewerAction( + 'review_milestone_extension', + teamId, + 'near-term milestones may need sponsor-approved extension or partial-credit handling' + ) + ); + affectedTeams.add(teamId); + } + + const audiences = notificationAudiences(team); + if (withdrawing.length > 0 && (!audiences.has('sponsor') || !audiences.has('reviewers'))) { + addFinding( + findings, + requiredActions, + { + type: 'stakeholder-notification-gap', + severity: 'medium', + target: teamId, + missingAudiences: ['sponsor', 'reviewers'].filter((audience) => !audiences.has(audience)), + message: `${teamId} has incomplete sponsor or reviewer withdrawal notifications.`, + }, + reviewerAction( + 'notify_stakeholders', + teamId, + 'sponsors and reviewers need synchronized withdrawal state before judging or deadlines change' + ) + ); + affectedTeams.add(teamId); + } + } + + const severities = countSeverities(findings); + return { + challengeId: input.challengeId || 'unknown-challenge', + generatedAt, + decision: decide(findings), + summary: { + teams: teams.length, + affectedTeams: affectedTeams.size, + withdrawingSolvers: teams.reduce((sum, team) => sum + withdrawingMembers(team).length, 0), + replacementsReady, + requiredActions: requiredActions.length, + ...severities, + }, + findings, + requiredActions, + }; +} + +function buildCoordinatorPacket(result) { + const lines = [ + '# Solver Withdrawal Continuity Guard', + '', + `Challenge: ${result.challengeId}`, + `Decision: ${result.decision}`, + `Generated: ${result.generatedAt}`, + '', + '## Summary', + '', + `- Teams reviewed: ${result.summary.teams}`, + `- Affected teams: ${result.summary.affectedTeams}`, + `- Withdrawing solvers: ${result.summary.withdrawingSolvers}`, + `- Ready replacements: ${result.summary.replacementsReady}`, + `- Required actions: ${result.summary.requiredActions}`, + '', + ]; + + if (result.findings.length === 0) { + lines.push('No blocking withdrawal continuity gaps detected.'); + } else { + lines.push('## Findings', ''); + for (const finding of result.findings) { + lines.push(`- **${finding.severity}** ${finding.type}: ${finding.message}`); + } + lines.push('', '## Required Actions', ''); + for (const action of result.requiredActions) { + lines.push(`- ${action.type} (${action.target}): ${action.reason}`); + } + } + + return `${lines.join('\n')}\n`; +} + +module.exports = { + evaluateSolverWithdrawalContinuity, + buildCoordinatorPacket, +}; diff --git a/challenge-solver-withdrawal-continuity-guard/reports/coordinator-packet.md b/challenge-solver-withdrawal-continuity-guard/reports/coordinator-packet.md new file mode 100644 index 00000000..a624cd38 --- /dev/null +++ b/challenge-solver-withdrawal-continuity-guard/reports/coordinator-packet.md @@ -0,0 +1,75 @@ +# Solver Withdrawal Continuity Guard + +Challenge: challenge-protein-folding +Decision: hold-workspace-continuity +Generated: 2026-05-22T15:20:00Z + +## Summary + +- Teams reviewed: 1 +- Affected teams: 1 +- Withdrawing solvers: 1 +- Ready replacements: 0 +- Required actions: 7 + +## Findings + +- **critical** missing-withdrawal-notice: solver-1 is withdrawing without a dated notice record. +- **critical** access-revocation-missing: solver-1 still needs workspace access revocation evidence. +- **high** ip-return-missing: solver-1 lacks IP return, retention, or destruction confirmation. +- **critical** team-quorum-broken: team-alpha falls below required active solver quorum after withdrawal. +- **high** artifact-custody-gap: team-alpha has withdrawing-solver artifacts without custody confirmation. +- **medium** milestone-continuity-risk: team-alpha has a milestone due within 7 days of a withdrawal event. +- **medium** stakeholder-notification-gap: team-alpha has incomplete sponsor or reviewer withdrawal notifications. + +## Required Actions + +- record_withdrawal_notice (solver-1): challenge coordinators need a dated notice before changing solver team state +- revoke_workspace_access (solver-1): private challenge data and submission workspaces must be protected after withdrawal +- confirm_ip_return_duties (solver-1): solver-owned and sponsor-confidential materials need explicit custody status +- restore_team_quorum (team-alpha): challenge fairness requires enough active or eligible replacement solvers before work continues +- confirm_artifact_custody (team-alpha): submission artifacts need owner handoff before sponsor review or scoring continues +- review_milestone_extension (team-alpha): near-term milestones may need sponsor-approved extension or partial-credit handling +- notify_stakeholders (team-alpha): sponsors and reviewers need synchronized withdrawal state before judging or deadlines change + +--- +# Solver Withdrawal Continuity Guard + +Challenge: challenge-materials-screen +Decision: coordinator-review +Generated: 2026-05-22T15:20:00Z + +## Summary + +- Teams reviewed: 1 +- Affected teams: 1 +- Withdrawing solvers: 1 +- Ready replacements: 1 +- Required actions: 2 + +## Findings + +- **high** artifact-custody-gap: team-beta has withdrawing-solver artifacts without custody confirmation. +- **medium** stakeholder-notification-gap: team-beta has incomplete sponsor or reviewer withdrawal notifications. + +## Required Actions + +- confirm_artifact_custody (team-beta): submission artifacts need owner handoff before sponsor review or scoring continues +- notify_stakeholders (team-beta): sponsors and reviewers need synchronized withdrawal state before judging or deadlines change + +--- +# Solver Withdrawal Continuity Guard + +Challenge: challenge-climate-nowcast +Decision: continuity-ready +Generated: 2026-05-22T15:20:00Z + +## Summary + +- Teams reviewed: 1 +- Affected teams: 1 +- Withdrawing solvers: 1 +- Ready replacements: 1 +- Required actions: 0 + +No blocking withdrawal continuity gaps detected. diff --git a/challenge-solver-withdrawal-continuity-guard/reports/demo.mp4 b/challenge-solver-withdrawal-continuity-guard/reports/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..9d81ec442eb4a627ab6a9fbd2bc5f4a1e379f9b8 GIT binary patch literal 6577 zcmcIod0bP+_P=3O^hr@rHthvLSwj+bF-TMt6fH_?6~T~|1hScgy%n&6imeqy(SV>; zs#Yl|6}O5D)Rtn!4Ft6cs1#fgK|n3MGYMA^{QdgJd+&ZSnYnYm=X<_$X70HG48w4~ zGC?L(NW~bYg`plAjTOtGie+w84AV;zOQo?GhKa69Vdyh>y>|oC z!|weCkslgqVVG{AQqGD7W0*2p9aDPhvMZHLrO2Jc! zBB4Lr;J4eQmxW>4D2g^lF`VeUorLKL9^bFFKKFM|4{|nUE9C=8rJces)G$+pQk`&Q zNBTes>@{Fv9cUxah327Y`EdrZ^I#5uuqkSr-axPR1?+!oemL)cdjEgv`cHX>`x^}8 zgNTQ2)j1qCKiu!1?g_qs!u_x9qtH5Fvx)uwr+8}JmT;yx$PuoAGo?QHFebTyEm31q zf5SP{<-W#53|c$tQH8UDhSm?yC`SrCx*&&ohoEQ2aFs0RL&ZFGk8D;$3n3IwIZGx(wd&pF zEYwt6+k;9eBAm=+shxoo!=NJZp(0T|pdz8@qxf=rI&}tR1_ftHSfT_4k4}kqok5AGQeAN|k1pWF zrQ%9C zPb3m5VB{6=#pNhrz*#M(Q$PZXn<$m==rpPmm5TFO3MEsfh!)BaLPM}x#+35;3Z9bg zOv9A|Id}jO6&FdR(JTSnG2cz%3XzZlVu%R}E|IH)aD-x(5(yDXlsvhJ1t6Gbi(=%g z1SUr+ma&v@&w(7li(DuH3INJk2*a1N#5@ILi_MfJz*xwo(_qZvvSer(Y$jXCQlKD0 zIZwT?IG!+4pk#whD&t9*ky060HIp)Mjpii)H=X8A85lC*^-8C?;R+524Gx zQ_ey;mGcw=aF=tK@0N?~at@s%=i_2{Yw^&s;D%0fB~$R#XfbpO*#m|$M6B*v@pKPw zm{2HrGP)Zsl))~+iGWQ6F4k%|0BAe06m(>H8`fc-{e4Z^J;pc7R;}tO)I@1+Ma&d{xzPoW4m5Uc1VvaP4`-m&LNz#1=EV+wK?~0@jYj}AnjX6 z#=W0jSqVmUHaeb+(szITX+YO~$z2>HZ7PZkP3W?m(zEs_ z>De1|raavd>l?Nt^{#(Ov!BP~8o~Lz&fhJ@zG+x>Ea`Q`YCWBm6+)kgBM=sG(^j_Y_$@M3-AZ_IAfSDV7m zdUW1*I>ukJ@{4?|Fpkl8{p#E5;PyqkQ}0ERwfAv;|4SN{@nhicGF|<;BPLrezPuCC zoDh8ARNBoWL32;Pn3cLuYrbJu_6ZLIFSF;^Xk2}EOtj|Oz3d7o=sNsW+>-vxr~TDW z`feB58TFX0FD$v&7`dt$pSI{yW_kUy@g~yoe+w|3E0Thl(YHtS{dquFW|Zt;ePaWL zWu;;mwwerv`Qm)xw%b3R*jl%PWxdGlvhRW=8LFyZ zi)sRHjt}P-*V#+budctinEvXF$Ki?8_AlJ(J1`rU{8*N)>n#t_GZ({^7gikbvlm`` z6TBy5GU*D33{||AM(-sP+$&q~Cp)6=5~;L1Xga(RK^-opye1{WLYA`0su5 zJj20*&QMj2kfUE+Es2cO4_r278b0HD@1`SB$9C-B6%%-V>yxluTNXJ?c}xzVo%r=d z4iG@9S_xq?!;3;&mi^ko-myk|e8RT8Idf*G?lk~8FjS9*>&mteN2QrjOV?dFG`g)i z)#|macV6{zA)q}NxnoCaD!3c+*0*fB@3PX|uE6fjNn6sB{PS*|i4?P4zA5=*eYFmF z;-mscLQFua`x>unXX?gI7d1{e;{2VZ?f&I9fN^K!k~Qpu`PWzc1G%$?d=Wv8e}LRy zLmoVWeE!{#H#-KD`+eRyDQikjwfEPay_GG;AG2}|w;usV8&ZLEfLyo7s9JsEEG+Wu zqHKQ)Ykj*fD8Hq)T9XP&3HG8v_Hz-Yrl%s4j|F1-P4mYNH3ok`|eEC}U&sXjU3U4<)d0M>fXI~n6Bq2a85n$tx0HJoKrNv3d z+VrE!0Oi2Qy{Qf`_)O|;yB_Uno>ABD(>~;&om`=j)2op)Fn^ZK{uW z{-9)zQ+tKaJ&!%$ij(q92|2tyeKoIs`eVc9t0xvuFDNT6sJA+s0vHd5%3{Q7D&`3O=^x;~b-&8D^4P{22Jca*1KUA@ z{>*?7YX(B-^nNJB6d9nX3>9m52;KhoQq=BO5tqMpT$bg9>@^*W7~nL8l%GOS*D;9f zlm)u2d(h?KxFRnhcp@qwJvx)5>$7?rxZ&2)kPv1nu35vFT*oi7haOAiT3i{Q>JijBQ$JUR2=a?7YSG4uf? z50si0kj}Qt1zX9Yj_W=u+GF;STVGdgXmwcmMkBX{kQ@6h4})?uG6CewP_+ggU1?YY^CEu#`txGQNWNS)e=r zz2q4~@-mylE~f8czEpmey(0zurjiQm2{rD`M1AQpk~PY+V#)(f|9Sk?UjTDu8;Sc>R{c2E{CC z4!fYI*`Oa%!3{R(8o_-@oA0LPHe*>Oj!XRQc+b%{KS|P4H0-q_*mK`gJ#71nksGjg z66|H~ls64w--JZK26c~MPkN8tq`&b=)BVtfT@zG~+(Y}^Dd)iNV^aP^f`0cP{nQyG zJ13L2hv?0c>T~Upy?Nm~} z7r~t|n3ypYzJaCJXPRVD|MhJiz}y%rpAq~%8tLKK)FP|X;nsWeYTf2XXeGa~lvUi@ zs@Jq0{NPm)O3*9)RA()Pt2(15q)%L-^O5h8@#SaZ0Rsgqd<3_JhMO6-cUiQ-)&BN} zfH%PUn z`@Z)yoA=pI4JW-50Seo4o)F7MHM1G$jxA`D>4hvvT z3{{J!7%sig-}iNintcC>D%RdvqsIN78dzKD8^tnM_*CB^-Te>1PbL-U60{$^qkV+$ zzu + + Solver Withdrawal Continuity Guard + Challenge continuity packet for solver withdrawals, replacements, and artifact custody + + + Workspace Hold + 1 + + + + Coordinator Review + 1 + + + + Continuity Ready + 1 + + Affected teams: 3 | Ready replacements: 2 | Findings: 9 + Checks: notice, access revocation, IP return, team quorum, artifact custody, milestone risk, notifications + Synthetic data only. No credentials, private challenge files, payout actions, KYC, bank, or live workspace calls. + diff --git a/challenge-solver-withdrawal-continuity-guard/reports/withdrawal-continuity-packet.json b/challenge-solver-withdrawal-continuity-guard/reports/withdrawal-continuity-packet.json new file mode 100644 index 00000000..2e091caf --- /dev/null +++ b/challenge-solver-withdrawal-continuity-guard/reports/withdrawal-continuity-packet.json @@ -0,0 +1,178 @@ +[ + { + "scenario": "broken-quorum-and-unrevoked-access", + "challengeId": "challenge-protein-folding", + "generatedAt": "2026-05-22T15:20:00Z", + "decision": "hold-workspace-continuity", + "summary": { + "teams": 1, + "affectedTeams": 1, + "withdrawingSolvers": 1, + "replacementsReady": 0, + "requiredActions": 7, + "critical": 3, + "high": 2, + "medium": 2 + }, + "findings": [ + { + "type": "missing-withdrawal-notice", + "severity": "critical", + "target": "solver-1", + "teamId": "team-alpha", + "message": "solver-1 is withdrawing without a dated notice record." + }, + { + "type": "access-revocation-missing", + "severity": "critical", + "target": "solver-1", + "teamId": "team-alpha", + "message": "solver-1 still needs workspace access revocation evidence." + }, + { + "type": "ip-return-missing", + "severity": "high", + "target": "solver-1", + "teamId": "team-alpha", + "message": "solver-1 lacks IP return, retention, or destruction confirmation." + }, + { + "type": "team-quorum-broken", + "severity": "critical", + "target": "team-alpha", + "activeContinuityMembers": 1, + "minActiveMembers": 2, + "message": "team-alpha falls below required active solver quorum after withdrawal." + }, + { + "type": "artifact-custody-gap", + "severity": "high", + "target": "team-alpha", + "artifactIds": [ + "model-checkpoint" + ], + "message": "team-alpha has withdrawing-solver artifacts without custody confirmation." + }, + { + "type": "milestone-continuity-risk", + "severity": "medium", + "target": "team-alpha", + "dueAt": "2026-05-25T00:00:00Z", + "message": "team-alpha has a milestone due within 7 days of a withdrawal event." + }, + { + "type": "stakeholder-notification-gap", + "severity": "medium", + "target": "team-alpha", + "missingAudiences": [ + "sponsor", + "reviewers" + ], + "message": "team-alpha has incomplete sponsor or reviewer withdrawal notifications." + } + ], + "requiredActions": [ + { + "type": "record_withdrawal_notice", + "target": "solver-1", + "reason": "challenge coordinators need a dated notice before changing solver team state" + }, + { + "type": "revoke_workspace_access", + "target": "solver-1", + "reason": "private challenge data and submission workspaces must be protected after withdrawal" + }, + { + "type": "confirm_ip_return_duties", + "target": "solver-1", + "reason": "solver-owned and sponsor-confidential materials need explicit custody status" + }, + { + "type": "restore_team_quorum", + "target": "team-alpha", + "reason": "challenge fairness requires enough active or eligible replacement solvers before work continues" + }, + { + "type": "confirm_artifact_custody", + "target": "team-alpha", + "reason": "submission artifacts need owner handoff before sponsor review or scoring continues" + }, + { + "type": "review_milestone_extension", + "target": "team-alpha", + "reason": "near-term milestones may need sponsor-approved extension or partial-credit handling" + }, + { + "type": "notify_stakeholders", + "target": "team-alpha", + "reason": "sponsors and reviewers need synchronized withdrawal state before judging or deadlines change" + } + ] + }, + { + "scenario": "replacement-ready-but-custody-gap", + "challengeId": "challenge-materials-screen", + "generatedAt": "2026-05-22T15:20:00Z", + "decision": "coordinator-review", + "summary": { + "teams": 1, + "affectedTeams": 1, + "withdrawingSolvers": 1, + "replacementsReady": 1, + "requiredActions": 2, + "critical": 0, + "high": 1, + "medium": 1 + }, + "findings": [ + { + "type": "artifact-custody-gap", + "severity": "high", + "target": "team-beta", + "artifactIds": [ + "screening-notebook" + ], + "message": "team-beta has withdrawing-solver artifacts without custody confirmation." + }, + { + "type": "stakeholder-notification-gap", + "severity": "medium", + "target": "team-beta", + "missingAudiences": [ + "reviewers" + ], + "message": "team-beta has incomplete sponsor or reviewer withdrawal notifications." + } + ], + "requiredActions": [ + { + "type": "confirm_artifact_custody", + "target": "team-beta", + "reason": "submission artifacts need owner handoff before sponsor review or scoring continues" + }, + { + "type": "notify_stakeholders", + "target": "team-beta", + "reason": "sponsors and reviewers need synchronized withdrawal state before judging or deadlines change" + } + ] + }, + { + "scenario": "clean-continuity-handoff", + "challengeId": "challenge-climate-nowcast", + "generatedAt": "2026-05-22T15:20:00Z", + "decision": "continuity-ready", + "summary": { + "teams": 1, + "affectedTeams": 1, + "withdrawingSolvers": 1, + "replacementsReady": 1, + "requiredActions": 0, + "critical": 0, + "high": 0, + "medium": 0 + }, + "findings": [], + "requiredActions": [] + } +] diff --git a/challenge-solver-withdrawal-continuity-guard/requirements-map.md b/challenge-solver-withdrawal-continuity-guard/requirements-map.md new file mode 100644 index 00000000..88f89af6 --- /dev/null +++ b/challenge-solver-withdrawal-continuity-guard/requirements-map.md @@ -0,0 +1,30 @@ +# Issue #18 Requirement Map + +## Secure Submission Engine + +- Preserves workspace continuity when a solver withdraws after private challenge access starts. +- Checks workspace revocation, team quorum, replacement readiness, and artifact custody before work continues. +- Keeps withdrawal handling separate from challenge intake, broad scoring, and final payout settlement. + +## Multi-Phase Challenges + +- Flags milestone deadlines within seven days of a withdrawal event. +- Creates coordinator actions for extension review, partial-credit handling, and sponsor/reviewer notifications. +- Supports replacement members who meet eligibility and NDA requirements. + +## Arbitration And Reward Distribution + +- Holds challenge continuity when team quorum, access revocation, or notice evidence is missing. +- Preserves audit evidence for later arbitration if a withdrawal affects scoring, compensation, or IP custody. +- Prevents sponsor review or payout handoff from continuing while withdrawn solver artifacts lack custody confirmation. + +## IP Management Options + +- Requires IP return, retention, or destruction confirmation for withdrawing solvers. +- Tracks artifact ownership by solver and confirms custody before sponsor/reviewer packets continue. + +## Review Evidence + +- `test.js` covers workspace hold, coordinator review, and continuity-ready outcomes. +- `demo.js` generates deterministic JSON, Markdown, SVG, and MP4 artifacts from synthetic data. +- No live challenge workspace, payment, KYC, bank, payout, sponsor data-room, or external account systems are used. diff --git a/challenge-solver-withdrawal-continuity-guard/sample-data.js b/challenge-solver-withdrawal-continuity-guard/sample-data.js new file mode 100644 index 00000000..e65e07e2 --- /dev/null +++ b/challenge-solver-withdrawal-continuity-guard/sample-data.js @@ -0,0 +1,89 @@ +const scenarios = [ + { + name: 'broken-quorum-and-unrevoked-access', + challengeId: 'challenge-protein-folding', + generatedAt: '2026-05-22T15:20:00Z', + teams: [ + { + id: 'team-alpha', + minActiveMembers: 2, + currentMilestoneDueAt: '2026-05-25T00:00:00Z', + members: [ + { + id: 'solver-1', + status: 'withdrawing', + noticeAt: '', + ndaAccepted: true, + ipReturnConfirmed: false, + accessRevokedAt: '', + lastWorkspaceAccessAt: '2026-05-22T14:00:00Z', + }, + {id: 'solver-2', status: 'active', ndaAccepted: true}, + ], + artifacts: [{id: 'model-checkpoint', ownerId: 'solver-1', custodyConfirmed: false}], + notifications: [], + }, + ], + }, + { + name: 'replacement-ready-but-custody-gap', + challengeId: 'challenge-materials-screen', + generatedAt: '2026-05-22T15:20:00Z', + teams: [ + { + id: 'team-beta', + minActiveMembers: 2, + currentMilestoneDueAt: '2026-06-15T00:00:00Z', + replacementPolicy: {allowsReplacement: true}, + members: [ + { + id: 'solver-3', + status: 'withdrawing', + noticeAt: '2026-05-21T10:00:00Z', + ndaAccepted: true, + ipReturnConfirmed: true, + accessRevokedAt: '2026-05-21T11:00:00Z', + lastWorkspaceAccessAt: '2026-05-21T09:30:00Z', + }, + {id: 'solver-4', status: 'active', ndaAccepted: true}, + {id: 'solver-5', status: 'replacement', ndaAccepted: true, replacementEligible: true}, + ], + artifacts: [{id: 'screening-notebook', ownerId: 'solver-3', custodyConfirmed: false}], + notifications: [{audience: 'sponsor', sentAt: '2026-05-21T12:00:00Z'}], + }, + ], + }, + { + name: 'clean-continuity-handoff', + challengeId: 'challenge-climate-nowcast', + generatedAt: '2026-05-22T15:20:00Z', + teams: [ + { + id: 'team-gamma', + minActiveMembers: 2, + currentMilestoneDueAt: '2026-06-30T00:00:00Z', + replacementPolicy: {allowsReplacement: true}, + members: [ + { + id: 'solver-6', + status: 'withdrawing', + noticeAt: '2026-05-20T09:00:00Z', + ndaAccepted: true, + ipReturnConfirmed: true, + accessRevokedAt: '2026-05-20T10:00:00Z', + lastWorkspaceAccessAt: '2026-05-20T09:30:00Z', + }, + {id: 'solver-7', status: 'active', ndaAccepted: true}, + {id: 'solver-8', status: 'replacement', ndaAccepted: true, replacementEligible: true}, + ], + artifacts: [{id: 'nowcast-dataset', ownerId: 'solver-6', custodyConfirmed: true}], + notifications: [ + {audience: 'sponsor', sentAt: '2026-05-20T10:30:00Z'}, + {audience: 'reviewers', sentAt: '2026-05-20T10:35:00Z'}, + ], + }, + ], + }, +]; + +module.exports = {scenarios}; diff --git a/challenge-solver-withdrawal-continuity-guard/test.js b/challenge-solver-withdrawal-continuity-guard/test.js new file mode 100644 index 00000000..659f5fac --- /dev/null +++ b/challenge-solver-withdrawal-continuity-guard/test.js @@ -0,0 +1,130 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + evaluateSolverWithdrawalContinuity, + buildCoordinatorPacket, +} = require('./index'); + +test('holds active challenge when withdrawal breaks quorum and access is not revoked', () => { + const result = evaluateSolverWithdrawalContinuity({ + challengeId: 'challenge-protein-folding', + generatedAt: '2026-05-22T15:20:00Z', + teams: [ + { + id: 'team-alpha', + minActiveMembers: 2, + currentMilestoneDueAt: '2026-05-25T00:00:00Z', + members: [ + { + id: 'solver-1', + status: 'withdrawing', + noticeAt: '', + ndaAccepted: true, + ipReturnConfirmed: false, + accessRevokedAt: '', + lastWorkspaceAccessAt: '2026-05-22T14:00:00Z', + }, + {id: 'solver-2', status: 'active', ndaAccepted: true}, + ], + artifacts: [{id: 'model-checkpoint', ownerId: 'solver-1', custodyConfirmed: false}], + notifications: [], + }, + ], + }); + + assert.equal(result.decision, 'hold-workspace-continuity'); + assert.deepEqual( + result.findings.map((finding) => finding.type), + [ + 'missing-withdrawal-notice', + 'access-revocation-missing', + 'ip-return-missing', + 'team-quorum-broken', + 'artifact-custody-gap', + 'milestone-continuity-risk', + 'stakeholder-notification-gap', + ] + ); + assert.equal(result.summary.critical, 3); + assert.equal(result.summary.affectedTeams, 1); +}); + +test('routes replacement and custody gaps to coordinator review without blocking clean quorum', () => { + const result = evaluateSolverWithdrawalContinuity({ + challengeId: 'challenge-materials-screen', + generatedAt: '2026-05-22T15:20:00Z', + teams: [ + { + id: 'team-beta', + minActiveMembers: 2, + currentMilestoneDueAt: '2026-06-15T00:00:00Z', + replacementPolicy: {allowsReplacement: true}, + members: [ + { + id: 'solver-3', + status: 'withdrawing', + noticeAt: '2026-05-21T10:00:00Z', + ndaAccepted: true, + ipReturnConfirmed: true, + accessRevokedAt: '2026-05-21T11:00:00Z', + lastWorkspaceAccessAt: '2026-05-21T09:30:00Z', + }, + {id: 'solver-4', status: 'active', ndaAccepted: true}, + {id: 'solver-5', status: 'replacement', ndaAccepted: true, replacementEligible: true}, + ], + artifacts: [{id: 'screening-notebook', ownerId: 'solver-3', custodyConfirmed: false}], + notifications: [{audience: 'sponsor', sentAt: '2026-05-21T12:00:00Z'}], + }, + ], + }); + + assert.equal(result.decision, 'coordinator-review'); + assert.deepEqual( + result.findings.map((finding) => finding.type), + ['artifact-custody-gap', 'stakeholder-notification-gap'] + ); + assert.equal(result.summary.replacementsReady, 1); + assert.equal(result.requiredActions[0].type, 'confirm_artifact_custody'); +}); + +test('approves continuity when withdrawal evidence, replacement, custody, and notices are complete', () => { + const result = evaluateSolverWithdrawalContinuity({ + challengeId: 'challenge-climate-nowcast', + generatedAt: '2026-05-22T15:20:00Z', + teams: [ + { + id: 'team-gamma', + minActiveMembers: 2, + currentMilestoneDueAt: '2026-06-30T00:00:00Z', + replacementPolicy: {allowsReplacement: true}, + members: [ + { + id: 'solver-6', + status: 'withdrawing', + noticeAt: '2026-05-20T09:00:00Z', + ndaAccepted: true, + ipReturnConfirmed: true, + accessRevokedAt: '2026-05-20T10:00:00Z', + lastWorkspaceAccessAt: '2026-05-20T09:30:00Z', + }, + {id: 'solver-7', status: 'active', ndaAccepted: true}, + {id: 'solver-8', status: 'replacement', ndaAccepted: true, replacementEligible: true}, + ], + artifacts: [{id: 'nowcast-dataset', ownerId: 'solver-6', custodyConfirmed: true}], + notifications: [ + {audience: 'sponsor', sentAt: '2026-05-20T10:30:00Z'}, + {audience: 'reviewers', sentAt: '2026-05-20T10:35:00Z'}, + ], + }, + ], + }); + const packet = buildCoordinatorPacket(result); + + assert.equal(result.decision, 'continuity-ready'); + assert.equal(result.findings.length, 0); + assert.equal(result.summary.replacementsReady, 1); + assert.match(packet, /Solver Withdrawal Continuity Guard/); + assert.match(packet, /continuity-ready/); + assert.match(packet, /No blocking withdrawal continuity gaps detected/); +});