diff --git a/challenge-amendment-consent-ledger/README.md b/challenge-amendment-consent-ledger/README.md new file mode 100644 index 0000000..c3c6b6e --- /dev/null +++ b/challenge-amendment-consent-ledger/README.md @@ -0,0 +1,24 @@ +# Challenge Amendment Consent Ledger + +This submission targets [SCIBASE issue #18](https://github.com/SCIBASE-AI/SCIBASE.AI/issues/18) with a focused Scientific Bounty System module. + +It handles sponsor-side change control after a scientific bounty is already live. When a sponsor changes deliverables, rubrics, deadlines, payout schedules, private-data requirements, or IP terms, the ledger classifies the amendment, locks pre-change submission evidence, requires solver re-consent, and produces an arbitration-ready packet. + +## What It Adds + +- Materiality scoring for bounty amendments. +- Solver re-consent decisions for changed deliverables, criteria, deadlines, payout terms, IP terms, and private-data requirements. +- Locked evidence packets for submissions made before the amendment. +- Protected withdrawal detection when a solver already submitted work before a material scope change. +- Arbitration packet output for payout holds, safe-to-continue teams, and amendment digests. + +## Demo + +```powershell +node challenge-amendment-consent-ledger/test.js +node challenge-amendment-consent-ledger/demo.js +``` + +`demo.mp4` is the reviewer-facing video artifact for the bounty submission. It walks through the problem, implementation, acceptance path, and command validation in 8.4 seconds. `demo.svg` provides a static workflow diagram. + +See `acceptance-notes.md` for the payout-gate evidence checklist. diff --git a/challenge-amendment-consent-ledger/acceptance-notes.md b/challenge-amendment-consent-ledger/acceptance-notes.md new file mode 100644 index 0000000..9a1b121 --- /dev/null +++ b/challenge-amendment-consent-ledger/acceptance-notes.md @@ -0,0 +1,31 @@ +# Acceptance Notes + +This is a focused implementation for SCIBASE issue #18, not a generic AI-generated content drop. The slice targets a specific marketplace trust problem that was not covered by the repeated broad intake, scoring, arbitration, escrow, or payout-ledger submissions already in the issue thread. + +## What Changed + +- Added materiality scoring for sponsor amendments after a bounty is live. +- Added solver re-consent decisions for changed deliverables, criteria, deadlines, payout terms, IP terms, and private-data requirements. +- Added locked evidence packets for submissions that existed before a material change. +- Added protected-withdrawal and payout-hold states for arbitration. +- Added focused dependency-free tests and demo data. + +## Video Demo + +- `demo.mp4` shows the problem, implementation, acceptance behavior, and validation command. +- `demo.svg` provides a static workflow diagram. + +## Validation + +Run from the repository root: + +```powershell +node challenge-amendment-consent-ledger/test.js +node challenge-amendment-consent-ledger/demo.js +``` + +Expected result: the test prints `challenge-amendment-consent-ledger tests passed`, and the demo prints active material amendments, protected withdrawal teams, blocked award teams, and an arbitration packet digest. + +## Integration Notes + +The module is dependency-free and uses plain challenge, amendment, team, and submission objects. The next integration step is wiring those objects to the SCIBASE challenge posting portal and sponsor amendment workflow. diff --git a/challenge-amendment-consent-ledger/demo.js b/challenge-amendment-consent-ledger/demo.js new file mode 100644 index 0000000..0a9afe9 --- /dev/null +++ b/challenge-amendment-consent-ledger/demo.js @@ -0,0 +1,48 @@ +"use strict"; + +const { buildAmendmentLedger } = require("./index"); + +const ledger = buildAmendmentLedger({ + challenge: { + id: "climate-forecasting-prize", + title: "Regional climate forecast benchmark", + finalDeadline: "2026-07-31T23:59:59Z", + payoutSchedule: "milestone_40_final_60", + ipTerms: "solver_retains_until_paid", + privateDataRequired: false + }, + amendments: [ + { + id: "amend-rubric-v2", + sponsorId: "climate-nonprofit", + requestedAt: "2026-07-14T12:00:00Z", + reason: "added wildfire smoke impact validation", + changes: { + deliverablesAdded: ["wildfire-smoke-validation-notebook"], + evaluationCriteriaAdded: ["smoke-event regional accuracy"], + rubricWeightDelta: 18, + deadlineMovedTo: "2026-07-24T23:59:59Z" + } + } + ], + teams: [ + { teamId: "open-climate-lab", consentedAmendmentIds: ["amend-rubric-v2"] }, + { teamId: "student-forecast-team", consentedAmendmentIds: [] } + ], + submissions: [ + { + id: "forecast-v1", + teamId: "student-forecast-team", + submittedAt: "2026-07-10T09:30:00Z", + artifactHashes: ["model:9d2a", "report:af18", "notebook:813c"] + } + ] +}); + +console.log(JSON.stringify({ + activeMaterialAmendments: ledger.arbitrationPacket.activeMaterialAmendmentIds, + safeToContinueTeamIds: ledger.arbitrationPacket.safeToContinueTeamIds, + protectedWithdrawalTeamIds: ledger.arbitrationPacket.protectedWithdrawalTeamIds, + blockedAwardTeamIds: ledger.arbitrationPacket.blockedAwardTeamIds, + packetDigest: ledger.arbitrationPacket.packetDigest +}, null, 2)); diff --git a/challenge-amendment-consent-ledger/demo.mp4 b/challenge-amendment-consent-ledger/demo.mp4 new file mode 100644 index 0000000..5ea3f42 Binary files /dev/null and b/challenge-amendment-consent-ledger/demo.mp4 differ diff --git a/challenge-amendment-consent-ledger/demo.svg b/challenge-amendment-consent-ledger/demo.svg new file mode 100644 index 0000000..dea2bb5 --- /dev/null +++ b/challenge-amendment-consent-ledger/demo.svg @@ -0,0 +1,27 @@ + + Challenge amendment consent ledger workflow + Sponsor amendment is classified, pre-change submissions are locked, solver consent is evaluated, and arbitration packet is emitted. + + + + + + Sponsor Change + deliverable, rubric, + deadline, IP terms + Materiality + score changes and + require consent + Evidence Lock + freeze prior hashes + for arbitration + Packet + holds, consent, and + withdrawal rights + + + + + student-forecast-team + submitted before amendment, did not consent, protected withdrawal available + diff --git a/challenge-amendment-consent-ledger/index.js b/challenge-amendment-consent-ledger/index.js new file mode 100644 index 0000000..9304ff6 --- /dev/null +++ b/challenge-amendment-consent-ledger/index.js @@ -0,0 +1,220 @@ +"use strict"; + +const crypto = require("crypto"); + +function stableJson(value) { + if (Array.isArray(value)) { + return value.map(stableJson); + } + + if (value && typeof value === "object") { + return Object.keys(value) + .sort() + .reduce((sorted, key) => { + sorted[key] = stableJson(value[key]); + return sorted; + }, {}); + } + + return value; +} + +function digest(value) { + return crypto + .createHash("sha256") + .update(JSON.stringify(stableJson(value))) + .digest("hex"); +} + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function daysBetween(start, end) { + const startTime = Date.parse(start); + const endTime = Date.parse(end); + if (!Number.isFinite(startTime) || !Number.isFinite(endTime)) return 0; + return Math.round((endTime - startTime) / 86400000); +} + +function classifyAmendment(amendment, challenge) { + const changes = amendment.changes || {}; + const materialChanges = []; + let materialityScore = 0; + + if (asArray(changes.deliverablesAdded).length > 0) { + materialChanges.push("deliverables_added"); + materialityScore += changes.deliverablesAdded.length * 20; + } + + if (asArray(changes.evaluationCriteriaAdded).length > 0) { + materialChanges.push("criteria_added"); + materialityScore += changes.evaluationCriteriaAdded.length * 16; + } + + if (changes.rubricWeightDelta && Math.abs(changes.rubricWeightDelta) >= 10) { + materialChanges.push("rubric_weight_shift"); + materialityScore += Math.min(30, Math.abs(changes.rubricWeightDelta)); + } + + if (changes.deadlineMovedTo) { + const shiftDays = daysBetween(challenge.finalDeadline, changes.deadlineMovedTo); + if (shiftDays < 0) { + materialChanges.push("deadline_shortened"); + materialityScore += Math.min(25, Math.abs(shiftDays) * 3); + } + } + + if (changes.payoutSchedule && changes.payoutSchedule !== challenge.payoutSchedule) { + materialChanges.push("payout_schedule_changed"); + materialityScore += 24; + } + + if (changes.ipTerms && changes.ipTerms !== challenge.ipTerms) { + materialChanges.push("ip_terms_changed"); + materialityScore += 30; + } + + if (changes.privateDataRequired === true && challenge.privateDataRequired !== true) { + materialChanges.push("private_data_added"); + materialityScore += 32; + } + + const requiresReconsent = materialityScore >= 20; + + return { + id: amendment.id, + sponsorId: amendment.sponsorId, + requestedAt: amendment.requestedAt, + reason: amendment.reason || "not provided", + materialChanges, + materialityScore, + requiresReconsent, + digest: digest({ + challengeId: challenge.id, + amendmentId: amendment.id, + changes, + requestedAt: amendment.requestedAt + }) + }; +} + +function lockSubmissionEvidence(team, submissions, amendment) { + const lockedSubmissions = submissions + .filter((submission) => { + return submission.teamId === team.teamId && + Date.parse(submission.submittedAt) <= Date.parse(amendment.requestedAt); + }) + .map((submission) => ({ + id: submission.id, + submittedAt: submission.submittedAt, + artifactHashes: asArray(submission.artifactHashes).slice().sort(), + manifestDigest: digest({ + id: submission.id, + artifactHashes: asArray(submission.artifactHashes).slice().sort() + }) + })); + + return { + lockedCount: lockedSubmissions.length, + lockedSubmissions, + evidenceDigest: digest({ + teamId: team.teamId, + amendmentId: amendment.id, + lockedSubmissions + }) + }; +} + +function evaluateTeamConsent({ team, amendment, submissions }) { + const consentReceived = asArray(team.consentedAmendmentIds).includes(amendment.id); + const evidenceLock = lockSubmissionEvidence(team, submissions, amendment); + const consentRequired = amendment.requiresReconsent; + const protectedWithdrawal = + consentRequired && + !consentReceived && + evidenceLock.lockedCount > 0; + + return { + teamId: team.teamId, + amendmentId: amendment.id, + consentRequired, + consentReceived, + protectedWithdrawal, + payoutHold: consentRequired && !consentReceived, + evidenceLock, + status: consentRequired + ? consentReceived + ? "accepted_changed_terms" + : protectedWithdrawal + ? "protected_withdrawal_available" + : "awaiting_consent" + : "no_reconsent_required" + }; +} + +function buildAmendmentLedger({ challenge, amendments, teams, submissions }) { + if (!challenge || !challenge.id) { + throw new Error("challenge.id is required"); + } + + const classifiedAmendments = asArray(amendments).map((amendment) => + classifyAmendment(amendment, challenge) + ); + + const teamStatuses = []; + for (const amendment of classifiedAmendments) { + for (const team of asArray(teams)) { + teamStatuses.push(evaluateTeamConsent({ + team, + amendment, + submissions: asArray(submissions) + })); + } + } + + const blockedAwardTeamIds = Array.from(new Set( + teamStatuses + .filter((status) => status.payoutHold) + .map((status) => status.teamId) + )).sort(); + + const protectedWithdrawalTeamIds = Array.from(new Set( + teamStatuses + .filter((status) => status.protectedWithdrawal) + .map((status) => status.teamId) + )).sort(); + + const safeToContinueTeamIds = asArray(teams) + .map((team) => team.teamId) + .filter((teamId) => !blockedAwardTeamIds.includes(teamId)) + .sort(); + + return { + challengeId: challenge.id, + challengeTitle: challenge.title, + classifiedAmendments, + teamStatuses, + arbitrationPacket: { + challengeId: challenge.id, + activeMaterialAmendmentIds: classifiedAmendments + .filter((amendment) => amendment.requiresReconsent) + .map((amendment) => amendment.id), + blockedAwardTeamIds, + protectedWithdrawalTeamIds, + safeToContinueTeamIds, + packetDigest: digest({ + challenge, + classifiedAmendments, + teamStatuses + }) + } + }; +} + +module.exports = { + buildAmendmentLedger, + classifyAmendment, + digest, + evaluateTeamConsent +}; diff --git a/challenge-amendment-consent-ledger/requirements-map.md b/challenge-amendment-consent-ledger/requirements-map.md new file mode 100644 index 0000000..d9fbca7 --- /dev/null +++ b/challenge-amendment-consent-ledger/requirements-map.md @@ -0,0 +1,18 @@ +# Requirements Map + +| Issue requirement | Implementation | +| --- | --- | +| Challenge posting portal with deliverables and rubrics | Amendment classifier reads changed deliverables, criteria, deadlines, payout schedules, private-data requirements, and IP terms. | +| Submission engine with version control and audit logs | `lockSubmissionEvidence` preserves pre-amendment submission manifests and artifact hashes. | +| Multi-phase challenges and milestone changes | Deadline, payout schedule, and criteria changes are scored as material amendments. | +| Arbitration and reward distribution | `arbitrationPacket` lists payout holds, safe-to-continue teams, protected withdrawals, and material amendment IDs. | +| IP management options | IP term changes require explicit solver re-consent. | +| Trust for sponsors and solvers | Deterministic digests make amendment decisions and locked evidence auditable. | + +## Reviewer Checklist + +- Run `node challenge-amendment-consent-ledger/test.js`. +- Run `node challenge-amendment-consent-ledger/demo.js`. +- Confirm material amendments require re-consent. +- Confirm pre-change submissions are locked for protected withdrawal. +- Confirm unconsented material changes hold awards until resolved. diff --git a/challenge-amendment-consent-ledger/test.js b/challenge-amendment-consent-ledger/test.js new file mode 100644 index 0000000..5f8477c --- /dev/null +++ b/challenge-amendment-consent-ledger/test.js @@ -0,0 +1,122 @@ +"use strict"; + +const assert = require("assert"); +const { + buildAmendmentLedger, + classifyAmendment, + digest +} = require("./index"); + +const challenge = { + id: "biomarker-bounty-2026", + title: "Identify early-stage biomarker panel", + finalDeadline: "2026-06-30T23:59:59Z", + payoutSchedule: "winner_takes_70_runner_up_30", + ipTerms: "solver_retains_until_paid", + privateDataRequired: false +}; + +const materialAmendment = { + id: "amend-2", + sponsorId: "pharma-sponsor", + requestedAt: "2026-06-10T10:00:00Z", + reason: "sponsor added a second validation cohort", + changes: { + deliverablesAdded: ["external-cohort-validation-report"], + evaluationCriteriaAdded: ["cross-site assay drift"], + rubricWeightDelta: 15, + deadlineMovedTo: "2026-06-20T23:59:59Z", + ipTerms: "ip_transfer_on_acceptance" + } +}; + +const typoAmendment = { + id: "amend-1", + sponsorId: "pharma-sponsor", + requestedAt: "2026-06-01T10:00:00Z", + reason: "copy edit", + changes: { + descriptionClarification: "fixed typo in data dictionary label" + } +}; + +const teams = [ + { + teamId: "lab-alpha", + consentedAmendmentIds: ["amend-2"] + }, + { + teamId: "student-consortium", + consentedAmendmentIds: [] + }, + { + teamId: "new-solver", + consentedAmendmentIds: [] + } +]; + +const submissions = [ + { + id: "sub-alpha-1", + teamId: "lab-alpha", + submittedAt: "2026-06-08T08:00:00Z", + artifactHashes: ["paper:aaa", "notebook:bbb"] + }, + { + id: "sub-student-1", + teamId: "student-consortium", + submittedAt: "2026-06-09T11:00:00Z", + artifactHashes: ["paper:ccc", "notebook:ddd"] + }, + { + id: "sub-new-1", + teamId: "new-solver", + submittedAt: "2026-06-14T11:00:00Z", + artifactHashes: ["paper:eee"] + } +]; + +const material = classifyAmendment(materialAmendment, challenge); +assert.strictEqual(material.requiresReconsent, true); +assert.ok(material.materialChanges.includes("deliverables_added")); +assert.ok(material.materialChanges.includes("deadline_shortened")); +assert.ok(material.materialChanges.includes("ip_terms_changed")); +assert.ok(material.materialityScore >= 80); + +const typo = classifyAmendment(typoAmendment, challenge); +assert.strictEqual(typo.requiresReconsent, false); +assert.deepStrictEqual(typo.materialChanges, []); + +const ledger = buildAmendmentLedger({ + challenge, + amendments: [typoAmendment, materialAmendment], + teams, + submissions +}); + +const studentStatus = ledger.teamStatuses.find((status) => + status.teamId === "student-consortium" && status.amendmentId === "amend-2" +); +assert.strictEqual(studentStatus.consentRequired, true); +assert.strictEqual(studentStatus.consentReceived, false); +assert.strictEqual(studentStatus.protectedWithdrawal, true); +assert.strictEqual(studentStatus.evidenceLock.lockedCount, 1); + +const labStatus = ledger.teamStatuses.find((status) => + status.teamId === "lab-alpha" && status.amendmentId === "amend-2" +); +assert.strictEqual(labStatus.status, "accepted_changed_terms"); +assert.strictEqual(labStatus.payoutHold, false); + +const newSolverStatus = ledger.teamStatuses.find((status) => + status.teamId === "new-solver" && status.amendmentId === "amend-2" +); +assert.strictEqual(newSolverStatus.status, "awaiting_consent"); +assert.strictEqual(newSolverStatus.evidenceLock.lockedCount, 0); + +assert.deepStrictEqual(ledger.arbitrationPacket.safeToContinueTeamIds, ["lab-alpha"]); +assert.deepStrictEqual(ledger.arbitrationPacket.protectedWithdrawalTeamIds, ["student-consortium"]); +assert.ok(ledger.arbitrationPacket.blockedAwardTeamIds.includes("new-solver")); +assert.strictEqual(digest(ledger.arbitrationPacket).length, 64); + +console.log("challenge-amendment-consent-ledger tests passed");