From 5c855a4524646408696cf0b22ce289516a75f857 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Thu, 28 May 2026 08:57:41 +0200 Subject: [PATCH 01/28] Add challenge prequalification fairness guard --- .../README.md | 23 ++ .../acceptance-notes.md | 20 + .../demo.js | 77 ++++ .../index.js | 368 ++++++++++++++++++ .../make-demo-video.py | 62 +++ .../package.json | 13 + .../reports/demo.mp4 | Bin 0 -> 45643 bytes .../prequalification-fairness-packet.json | 105 +++++ .../prequalification-fairness-report.md | 28 ++ .../reports/summary.svg | 11 + .../requirements-map.md | 25 ++ .../test.js | 96 +++++ 12 files changed, 828 insertions(+) create mode 100644 challenge-prequalification-fairness-guard/README.md create mode 100644 challenge-prequalification-fairness-guard/acceptance-notes.md create mode 100644 challenge-prequalification-fairness-guard/demo.js create mode 100644 challenge-prequalification-fairness-guard/index.js create mode 100644 challenge-prequalification-fairness-guard/make-demo-video.py create mode 100644 challenge-prequalification-fairness-guard/package.json create mode 100644 challenge-prequalification-fairness-guard/reports/demo.mp4 create mode 100644 challenge-prequalification-fairness-guard/reports/prequalification-fairness-packet.json create mode 100644 challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md create mode 100644 challenge-prequalification-fairness-guard/reports/summary.svg create mode 100644 challenge-prequalification-fairness-guard/requirements-map.md create mode 100644 challenge-prequalification-fairness-guard/test.js diff --git a/challenge-prequalification-fairness-guard/README.md b/challenge-prequalification-fairness-guard/README.md new file mode 100644 index 00000000..cceccda5 --- /dev/null +++ b/challenge-prequalification-fairness-guard/README.md @@ -0,0 +1,23 @@ +# Challenge Prequalification Fairness Guard + +This module adds a focused Scientific Bounty System slice for SCIBASE issue #18. It evaluates sponsor-side prequalification rounds before solver teams are accepted or rejected from a challenge. + +The guard checks published screening criteria, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, reviewer quorum, rejection reason completeness, appeal windows, and audit evidence. Unfair or incomplete screening decisions are held for remediation before challenge access changes. + +## Run + +```bash +npm test +npm run demo +npm run video +npm run check +``` + +## Outputs + +- `reports/prequalification-fairness-packet.json` +- `reports/prequalification-fairness-report.md` +- `reports/summary.svg` +- `reports/demo.mp4` + +All data is synthetic. The module does not call payment processors, identity providers, private workspaces, sponsor systems, solver accounts, or external APIs. diff --git a/challenge-prequalification-fairness-guard/acceptance-notes.md b/challenge-prequalification-fairness-guard/acceptance-notes.md new file mode 100644 index 00000000..98b35b53 --- /dev/null +++ b/challenge-prequalification-fairness-guard/acceptance-notes.md @@ -0,0 +1,20 @@ +# Acceptance Notes + +This #18 slice focuses specifically on fair sponsor-side prequalification before solvers enter or are rejected from a scientific challenge. + +It is not: + +- a broad scientific bounty marketplace module +- a general challenge intake compliance gate +- a submission workspace privacy or data-room access guard +- an arbitration scoring or payout eligibility ledger +- a clarification freeze, benchmark leakage, evaluator calibration, or reviewer workload guard + +Validation coverage: + +- eligible applicants are accepted when published criteria, quorum, and weighted thresholds are satisfied +- anonymous-screening leaks hold a candidate for fairness review +- inconsistent threshold decisions are held before rejection is published +- conflicted reviewer participation and missing rejection reasons remain auditable +- unpublished screening criteria are blocked before results are published +- audit digests are deterministic and private-data free diff --git a/challenge-prequalification-fairness-guard/demo.js b/challenge-prequalification-fairness-guard/demo.js new file mode 100644 index 00000000..25e5fe34 --- /dev/null +++ b/challenge-prequalification-fairness-guard/demo.js @@ -0,0 +1,77 @@ +const fs = require('fs'); +const path = require('path'); +const { evaluatePrequalificationRound, buildSampleRound } = require('./index'); + +const reportsDir = path.join(__dirname, 'reports'); +fs.mkdirSync(reportsDir, { recursive: true }); + +const result = evaluatePrequalificationRound(buildSampleRound()); + +const packetPath = path.join(reportsDir, 'prequalification-fairness-packet.json'); +const reportPath = path.join(reportsDir, 'prequalification-fairness-report.md'); +const svgPath = path.join(reportsDir, 'summary.svg'); + +fs.writeFileSync(packetPath, `${JSON.stringify(result, null, 2)}\n`); + +const decisions = result.decisions + .map( + (decision) => + `- ${decision.applicantId}: ${decision.decision}, score ${decision.weightedScore}, reasons: ${ + decision.reasons.length > 0 ? decision.reasons.join(', ') : 'none' + }` + ) + .join('\n'); + +const actions = result.remediationActions + .map((action) => `- ${action.id}: ${action.action} (${action.priority})`) + .join('\n'); + +const markdown = `# Challenge Prequalification Fairness Guard + +Challenge: ${result.challengeId} +Generated: ${result.generatedAt} + +## Summary + +- Accepted applicants: ${result.summary.accepted} +- Held for fairness review: ${result.summary.held} +- Rejected with audit trail: ${result.summary.rejectedWithAudit} +- Remediation actions: ${result.summary.remediationActions} +- Criteria digest: ${result.criteriaDigest} +- Audit digest: ${result.auditDigest} + +## Decisions + +${decisions} + +## Remediation Actions + +${actions} + +## Safety + +All fixtures are synthetic. The guard does not call payment processors, identity providers, private workspaces, sponsor systems, or external APIs. +`; + +fs.writeFileSync(reportPath, markdown); + +const svg = ` + + + Challenge Prequalification Fairness Guard + Accepted applicants: ${result.summary.accepted} + Held for fairness review: ${result.summary.held} + Remediation actions: ${result.summary.remediationActions} + Checks: criteria, thresholds, anonymity, conflicts, rejection reasons, appeal windows + Unfair screening decisions are held before applicants are accepted or rejected. + ${result.auditDigest} + +`; + +fs.writeFileSync(svgPath, svg); + +console.log(`Wrote ${path.relative(__dirname, packetPath)}`); +console.log(`Wrote ${path.relative(__dirname, reportPath)}`); +console.log(`Wrote ${path.relative(__dirname, svgPath)}`); +console.log(`Accepted applicants: ${result.summary.accepted}`); +console.log(`Held applicants: ${result.summary.held}`); diff --git a/challenge-prequalification-fairness-guard/index.js b/challenge-prequalification-fairness-guard/index.js new file mode 100644 index 00000000..268c823b --- /dev/null +++ b/challenge-prequalification-fairness-guard/index.js @@ -0,0 +1,368 @@ +const crypto = require('crypto'); + +function stableStringify(value) { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]`; + } + + if (value && typeof value === 'object') { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(',')}}`; + } + + return JSON.stringify(value); +} + +function digest(value) { + return `sha256:${crypto.createHash('sha256').update(stableStringify(value)).digest('hex')}`; +} + +function groupBy(items, getKey) { + return items.reduce((groups, item) => { + const key = getKey(item); + if (!groups[key]) { + groups[key] = []; + } + groups[key].push(item); + return groups; + }, {}); +} + +function scoreForCriterion(reviews, criterionId) { + const scores = reviews + .map((review) => review.scores[criterionId]) + .filter((score) => typeof score === 'number'); + + if (scores.length === 0) { + return null; + } + + return scores.reduce((total, score) => total + score, 0) / scores.length; +} + +function weightedScore(criteria, reviews) { + const weighted = criteria.reduce((total, criterion) => { + const average = scoreForCriterion(reviews, criterion.id); + if (average === null) { + return total; + } + return total + average * (criterion.weight / 100); + }, 0); + + return Math.round(weighted); +} + +function uniqueSorted(values) { + return Array.from(new Set(values)).sort(); +} + +function publicCriteriaIds(round) { + return round.criteria.map((criterion) => criterion.id); +} + +function reviewUsesHiddenCriteria(review, criteriaIds) { + return Object.keys(review.scores).some((criterionId) => !criteriaIds.includes(criterionId)); +} + +function appealStatus(applicant, round) { + if (applicant.sponsorDecision !== 'reject') { + return 'not-required'; + } + + if (!applicant.appealDueAt) { + return 'missing'; + } + + return applicant.appealDueAt >= round.generatedAt ? 'open' : 'expired'; +} + +function reasonsForApplicant(applicant, reviews, round) { + const criteriaIds = publicCriteriaIds(round); + const nonConflictedReviews = reviews.filter((review) => !review.conflict); + const reasons = []; + + if (round.anonymousScreeningRequired && reviews.some((review) => !review.anonymousScreeningObserved)) { + reasons.push('anonymous-screening-leak'); + } + + if (reviews.some((review) => review.conflict)) { + reasons.push('reviewer-conflict'); + } + + if (nonConflictedReviews.length < round.minReviewers) { + reasons.push('reviewer-quorum-shortfall'); + } + + if (reviews.some((review) => reviewUsesHiddenCriteria(review, criteriaIds))) { + reasons.push('unpublished-screening-criterion'); + } + + if ( + criteriaIds.some((criterionId) => + reviews.some((review) => typeof review.scores[criterionId] !== 'number') + ) + ) { + reasons.push('missing-published-criterion-score'); + } + + const score = weightedScore(round.criteria, reviews); + const passesThreshold = score >= round.passThreshold; + + if ( + (applicant.sponsorDecision === 'reject' && passesThreshold) || + (applicant.sponsorDecision === 'accept' && !passesThreshold) + ) { + reasons.push('inconsistent-threshold-decision'); + } + + if (applicant.sponsorDecision === 'reject' && applicant.rejectionReasons.length === 0) { + reasons.push('missing-rejection-reason'); + } + + if (applicant.sponsorDecision === 'reject' && appealStatus(applicant, round) === 'missing') { + reasons.push('missing-appeal-window'); + } + + return uniqueSorted(reasons); +} + +function remediationAction(applicant, reasons) { + if (reasons.includes('anonymous-screening-leak')) { + return 'rerun-blinded-prequalification-review'; + } + + if (reasons.includes('unpublished-screening-criterion')) { + return 'remove-unpublished-criterion-and-rescore'; + } + + if (reasons.includes('reviewer-conflict')) { + return 'replace-conflicted-reviewer'; + } + + if (reasons.includes('missing-rejection-reason') || reasons.includes('missing-appeal-window')) { + return 'publish-rejection-reasons-and-appeal-window'; + } + + if (reasons.includes('inconsistent-threshold-decision')) { + return 'reconcile-score-threshold-decision'; + } + + return 'complete-prequalification-evidence'; +} + +function evaluatePrequalificationRound(round) { + const reviewsByApplicant = groupBy(round.reviews, (review) => review.applicantId); + + const decisions = round.applicants.map((applicant) => { + const reviews = reviewsByApplicant[applicant.id] || []; + const reasons = reasonsForApplicant(applicant, reviews, round); + const score = weightedScore(round.criteria, reviews); + const decision = + reasons.length > 0 + ? 'hold-for-fairness-review' + : score >= round.passThreshold + ? 'accept-prequalified' + : 'reject-with-audit'; + + return { + id: applicant.id, + applicantId: applicant.id, + challengeId: round.challengeId, + decision, + sponsorDecision: applicant.sponsorDecision, + weightedScore: score, + passThreshold: round.passThreshold, + reviewersCounted: reviews.filter((review) => !review.conflict).length, + criteriaApplied: publicCriteriaIds(round), + reasons, + rejectionReasons: applicant.rejectionReasons, + appealStatus: appealStatus(applicant, round), + auditDigest: digest({ + applicantId: applicant.id, + challengeId: round.challengeId, + score, + reasons, + reviews: reviews.map((review) => ({ + reviewerId: review.reviewerId, + conflict: review.conflict, + anonymousScreeningObserved: review.anonymousScreeningObserved, + scores: review.scores + })) + }) + }; + }); + + const remediationActions = decisions + .filter((decision) => decision.decision === 'hold-for-fairness-review') + .map((decision) => ({ + id: `remediate-${decision.applicantId}`, + applicantId: decision.applicantId, + action: remediationAction(decision, decision.reasons), + priority: + decision.reasons.includes('anonymous-screening-leak') || + decision.reasons.includes('reviewer-conflict') || + decision.reasons.includes('unpublished-screening-criterion') + ? 'high' + : 'normal', + reasons: decision.reasons + })); + + const summary = { + accepted: decisions.filter((decision) => decision.decision === 'accept-prequalified').length, + held: decisions.filter((decision) => decision.decision === 'hold-for-fairness-review').length, + rejectedWithAudit: decisions.filter((decision) => decision.decision === 'reject-with-audit') + .length, + remediationActions: remediationActions.length + }; + + return { + challengeId: round.challengeId, + generatedAt: round.generatedAt, + criteriaDigest: digest(round.criteria), + decisions, + remediationActions, + summary, + auditDigest: digest({ + challengeId: round.challengeId, + generatedAt: round.generatedAt, + decisions, + remediationActions, + summary + }) + }; +} + +function buildSampleRound() { + return { + challengeId: 'challenge-18-prequalification-rna-biomarker', + generatedAt: '2026-05-28T08:00:00Z', + anonymousScreeningRequired: true, + minReviewers: 2, + passThreshold: 75, + criteria: [ + { + id: 'domain-fit', + label: 'Domain fit for the scientific challenge', + weight: 40 + }, + { + id: 'data-readiness', + label: 'Evidence that required data and tools are ready', + weight: 35 + }, + { + id: 'safety-plan', + label: 'Risk, NDA, and responsible-use plan', + weight: 25 + } + ], + applicants: [ + { + id: 'applicant-biofoundry', + sponsorDecision: 'accept', + rejectionReasons: [], + appealDueAt: null + }, + { + id: 'applicant-neuro-lab', + sponsorDecision: 'reject', + rejectionReasons: ['methods plan did not match expected wet-lab validation'], + appealDueAt: '2026-06-04T08:00:00Z' + }, + { + id: 'applicant-sponsor-alumni', + sponsorDecision: 'reject', + rejectionReasons: [], + appealDueAt: null + } + ], + reviews: [ + { + applicantId: 'applicant-biofoundry', + reviewerId: 'reviewer-alpha', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 90, + 'data-readiness': 86, + 'safety-plan': 84 + } + }, + { + applicantId: 'applicant-biofoundry', + reviewerId: 'reviewer-beta', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 88, + 'data-readiness': 85, + 'safety-plan': 85 + } + }, + { + applicantId: 'applicant-neuro-lab', + reviewerId: 'reviewer-alpha', + anonymousScreeningObserved: false, + conflict: false, + recommendedDecision: 'reject', + rejectionReasons: ['identity visible in screening packet'], + scores: { + 'domain-fit': 82, + 'data-readiness': 80, + 'safety-plan': 78 + } + }, + { + applicantId: 'applicant-neuro-lab', + reviewerId: 'reviewer-gamma', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'reject', + rejectionReasons: ['identity visible in screening packet'], + scores: { + 'domain-fit': 84, + 'data-readiness': 81, + 'safety-plan': 79 + } + }, + { + applicantId: 'applicant-sponsor-alumni', + reviewerId: 'reviewer-delta', + anonymousScreeningObserved: true, + conflict: true, + recommendedDecision: 'reject', + rejectionReasons: [], + scores: { + 'domain-fit': 74, + 'data-readiness': 72, + 'safety-plan': 76 + } + }, + { + applicantId: 'applicant-sponsor-alumni', + reviewerId: 'reviewer-epsilon', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'reject', + rejectionReasons: [], + scores: { + 'domain-fit': 70, + 'data-readiness': 68, + 'safety-plan': 73 + } + } + ] + }; +} + +module.exports = { + evaluatePrequalificationRound, + buildSampleRound, + digest +}; diff --git a/challenge-prequalification-fairness-guard/make-demo-video.py b/challenge-prequalification-fairness-guard/make-demo-video.py new file mode 100644 index 00000000..f50b2e2e --- /dev/null +++ b/challenge-prequalification-fairness-guard/make-demo-video.py @@ -0,0 +1,62 @@ +import os +import subprocess + + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPORTS = os.path.join(HERE, "reports") +FRAME = os.path.join(REPORTS, "demo-frame.png") +OUTPUT = os.path.join(REPORTS, "demo.mp4") +os.makedirs(REPORTS, exist_ok=True) + + +def draw_frame_with_pillow(): + from PIL import Image, ImageDraw, ImageFont + + image = Image.new("RGB", (1280, 720), "#102027") + draw = ImageDraw.Draw(image) + draw.rounded_rectangle((54, 58, 1226, 662), radius=16, fill="#17313a", outline="#9bd67a", width=4) + + try: + title_font = ImageFont.truetype("arial.ttf", 48) + body_font = ImageFont.truetype("arial.ttf", 30) + note_font = ImageFont.truetype("arial.ttf", 26) + except OSError: + title_font = ImageFont.load_default() + body_font = ImageFont.load_default() + note_font = ImageFont.load_default() + + draw.text((96, 102), "Challenge Prequalification Fairness Guard", fill="white", font=title_font) + draw.text((96, 190), "Published criteria plus weighted threshold checks", fill="#dff5d5", font=body_font) + draw.text((96, 248), "Anonymous screening leaks and conflicts are held", fill="#dff5d5", font=body_font) + draw.text((96, 306), "Missing rejection reasons create remediation actions", fill="#dff5d5", font=body_font) + draw.text((96, 402), "Synthetic data only. No sponsor, solver, payout, or identity systems are called.", fill="#ffd37a", font=note_font) + + image.save(FRAME) + + +draw_frame_with_pillow() + +cmd = [ + "ffmpeg", + "-y", + "-loop", + "1", + "-i", + FRAME, + "-t", + "4", + "-r", + "30", + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + OUTPUT, +] + +subprocess.run(cmd, check=True) +if os.path.exists(FRAME): + os.remove(FRAME) +print(f"Wrote {os.path.relpath(OUTPUT, HERE)}") diff --git a/challenge-prequalification-fairness-guard/package.json b/challenge-prequalification-fairness-guard/package.json new file mode 100644 index 00000000..069a41a9 --- /dev/null +++ b/challenge-prequalification-fairness-guard/package.json @@ -0,0 +1,13 @@ +{ + "name": "challenge-prequalification-fairness-guard", + "version": "1.0.0", + "description": "Dependency-free prequalification fairness guard for SCIBASE scientific bounty challenges.", + "main": "index.js", + "private": true, + "scripts": { + "test": "node test.js", + "demo": "node demo.js", + "video": "python make-demo-video.py", + "check": "npm test && npm run demo && npm run video" + } +} diff --git a/challenge-prequalification-fairness-guard/reports/demo.mp4 b/challenge-prequalification-fairness-guard/reports/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..9c66801886d254a968e5006a3fc04524711bf0ce GIT binary patch literal 45643 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 literal 0 HcmV?d00001 diff --git a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-packet.json b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-packet.json new file mode 100644 index 00000000..643714f3 --- /dev/null +++ b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-packet.json @@ -0,0 +1,105 @@ +{ + "challengeId": "challenge-18-prequalification-rna-biomarker", + "generatedAt": "2026-05-28T08:00:00Z", + "criteriaDigest": "sha256:d643b033793917b9d0488787518a11e97094e671d52b86b69a6153375d726721", + "decisions": [ + { + "id": "applicant-biofoundry", + "applicantId": "applicant-biofoundry", + "challengeId": "challenge-18-prequalification-rna-biomarker", + "decision": "accept-prequalified", + "sponsorDecision": "accept", + "weightedScore": 87, + "passThreshold": 75, + "reviewersCounted": 2, + "criteriaApplied": [ + "domain-fit", + "data-readiness", + "safety-plan" + ], + "reasons": [], + "rejectionReasons": [], + "appealStatus": "not-required", + "auditDigest": "sha256:0717493fb82a925e390d8b4fbd7cac2039dd71a4bf7d8db19848a69efd926c53" + }, + { + "id": "applicant-neuro-lab", + "applicantId": "applicant-neuro-lab", + "challengeId": "challenge-18-prequalification-rna-biomarker", + "decision": "hold-for-fairness-review", + "sponsorDecision": "reject", + "weightedScore": 81, + "passThreshold": 75, + "reviewersCounted": 2, + "criteriaApplied": [ + "domain-fit", + "data-readiness", + "safety-plan" + ], + "reasons": [ + "anonymous-screening-leak", + "inconsistent-threshold-decision" + ], + "rejectionReasons": [ + "methods plan did not match expected wet-lab validation" + ], + "appealStatus": "open", + "auditDigest": "sha256:74514248ce1bdd761a0c8034d9b39962227e44f53bd826152246014a29608bfc" + }, + { + "id": "applicant-sponsor-alumni", + "applicantId": "applicant-sponsor-alumni", + "challengeId": "challenge-18-prequalification-rna-biomarker", + "decision": "hold-for-fairness-review", + "sponsorDecision": "reject", + "weightedScore": 72, + "passThreshold": 75, + "reviewersCounted": 1, + "criteriaApplied": [ + "domain-fit", + "data-readiness", + "safety-plan" + ], + "reasons": [ + "missing-appeal-window", + "missing-rejection-reason", + "reviewer-conflict", + "reviewer-quorum-shortfall" + ], + "rejectionReasons": [], + "appealStatus": "missing", + "auditDigest": "sha256:5d0ce06bf207fb2e881512558056970ea0e23834fd80ec4c8f57b3a3b820a90e" + } + ], + "remediationActions": [ + { + "id": "remediate-applicant-neuro-lab", + "applicantId": "applicant-neuro-lab", + "action": "rerun-blinded-prequalification-review", + "priority": "high", + "reasons": [ + "anonymous-screening-leak", + "inconsistent-threshold-decision" + ] + }, + { + "id": "remediate-applicant-sponsor-alumni", + "applicantId": "applicant-sponsor-alumni", + "action": "replace-conflicted-reviewer", + "priority": "high", + "reasons": [ + "missing-appeal-window", + "missing-rejection-reason", + "reviewer-conflict", + "reviewer-quorum-shortfall" + ] + } + ], + "summary": { + "accepted": 1, + "held": 2, + "rejectedWithAudit": 0, + "remediationActions": 2 + }, + "auditDigest": "sha256:b00d02db12ede48b0d57d7a03982a22d2a3e3a510d596c8477fff5a9af759589" +} diff --git a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md new file mode 100644 index 00000000..3e6fed60 --- /dev/null +++ b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md @@ -0,0 +1,28 @@ +# Challenge Prequalification Fairness Guard + +Challenge: challenge-18-prequalification-rna-biomarker +Generated: 2026-05-28T08:00:00Z + +## Summary + +- Accepted applicants: 1 +- Held for fairness review: 2 +- Rejected with audit trail: 0 +- Remediation actions: 2 +- Criteria digest: sha256:d643b033793917b9d0488787518a11e97094e671d52b86b69a6153375d726721 +- Audit digest: sha256:b00d02db12ede48b0d57d7a03982a22d2a3e3a510d596c8477fff5a9af759589 + +## Decisions + +- applicant-biofoundry: accept-prequalified, score 87, reasons: none +- applicant-neuro-lab: hold-for-fairness-review, score 81, reasons: anonymous-screening-leak, inconsistent-threshold-decision +- applicant-sponsor-alumni: hold-for-fairness-review, score 72, reasons: missing-appeal-window, missing-rejection-reason, reviewer-conflict, reviewer-quorum-shortfall + +## Remediation Actions + +- remediate-applicant-neuro-lab: rerun-blinded-prequalification-review (high) +- remediate-applicant-sponsor-alumni: replace-conflicted-reviewer (high) + +## Safety + +All fixtures are synthetic. The guard does not call payment processors, identity providers, private workspaces, sponsor systems, or external APIs. diff --git a/challenge-prequalification-fairness-guard/reports/summary.svg b/challenge-prequalification-fairness-guard/reports/summary.svg new file mode 100644 index 00000000..f39bc43e --- /dev/null +++ b/challenge-prequalification-fairness-guard/reports/summary.svg @@ -0,0 +1,11 @@ + + + + Challenge Prequalification Fairness Guard + Accepted applicants: 1 + Held for fairness review: 2 + Remediation actions: 2 + Checks: criteria, thresholds, anonymity, conflicts, rejection reasons, appeal windows + Unfair screening decisions are held before applicants are accepted or rejected. + sha256:b00d02db12ede48b0d57d7a03982a22d2a3e3a510d596c8477fff5a9af759589 + diff --git a/challenge-prequalification-fairness-guard/requirements-map.md b/challenge-prequalification-fairness-guard/requirements-map.md new file mode 100644 index 00000000..7b1937d4 --- /dev/null +++ b/challenge-prequalification-fairness-guard/requirements-map.md @@ -0,0 +1,25 @@ +# Requirements Map + +## Challenge Posting Portal + +- Verifies that prequalification rounds use published criteria, weights, and pass thresholds. +- Blocks unpublished sponsor preferences from entering solver-screening decisions. +- Keeps prequalification decisions tied to challenge timelines and appeal windows. + +## Submission Engine + +- Protects anonymous or named participation settings during prequalification review. +- Requires reviewer quorum before a solver team is accepted or rejected. +- Preserves audit evidence for each applicant before access to private challenge workspaces changes. + +## Arbitration And Reward Distribution + +- Holds inconsistent threshold decisions for fairness review before a solver is excluded. +- Flags reviewer conflicts and missing rejection reasons for arbitration-ready remediation. +- Produces deterministic digests for challenge administrators and third-party reviewers. + +## Safety And Scope + +- Synthetic data only. +- No credentials, payment processors, identity providers, sponsor systems, private workspaces, or external APIs. +- This slice is distinct from intake compliance, workspace privacy, clarification freeze, arbitration scoring, payout eligibility, benchmark leakage, sponsor data-room access, and reviewer workload SLA guards. diff --git a/challenge-prequalification-fairness-guard/test.js b/challenge-prequalification-fairness-guard/test.js new file mode 100644 index 00000000..c2bd1282 --- /dev/null +++ b/challenge-prequalification-fairness-guard/test.js @@ -0,0 +1,96 @@ +const assert = require('assert'); +const { + evaluatePrequalificationRound, + buildSampleRound +} = require('./index'); + +function byId(items, id) { + return items.find((item) => item.id === id); +} + +function testEligibleApplicantIsAcceptedWithPublishedCriteriaAndQuorum() { + const result = evaluatePrequalificationRound(buildSampleRound()); + const decision = byId(result.decisions, 'applicant-biofoundry'); + + assert.equal(decision.decision, 'accept-prequalified'); + assert.equal(decision.weightedScore, 87); + assert.equal(decision.reviewersCounted, 2); + assert.deepEqual(decision.criteriaApplied, [ + 'domain-fit', + 'data-readiness', + 'safety-plan' + ]); +} + +function testAnonymousScreeningLeakHoldsApplicantForFairnessReview() { + const result = evaluatePrequalificationRound(buildSampleRound()); + const decision = byId(result.decisions, 'applicant-neuro-lab'); + + assert.equal(decision.decision, 'hold-for-fairness-review'); + assert.equal(decision.reasons.includes('anonymous-screening-leak'), true); + assert.equal(decision.reasons.includes('inconsistent-threshold-decision'), true); + + const action = byId(result.remediationActions, 'remediate-applicant-neuro-lab'); + assert.equal(action.action, 'rerun-blinded-prequalification-review'); + assert.equal(action.priority, 'high'); +} + +function testConflictedAndIncompleteRejectionsStayAuditable() { + const result = evaluatePrequalificationRound(buildSampleRound()); + const decision = byId(result.decisions, 'applicant-sponsor-alumni'); + + assert.equal(decision.decision, 'hold-for-fairness-review'); + assert.equal(decision.reasons.includes('reviewer-conflict'), true); + assert.equal(decision.reasons.includes('missing-rejection-reason'), true); + assert.equal(decision.appealStatus, 'missing'); +} + +function testHiddenCriteriaAreBlockedBeforeScreeningResultsPublish() { + const round = buildSampleRound(); + round.reviews.push({ + applicantId: 'applicant-biofoundry', + reviewerId: 'reviewer-hidden', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'reject', + rejectionReasons: ['private sponsor preference'], + scores: { + 'domain-fit': 80, + 'data-readiness': 84, + 'safety-plan': 88, + 'brand-prestige': 15 + } + }); + + const result = evaluatePrequalificationRound(round); + const decision = byId(result.decisions, 'applicant-biofoundry'); + + assert.equal(decision.decision, 'hold-for-fairness-review'); + assert.equal(decision.reasons.includes('unpublished-screening-criterion'), true); +} + +function testAuditDigestIsDeterministicAndPrivateFree() { + const first = evaluatePrequalificationRound(buildSampleRound()); + const second = evaluatePrequalificationRound(buildSampleRound()); + + assert.equal(first.auditDigest, second.auditDigest); + assert.ok(first.auditDigest.startsWith('sha256:')); + assert.equal(first.summary.accepted, 1); + assert.equal(first.summary.held, 2); + assert.equal(JSON.stringify(first).includes('private@'), false); + assert.equal(JSON.stringify(first).includes('government_id'), false); +} + +const tests = [ + testEligibleApplicantIsAcceptedWithPublishedCriteriaAndQuorum, + testAnonymousScreeningLeakHoldsApplicantForFairnessReview, + testConflictedAndIncompleteRejectionsStayAuditable, + testHiddenCriteriaAreBlockedBeforeScreeningResultsPublish, + testAuditDigestIsDeterministicAndPrivateFree +]; + +for (const test of tests) { + test(); +} + +console.log(`${tests.length} challenge prequalification fairness tests passed`); From 25af825e1c5fd2a3b36ccf1b181c1a80e71d0c4b Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Thu, 28 May 2026 17:13:49 +0200 Subject: [PATCH 02/28] Harden prequalification conflict scoring --- .../README.md | 2 +- .../acceptance-notes.md | 1 + .../index.js | 5 +- .../prequalification-fairness-packet.json | 6 +-- .../prequalification-fairness-report.md | 4 +- .../reports/summary.svg | 2 +- .../requirements-map.md | 1 + .../test.js | 49 +++++++++++++++++++ 8 files changed, 61 insertions(+), 9 deletions(-) diff --git a/challenge-prequalification-fairness-guard/README.md b/challenge-prequalification-fairness-guard/README.md index cceccda5..ce67a192 100644 --- a/challenge-prequalification-fairness-guard/README.md +++ b/challenge-prequalification-fairness-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Bounty System slice for SCIBASE issue #18. It evaluates sponsor-side prequalification rounds before solver teams are accepted or rejected from a challenge. -The guard checks published screening criteria, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, reviewer quorum, rejection reason completeness, appeal windows, and audit evidence. Unfair or incomplete screening decisions are held for remediation before challenge access changes. +The guard checks published screening criteria, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, reviewer quorum, rejection reason completeness, appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable. Unfair or incomplete screening decisions are held for remediation before challenge access changes. ## Run diff --git a/challenge-prequalification-fairness-guard/acceptance-notes.md b/challenge-prequalification-fairness-guard/acceptance-notes.md index 98b35b53..50b9a7cd 100644 --- a/challenge-prequalification-fairness-guard/acceptance-notes.md +++ b/challenge-prequalification-fairness-guard/acceptance-notes.md @@ -16,5 +16,6 @@ Validation coverage: - anonymous-screening leaks hold a candidate for fairness review - inconsistent threshold decisions are held before rejection is published - conflicted reviewer participation and missing rejection reasons remain auditable +- conflicted reviewer scores are excluded from weighted threshold evidence - unpublished screening criteria are blocked before results are published - audit digests are deterministic and private-data free diff --git a/challenge-prequalification-fairness-guard/index.js b/challenge-prequalification-fairness-guard/index.js index 268c823b..b8330f45 100644 --- a/challenge-prequalification-fairness-guard/index.js +++ b/challenge-prequalification-fairness-guard/index.js @@ -107,7 +107,7 @@ function reasonsForApplicant(applicant, reviews, round) { reasons.push('missing-published-criterion-score'); } - const score = weightedScore(round.criteria, reviews); + const score = weightedScore(round.criteria, nonConflictedReviews); const passesThreshold = score >= round.passThreshold; if ( @@ -157,8 +157,9 @@ function evaluatePrequalificationRound(round) { const decisions = round.applicants.map((applicant) => { const reviews = reviewsByApplicant[applicant.id] || []; + const nonConflictedReviews = reviews.filter((review) => !review.conflict); const reasons = reasonsForApplicant(applicant, reviews, round); - const score = weightedScore(round.criteria, reviews); + const score = weightedScore(round.criteria, nonConflictedReviews); const decision = reasons.length > 0 ? 'hold-for-fairness-review' diff --git a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-packet.json b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-packet.json index 643714f3..49930eaa 100644 --- a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-packet.json +++ b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-packet.json @@ -52,7 +52,7 @@ "challengeId": "challenge-18-prequalification-rna-biomarker", "decision": "hold-for-fairness-review", "sponsorDecision": "reject", - "weightedScore": 72, + "weightedScore": 70, "passThreshold": 75, "reviewersCounted": 1, "criteriaApplied": [ @@ -68,7 +68,7 @@ ], "rejectionReasons": [], "appealStatus": "missing", - "auditDigest": "sha256:5d0ce06bf207fb2e881512558056970ea0e23834fd80ec4c8f57b3a3b820a90e" + "auditDigest": "sha256:155ac57faaf2c7ccabbb1799d38d4f73c1ed458c2da1a0bfc8fe49a2f4120dee" } ], "remediationActions": [ @@ -101,5 +101,5 @@ "rejectedWithAudit": 0, "remediationActions": 2 }, - "auditDigest": "sha256:b00d02db12ede48b0d57d7a03982a22d2a3e3a510d596c8477fff5a9af759589" + "auditDigest": "sha256:d2c3f92e0cbdf23779537887ca5da9ecd84c708b359cab01999f38ed79b40b1e" } diff --git a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md index 3e6fed60..c03858fc 100644 --- a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md +++ b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md @@ -10,13 +10,13 @@ Generated: 2026-05-28T08:00:00Z - Rejected with audit trail: 0 - Remediation actions: 2 - Criteria digest: sha256:d643b033793917b9d0488787518a11e97094e671d52b86b69a6153375d726721 -- Audit digest: sha256:b00d02db12ede48b0d57d7a03982a22d2a3e3a510d596c8477fff5a9af759589 +- Audit digest: sha256:d2c3f92e0cbdf23779537887ca5da9ecd84c708b359cab01999f38ed79b40b1e ## Decisions - applicant-biofoundry: accept-prequalified, score 87, reasons: none - applicant-neuro-lab: hold-for-fairness-review, score 81, reasons: anonymous-screening-leak, inconsistent-threshold-decision -- applicant-sponsor-alumni: hold-for-fairness-review, score 72, reasons: missing-appeal-window, missing-rejection-reason, reviewer-conflict, reviewer-quorum-shortfall +- applicant-sponsor-alumni: hold-for-fairness-review, score 70, reasons: missing-appeal-window, missing-rejection-reason, reviewer-conflict, reviewer-quorum-shortfall ## Remediation Actions diff --git a/challenge-prequalification-fairness-guard/reports/summary.svg b/challenge-prequalification-fairness-guard/reports/summary.svg index f39bc43e..cd3b3ee6 100644 --- a/challenge-prequalification-fairness-guard/reports/summary.svg +++ b/challenge-prequalification-fairness-guard/reports/summary.svg @@ -7,5 +7,5 @@ Remediation actions: 2 Checks: criteria, thresholds, anonymity, conflicts, rejection reasons, appeal windows Unfair screening decisions are held before applicants are accepted or rejected. - sha256:b00d02db12ede48b0d57d7a03982a22d2a3e3a510d596c8477fff5a9af759589 + sha256:d2c3f92e0cbdf23779537887ca5da9ecd84c708b359cab01999f38ed79b40b1e diff --git a/challenge-prequalification-fairness-guard/requirements-map.md b/challenge-prequalification-fairness-guard/requirements-map.md index 7b1937d4..577cd3a1 100644 --- a/challenge-prequalification-fairness-guard/requirements-map.md +++ b/challenge-prequalification-fairness-guard/requirements-map.md @@ -16,6 +16,7 @@ - Holds inconsistent threshold decisions for fairness review before a solver is excluded. - Flags reviewer conflicts and missing rejection reasons for arbitration-ready remediation. +- Excludes conflicted reviewer scores from weighted threshold evidence while retaining the conflict finding. - Produces deterministic digests for challenge administrators and third-party reviewers. ## Safety And Scope diff --git a/challenge-prequalification-fairness-guard/test.js b/challenge-prequalification-fairness-guard/test.js index c2bd1282..aa8bcbad 100644 --- a/challenge-prequalification-fairness-guard/test.js +++ b/challenge-prequalification-fairness-guard/test.js @@ -45,6 +45,54 @@ function testConflictedAndIncompleteRejectionsStayAuditable() { assert.equal(decision.appealStatus, 'missing'); } +function testConflictedReviewerScoresDoNotInflateWeightedScore() { + const round = buildSampleRound(); + round.minReviewers = 1; + round.applicants = [ + { + id: 'applicant-conflicted-score', + sponsorDecision: 'accept', + rejectionReasons: [], + appealDueAt: null + } + ]; + round.reviews = [ + { + applicantId: 'applicant-conflicted-score', + reviewerId: 'reviewer-sponsor-advisor', + anonymousScreeningObserved: true, + conflict: true, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 100, + 'data-readiness': 100, + 'safety-plan': 100 + } + }, + { + applicantId: 'applicant-conflicted-score', + reviewerId: 'reviewer-independent', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'reject', + rejectionReasons: ['missing independent validation plan'], + scores: { + 'domain-fit': 50, + 'data-readiness': 50, + 'safety-plan': 50 + } + } + ]; + + const result = evaluatePrequalificationRound(round); + const decision = byId(result.decisions, 'applicant-conflicted-score'); + + assert.equal(decision.weightedScore, 50); + assert.equal(decision.reasons.includes('reviewer-conflict'), true); + assert.equal(decision.reasons.includes('inconsistent-threshold-decision'), true); +} + function testHiddenCriteriaAreBlockedBeforeScreeningResultsPublish() { const round = buildSampleRound(); round.reviews.push({ @@ -85,6 +133,7 @@ const tests = [ testEligibleApplicantIsAcceptedWithPublishedCriteriaAndQuorum, testAnonymousScreeningLeakHoldsApplicantForFairnessReview, testConflictedAndIncompleteRejectionsStayAuditable, + testConflictedReviewerScoresDoNotInflateWeightedScore, testHiddenCriteriaAreBlockedBeforeScreeningResultsPublish, testAuditDigestIsDeterministicAndPrivateFree ]; From 9033fef24a5eb455090229959dd3e66e6e08085e Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Thu, 28 May 2026 18:08:13 +0200 Subject: [PATCH 03/28] Harden prequalification appeal windows --- .../index.js | 10 +++- .../test.js | 50 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/challenge-prequalification-fairness-guard/index.js b/challenge-prequalification-fairness-guard/index.js index b8330f45..efbd2df4 100644 --- a/challenge-prequalification-fairness-guard/index.js +++ b/challenge-prequalification-fairness-guard/index.js @@ -125,6 +125,10 @@ function reasonsForApplicant(applicant, reviews, round) { reasons.push('missing-appeal-window'); } + if (applicant.sponsorDecision === 'reject' && appealStatus(applicant, round) === 'expired') { + reasons.push('expired-appeal-window'); + } + return uniqueSorted(reasons); } @@ -141,7 +145,11 @@ function remediationAction(applicant, reasons) { return 'replace-conflicted-reviewer'; } - if (reasons.includes('missing-rejection-reason') || reasons.includes('missing-appeal-window')) { + if ( + reasons.includes('missing-rejection-reason') || + reasons.includes('missing-appeal-window') || + reasons.includes('expired-appeal-window') + ) { return 'publish-rejection-reasons-and-appeal-window'; } diff --git a/challenge-prequalification-fairness-guard/test.js b/challenge-prequalification-fairness-guard/test.js index aa8bcbad..a55853ee 100644 --- a/challenge-prequalification-fairness-guard/test.js +++ b/challenge-prequalification-fairness-guard/test.js @@ -117,6 +117,55 @@ function testHiddenCriteriaAreBlockedBeforeScreeningResultsPublish() { assert.equal(decision.reasons.includes('unpublished-screening-criterion'), true); } +function testExpiredAppealWindowHoldsRejectedApplicantForFairnessReview() { + const round = buildSampleRound(); + round.applicants = [ + { + id: 'applicant-expired-appeal', + sponsorDecision: 'reject', + rejectionReasons: ['evidence package missed challenge-specific validation'], + appealDueAt: '2026-05-27T08:00:00Z' + } + ]; + round.reviews = [ + { + applicantId: 'applicant-expired-appeal', + reviewerId: 'reviewer-independent-a', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'reject', + rejectionReasons: ['evidence package missed challenge-specific validation'], + scores: { + 'domain-fit': 60, + 'data-readiness': 58, + 'safety-plan': 62 + } + }, + { + applicantId: 'applicant-expired-appeal', + reviewerId: 'reviewer-independent-b', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'reject', + rejectionReasons: ['evidence package missed challenge-specific validation'], + scores: { + 'domain-fit': 62, + 'data-readiness': 57, + 'safety-plan': 61 + } + } + ]; + + const result = evaluatePrequalificationRound(round); + const decision = byId(result.decisions, 'applicant-expired-appeal'); + const action = byId(result.remediationActions, 'remediate-applicant-expired-appeal'); + + assert.equal(decision.decision, 'hold-for-fairness-review'); + assert.equal(decision.appealStatus, 'expired'); + assert.equal(decision.reasons.includes('expired-appeal-window'), true); + assert.equal(action.action, 'publish-rejection-reasons-and-appeal-window'); +} + function testAuditDigestIsDeterministicAndPrivateFree() { const first = evaluatePrequalificationRound(buildSampleRound()); const second = evaluatePrequalificationRound(buildSampleRound()); @@ -135,6 +184,7 @@ const tests = [ testConflictedAndIncompleteRejectionsStayAuditable, testConflictedReviewerScoresDoNotInflateWeightedScore, testHiddenCriteriaAreBlockedBeforeScreeningResultsPublish, + testExpiredAppealWindowHoldsRejectedApplicantForFairnessReview, testAuditDigestIsDeterministicAndPrivateFree ]; From 950e3e85bdac5055a5ab4428b170e765945f19a6 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Thu, 28 May 2026 18:46:13 +0200 Subject: [PATCH 04/28] Validate prequalification rubric weights --- .../README.md | 2 +- .../index.js | 15 ++++++++- .../test.js | 31 +++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/challenge-prequalification-fairness-guard/README.md b/challenge-prequalification-fairness-guard/README.md index ce67a192..1608a97b 100644 --- a/challenge-prequalification-fairness-guard/README.md +++ b/challenge-prequalification-fairness-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Bounty System slice for SCIBASE issue #18. It evaluates sponsor-side prequalification rounds before solver teams are accepted or rejected from a challenge. -The guard checks published screening criteria, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, reviewer quorum, rejection reason completeness, appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable. Unfair or incomplete screening decisions are held for remediation before challenge access changes. +The guard checks published screening criteria, valid criterion weight totals, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, reviewer quorum, rejection reason completeness, appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable. Unfair or incomplete screening decisions are held for remediation before challenge access changes. ## Run diff --git a/challenge-prequalification-fairness-guard/index.js b/challenge-prequalification-fairness-guard/index.js index efbd2df4..8803b955 100644 --- a/challenge-prequalification-fairness-guard/index.js +++ b/challenge-prequalification-fairness-guard/index.js @@ -62,6 +62,10 @@ function publicCriteriaIds(round) { return round.criteria.map((criterion) => criterion.id); } +function criteriaWeightTotal(round) { + return round.criteria.reduce((total, criterion) => total + criterion.weight, 0); +} + function reviewUsesHiddenCriteria(review, criteriaIds) { return Object.keys(review.scores).some((criterionId) => !criteriaIds.includes(criterionId)); } @@ -91,6 +95,10 @@ function reasonsForApplicant(applicant, reviews, round) { reasons.push('reviewer-conflict'); } + if (criteriaWeightTotal(round) !== 100) { + reasons.push('criteria-weight-total-invalid'); + } + if (nonConflictedReviews.length < round.minReviewers) { reasons.push('reviewer-quorum-shortfall'); } @@ -141,6 +149,10 @@ function remediationAction(applicant, reasons) { return 'remove-unpublished-criterion-and-rescore'; } + if (reasons.includes('criteria-weight-total-invalid')) { + return 'publish-valid-weighted-scoring-rubric'; + } + if (reasons.includes('reviewer-conflict')) { return 'replace-conflicted-reviewer'; } @@ -212,7 +224,8 @@ function evaluatePrequalificationRound(round) { priority: decision.reasons.includes('anonymous-screening-leak') || decision.reasons.includes('reviewer-conflict') || - decision.reasons.includes('unpublished-screening-criterion') + decision.reasons.includes('unpublished-screening-criterion') || + decision.reasons.includes('criteria-weight-total-invalid') ? 'high' : 'normal', reasons: decision.reasons diff --git a/challenge-prequalification-fairness-guard/test.js b/challenge-prequalification-fairness-guard/test.js index a55853ee..44acec1d 100644 --- a/challenge-prequalification-fairness-guard/test.js +++ b/challenge-prequalification-fairness-guard/test.js @@ -166,6 +166,36 @@ function testExpiredAppealWindowHoldsRejectedApplicantForFairnessReview() { assert.equal(action.action, 'publish-rejection-reasons-and-appeal-window'); } +function testInvalidCriterionWeightsHoldPrequalificationRound() { + const round = buildSampleRound(); + round.criteria = [ + { + id: 'domain-fit', + label: 'Domain fit for the scientific challenge', + weight: 60 + }, + { + id: 'data-readiness', + label: 'Evidence that required data and tools are ready', + weight: 60 + }, + { + id: 'safety-plan', + label: 'Risk, NDA, and responsible-use plan', + weight: 25 + } + ]; + + const result = evaluatePrequalificationRound(round); + const decision = byId(result.decisions, 'applicant-biofoundry'); + const action = byId(result.remediationActions, 'remediate-applicant-biofoundry'); + + assert.equal(decision.decision, 'hold-for-fairness-review'); + assert.equal(decision.reasons.includes('criteria-weight-total-invalid'), true); + assert.equal(action.action, 'publish-valid-weighted-scoring-rubric'); + assert.equal(action.priority, 'high'); +} + function testAuditDigestIsDeterministicAndPrivateFree() { const first = evaluatePrequalificationRound(buildSampleRound()); const second = evaluatePrequalificationRound(buildSampleRound()); @@ -185,6 +215,7 @@ const tests = [ testConflictedReviewerScoresDoNotInflateWeightedScore, testHiddenCriteriaAreBlockedBeforeScreeningResultsPublish, testExpiredAppealWindowHoldsRejectedApplicantForFairnessReview, + testInvalidCriterionWeightsHoldPrequalificationRound, testAuditDigestIsDeterministicAndPrivateFree ]; From 2ff5d97aede2eb454de0b91de2d9bffbd54c7e40 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Fri, 29 May 2026 18:03:52 +0200 Subject: [PATCH 05/28] Harden prequalification rubric weights --- .../README.md | 2 +- .../acceptance-notes.md | 1 + .../index.js | 21 ++++++++++++- .../requirements-map.md | 2 +- .../test.js | 31 +++++++++++++++++++ 5 files changed, 54 insertions(+), 3 deletions(-) diff --git a/challenge-prequalification-fairness-guard/README.md b/challenge-prequalification-fairness-guard/README.md index 1608a97b..7aaba777 100644 --- a/challenge-prequalification-fairness-guard/README.md +++ b/challenge-prequalification-fairness-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Bounty System slice for SCIBASE issue #18. It evaluates sponsor-side prequalification rounds before solver teams are accepted or rejected from a challenge. -The guard checks published screening criteria, valid criterion weight totals, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, reviewer quorum, rejection reason completeness, appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable. Unfair or incomplete screening decisions are held for remediation before challenge access changes. +The guard checks published screening criteria, valid criterion weight values and totals, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, reviewer quorum, rejection reason completeness, appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable. Unfair or incomplete screening decisions are held for remediation before challenge access changes. ## Run diff --git a/challenge-prequalification-fairness-guard/acceptance-notes.md b/challenge-prequalification-fairness-guard/acceptance-notes.md index 50b9a7cd..31dd18f5 100644 --- a/challenge-prequalification-fairness-guard/acceptance-notes.md +++ b/challenge-prequalification-fairness-guard/acceptance-notes.md @@ -18,4 +18,5 @@ Validation coverage: - conflicted reviewer participation and missing rejection reasons remain auditable - conflicted reviewer scores are excluded from weighted threshold evidence - unpublished screening criteria are blocked before results are published +- invalid individual criterion weights are held even when the total still sums to 100 - audit digests are deterministic and private-data free diff --git a/challenge-prequalification-fairness-guard/index.js b/challenge-prequalification-fairness-guard/index.js index 8803b955..75088ea2 100644 --- a/challenge-prequalification-fairness-guard/index.js +++ b/challenge-prequalification-fairness-guard/index.js @@ -66,6 +66,16 @@ function criteriaWeightTotal(round) { return round.criteria.reduce((total, criterion) => total + criterion.weight, 0); } +function criteriaWeightsHaveInvalidValues(round) { + return round.criteria.some( + (criterion) => + typeof criterion.weight !== 'number' || + !Number.isFinite(criterion.weight) || + criterion.weight < 0 || + criterion.weight > 100 + ); +} + function reviewUsesHiddenCriteria(review, criteriaIds) { return Object.keys(review.scores).some((criterionId) => !criteriaIds.includes(criterionId)); } @@ -99,6 +109,10 @@ function reasonsForApplicant(applicant, reviews, round) { reasons.push('criteria-weight-total-invalid'); } + if (criteriaWeightsHaveInvalidValues(round)) { + reasons.push('criteria-weight-value-invalid'); + } + if (nonConflictedReviews.length < round.minReviewers) { reasons.push('reviewer-quorum-shortfall'); } @@ -153,6 +167,10 @@ function remediationAction(applicant, reasons) { return 'publish-valid-weighted-scoring-rubric'; } + if (reasons.includes('criteria-weight-value-invalid')) { + return 'publish-valid-weighted-scoring-rubric'; + } + if (reasons.includes('reviewer-conflict')) { return 'replace-conflicted-reviewer'; } @@ -225,7 +243,8 @@ function evaluatePrequalificationRound(round) { decision.reasons.includes('anonymous-screening-leak') || decision.reasons.includes('reviewer-conflict') || decision.reasons.includes('unpublished-screening-criterion') || - decision.reasons.includes('criteria-weight-total-invalid') + decision.reasons.includes('criteria-weight-total-invalid') || + decision.reasons.includes('criteria-weight-value-invalid') ? 'high' : 'normal', reasons: decision.reasons diff --git a/challenge-prequalification-fairness-guard/requirements-map.md b/challenge-prequalification-fairness-guard/requirements-map.md index 577cd3a1..986f1d4a 100644 --- a/challenge-prequalification-fairness-guard/requirements-map.md +++ b/challenge-prequalification-fairness-guard/requirements-map.md @@ -2,7 +2,7 @@ ## Challenge Posting Portal -- Verifies that prequalification rounds use published criteria, weights, and pass thresholds. +- Verifies that prequalification rounds use published criteria, nonnegative weights, valid weight totals, and pass thresholds. - Blocks unpublished sponsor preferences from entering solver-screening decisions. - Keeps prequalification decisions tied to challenge timelines and appeal windows. diff --git a/challenge-prequalification-fairness-guard/test.js b/challenge-prequalification-fairness-guard/test.js index 44acec1d..4f02bbe2 100644 --- a/challenge-prequalification-fairness-guard/test.js +++ b/challenge-prequalification-fairness-guard/test.js @@ -196,6 +196,36 @@ function testInvalidCriterionWeightsHoldPrequalificationRound() { assert.equal(action.priority, 'high'); } +function testInvalidIndividualCriterionWeightsHoldPrequalificationRound() { + const round = buildSampleRound(); + round.criteria = [ + { + id: 'domain-fit', + label: 'Domain fit for the scientific challenge', + weight: 120 + }, + { + id: 'data-readiness', + label: 'Evidence that required data and tools are ready', + weight: -20 + }, + { + id: 'safety-plan', + label: 'Risk, NDA, and responsible-use plan', + weight: 0 + } + ]; + + const result = evaluatePrequalificationRound(round); + const decision = byId(result.decisions, 'applicant-biofoundry'); + const action = byId(result.remediationActions, 'remediate-applicant-biofoundry'); + + assert.equal(decision.decision, 'hold-for-fairness-review'); + assert.equal(decision.reasons.includes('criteria-weight-value-invalid'), true); + assert.equal(action.action, 'publish-valid-weighted-scoring-rubric'); + assert.equal(action.priority, 'high'); +} + function testAuditDigestIsDeterministicAndPrivateFree() { const first = evaluatePrequalificationRound(buildSampleRound()); const second = evaluatePrequalificationRound(buildSampleRound()); @@ -216,6 +246,7 @@ const tests = [ testHiddenCriteriaAreBlockedBeforeScreeningResultsPublish, testExpiredAppealWindowHoldsRejectedApplicantForFairnessReview, testInvalidCriterionWeightsHoldPrequalificationRound, + testInvalidIndividualCriterionWeightsHoldPrequalificationRound, testAuditDigestIsDeterministicAndPrivateFree ]; From 7bb2370d645e50105c50feabb4fa3345f8e1eebc Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Fri, 29 May 2026 19:14:59 +0200 Subject: [PATCH 06/28] Handle incomplete prequalification scores --- .../README.md | 2 +- .../acceptance-notes.md | 1 + .../index.js | 16 +++++-- .../requirements-map.md | 1 + .../test.js | 43 +++++++++++++++++++ 5 files changed, 58 insertions(+), 5 deletions(-) diff --git a/challenge-prequalification-fairness-guard/README.md b/challenge-prequalification-fairness-guard/README.md index 7aaba777..c2e64b32 100644 --- a/challenge-prequalification-fairness-guard/README.md +++ b/challenge-prequalification-fairness-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Bounty System slice for SCIBASE issue #18. It evaluates sponsor-side prequalification rounds before solver teams are accepted or rejected from a challenge. -The guard checks published screening criteria, valid criterion weight values and totals, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, reviewer quorum, rejection reason completeness, appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable. Unfair or incomplete screening decisions are held for remediation before challenge access changes. +The guard checks published screening criteria, valid criterion weight values and totals, complete reviewer score evidence, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, reviewer quorum, rejection reason completeness, appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable. Unfair or incomplete screening decisions are held for remediation before challenge access changes. ## Run diff --git a/challenge-prequalification-fairness-guard/acceptance-notes.md b/challenge-prequalification-fairness-guard/acceptance-notes.md index 31dd18f5..e72e5e12 100644 --- a/challenge-prequalification-fairness-guard/acceptance-notes.md +++ b/challenge-prequalification-fairness-guard/acceptance-notes.md @@ -19,4 +19,5 @@ Validation coverage: - conflicted reviewer scores are excluded from weighted threshold evidence - unpublished screening criteria are blocked before results are published - invalid individual criterion weights are held even when the total still sums to 100 +- incomplete reviewer score evidence is held for completion without crashing the prequalification packet - audit digests are deterministic and private-data free diff --git a/challenge-prequalification-fairness-guard/index.js b/challenge-prequalification-fairness-guard/index.js index 75088ea2..60e437d8 100644 --- a/challenge-prequalification-fairness-guard/index.js +++ b/challenge-prequalification-fairness-guard/index.js @@ -32,7 +32,7 @@ function groupBy(items, getKey) { function scoreForCriterion(reviews, criterionId) { const scores = reviews - .map((review) => review.scores[criterionId]) + .map((review) => reviewScores(review)[criterionId]) .filter((score) => typeof score === 'number'); if (scores.length === 0) { @@ -62,6 +62,10 @@ function publicCriteriaIds(round) { return round.criteria.map((criterion) => criterion.id); } +function reviewScores(review) { + return review.scores && typeof review.scores === 'object' ? review.scores : {}; +} + function criteriaWeightTotal(round) { return round.criteria.reduce((total, criterion) => total + criterion.weight, 0); } @@ -77,7 +81,7 @@ function criteriaWeightsHaveInvalidValues(round) { } function reviewUsesHiddenCriteria(review, criteriaIds) { - return Object.keys(review.scores).some((criterionId) => !criteriaIds.includes(criterionId)); + return Object.keys(reviewScores(review)).some((criterionId) => !criteriaIds.includes(criterionId)); } function appealStatus(applicant, round) { @@ -123,7 +127,7 @@ function reasonsForApplicant(applicant, reviews, round) { if ( criteriaIds.some((criterionId) => - reviews.some((review) => typeof review.scores[criterionId] !== 'number') + reviews.some((review) => typeof reviewScores(review)[criterionId] !== 'number') ) ) { reasons.push('missing-published-criterion-score'); @@ -183,6 +187,10 @@ function remediationAction(applicant, reasons) { return 'publish-rejection-reasons-and-appeal-window'; } + if (reasons.includes('missing-published-criterion-score')) { + return 'complete-prequalification-evidence'; + } + if (reasons.includes('inconsistent-threshold-decision')) { return 'reconcile-score-threshold-decision'; } @@ -227,7 +235,7 @@ function evaluatePrequalificationRound(round) { reviewerId: review.reviewerId, conflict: review.conflict, anonymousScreeningObserved: review.anonymousScreeningObserved, - scores: review.scores + scores: reviewScores(review) })) }) }; diff --git a/challenge-prequalification-fairness-guard/requirements-map.md b/challenge-prequalification-fairness-guard/requirements-map.md index 986f1d4a..1a2964ad 100644 --- a/challenge-prequalification-fairness-guard/requirements-map.md +++ b/challenge-prequalification-fairness-guard/requirements-map.md @@ -10,6 +10,7 @@ - Protects anonymous or named participation settings during prequalification review. - Requires reviewer quorum before a solver team is accepted or rejected. +- Holds incomplete reviewer score packets for evidence completion instead of letting malformed review records crash or drive decisions. - Preserves audit evidence for each applicant before access to private challenge workspaces changes. ## Arbitration And Reward Distribution diff --git a/challenge-prequalification-fairness-guard/test.js b/challenge-prequalification-fairness-guard/test.js index 4f02bbe2..df489291 100644 --- a/challenge-prequalification-fairness-guard/test.js +++ b/challenge-prequalification-fairness-guard/test.js @@ -226,6 +226,48 @@ function testInvalidIndividualCriterionWeightsHoldPrequalificationRound() { assert.equal(action.priority, 'high'); } +function testIncompleteReviewerScoreEvidenceHoldsWithoutCrashing() { + const round = buildSampleRound(); + round.applicants = [ + { + id: 'applicant-incomplete-review-evidence', + sponsorDecision: 'accept', + rejectionReasons: [], + appealDueAt: null + } + ]; + round.reviews = [ + { + applicantId: 'applicant-incomplete-review-evidence', + reviewerId: 'reviewer-incomplete', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [] + }, + { + applicantId: 'applicant-incomplete-review-evidence', + reviewerId: 'reviewer-partial', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 90 + } + } + ]; + + const result = evaluatePrequalificationRound(round); + const decision = byId(result.decisions, 'applicant-incomplete-review-evidence'); + const action = byId(result.remediationActions, 'remediate-applicant-incomplete-review-evidence'); + + assert.equal(decision.decision, 'hold-for-fairness-review'); + assert.equal(decision.weightedScore, 36); + assert.equal(decision.reasons.includes('missing-published-criterion-score'), true); + assert.equal(action.action, 'complete-prequalification-evidence'); +} + function testAuditDigestIsDeterministicAndPrivateFree() { const first = evaluatePrequalificationRound(buildSampleRound()); const second = evaluatePrequalificationRound(buildSampleRound()); @@ -247,6 +289,7 @@ const tests = [ testExpiredAppealWindowHoldsRejectedApplicantForFairnessReview, testInvalidCriterionWeightsHoldPrequalificationRound, testInvalidIndividualCriterionWeightsHoldPrequalificationRound, + testIncompleteReviewerScoreEvidenceHoldsWithoutCrashing, testAuditDigestIsDeterministicAndPrivateFree ]; From d70a8c8f61b132d0274aaf2475cb264ccb375058 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Fri, 29 May 2026 20:53:29 +0200 Subject: [PATCH 07/28] Deduplicate reviewer score evidence --- .../README.md | 2 +- .../acceptance-notes.md | 1 + .../demo.js | 2 +- .../index.js | 61 ++++++++++++++++++- .../reports/summary.svg | 2 +- .../requirements-map.md | 3 +- .../test.js | 53 ++++++++++++++++ 7 files changed, 117 insertions(+), 7 deletions(-) diff --git a/challenge-prequalification-fairness-guard/README.md b/challenge-prequalification-fairness-guard/README.md index c2e64b32..8cdc784c 100644 --- a/challenge-prequalification-fairness-guard/README.md +++ b/challenge-prequalification-fairness-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Bounty System slice for SCIBASE issue #18. It evaluates sponsor-side prequalification rounds before solver teams are accepted or rejected from a challenge. -The guard checks published screening criteria, valid criterion weight values and totals, complete reviewer score evidence, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, reviewer quorum, rejection reason completeness, appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable. Unfair or incomplete screening decisions are held for remediation before challenge access changes. +The guard checks published screening criteria, valid criterion weight values and totals, complete reviewer score evidence, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, duplicate reviewer score evidence, rejection reason completeness, appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, and repeated reviewer identities are deduplicated before quorum or threshold scoring. Unfair or incomplete screening decisions are held for remediation before challenge access changes. ## Run diff --git a/challenge-prequalification-fairness-guard/acceptance-notes.md b/challenge-prequalification-fairness-guard/acceptance-notes.md index e72e5e12..e8df6d48 100644 --- a/challenge-prequalification-fairness-guard/acceptance-notes.md +++ b/challenge-prequalification-fairness-guard/acceptance-notes.md @@ -20,4 +20,5 @@ Validation coverage: - unpublished screening criteria are blocked before results are published - invalid individual criterion weights are held even when the total still sums to 100 - incomplete reviewer score evidence is held for completion without crashing the prequalification packet +- duplicate reviewer score evidence is held and deduplicated before quorum or weighted threshold scoring - audit digests are deterministic and private-data free diff --git a/challenge-prequalification-fairness-guard/demo.js b/challenge-prequalification-fairness-guard/demo.js index 25e5fe34..99f30754 100644 --- a/challenge-prequalification-fairness-guard/demo.js +++ b/challenge-prequalification-fairness-guard/demo.js @@ -62,7 +62,7 @@ const svg = `Accepted applicants: ${result.summary.accepted} Held for fairness review: ${result.summary.held} Remediation actions: ${result.summary.remediationActions} - Checks: criteria, thresholds, anonymity, conflicts, rejection reasons, appeal windows + Checks: criteria, distinct reviewer quorum, thresholds, anonymity, conflicts, appeals Unfair screening decisions are held before applicants are accepted or rejected. ${result.auditDigest} diff --git a/challenge-prequalification-fairness-guard/index.js b/challenge-prequalification-fairness-guard/index.js index 60e437d8..44b860b2 100644 --- a/challenge-prequalification-fairness-guard/index.js +++ b/challenge-prequalification-fairness-guard/index.js @@ -58,6 +58,51 @@ function uniqueSorted(values) { return Array.from(new Set(values)).sort(); } +function reviewerIdFor(review) { + return typeof review.reviewerId === 'string' ? review.reviewerId.trim() : ''; +} + +function duplicateNonConflictedReviewerIds(reviews) { + const counts = reviews + .filter((review) => !review.conflict) + .reduce((reviewerCounts, review) => { + const reviewerId = reviewerIdFor(review); + if (!reviewerId) { + return reviewerCounts; + } + reviewerCounts[reviewerId] = (reviewerCounts[reviewerId] || 0) + 1; + return reviewerCounts; + }, {}); + + return uniqueSorted( + Object.entries(counts) + .filter(([, count]) => count > 1) + .map(([reviewerId]) => reviewerId) + ); +} + +function countableNonConflictedReviews(reviews) { + const seenReviewerIds = new Set(); + + return reviews.filter((review) => { + if (review.conflict) { + return false; + } + + const reviewerId = reviewerIdFor(review); + if (!reviewerId) { + return true; + } + + if (seenReviewerIds.has(reviewerId)) { + return false; + } + + seenReviewerIds.add(reviewerId); + return true; + }); +} + function publicCriteriaIds(round) { return round.criteria.map((criterion) => criterion.id); } @@ -98,7 +143,8 @@ function appealStatus(applicant, round) { function reasonsForApplicant(applicant, reviews, round) { const criteriaIds = publicCriteriaIds(round); - const nonConflictedReviews = reviews.filter((review) => !review.conflict); + const nonConflictedReviews = countableNonConflictedReviews(reviews); + const duplicateReviewerIds = duplicateNonConflictedReviewerIds(reviews); const reasons = []; if (round.anonymousScreeningRequired && reviews.some((review) => !review.anonymousScreeningObserved)) { @@ -109,6 +155,10 @@ function reasonsForApplicant(applicant, reviews, round) { reasons.push('reviewer-conflict'); } + if (duplicateReviewerIds.length > 0) { + reasons.push('duplicate-reviewer-score-evidence'); + } + if (criteriaWeightTotal(round) !== 100) { reasons.push('criteria-weight-total-invalid'); } @@ -179,6 +229,10 @@ function remediationAction(applicant, reasons) { return 'replace-conflicted-reviewer'; } + if (reasons.includes('duplicate-reviewer-score-evidence')) { + return 'deduplicate-reviewer-score-evidence'; + } + if ( reasons.includes('missing-rejection-reason') || reasons.includes('missing-appeal-window') || @@ -203,7 +257,7 @@ function evaluatePrequalificationRound(round) { const decisions = round.applicants.map((applicant) => { const reviews = reviewsByApplicant[applicant.id] || []; - const nonConflictedReviews = reviews.filter((review) => !review.conflict); + const nonConflictedReviews = countableNonConflictedReviews(reviews); const reasons = reasonsForApplicant(applicant, reviews, round); const score = weightedScore(round.criteria, nonConflictedReviews); const decision = @@ -221,7 +275,7 @@ function evaluatePrequalificationRound(round) { sponsorDecision: applicant.sponsorDecision, weightedScore: score, passThreshold: round.passThreshold, - reviewersCounted: reviews.filter((review) => !review.conflict).length, + reviewersCounted: nonConflictedReviews.length, criteriaApplied: publicCriteriaIds(round), reasons, rejectionReasons: applicant.rejectionReasons, @@ -250,6 +304,7 @@ function evaluatePrequalificationRound(round) { priority: decision.reasons.includes('anonymous-screening-leak') || decision.reasons.includes('reviewer-conflict') || + decision.reasons.includes('duplicate-reviewer-score-evidence') || decision.reasons.includes('unpublished-screening-criterion') || decision.reasons.includes('criteria-weight-total-invalid') || decision.reasons.includes('criteria-weight-value-invalid') diff --git a/challenge-prequalification-fairness-guard/reports/summary.svg b/challenge-prequalification-fairness-guard/reports/summary.svg index cd3b3ee6..3f03fe9d 100644 --- a/challenge-prequalification-fairness-guard/reports/summary.svg +++ b/challenge-prequalification-fairness-guard/reports/summary.svg @@ -5,7 +5,7 @@ Accepted applicants: 1 Held for fairness review: 2 Remediation actions: 2 - Checks: criteria, thresholds, anonymity, conflicts, rejection reasons, appeal windows + Checks: criteria, distinct reviewer quorum, thresholds, anonymity, conflicts, appeals Unfair screening decisions are held before applicants are accepted or rejected. sha256:d2c3f92e0cbdf23779537887ca5da9ecd84c708b359cab01999f38ed79b40b1e diff --git a/challenge-prequalification-fairness-guard/requirements-map.md b/challenge-prequalification-fairness-guard/requirements-map.md index 1a2964ad..62954d7e 100644 --- a/challenge-prequalification-fairness-guard/requirements-map.md +++ b/challenge-prequalification-fairness-guard/requirements-map.md @@ -9,7 +9,7 @@ ## Submission Engine - Protects anonymous or named participation settings during prequalification review. -- Requires reviewer quorum before a solver team is accepted or rejected. +- Requires distinct reviewer quorum before a solver team is accepted or rejected. - Holds incomplete reviewer score packets for evidence completion instead of letting malformed review records crash or drive decisions. - Preserves audit evidence for each applicant before access to private challenge workspaces changes. @@ -18,6 +18,7 @@ - Holds inconsistent threshold decisions for fairness review before a solver is excluded. - Flags reviewer conflicts and missing rejection reasons for arbitration-ready remediation. - Excludes conflicted reviewer scores from weighted threshold evidence while retaining the conflict finding. +- Deduplicates repeated reviewer identities before quorum and weighted threshold scoring while retaining the duplicate-evidence finding. - Produces deterministic digests for challenge administrators and third-party reviewers. ## Safety And Scope diff --git a/challenge-prequalification-fairness-guard/test.js b/challenge-prequalification-fairness-guard/test.js index df489291..03f0568a 100644 --- a/challenge-prequalification-fairness-guard/test.js +++ b/challenge-prequalification-fairness-guard/test.js @@ -268,6 +268,58 @@ function testIncompleteReviewerScoreEvidenceHoldsWithoutCrashing() { assert.equal(action.action, 'complete-prequalification-evidence'); } +function testDuplicateReviewerScoreEvidenceDoesNotSatisfyQuorum() { + const round = buildSampleRound(); + round.minReviewers = 2; + round.applicants = [ + { + id: 'applicant-duplicate-reviewer', + sponsorDecision: 'accept', + rejectionReasons: [], + appealDueAt: null + } + ]; + round.reviews = [ + { + applicantId: 'applicant-duplicate-reviewer', + reviewerId: 'reviewer-repeat', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 96, + 'data-readiness': 94, + 'safety-plan': 95 + } + }, + { + applicantId: 'applicant-duplicate-reviewer', + reviewerId: 'reviewer-repeat', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 92, + 'data-readiness': 93, + 'safety-plan': 94 + } + } + ]; + + const result = evaluatePrequalificationRound(round); + const decision = byId(result.decisions, 'applicant-duplicate-reviewer'); + const action = byId(result.remediationActions, 'remediate-applicant-duplicate-reviewer'); + + assert.equal(decision.decision, 'hold-for-fairness-review'); + assert.equal(decision.reviewersCounted, 1); + assert.equal(decision.reasons.includes('duplicate-reviewer-score-evidence'), true); + assert.equal(decision.reasons.includes('reviewer-quorum-shortfall'), true); + assert.equal(action.action, 'deduplicate-reviewer-score-evidence'); + assert.equal(action.priority, 'high'); +} + function testAuditDigestIsDeterministicAndPrivateFree() { const first = evaluatePrequalificationRound(buildSampleRound()); const second = evaluatePrequalificationRound(buildSampleRound()); @@ -290,6 +342,7 @@ const tests = [ testInvalidCriterionWeightsHoldPrequalificationRound, testInvalidIndividualCriterionWeightsHoldPrequalificationRound, testIncompleteReviewerScoreEvidenceHoldsWithoutCrashing, + testDuplicateReviewerScoreEvidenceDoesNotSatisfyQuorum, testAuditDigestIsDeterministicAndPrivateFree ]; From 40955b67a4c6cc7ebbae831ebb207a12f5416e2f Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Fri, 29 May 2026 21:49:15 +0200 Subject: [PATCH 08/28] Validate prequalification appeal timestamps --- .../README.md | 2 +- .../acceptance-notes.md | 1 + .../index.js | 20 ++++++-- .../requirements-map.md | 4 +- .../test.js | 50 +++++++++++++++++++ 5 files changed, 70 insertions(+), 7 deletions(-) diff --git a/challenge-prequalification-fairness-guard/README.md b/challenge-prequalification-fairness-guard/README.md index 8cdc784c..9b93e8af 100644 --- a/challenge-prequalification-fairness-guard/README.md +++ b/challenge-prequalification-fairness-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Bounty System slice for SCIBASE issue #18. It evaluates sponsor-side prequalification rounds before solver teams are accepted or rejected from a challenge. -The guard checks published screening criteria, valid criterion weight values and totals, complete reviewer score evidence, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, duplicate reviewer score evidence, rejection reason completeness, appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, and repeated reviewer identities are deduplicated before quorum or threshold scoring. Unfair or incomplete screening decisions are held for remediation before challenge access changes. +The guard checks published screening criteria, valid criterion weight values and totals, complete reviewer score evidence, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, duplicate reviewer score evidence, rejection reason completeness, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, and repeated reviewer identities are deduplicated before quorum or threshold scoring. Unfair or incomplete screening decisions are held for remediation before challenge access changes. ## Run diff --git a/challenge-prequalification-fairness-guard/acceptance-notes.md b/challenge-prequalification-fairness-guard/acceptance-notes.md index e8df6d48..4d6ec90b 100644 --- a/challenge-prequalification-fairness-guard/acceptance-notes.md +++ b/challenge-prequalification-fairness-guard/acceptance-notes.md @@ -18,6 +18,7 @@ Validation coverage: - conflicted reviewer participation and missing rejection reasons remain auditable - conflicted reviewer scores are excluded from weighted threshold evidence - unpublished screening criteria are blocked before results are published +- invalid appeal-window timestamps hold rejected applicants before rejection packets are published - invalid individual criterion weights are held even when the total still sums to 100 - incomplete reviewer score evidence is held for completion without crashing the prequalification packet - duplicate reviewer score evidence is held and deduplicated before quorum or weighted threshold scoring diff --git a/challenge-prequalification-fairness-guard/index.js b/challenge-prequalification-fairness-guard/index.js index 44b860b2..3a22713e 100644 --- a/challenge-prequalification-fairness-guard/index.js +++ b/challenge-prequalification-fairness-guard/index.js @@ -138,13 +138,20 @@ function appealStatus(applicant, round) { return 'missing'; } - return applicant.appealDueAt >= round.generatedAt ? 'open' : 'expired'; + const appealDueAt = Date.parse(applicant.appealDueAt); + const generatedAt = Date.parse(round.generatedAt); + if (!Number.isFinite(appealDueAt) || !Number.isFinite(generatedAt)) { + return 'invalid'; + } + + return appealDueAt >= generatedAt ? 'open' : 'expired'; } function reasonsForApplicant(applicant, reviews, round) { const criteriaIds = publicCriteriaIds(round); const nonConflictedReviews = countableNonConflictedReviews(reviews); const duplicateReviewerIds = duplicateNonConflictedReviewerIds(reviews); + const applicantAppealStatus = appealStatus(applicant, round); const reasons = []; if (round.anonymousScreeningRequired && reviews.some((review) => !review.anonymousScreeningObserved)) { @@ -197,14 +204,18 @@ function reasonsForApplicant(applicant, reviews, round) { reasons.push('missing-rejection-reason'); } - if (applicant.sponsorDecision === 'reject' && appealStatus(applicant, round) === 'missing') { + if (applicant.sponsorDecision === 'reject' && applicantAppealStatus === 'missing') { reasons.push('missing-appeal-window'); } - if (applicant.sponsorDecision === 'reject' && appealStatus(applicant, round) === 'expired') { + if (applicant.sponsorDecision === 'reject' && applicantAppealStatus === 'expired') { reasons.push('expired-appeal-window'); } + if (applicant.sponsorDecision === 'reject' && applicantAppealStatus === 'invalid') { + reasons.push('invalid-appeal-window'); + } + return uniqueSorted(reasons); } @@ -236,7 +247,8 @@ function remediationAction(applicant, reasons) { if ( reasons.includes('missing-rejection-reason') || reasons.includes('missing-appeal-window') || - reasons.includes('expired-appeal-window') + reasons.includes('expired-appeal-window') || + reasons.includes('invalid-appeal-window') ) { return 'publish-rejection-reasons-and-appeal-window'; } diff --git a/challenge-prequalification-fairness-guard/requirements-map.md b/challenge-prequalification-fairness-guard/requirements-map.md index 62954d7e..f55d5744 100644 --- a/challenge-prequalification-fairness-guard/requirements-map.md +++ b/challenge-prequalification-fairness-guard/requirements-map.md @@ -4,7 +4,7 @@ - Verifies that prequalification rounds use published criteria, nonnegative weights, valid weight totals, and pass thresholds. - Blocks unpublished sponsor preferences from entering solver-screening decisions. -- Keeps prequalification decisions tied to challenge timelines and appeal windows. +- Keeps prequalification decisions tied to challenge timelines and parseable appeal windows. ## Submission Engine @@ -16,7 +16,7 @@ ## Arbitration And Reward Distribution - Holds inconsistent threshold decisions for fairness review before a solver is excluded. -- Flags reviewer conflicts and missing rejection reasons for arbitration-ready remediation. +- Flags reviewer conflicts, missing rejection reasons, and invalid appeal-window evidence for arbitration-ready remediation. - Excludes conflicted reviewer scores from weighted threshold evidence while retaining the conflict finding. - Deduplicates repeated reviewer identities before quorum and weighted threshold scoring while retaining the duplicate-evidence finding. - Produces deterministic digests for challenge administrators and third-party reviewers. diff --git a/challenge-prequalification-fairness-guard/test.js b/challenge-prequalification-fairness-guard/test.js index 03f0568a..75db1065 100644 --- a/challenge-prequalification-fairness-guard/test.js +++ b/challenge-prequalification-fairness-guard/test.js @@ -166,6 +166,55 @@ function testExpiredAppealWindowHoldsRejectedApplicantForFairnessReview() { assert.equal(action.action, 'publish-rejection-reasons-and-appeal-window'); } +function testInvalidAppealWindowHoldsRejectedApplicantForFairnessReview() { + const round = buildSampleRound(); + round.applicants = [ + { + id: 'applicant-invalid-appeal', + sponsorDecision: 'reject', + rejectionReasons: ['evidence package missed challenge-specific validation'], + appealDueAt: 'not-a-date' + } + ]; + round.reviews = [ + { + applicantId: 'applicant-invalid-appeal', + reviewerId: 'reviewer-independent-a', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'reject', + rejectionReasons: ['evidence package missed challenge-specific validation'], + scores: { + 'domain-fit': 60, + 'data-readiness': 58, + 'safety-plan': 62 + } + }, + { + applicantId: 'applicant-invalid-appeal', + reviewerId: 'reviewer-independent-b', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'reject', + rejectionReasons: ['evidence package missed challenge-specific validation'], + scores: { + 'domain-fit': 62, + 'data-readiness': 57, + 'safety-plan': 61 + } + } + ]; + + const result = evaluatePrequalificationRound(round); + const decision = byId(result.decisions, 'applicant-invalid-appeal'); + const action = byId(result.remediationActions, 'remediate-applicant-invalid-appeal'); + + assert.equal(decision.decision, 'hold-for-fairness-review'); + assert.equal(decision.appealStatus, 'invalid'); + assert.equal(decision.reasons.includes('invalid-appeal-window'), true); + assert.equal(action.action, 'publish-rejection-reasons-and-appeal-window'); +} + function testInvalidCriterionWeightsHoldPrequalificationRound() { const round = buildSampleRound(); round.criteria = [ @@ -339,6 +388,7 @@ const tests = [ testConflictedReviewerScoresDoNotInflateWeightedScore, testHiddenCriteriaAreBlockedBeforeScreeningResultsPublish, testExpiredAppealWindowHoldsRejectedApplicantForFairnessReview, + testInvalidAppealWindowHoldsRejectedApplicantForFairnessReview, testInvalidCriterionWeightsHoldPrequalificationRound, testInvalidIndividualCriterionWeightsHoldPrequalificationRound, testIncompleteReviewerScoreEvidenceHoldsWithoutCrashing, From 2a6c286b9b8bf3a1b54ea73d478f75da3677b5f6 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Fri, 29 May 2026 22:33:37 +0200 Subject: [PATCH 09/28] Validate prequalification pass thresholds --- .../README.md | 2 +- .../acceptance-notes.md | 1 + .../index.js | 20 ++++++++++++++++++- .../requirements-map.md | 3 ++- .../test.js | 15 ++++++++++++++ 5 files changed, 38 insertions(+), 3 deletions(-) diff --git a/challenge-prequalification-fairness-guard/README.md b/challenge-prequalification-fairness-guard/README.md index 9b93e8af..ea6104ce 100644 --- a/challenge-prequalification-fairness-guard/README.md +++ b/challenge-prequalification-fairness-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Bounty System slice for SCIBASE issue #18. It evaluates sponsor-side prequalification rounds before solver teams are accepted or rejected from a challenge. -The guard checks published screening criteria, valid criterion weight values and totals, complete reviewer score evidence, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, duplicate reviewer score evidence, rejection reason completeness, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, and repeated reviewer identities are deduplicated before quorum or threshold scoring. Unfair or incomplete screening decisions are held for remediation before challenge access changes. +The guard checks published screening criteria, valid criterion weight values and totals, valid pass thresholds, complete reviewer score evidence, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, duplicate reviewer score evidence, rejection reason completeness, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, and repeated reviewer identities are deduplicated before quorum or threshold scoring. Unfair or incomplete screening decisions are held for remediation before challenge access changes. ## Run diff --git a/challenge-prequalification-fairness-guard/acceptance-notes.md b/challenge-prequalification-fairness-guard/acceptance-notes.md index 4d6ec90b..e44a243b 100644 --- a/challenge-prequalification-fairness-guard/acceptance-notes.md +++ b/challenge-prequalification-fairness-guard/acceptance-notes.md @@ -20,6 +20,7 @@ Validation coverage: - unpublished screening criteria are blocked before results are published - invalid appeal-window timestamps hold rejected applicants before rejection packets are published - invalid individual criterion weights are held even when the total still sums to 100 +- invalid pass thresholds are held before sponsor accept/reject decisions can take effect - incomplete reviewer score evidence is held for completion without crashing the prequalification packet - duplicate reviewer score evidence is held and deduplicated before quorum or weighted threshold scoring - audit digests are deterministic and private-data free diff --git a/challenge-prequalification-fairness-guard/index.js b/challenge-prequalification-fairness-guard/index.js index 3a22713e..84af9cb9 100644 --- a/challenge-prequalification-fairness-guard/index.js +++ b/challenge-prequalification-fairness-guard/index.js @@ -125,6 +125,15 @@ function criteriaWeightsHaveInvalidValues(round) { ); } +function passThresholdIsInvalid(round) { + return ( + typeof round.passThreshold !== 'number' || + !Number.isFinite(round.passThreshold) || + round.passThreshold < 0 || + round.passThreshold > 100 + ); +} + function reviewUsesHiddenCriteria(review, criteriaIds) { return Object.keys(reviewScores(review)).some((criterionId) => !criteriaIds.includes(criterionId)); } @@ -174,6 +183,10 @@ function reasonsForApplicant(applicant, reviews, round) { reasons.push('criteria-weight-value-invalid'); } + if (passThresholdIsInvalid(round)) { + reasons.push('pass-threshold-invalid'); + } + if (nonConflictedReviews.length < round.minReviewers) { reasons.push('reviewer-quorum-shortfall'); } @@ -236,6 +249,10 @@ function remediationAction(applicant, reasons) { return 'publish-valid-weighted-scoring-rubric'; } + if (reasons.includes('pass-threshold-invalid')) { + return 'publish-valid-prequalification-threshold'; + } + if (reasons.includes('reviewer-conflict')) { return 'replace-conflicted-reviewer'; } @@ -319,7 +336,8 @@ function evaluatePrequalificationRound(round) { decision.reasons.includes('duplicate-reviewer-score-evidence') || decision.reasons.includes('unpublished-screening-criterion') || decision.reasons.includes('criteria-weight-total-invalid') || - decision.reasons.includes('criteria-weight-value-invalid') + decision.reasons.includes('criteria-weight-value-invalid') || + decision.reasons.includes('pass-threshold-invalid') ? 'high' : 'normal', reasons: decision.reasons diff --git a/challenge-prequalification-fairness-guard/requirements-map.md b/challenge-prequalification-fairness-guard/requirements-map.md index f55d5744..8c2ddb44 100644 --- a/challenge-prequalification-fairness-guard/requirements-map.md +++ b/challenge-prequalification-fairness-guard/requirements-map.md @@ -2,7 +2,7 @@ ## Challenge Posting Portal -- Verifies that prequalification rounds use published criteria, nonnegative weights, valid weight totals, and pass thresholds. +- Verifies that prequalification rounds use published criteria, nonnegative weights, valid weight totals, and valid 0-100 pass thresholds. - Blocks unpublished sponsor preferences from entering solver-screening decisions. - Keeps prequalification decisions tied to challenge timelines and parseable appeal windows. @@ -16,6 +16,7 @@ ## Arbitration And Reward Distribution - Holds inconsistent threshold decisions for fairness review before a solver is excluded. +- Holds invalid pass thresholds for fairness review before sponsor accept/reject decisions can take effect. - Flags reviewer conflicts, missing rejection reasons, and invalid appeal-window evidence for arbitration-ready remediation. - Excludes conflicted reviewer scores from weighted threshold evidence while retaining the conflict finding. - Deduplicates repeated reviewer identities before quorum and weighted threshold scoring while retaining the duplicate-evidence finding. diff --git a/challenge-prequalification-fairness-guard/test.js b/challenge-prequalification-fairness-guard/test.js index 75db1065..d2a73e72 100644 --- a/challenge-prequalification-fairness-guard/test.js +++ b/challenge-prequalification-fairness-guard/test.js @@ -275,6 +275,20 @@ function testInvalidIndividualCriterionWeightsHoldPrequalificationRound() { assert.equal(action.priority, 'high'); } +function testInvalidPassThresholdHoldsPrequalificationRound() { + const round = buildSampleRound(); + round.passThreshold = -5; + + const result = evaluatePrequalificationRound(round); + const decision = byId(result.decisions, 'applicant-biofoundry'); + const action = byId(result.remediationActions, 'remediate-applicant-biofoundry'); + + assert.equal(decision.decision, 'hold-for-fairness-review'); + assert.equal(decision.reasons.includes('pass-threshold-invalid'), true); + assert.equal(action.action, 'publish-valid-prequalification-threshold'); + assert.equal(action.priority, 'high'); +} + function testIncompleteReviewerScoreEvidenceHoldsWithoutCrashing() { const round = buildSampleRound(); round.applicants = [ @@ -391,6 +405,7 @@ const tests = [ testInvalidAppealWindowHoldsRejectedApplicantForFairnessReview, testInvalidCriterionWeightsHoldPrequalificationRound, testInvalidIndividualCriterionWeightsHoldPrequalificationRound, + testInvalidPassThresholdHoldsPrequalificationRound, testIncompleteReviewerScoreEvidenceHoldsWithoutCrashing, testDuplicateReviewerScoreEvidenceDoesNotSatisfyQuorum, testAuditDigestIsDeterministicAndPrivateFree From 3d7c8d7d314126f807f3142af7d33beeedf3f072 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Fri, 29 May 2026 23:06:54 +0200 Subject: [PATCH 10/28] Handle missing rejection reason lists --- .../README.md | 2 +- .../acceptance-notes.md | 1 + .../index.js | 8 ++- .../requirements-map.md | 2 +- .../test.js | 49 +++++++++++++++++++ 5 files changed, 58 insertions(+), 4 deletions(-) diff --git a/challenge-prequalification-fairness-guard/README.md b/challenge-prequalification-fairness-guard/README.md index ea6104ce..652e7e1b 100644 --- a/challenge-prequalification-fairness-guard/README.md +++ b/challenge-prequalification-fairness-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Bounty System slice for SCIBASE issue #18. It evaluates sponsor-side prequalification rounds before solver teams are accepted or rejected from a challenge. -The guard checks published screening criteria, valid criterion weight values and totals, valid pass thresholds, complete reviewer score evidence, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, duplicate reviewer score evidence, rejection reason completeness, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, and repeated reviewer identities are deduplicated before quorum or threshold scoring. Unfair or incomplete screening decisions are held for remediation before challenge access changes. +The guard checks published screening criteria, valid criterion weight values and totals, valid pass thresholds, complete reviewer score evidence, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, duplicate reviewer score evidence, missing or empty rejection reason lists, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, and repeated reviewer identities are deduplicated before quorum or threshold scoring. Unfair or incomplete screening decisions are held for remediation before challenge access changes. ## Run diff --git a/challenge-prequalification-fairness-guard/acceptance-notes.md b/challenge-prequalification-fairness-guard/acceptance-notes.md index e44a243b..8e9157ee 100644 --- a/challenge-prequalification-fairness-guard/acceptance-notes.md +++ b/challenge-prequalification-fairness-guard/acceptance-notes.md @@ -21,6 +21,7 @@ Validation coverage: - invalid appeal-window timestamps hold rejected applicants before rejection packets are published - invalid individual criterion weights are held even when the total still sums to 100 - invalid pass thresholds are held before sponsor accept/reject decisions can take effect +- missing rejection reason lists are normalized to an auditable fairness hold instead of crashing the prequalification packet - incomplete reviewer score evidence is held for completion without crashing the prequalification packet - duplicate reviewer score evidence is held and deduplicated before quorum or weighted threshold scoring - audit digests are deterministic and private-data free diff --git a/challenge-prequalification-fairness-guard/index.js b/challenge-prequalification-fairness-guard/index.js index 84af9cb9..de4fd937 100644 --- a/challenge-prequalification-fairness-guard/index.js +++ b/challenge-prequalification-fairness-guard/index.js @@ -111,6 +111,10 @@ function reviewScores(review) { return review.scores && typeof review.scores === 'object' ? review.scores : {}; } +function applicantRejectionReasons(applicant) { + return Array.isArray(applicant.rejectionReasons) ? applicant.rejectionReasons : []; +} + function criteriaWeightTotal(round) { return round.criteria.reduce((total, criterion) => total + criterion.weight, 0); } @@ -213,7 +217,7 @@ function reasonsForApplicant(applicant, reviews, round) { reasons.push('inconsistent-threshold-decision'); } - if (applicant.sponsorDecision === 'reject' && applicant.rejectionReasons.length === 0) { + if (applicant.sponsorDecision === 'reject' && applicantRejectionReasons(applicant).length === 0) { reasons.push('missing-rejection-reason'); } @@ -307,7 +311,7 @@ function evaluatePrequalificationRound(round) { reviewersCounted: nonConflictedReviews.length, criteriaApplied: publicCriteriaIds(round), reasons, - rejectionReasons: applicant.rejectionReasons, + rejectionReasons: applicantRejectionReasons(applicant), appealStatus: appealStatus(applicant, round), auditDigest: digest({ applicantId: applicant.id, diff --git a/challenge-prequalification-fairness-guard/requirements-map.md b/challenge-prequalification-fairness-guard/requirements-map.md index 8c2ddb44..60b93180 100644 --- a/challenge-prequalification-fairness-guard/requirements-map.md +++ b/challenge-prequalification-fairness-guard/requirements-map.md @@ -17,7 +17,7 @@ - Holds inconsistent threshold decisions for fairness review before a solver is excluded. - Holds invalid pass thresholds for fairness review before sponsor accept/reject decisions can take effect. -- Flags reviewer conflicts, missing rejection reasons, and invalid appeal-window evidence for arbitration-ready remediation. +- Flags reviewer conflicts, missing or omitted rejection reason lists, and invalid appeal-window evidence for arbitration-ready remediation. - Excludes conflicted reviewer scores from weighted threshold evidence while retaining the conflict finding. - Deduplicates repeated reviewer identities before quorum and weighted threshold scoring while retaining the duplicate-evidence finding. - Produces deterministic digests for challenge administrators and third-party reviewers. diff --git a/challenge-prequalification-fairness-guard/test.js b/challenge-prequalification-fairness-guard/test.js index d2a73e72..33abaec6 100644 --- a/challenge-prequalification-fairness-guard/test.js +++ b/challenge-prequalification-fairness-guard/test.js @@ -289,6 +289,54 @@ function testInvalidPassThresholdHoldsPrequalificationRound() { assert.equal(action.priority, 'high'); } +function testMissingRejectionReasonListHoldsWithoutCrashing() { + const round = buildSampleRound(); + round.applicants = [ + { + id: 'applicant-missing-rejection-list', + sponsorDecision: 'reject', + appealDueAt: '2026-06-04T08:00:00Z' + } + ]; + round.reviews = [ + { + applicantId: 'applicant-missing-rejection-list', + reviewerId: 'reviewer-independent-a', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'reject', + rejectionReasons: ['insufficient validation plan'], + scores: { + 'domain-fit': 58, + 'data-readiness': 60, + 'safety-plan': 62 + } + }, + { + applicantId: 'applicant-missing-rejection-list', + reviewerId: 'reviewer-independent-b', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'reject', + rejectionReasons: ['insufficient validation plan'], + scores: { + 'domain-fit': 59, + 'data-readiness': 61, + 'safety-plan': 60 + } + } + ]; + + const result = evaluatePrequalificationRound(round); + const decision = byId(result.decisions, 'applicant-missing-rejection-list'); + const action = byId(result.remediationActions, 'remediate-applicant-missing-rejection-list'); + + assert.equal(decision.decision, 'hold-for-fairness-review'); + assert.deepEqual(decision.rejectionReasons, []); + assert.equal(decision.reasons.includes('missing-rejection-reason'), true); + assert.equal(action.action, 'publish-rejection-reasons-and-appeal-window'); +} + function testIncompleteReviewerScoreEvidenceHoldsWithoutCrashing() { const round = buildSampleRound(); round.applicants = [ @@ -406,6 +454,7 @@ const tests = [ testInvalidCriterionWeightsHoldPrequalificationRound, testInvalidIndividualCriterionWeightsHoldPrequalificationRound, testInvalidPassThresholdHoldsPrequalificationRound, + testMissingRejectionReasonListHoldsWithoutCrashing, testIncompleteReviewerScoreEvidenceHoldsWithoutCrashing, testDuplicateReviewerScoreEvidenceDoesNotSatisfyQuorum, testAuditDigestIsDeterministicAndPrivateFree From 9be11bcb71daff87471a764209198efa01b03eb8 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Sat, 30 May 2026 00:25:21 +0200 Subject: [PATCH 11/28] Validate unique prequalification criteria --- .../README.md | 2 +- .../acceptance-notes.md | 1 + .../demo.js | 2 +- .../index.js | 23 +++++++ .../reports/summary.svg | 2 +- .../requirements-map.md | 3 +- .../test.js | 65 +++++++++++++++++++ 7 files changed, 94 insertions(+), 4 deletions(-) diff --git a/challenge-prequalification-fairness-guard/README.md b/challenge-prequalification-fairness-guard/README.md index 652e7e1b..20e801d0 100644 --- a/challenge-prequalification-fairness-guard/README.md +++ b/challenge-prequalification-fairness-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Bounty System slice for SCIBASE issue #18. It evaluates sponsor-side prequalification rounds before solver teams are accepted or rejected from a challenge. -The guard checks published screening criteria, valid criterion weight values and totals, valid pass thresholds, complete reviewer score evidence, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, duplicate reviewer score evidence, missing or empty rejection reason lists, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, and repeated reviewer identities are deduplicated before quorum or threshold scoring. Unfair or incomplete screening decisions are held for remediation before challenge access changes. +The guard checks published screening criteria, unique criterion identifiers, valid criterion weight values and totals, valid pass thresholds, complete reviewer score evidence, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, duplicate reviewer score evidence, missing or empty rejection reason lists, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, and repeated reviewer identities are deduplicated before quorum or threshold scoring. Unfair or incomplete screening decisions are held for remediation before challenge access changes. ## Run diff --git a/challenge-prequalification-fairness-guard/acceptance-notes.md b/challenge-prequalification-fairness-guard/acceptance-notes.md index 8e9157ee..d4a4b630 100644 --- a/challenge-prequalification-fairness-guard/acceptance-notes.md +++ b/challenge-prequalification-fairness-guard/acceptance-notes.md @@ -20,6 +20,7 @@ Validation coverage: - unpublished screening criteria are blocked before results are published - invalid appeal-window timestamps hold rejected applicants before rejection packets are published - invalid individual criterion weights are held even when the total still sums to 100 +- duplicate published criterion IDs are held before ambiguous rubric evidence can drive acceptance or rejection - invalid pass thresholds are held before sponsor accept/reject decisions can take effect - missing rejection reason lists are normalized to an auditable fairness hold instead of crashing the prequalification packet - incomplete reviewer score evidence is held for completion without crashing the prequalification packet diff --git a/challenge-prequalification-fairness-guard/demo.js b/challenge-prequalification-fairness-guard/demo.js index 99f30754..1693ba96 100644 --- a/challenge-prequalification-fairness-guard/demo.js +++ b/challenge-prequalification-fairness-guard/demo.js @@ -62,7 +62,7 @@ const svg = `Accepted applicants: ${result.summary.accepted} Held for fairness review: ${result.summary.held} Remediation actions: ${result.summary.remediationActions} - Checks: criteria, distinct reviewer quorum, thresholds, anonymity, conflicts, appeals + Checks: unique criteria, reviewer quorum, thresholds, anonymity, conflicts, appeals Unfair screening decisions are held before applicants are accepted or rejected. ${result.auditDigest} diff --git a/challenge-prequalification-fairness-guard/index.js b/challenge-prequalification-fairness-guard/index.js index de4fd937..f1d25ddf 100644 --- a/challenge-prequalification-fairness-guard/index.js +++ b/challenge-prequalification-fairness-guard/index.js @@ -58,6 +58,19 @@ function uniqueSorted(values) { return Array.from(new Set(values)).sort(); } +function duplicatePublishedCriterionIds(round) { + const criterionCounts = round.criteria.reduce((counts, criterion) => { + counts[criterion.id] = (counts[criterion.id] || 0) + 1; + return counts; + }, Object.create(null)); + + return uniqueSorted( + Object.entries(criterionCounts) + .filter(([, count]) => count > 1) + .map(([criterionId]) => criterionId) + ); +} + function reviewerIdFor(review) { return typeof review.reviewerId === 'string' ? review.reviewerId.trim() : ''; } @@ -162,6 +175,7 @@ function appealStatus(applicant, round) { function reasonsForApplicant(applicant, reviews, round) { const criteriaIds = publicCriteriaIds(round); + const duplicateCriterionIds = duplicatePublishedCriterionIds(round); const nonConflictedReviews = countableNonConflictedReviews(reviews); const duplicateReviewerIds = duplicateNonConflictedReviewerIds(reviews); const applicantAppealStatus = appealStatus(applicant, round); @@ -175,6 +189,10 @@ function reasonsForApplicant(applicant, reviews, round) { reasons.push('reviewer-conflict'); } + if (duplicateCriterionIds.length > 0) { + reasons.push('duplicate-published-criterion'); + } + if (duplicateReviewerIds.length > 0) { reasons.push('duplicate-reviewer-score-evidence'); } @@ -245,6 +263,10 @@ function remediationAction(applicant, reasons) { return 'remove-unpublished-criterion-and-rescore'; } + if (reasons.includes('duplicate-published-criterion')) { + return 'publish-unique-screening-criteria'; + } + if (reasons.includes('criteria-weight-total-invalid')) { return 'publish-valid-weighted-scoring-rubric'; } @@ -337,6 +359,7 @@ function evaluatePrequalificationRound(round) { priority: decision.reasons.includes('anonymous-screening-leak') || decision.reasons.includes('reviewer-conflict') || + decision.reasons.includes('duplicate-published-criterion') || decision.reasons.includes('duplicate-reviewer-score-evidence') || decision.reasons.includes('unpublished-screening-criterion') || decision.reasons.includes('criteria-weight-total-invalid') || diff --git a/challenge-prequalification-fairness-guard/reports/summary.svg b/challenge-prequalification-fairness-guard/reports/summary.svg index 3f03fe9d..7e48f850 100644 --- a/challenge-prequalification-fairness-guard/reports/summary.svg +++ b/challenge-prequalification-fairness-guard/reports/summary.svg @@ -5,7 +5,7 @@ Accepted applicants: 1 Held for fairness review: 2 Remediation actions: 2 - Checks: criteria, distinct reviewer quorum, thresholds, anonymity, conflicts, appeals + Checks: unique criteria, reviewer quorum, thresholds, anonymity, conflicts, appeals Unfair screening decisions are held before applicants are accepted or rejected. sha256:d2c3f92e0cbdf23779537887ca5da9ecd84c708b359cab01999f38ed79b40b1e diff --git a/challenge-prequalification-fairness-guard/requirements-map.md b/challenge-prequalification-fairness-guard/requirements-map.md index 60b93180..25306d0c 100644 --- a/challenge-prequalification-fairness-guard/requirements-map.md +++ b/challenge-prequalification-fairness-guard/requirements-map.md @@ -2,7 +2,7 @@ ## Challenge Posting Portal -- Verifies that prequalification rounds use published criteria, nonnegative weights, valid weight totals, and valid 0-100 pass thresholds. +- Verifies that prequalification rounds use published criteria, unique criterion identifiers, nonnegative weights, valid weight totals, and valid 0-100 pass thresholds. - Blocks unpublished sponsor preferences from entering solver-screening decisions. - Keeps prequalification decisions tied to challenge timelines and parseable appeal windows. @@ -17,6 +17,7 @@ - Holds inconsistent threshold decisions for fairness review before a solver is excluded. - Holds invalid pass thresholds for fairness review before sponsor accept/reject decisions can take effect. +- Holds duplicate published criterion identifiers for fairness review before ambiguous rubric evidence can drive sponsor decisions. - Flags reviewer conflicts, missing or omitted rejection reason lists, and invalid appeal-window evidence for arbitration-ready remediation. - Excludes conflicted reviewer scores from weighted threshold evidence while retaining the conflict finding. - Deduplicates repeated reviewer identities before quorum and weighted threshold scoring while retaining the duplicate-evidence finding. diff --git a/challenge-prequalification-fairness-guard/test.js b/challenge-prequalification-fairness-guard/test.js index 33abaec6..350a6562 100644 --- a/challenge-prequalification-fairness-guard/test.js +++ b/challenge-prequalification-fairness-guard/test.js @@ -275,6 +275,70 @@ function testInvalidIndividualCriterionWeightsHoldPrequalificationRound() { assert.equal(action.priority, 'high'); } +function testDuplicatePublishedCriterionIdsHoldPrequalificationRound() { + const round = buildSampleRound(); + round.criteria = [ + { + id: 'domain-fit', + label: 'Domain fit for the scientific challenge', + weight: 50 + }, + { + id: 'domain-fit', + label: 'Duplicated sponsor rubric identifier', + weight: 25 + }, + { + id: 'safety-plan', + label: 'Risk, NDA, and responsible-use plan', + weight: 25 + } + ]; + round.applicants = [ + { + id: 'applicant-duplicate-criterion', + sponsorDecision: 'accept', + rejectionReasons: [], + appealDueAt: null + } + ]; + round.reviews = [ + { + applicantId: 'applicant-duplicate-criterion', + reviewerId: 'reviewer-independent-a', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 94, + 'safety-plan': 92 + } + }, + { + applicantId: 'applicant-duplicate-criterion', + reviewerId: 'reviewer-independent-b', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 96, + 'safety-plan': 94 + } + } + ]; + + const result = evaluatePrequalificationRound(round); + const decision = byId(result.decisions, 'applicant-duplicate-criterion'); + const action = byId(result.remediationActions, 'remediate-applicant-duplicate-criterion'); + + assert.equal(decision.decision, 'hold-for-fairness-review'); + assert.equal(decision.reasons.includes('duplicate-published-criterion'), true); + assert.equal(action.action, 'publish-unique-screening-criteria'); + assert.equal(action.priority, 'high'); +} + function testInvalidPassThresholdHoldsPrequalificationRound() { const round = buildSampleRound(); round.passThreshold = -5; @@ -453,6 +517,7 @@ const tests = [ testInvalidAppealWindowHoldsRejectedApplicantForFairnessReview, testInvalidCriterionWeightsHoldPrequalificationRound, testInvalidIndividualCriterionWeightsHoldPrequalificationRound, + testDuplicatePublishedCriterionIdsHoldPrequalificationRound, testInvalidPassThresholdHoldsPrequalificationRound, testMissingRejectionReasonListHoldsWithoutCrashing, testIncompleteReviewerScoreEvidenceHoldsWithoutCrashing, From ef304da23738bd55b28aafc4ec49345d3182d8ab Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Sat, 30 May 2026 03:00:13 +0200 Subject: [PATCH 12/28] Require reviewer identity for quorum --- .../README.md | 2 +- .../acceptance-notes.md | 1 + .../demo.js | 2 +- .../index.js | 46 ++++++++++++++- .../make-demo-video.py | 2 +- .../reports/demo.mp4 | Bin 45643 -> 44989 bytes .../prequalification-fairness-packet.json | 40 ++++++++++++- .../prequalification-fairness-report.md | 8 ++- .../reports/summary.svg | 8 +-- .../requirements-map.md | 3 +- .../test.js | 53 +++++++++++++++++- 11 files changed, 149 insertions(+), 16 deletions(-) diff --git a/challenge-prequalification-fairness-guard/README.md b/challenge-prequalification-fairness-guard/README.md index 20e801d0..029aaf35 100644 --- a/challenge-prequalification-fairness-guard/README.md +++ b/challenge-prequalification-fairness-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Bounty System slice for SCIBASE issue #18. It evaluates sponsor-side prequalification rounds before solver teams are accepted or rejected from a challenge. -The guard checks published screening criteria, unique criterion identifiers, valid criterion weight values and totals, valid pass thresholds, complete reviewer score evidence, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, duplicate reviewer score evidence, missing or empty rejection reason lists, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, and repeated reviewer identities are deduplicated before quorum or threshold scoring. Unfair or incomplete screening decisions are held for remediation before challenge access changes. +The guard checks published screening criteria, unique criterion identifiers, valid criterion weight values and totals, valid pass thresholds, complete reviewer score evidence, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, missing reviewer identity evidence, duplicate reviewer score evidence, missing or empty rejection reason lists, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, repeated reviewer identities are deduplicated before quorum or threshold scoring, and missing reviewer identities are excluded from quorum until the evidence is completed. Unfair or incomplete screening decisions are held for remediation before challenge access changes. ## Run diff --git a/challenge-prequalification-fairness-guard/acceptance-notes.md b/challenge-prequalification-fairness-guard/acceptance-notes.md index d4a4b630..891a2b28 100644 --- a/challenge-prequalification-fairness-guard/acceptance-notes.md +++ b/challenge-prequalification-fairness-guard/acceptance-notes.md @@ -25,4 +25,5 @@ Validation coverage: - missing rejection reason lists are normalized to an auditable fairness hold instead of crashing the prequalification packet - incomplete reviewer score evidence is held for completion without crashing the prequalification packet - duplicate reviewer score evidence is held and deduplicated before quorum or weighted threshold scoring +- missing or blank reviewer identities are held and excluded from reviewer quorum until evidence is completed - audit digests are deterministic and private-data free diff --git a/challenge-prequalification-fairness-guard/demo.js b/challenge-prequalification-fairness-guard/demo.js index 1693ba96..2d763acf 100644 --- a/challenge-prequalification-fairness-guard/demo.js +++ b/challenge-prequalification-fairness-guard/demo.js @@ -62,7 +62,7 @@ const svg = `Accepted applicants: ${result.summary.accepted} Held for fairness review: ${result.summary.held} Remediation actions: ${result.summary.remediationActions} - Checks: unique criteria, reviewer quorum, thresholds, anonymity, conflicts, appeals + Checks: unique criteria, reviewer identity/quorum, thresholds, anonymity, conflicts, appeals Unfair screening decisions are held before applicants are accepted or rejected. ${result.auditDigest} diff --git a/challenge-prequalification-fairness-guard/index.js b/challenge-prequalification-fairness-guard/index.js index f1d25ddf..4d440b16 100644 --- a/challenge-prequalification-fairness-guard/index.js +++ b/challenge-prequalification-fairness-guard/index.js @@ -94,6 +94,10 @@ function duplicateNonConflictedReviewerIds(reviews) { ); } +function hasMissingReviewerIdentity(reviews) { + return reviews.some((review) => !reviewerIdFor(review)); +} + function countableNonConflictedReviews(reviews) { const seenReviewerIds = new Set(); @@ -104,7 +108,7 @@ function countableNonConflictedReviews(reviews) { const reviewerId = reviewerIdFor(review); if (!reviewerId) { - return true; + return false; } if (seenReviewerIds.has(reviewerId)) { @@ -197,6 +201,10 @@ function reasonsForApplicant(applicant, reviews, round) { reasons.push('duplicate-reviewer-score-evidence'); } + if (hasMissingReviewerIdentity(reviews)) { + reasons.push('missing-reviewer-identity'); + } + if (criteriaWeightTotal(round) !== 100) { reasons.push('criteria-weight-total-invalid'); } @@ -300,6 +308,10 @@ function remediationAction(applicant, reasons) { return 'complete-prequalification-evidence'; } + if (reasons.includes('missing-reviewer-identity')) { + return 'complete-prequalification-evidence'; + } + if (reasons.includes('inconsistent-threshold-decision')) { return 'reconcile-score-threshold-decision'; } @@ -361,6 +373,7 @@ function evaluatePrequalificationRound(round) { decision.reasons.includes('reviewer-conflict') || decision.reasons.includes('duplicate-published-criterion') || decision.reasons.includes('duplicate-reviewer-score-evidence') || + decision.reasons.includes('missing-reviewer-identity') || decision.reasons.includes('unpublished-screening-criterion') || decision.reasons.includes('criteria-weight-total-invalid') || decision.reasons.includes('criteria-weight-value-invalid') || @@ -437,6 +450,12 @@ function buildSampleRound() { sponsorDecision: 'reject', rejectionReasons: [], appealDueAt: null + }, + { + id: 'applicant-missing-reviewer-identity', + sponsorDecision: 'accept', + rejectionReasons: [], + appealDueAt: null } ], reviews: [ @@ -517,6 +536,31 @@ function buildSampleRound() { 'data-readiness': 68, 'safety-plan': 73 } + }, + { + applicantId: 'applicant-missing-reviewer-identity', + reviewerId: ' ', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 94, + 'data-readiness': 95, + 'safety-plan': 93 + } + }, + { + applicantId: 'applicant-missing-reviewer-identity', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 92, + 'data-readiness': 94, + 'safety-plan': 91 + } } ] }; diff --git a/challenge-prequalification-fairness-guard/make-demo-video.py b/challenge-prequalification-fairness-guard/make-demo-video.py index f50b2e2e..4afb386c 100644 --- a/challenge-prequalification-fairness-guard/make-demo-video.py +++ b/challenge-prequalification-fairness-guard/make-demo-video.py @@ -28,7 +28,7 @@ def draw_frame_with_pillow(): draw.text((96, 102), "Challenge Prequalification Fairness Guard", fill="white", font=title_font) draw.text((96, 190), "Published criteria plus weighted threshold checks", fill="#dff5d5", font=body_font) draw.text((96, 248), "Anonymous screening leaks and conflicts are held", fill="#dff5d5", font=body_font) - draw.text((96, 306), "Missing rejection reasons create remediation actions", fill="#dff5d5", font=body_font) + draw.text((96, 306), "Missing reviewer identities cannot satisfy quorum", fill="#dff5d5", font=body_font) draw.text((96, 402), "Synthetic data only. No sponsor, solver, payout, or identity systems are called.", fill="#ffd37a", font=note_font) image.save(FRAME) diff --git a/challenge-prequalification-fairness-guard/reports/demo.mp4 b/challenge-prequalification-fairness-guard/reports/demo.mp4 index 9c66801886d254a968e5006a3fc04524711bf0ce..8a0f322fef3e899044301f584846fbe0f87b81a5 100644 GIT binary patch delta 18049 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 diff --git a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-packet.json b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-packet.json index 49930eaa..9635156a 100644 --- a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-packet.json +++ b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-packet.json @@ -69,6 +69,29 @@ "rejectionReasons": [], "appealStatus": "missing", "auditDigest": "sha256:155ac57faaf2c7ccabbb1799d38d4f73c1ed458c2da1a0bfc8fe49a2f4120dee" + }, + { + "id": "applicant-missing-reviewer-identity", + "applicantId": "applicant-missing-reviewer-identity", + "challengeId": "challenge-18-prequalification-rna-biomarker", + "decision": "hold-for-fairness-review", + "sponsorDecision": "accept", + "weightedScore": 0, + "passThreshold": 75, + "reviewersCounted": 0, + "criteriaApplied": [ + "domain-fit", + "data-readiness", + "safety-plan" + ], + "reasons": [ + "inconsistent-threshold-decision", + "missing-reviewer-identity", + "reviewer-quorum-shortfall" + ], + "rejectionReasons": [], + "appealStatus": "not-required", + "auditDigest": "sha256:155b16a3cbdbb41a53a5df5faf6e7e64e6854dbac37beb448deb2a829e4edeec" } ], "remediationActions": [ @@ -93,13 +116,24 @@ "reviewer-conflict", "reviewer-quorum-shortfall" ] + }, + { + "id": "remediate-applicant-missing-reviewer-identity", + "applicantId": "applicant-missing-reviewer-identity", + "action": "complete-prequalification-evidence", + "priority": "high", + "reasons": [ + "inconsistent-threshold-decision", + "missing-reviewer-identity", + "reviewer-quorum-shortfall" + ] } ], "summary": { "accepted": 1, - "held": 2, + "held": 3, "rejectedWithAudit": 0, - "remediationActions": 2 + "remediationActions": 3 }, - "auditDigest": "sha256:d2c3f92e0cbdf23779537887ca5da9ecd84c708b359cab01999f38ed79b40b1e" + "auditDigest": "sha256:5ec541696fa83ff5f5ac49891dbb4bdf0e1174638039f2a2e7b6e65f2df49d16" } diff --git a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md index c03858fc..b682141c 100644 --- a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md +++ b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md @@ -6,22 +6,24 @@ Generated: 2026-05-28T08:00:00Z ## Summary - Accepted applicants: 1 -- Held for fairness review: 2 +- Held for fairness review: 3 - Rejected with audit trail: 0 -- Remediation actions: 2 +- Remediation actions: 3 - Criteria digest: sha256:d643b033793917b9d0488787518a11e97094e671d52b86b69a6153375d726721 -- Audit digest: sha256:d2c3f92e0cbdf23779537887ca5da9ecd84c708b359cab01999f38ed79b40b1e +- Audit digest: sha256:5ec541696fa83ff5f5ac49891dbb4bdf0e1174638039f2a2e7b6e65f2df49d16 ## Decisions - applicant-biofoundry: accept-prequalified, score 87, reasons: none - applicant-neuro-lab: hold-for-fairness-review, score 81, reasons: anonymous-screening-leak, inconsistent-threshold-decision - applicant-sponsor-alumni: hold-for-fairness-review, score 70, reasons: missing-appeal-window, missing-rejection-reason, reviewer-conflict, reviewer-quorum-shortfall +- applicant-missing-reviewer-identity: hold-for-fairness-review, score 0, reasons: inconsistent-threshold-decision, missing-reviewer-identity, reviewer-quorum-shortfall ## Remediation Actions - remediate-applicant-neuro-lab: rerun-blinded-prequalification-review (high) - remediate-applicant-sponsor-alumni: replace-conflicted-reviewer (high) +- remediate-applicant-missing-reviewer-identity: complete-prequalification-evidence (high) ## Safety diff --git a/challenge-prequalification-fairness-guard/reports/summary.svg b/challenge-prequalification-fairness-guard/reports/summary.svg index 7e48f850..8bc7732c 100644 --- a/challenge-prequalification-fairness-guard/reports/summary.svg +++ b/challenge-prequalification-fairness-guard/reports/summary.svg @@ -3,9 +3,9 @@ Challenge Prequalification Fairness Guard Accepted applicants: 1 - Held for fairness review: 2 - Remediation actions: 2 - Checks: unique criteria, reviewer quorum, thresholds, anonymity, conflicts, appeals + Held for fairness review: 3 + Remediation actions: 3 + Checks: unique criteria, reviewer identity/quorum, thresholds, anonymity, conflicts, appeals Unfair screening decisions are held before applicants are accepted or rejected. - sha256:d2c3f92e0cbdf23779537887ca5da9ecd84c708b359cab01999f38ed79b40b1e + sha256:5ec541696fa83ff5f5ac49891dbb4bdf0e1174638039f2a2e7b6e65f2df49d16 diff --git a/challenge-prequalification-fairness-guard/requirements-map.md b/challenge-prequalification-fairness-guard/requirements-map.md index 25306d0c..7babb712 100644 --- a/challenge-prequalification-fairness-guard/requirements-map.md +++ b/challenge-prequalification-fairness-guard/requirements-map.md @@ -9,7 +9,7 @@ ## Submission Engine - Protects anonymous or named participation settings during prequalification review. -- Requires distinct reviewer quorum before a solver team is accepted or rejected. +- Requires distinct reviewer quorum before a solver team is accepted or rejected, and excludes missing or blank reviewer identities from quorum until reviewer evidence is completed. - Holds incomplete reviewer score packets for evidence completion instead of letting malformed review records crash or drive decisions. - Preserves audit evidence for each applicant before access to private challenge workspaces changes. @@ -21,6 +21,7 @@ - Flags reviewer conflicts, missing or omitted rejection reason lists, and invalid appeal-window evidence for arbitration-ready remediation. - Excludes conflicted reviewer scores from weighted threshold evidence while retaining the conflict finding. - Deduplicates repeated reviewer identities before quorum and weighted threshold scoring while retaining the duplicate-evidence finding. +- Holds missing reviewer identity evidence before anonymous or malformed reviewer rows can satisfy quorum. - Produces deterministic digests for challenge administrators and third-party reviewers. ## Safety And Scope diff --git a/challenge-prequalification-fairness-guard/test.js b/challenge-prequalification-fairness-guard/test.js index 350a6562..5cbd40fa 100644 --- a/challenge-prequalification-fairness-guard/test.js +++ b/challenge-prequalification-fairness-guard/test.js @@ -495,6 +495,56 @@ function testDuplicateReviewerScoreEvidenceDoesNotSatisfyQuorum() { assert.equal(action.priority, 'high'); } +function testMissingReviewerIdentityDoesNotSatisfyQuorum() { + const round = buildSampleRound(); + round.minReviewers = 2; + round.applicants = [ + { + id: 'applicant-missing-reviewer-identity', + sponsorDecision: 'accept', + rejectionReasons: [], + appealDueAt: null + } + ]; + round.reviews = [ + { + applicantId: 'applicant-missing-reviewer-identity', + reviewerId: ' ', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 96, + 'data-readiness': 94, + 'safety-plan': 95 + } + }, + { + applicantId: 'applicant-missing-reviewer-identity', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 92, + 'data-readiness': 93, + 'safety-plan': 94 + } + } + ]; + + const result = evaluatePrequalificationRound(round); + const decision = byId(result.decisions, 'applicant-missing-reviewer-identity'); + const action = byId(result.remediationActions, 'remediate-applicant-missing-reviewer-identity'); + + assert.equal(decision.decision, 'hold-for-fairness-review'); + assert.equal(decision.reviewersCounted, 0); + assert.equal(decision.reasons.includes('missing-reviewer-identity'), true); + assert.equal(decision.reasons.includes('reviewer-quorum-shortfall'), true); + assert.equal(action.action, 'complete-prequalification-evidence'); +} + function testAuditDigestIsDeterministicAndPrivateFree() { const first = evaluatePrequalificationRound(buildSampleRound()); const second = evaluatePrequalificationRound(buildSampleRound()); @@ -502,7 +552,7 @@ function testAuditDigestIsDeterministicAndPrivateFree() { assert.equal(first.auditDigest, second.auditDigest); assert.ok(first.auditDigest.startsWith('sha256:')); assert.equal(first.summary.accepted, 1); - assert.equal(first.summary.held, 2); + assert.equal(first.summary.held, 3); assert.equal(JSON.stringify(first).includes('private@'), false); assert.equal(JSON.stringify(first).includes('government_id'), false); } @@ -522,6 +572,7 @@ const tests = [ testMissingRejectionReasonListHoldsWithoutCrashing, testIncompleteReviewerScoreEvidenceHoldsWithoutCrashing, testDuplicateReviewerScoreEvidenceDoesNotSatisfyQuorum, + testMissingReviewerIdentityDoesNotSatisfyQuorum, testAuditDigestIsDeterministicAndPrivateFree ]; From b803d034450d2e04c265628b4589448a8d01b3f2 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Sat, 30 May 2026 07:41:41 +0200 Subject: [PATCH 13/28] Validate complete prequalification criteria identifiers --- .../README.md | 3 +- .../acceptance-notes.md | 1 + .../demo.js | 72 ++++++++++++++++++- .../index.js | 15 ++++ .../reports/missing-criterion-id-packet.json | 46 ++++++++++++ .../prequalification-fairness-report.md | 8 +++ .../reports/summary.svg | 2 +- .../requirements-map.md | 3 +- .../test.js | 67 +++++++++++++++++ 9 files changed, 213 insertions(+), 4 deletions(-) create mode 100644 challenge-prequalification-fairness-guard/reports/missing-criterion-id-packet.json diff --git a/challenge-prequalification-fairness-guard/README.md b/challenge-prequalification-fairness-guard/README.md index 029aaf35..27f3afa5 100644 --- a/challenge-prequalification-fairness-guard/README.md +++ b/challenge-prequalification-fairness-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Bounty System slice for SCIBASE issue #18. It evaluates sponsor-side prequalification rounds before solver teams are accepted or rejected from a challenge. -The guard checks published screening criteria, unique criterion identifiers, valid criterion weight values and totals, valid pass thresholds, complete reviewer score evidence, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, missing reviewer identity evidence, duplicate reviewer score evidence, missing or empty rejection reason lists, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, repeated reviewer identities are deduplicated before quorum or threshold scoring, and missing reviewer identities are excluded from quorum until the evidence is completed. Unfair or incomplete screening decisions are held for remediation before challenge access changes. +The guard checks published screening criteria, complete and unique criterion identifiers, valid criterion weight values and totals, valid pass thresholds, complete reviewer score evidence, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, missing reviewer identity evidence, duplicate reviewer score evidence, missing or empty rejection reason lists, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, repeated reviewer identities are deduplicated before quorum or threshold scoring, and missing reviewer identities are excluded from quorum until the evidence is completed. Unfair or incomplete screening decisions are held for remediation before challenge access changes. ## Run @@ -16,6 +16,7 @@ npm run check ## Outputs - `reports/prequalification-fairness-packet.json` +- `reports/missing-criterion-id-packet.json` - `reports/prequalification-fairness-report.md` - `reports/summary.svg` - `reports/demo.mp4` diff --git a/challenge-prequalification-fairness-guard/acceptance-notes.md b/challenge-prequalification-fairness-guard/acceptance-notes.md index 891a2b28..ffe9784c 100644 --- a/challenge-prequalification-fairness-guard/acceptance-notes.md +++ b/challenge-prequalification-fairness-guard/acceptance-notes.md @@ -21,6 +21,7 @@ Validation coverage: - invalid appeal-window timestamps hold rejected applicants before rejection packets are published - invalid individual criterion weights are held even when the total still sums to 100 - duplicate published criterion IDs are held before ambiguous rubric evidence can drive acceptance or rejection +- missing or blank published criterion IDs are held before unauditable rubric evidence can drive acceptance or rejection - invalid pass thresholds are held before sponsor accept/reject decisions can take effect - missing rejection reason lists are normalized to an auditable fairness hold instead of crashing the prequalification packet - incomplete reviewer score evidence is held for completion without crashing the prequalification packet diff --git a/challenge-prequalification-fairness-guard/demo.js b/challenge-prequalification-fairness-guard/demo.js index 2d763acf..5d0f9944 100644 --- a/challenge-prequalification-fairness-guard/demo.js +++ b/challenge-prequalification-fairness-guard/demo.js @@ -6,12 +6,15 @@ const reportsDir = path.join(__dirname, 'reports'); fs.mkdirSync(reportsDir, { recursive: true }); const result = evaluatePrequalificationRound(buildSampleRound()); +const missingCriterionResult = evaluatePrequalificationRound(buildMissingCriterionIdRound()); const packetPath = path.join(reportsDir, 'prequalification-fairness-packet.json'); +const missingCriterionPacketPath = path.join(reportsDir, 'missing-criterion-id-packet.json'); const reportPath = path.join(reportsDir, 'prequalification-fairness-report.md'); const svgPath = path.join(reportsDir, 'summary.svg'); fs.writeFileSync(packetPath, `${JSON.stringify(result, null, 2)}\n`); +fs.writeFileSync(missingCriterionPacketPath, `${JSON.stringify(missingCriterionResult, null, 2)}\n`); const decisions = result.decisions .map( @@ -48,6 +51,14 @@ ${decisions} ${actions} +## Missing Criterion Identifier Packet + +- Applicant: ${missingCriterionResult.decisions[0].applicantId} +- Decision: ${missingCriterionResult.decisions[0].decision} +- Reasons: ${missingCriterionResult.decisions[0].reasons.join(', ')} +- Remediation: ${missingCriterionResult.remediationActions[0].action} +- Audit digest: ${missingCriterionResult.auditDigest} + ## Safety All fixtures are synthetic. The guard does not call payment processors, identity providers, private workspaces, sponsor systems, or external APIs. @@ -62,7 +73,7 @@ const svg = `Accepted applicants: ${result.summary.accepted} Held for fairness review: ${result.summary.held} Remediation actions: ${result.summary.remediationActions} - Checks: unique criteria, reviewer identity/quorum, thresholds, anonymity, conflicts, appeals + Checks: complete/unique criteria, reviewer identity/quorum, thresholds, anonymity, conflicts, appeals Unfair screening decisions are held before applicants are accepted or rejected. ${result.auditDigest} @@ -71,7 +82,66 @@ const svg = ` typeof criterion.id !== 'string' || criterion.id.trim().length === 0 + ); +} + function reviewerIdFor(review) { return typeof review.reviewerId === 'string' ? review.reviewerId.trim() : ''; } @@ -197,6 +203,10 @@ function reasonsForApplicant(applicant, reviews, round) { reasons.push('duplicate-published-criterion'); } + if (hasMissingPublishedCriterionIds(round)) { + reasons.push('missing-published-criterion-id'); + } + if (duplicateReviewerIds.length > 0) { reasons.push('duplicate-reviewer-score-evidence'); } @@ -275,6 +285,10 @@ function remediationAction(applicant, reasons) { return 'publish-unique-screening-criteria'; } + if (reasons.includes('missing-published-criterion-id')) { + return 'publish-complete-screening-criteria'; + } + if (reasons.includes('criteria-weight-total-invalid')) { return 'publish-valid-weighted-scoring-rubric'; } @@ -372,6 +386,7 @@ function evaluatePrequalificationRound(round) { decision.reasons.includes('anonymous-screening-leak') || decision.reasons.includes('reviewer-conflict') || decision.reasons.includes('duplicate-published-criterion') || + decision.reasons.includes('missing-published-criterion-id') || decision.reasons.includes('duplicate-reviewer-score-evidence') || decision.reasons.includes('missing-reviewer-identity') || decision.reasons.includes('unpublished-screening-criterion') || diff --git a/challenge-prequalification-fairness-guard/reports/missing-criterion-id-packet.json b/challenge-prequalification-fairness-guard/reports/missing-criterion-id-packet.json new file mode 100644 index 00000000..59f34bec --- /dev/null +++ b/challenge-prequalification-fairness-guard/reports/missing-criterion-id-packet.json @@ -0,0 +1,46 @@ +{ + "challengeId": "challenge-18-prequalification-rna-biomarker", + "generatedAt": "2026-05-28T08:00:00Z", + "criteriaDigest": "sha256:9e2cf980ff56d806dbc5ec4ff2a53670f3cf7986d200238134d1f120463db3eb", + "decisions": [ + { + "id": "applicant-missing-criterion-id", + "applicantId": "applicant-missing-criterion-id", + "challengeId": "challenge-18-prequalification-rna-biomarker", + "decision": "hold-for-fairness-review", + "sponsorDecision": "accept", + "weightedScore": 94, + "passThreshold": 75, + "reviewersCounted": 2, + "criteriaApplied": [ + "domain-fit", + " ", + "safety-plan" + ], + "reasons": [ + "missing-published-criterion-id" + ], + "rejectionReasons": [], + "appealStatus": "not-required", + "auditDigest": "sha256:d30b6b4dd2eebd762e453ca03cd6c8a866bbdcc692e225bbfc042b97f0626728" + } + ], + "remediationActions": [ + { + "id": "remediate-applicant-missing-criterion-id", + "applicantId": "applicant-missing-criterion-id", + "action": "publish-complete-screening-criteria", + "priority": "high", + "reasons": [ + "missing-published-criterion-id" + ] + } + ], + "summary": { + "accepted": 0, + "held": 1, + "rejectedWithAudit": 0, + "remediationActions": 1 + }, + "auditDigest": "sha256:b9df8bf4b4cf0ab259501a17673e27b1537153778f87a19190f026759151b27e" +} diff --git a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md index b682141c..3e3074b5 100644 --- a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md +++ b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md @@ -25,6 +25,14 @@ Generated: 2026-05-28T08:00:00Z - remediate-applicant-sponsor-alumni: replace-conflicted-reviewer (high) - remediate-applicant-missing-reviewer-identity: complete-prequalification-evidence (high) +## Missing Criterion Identifier Packet + +- Applicant: applicant-missing-criterion-id +- Decision: hold-for-fairness-review +- Reasons: missing-published-criterion-id +- Remediation: publish-complete-screening-criteria +- Audit digest: sha256:b9df8bf4b4cf0ab259501a17673e27b1537153778f87a19190f026759151b27e + ## Safety All fixtures are synthetic. The guard does not call payment processors, identity providers, private workspaces, sponsor systems, or external APIs. diff --git a/challenge-prequalification-fairness-guard/reports/summary.svg b/challenge-prequalification-fairness-guard/reports/summary.svg index 8bc7732c..9a21934d 100644 --- a/challenge-prequalification-fairness-guard/reports/summary.svg +++ b/challenge-prequalification-fairness-guard/reports/summary.svg @@ -5,7 +5,7 @@ Accepted applicants: 1 Held for fairness review: 3 Remediation actions: 3 - Checks: unique criteria, reviewer identity/quorum, thresholds, anonymity, conflicts, appeals + Checks: complete/unique criteria, reviewer identity/quorum, thresholds, anonymity, conflicts, appeals Unfair screening decisions are held before applicants are accepted or rejected. sha256:5ec541696fa83ff5f5ac49891dbb4bdf0e1174638039f2a2e7b6e65f2df49d16 diff --git a/challenge-prequalification-fairness-guard/requirements-map.md b/challenge-prequalification-fairness-guard/requirements-map.md index 7babb712..cfecf24b 100644 --- a/challenge-prequalification-fairness-guard/requirements-map.md +++ b/challenge-prequalification-fairness-guard/requirements-map.md @@ -2,7 +2,7 @@ ## Challenge Posting Portal -- Verifies that prequalification rounds use published criteria, unique criterion identifiers, nonnegative weights, valid weight totals, and valid 0-100 pass thresholds. +- Verifies that prequalification rounds use published criteria, complete and unique criterion identifiers, nonnegative weights, valid weight totals, and valid 0-100 pass thresholds. - Blocks unpublished sponsor preferences from entering solver-screening decisions. - Keeps prequalification decisions tied to challenge timelines and parseable appeal windows. @@ -18,6 +18,7 @@ - Holds inconsistent threshold decisions for fairness review before a solver is excluded. - Holds invalid pass thresholds for fairness review before sponsor accept/reject decisions can take effect. - Holds duplicate published criterion identifiers for fairness review before ambiguous rubric evidence can drive sponsor decisions. +- Holds missing or blank published criterion identifiers for fairness review before unauditable rubric evidence can drive sponsor decisions. - Flags reviewer conflicts, missing or omitted rejection reason lists, and invalid appeal-window evidence for arbitration-ready remediation. - Excludes conflicted reviewer scores from weighted threshold evidence while retaining the conflict finding. - Deduplicates repeated reviewer identities before quorum and weighted threshold scoring while retaining the duplicate-evidence finding. diff --git a/challenge-prequalification-fairness-guard/test.js b/challenge-prequalification-fairness-guard/test.js index 5cbd40fa..93801408 100644 --- a/challenge-prequalification-fairness-guard/test.js +++ b/challenge-prequalification-fairness-guard/test.js @@ -339,6 +339,72 @@ function testDuplicatePublishedCriterionIdsHoldPrequalificationRound() { assert.equal(action.priority, 'high'); } +function testMissingPublishedCriterionIdsHoldPrequalificationRound() { + const round = buildSampleRound(); + round.criteria = [ + { + id: 'domain-fit', + label: 'Domain fit for the scientific challenge', + weight: 50 + }, + { + id: ' ', + label: 'Blank sponsor rubric identifier', + weight: 25 + }, + { + id: 'safety-plan', + label: 'Risk, NDA, and responsible-use plan', + weight: 25 + } + ]; + round.applicants = [ + { + id: 'applicant-missing-criterion-id', + sponsorDecision: 'accept', + rejectionReasons: [], + appealDueAt: null + } + ]; + round.reviews = [ + { + applicantId: 'applicant-missing-criterion-id', + reviewerId: 'reviewer-independent-a', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 94, + ' ': 95, + 'safety-plan': 92 + } + }, + { + applicantId: 'applicant-missing-criterion-id', + reviewerId: 'reviewer-independent-b', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 96, + ' ': 94, + 'safety-plan': 94 + } + } + ]; + + const result = evaluatePrequalificationRound(round); + const decision = byId(result.decisions, 'applicant-missing-criterion-id'); + const action = byId(result.remediationActions, 'remediate-applicant-missing-criterion-id'); + + assert.equal(decision.decision, 'hold-for-fairness-review'); + assert.equal(decision.reasons.includes('missing-published-criterion-id'), true); + assert.equal(action.action, 'publish-complete-screening-criteria'); + assert.equal(action.priority, 'high'); +} + function testInvalidPassThresholdHoldsPrequalificationRound() { const round = buildSampleRound(); round.passThreshold = -5; @@ -568,6 +634,7 @@ const tests = [ testInvalidCriterionWeightsHoldPrequalificationRound, testInvalidIndividualCriterionWeightsHoldPrequalificationRound, testDuplicatePublishedCriterionIdsHoldPrequalificationRound, + testMissingPublishedCriterionIdsHoldPrequalificationRound, testInvalidPassThresholdHoldsPrequalificationRound, testMissingRejectionReasonListHoldsWithoutCrashing, testIncompleteReviewerScoreEvidenceHoldsWithoutCrashing, From 1cb4160fab9b1da07b279dde8367049d794eac74 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Sat, 30 May 2026 08:13:03 +0200 Subject: [PATCH 14/28] Validate reviewer score evidence range --- .../README.md | 3 +- .../acceptance-notes.md | 1 + .../demo.js | 55 ++++++++++++++++++- .../index.js | 26 ++++++++- .../invalid-reviewer-score-packet.json | 46 ++++++++++++++++ .../prequalification-fairness-report.md | 8 +++ .../reports/summary.svg | 2 +- .../requirements-map.md | 3 +- .../test.js | 50 +++++++++++++++++ 9 files changed, 188 insertions(+), 6 deletions(-) create mode 100644 challenge-prequalification-fairness-guard/reports/invalid-reviewer-score-packet.json diff --git a/challenge-prequalification-fairness-guard/README.md b/challenge-prequalification-fairness-guard/README.md index 27f3afa5..6eb17947 100644 --- a/challenge-prequalification-fairness-guard/README.md +++ b/challenge-prequalification-fairness-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Bounty System slice for SCIBASE issue #18. It evaluates sponsor-side prequalification rounds before solver teams are accepted or rejected from a challenge. -The guard checks published screening criteria, complete and unique criterion identifiers, valid criterion weight values and totals, valid pass thresholds, complete reviewer score evidence, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, missing reviewer identity evidence, duplicate reviewer score evidence, missing or empty rejection reason lists, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, repeated reviewer identities are deduplicated before quorum or threshold scoring, and missing reviewer identities are excluded from quorum until the evidence is completed. Unfair or incomplete screening decisions are held for remediation before challenge access changes. +The guard checks published screening criteria, complete and unique criterion identifiers, valid criterion weight values and totals, valid pass thresholds, complete reviewer score evidence, valid finite 0-100 reviewer score values, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, missing reviewer identity evidence, duplicate reviewer score evidence, missing or empty rejection reason lists, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, repeated reviewer identities are deduplicated before quorum or threshold scoring, and missing reviewer identities or invalid score values are excluded from scoring until the evidence is completed. Unfair or incomplete screening decisions are held for remediation before challenge access changes. ## Run @@ -17,6 +17,7 @@ npm run check - `reports/prequalification-fairness-packet.json` - `reports/missing-criterion-id-packet.json` +- `reports/invalid-reviewer-score-packet.json` - `reports/prequalification-fairness-report.md` - `reports/summary.svg` - `reports/demo.mp4` diff --git a/challenge-prequalification-fairness-guard/acceptance-notes.md b/challenge-prequalification-fairness-guard/acceptance-notes.md index ffe9784c..887ea290 100644 --- a/challenge-prequalification-fairness-guard/acceptance-notes.md +++ b/challenge-prequalification-fairness-guard/acceptance-notes.md @@ -23,6 +23,7 @@ Validation coverage: - duplicate published criterion IDs are held before ambiguous rubric evidence can drive acceptance or rejection - missing or blank published criterion IDs are held before unauditable rubric evidence can drive acceptance or rejection - invalid pass thresholds are held before sponsor accept/reject decisions can take effect +- invalid reviewer score values outside the finite 0-100 range are held before malformed scoring evidence can drive acceptance or rejection - missing rejection reason lists are normalized to an auditable fairness hold instead of crashing the prequalification packet - incomplete reviewer score evidence is held for completion without crashing the prequalification packet - duplicate reviewer score evidence is held and deduplicated before quorum or weighted threshold scoring diff --git a/challenge-prequalification-fairness-guard/demo.js b/challenge-prequalification-fairness-guard/demo.js index 5d0f9944..fc260ed5 100644 --- a/challenge-prequalification-fairness-guard/demo.js +++ b/challenge-prequalification-fairness-guard/demo.js @@ -7,14 +7,17 @@ fs.mkdirSync(reportsDir, { recursive: true }); const result = evaluatePrequalificationRound(buildSampleRound()); const missingCriterionResult = evaluatePrequalificationRound(buildMissingCriterionIdRound()); +const invalidScoreResult = evaluatePrequalificationRound(buildInvalidReviewerScoreRound()); const packetPath = path.join(reportsDir, 'prequalification-fairness-packet.json'); const missingCriterionPacketPath = path.join(reportsDir, 'missing-criterion-id-packet.json'); +const invalidScorePacketPath = path.join(reportsDir, 'invalid-reviewer-score-packet.json'); const reportPath = path.join(reportsDir, 'prequalification-fairness-report.md'); const svgPath = path.join(reportsDir, 'summary.svg'); fs.writeFileSync(packetPath, `${JSON.stringify(result, null, 2)}\n`); fs.writeFileSync(missingCriterionPacketPath, `${JSON.stringify(missingCriterionResult, null, 2)}\n`); +fs.writeFileSync(invalidScorePacketPath, `${JSON.stringify(invalidScoreResult, null, 2)}\n`); const decisions = result.decisions .map( @@ -59,6 +62,14 @@ ${actions} - Remediation: ${missingCriterionResult.remediationActions[0].action} - Audit digest: ${missingCriterionResult.auditDigest} +## Invalid Reviewer Score Packet + +- Applicant: ${invalidScoreResult.decisions[0].applicantId} +- Decision: ${invalidScoreResult.decisions[0].decision} +- Reasons: ${invalidScoreResult.decisions[0].reasons.join(', ')} +- Remediation: ${invalidScoreResult.remediationActions[0].action} +- Audit digest: ${invalidScoreResult.auditDigest} + ## Safety All fixtures are synthetic. The guard does not call payment processors, identity providers, private workspaces, sponsor systems, or external APIs. @@ -73,7 +84,7 @@ const svg = `Accepted applicants: ${result.summary.accepted} Held for fairness review: ${result.summary.held} Remediation actions: ${result.summary.remediationActions} - Checks: complete/unique criteria, reviewer identity/quorum, thresholds, anonymity, conflicts, appeals + Checks: complete/unique criteria, valid scores, reviewer identity/quorum, thresholds, anonymity, conflicts, appeals Unfair screening decisions are held before applicants are accepted or rejected. ${result.auditDigest} @@ -83,6 +94,7 @@ fs.writeFileSync(svgPath, svg); console.log(`Wrote ${path.relative(__dirname, packetPath)}`); console.log(`Wrote ${path.relative(__dirname, missingCriterionPacketPath)}`); +console.log(`Wrote ${path.relative(__dirname, invalidScorePacketPath)}`); console.log(`Wrote ${path.relative(__dirname, reportPath)}`); console.log(`Wrote ${path.relative(__dirname, svgPath)}`); console.log(`Accepted applicants: ${result.summary.accepted}`); @@ -145,3 +157,44 @@ function buildMissingCriterionIdRound() { ]; return round; } + +function buildInvalidReviewerScoreRound() { + const round = buildSampleRound(); + round.applicants = [ + { + id: 'applicant-invalid-reviewer-score', + sponsorDecision: 'accept', + rejectionReasons: [], + appealDueAt: null + } + ]; + round.reviews = [ + { + applicantId: 'applicant-invalid-reviewer-score', + reviewerId: 'reviewer-independent-a', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 140, + 'data-readiness': 96, + 'safety-plan': 94 + } + }, + { + applicantId: 'applicant-invalid-reviewer-score', + reviewerId: 'reviewer-independent-b', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 92, + 'data-readiness': 94, + 'safety-plan': 93 + } + } + ]; + return round; +} diff --git a/challenge-prequalification-fairness-guard/index.js b/challenge-prequalification-fairness-guard/index.js index d77875a6..57c15fb3 100644 --- a/challenge-prequalification-fairness-guard/index.js +++ b/challenge-prequalification-fairness-guard/index.js @@ -33,7 +33,7 @@ function groupBy(items, getKey) { function scoreForCriterion(reviews, criterionId) { const scores = reviews .map((review) => reviewScores(review)[criterionId]) - .filter((score) => typeof score === 'number'); + .filter((score) => isValidReviewerScore(score)); if (scores.length === 0) { return null; @@ -134,6 +134,10 @@ function reviewScores(review) { return review.scores && typeof review.scores === 'object' ? review.scores : {}; } +function isValidReviewerScore(score) { + return typeof score === 'number' && Number.isFinite(score) && score >= 0 && score <= 100; +} + function applicantRejectionReasons(applicant) { return Array.isArray(applicant.rejectionReasons) ? applicant.rejectionReasons : []; } @@ -165,6 +169,15 @@ function reviewUsesHiddenCriteria(review, criteriaIds) { return Object.keys(reviewScores(review)).some((criterionId) => !criteriaIds.includes(criterionId)); } +function reviewHasInvalidPublishedScoreValues(review, criteriaIds) { + const scores = reviewScores(review); + return criteriaIds.some( + (criterionId) => + Object.prototype.hasOwnProperty.call(scores, criterionId) && + !isValidReviewerScore(scores[criterionId]) + ); +} + function appealStatus(applicant, round) { if (applicant.sponsorDecision !== 'reject') { return 'not-required'; @@ -243,6 +256,10 @@ function reasonsForApplicant(applicant, reviews, round) { reasons.push('missing-published-criterion-score'); } + if (reviews.some((review) => reviewHasInvalidPublishedScoreValues(review, criteriaIds))) { + reasons.push('reviewer-score-value-invalid'); + } + const score = weightedScore(round.criteria, nonConflictedReviews); const passesThreshold = score >= round.passThreshold; @@ -301,6 +318,10 @@ function remediationAction(applicant, reasons) { return 'publish-valid-prequalification-threshold'; } + if (reasons.includes('reviewer-score-value-invalid')) { + return 'publish-valid-reviewer-score-evidence'; + } + if (reasons.includes('reviewer-conflict')) { return 'replace-conflicted-reviewer'; } @@ -392,7 +413,8 @@ function evaluatePrequalificationRound(round) { decision.reasons.includes('unpublished-screening-criterion') || decision.reasons.includes('criteria-weight-total-invalid') || decision.reasons.includes('criteria-weight-value-invalid') || - decision.reasons.includes('pass-threshold-invalid') + decision.reasons.includes('pass-threshold-invalid') || + decision.reasons.includes('reviewer-score-value-invalid') ? 'high' : 'normal', reasons: decision.reasons diff --git a/challenge-prequalification-fairness-guard/reports/invalid-reviewer-score-packet.json b/challenge-prequalification-fairness-guard/reports/invalid-reviewer-score-packet.json new file mode 100644 index 00000000..a5f15bd7 --- /dev/null +++ b/challenge-prequalification-fairness-guard/reports/invalid-reviewer-score-packet.json @@ -0,0 +1,46 @@ +{ + "challengeId": "challenge-18-prequalification-rna-biomarker", + "generatedAt": "2026-05-28T08:00:00Z", + "criteriaDigest": "sha256:d643b033793917b9d0488787518a11e97094e671d52b86b69a6153375d726721", + "decisions": [ + { + "id": "applicant-invalid-reviewer-score", + "applicantId": "applicant-invalid-reviewer-score", + "challengeId": "challenge-18-prequalification-rna-biomarker", + "decision": "hold-for-fairness-review", + "sponsorDecision": "accept", + "weightedScore": 93, + "passThreshold": 75, + "reviewersCounted": 2, + "criteriaApplied": [ + "domain-fit", + "data-readiness", + "safety-plan" + ], + "reasons": [ + "reviewer-score-value-invalid" + ], + "rejectionReasons": [], + "appealStatus": "not-required", + "auditDigest": "sha256:fff8ddec8e225f5de87c9e73653b27e47f45a534d6b6759d74811656c403020c" + } + ], + "remediationActions": [ + { + "id": "remediate-applicant-invalid-reviewer-score", + "applicantId": "applicant-invalid-reviewer-score", + "action": "publish-valid-reviewer-score-evidence", + "priority": "high", + "reasons": [ + "reviewer-score-value-invalid" + ] + } + ], + "summary": { + "accepted": 0, + "held": 1, + "rejectedWithAudit": 0, + "remediationActions": 1 + }, + "auditDigest": "sha256:806cf8166a6f1d58824929c4964ec009b6d6a8ce4d613b9e357a1e0e684fda69" +} diff --git a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md index 3e3074b5..ac636990 100644 --- a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md +++ b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md @@ -33,6 +33,14 @@ Generated: 2026-05-28T08:00:00Z - Remediation: publish-complete-screening-criteria - Audit digest: sha256:b9df8bf4b4cf0ab259501a17673e27b1537153778f87a19190f026759151b27e +## Invalid Reviewer Score Packet + +- Applicant: applicant-invalid-reviewer-score +- Decision: hold-for-fairness-review +- Reasons: reviewer-score-value-invalid +- Remediation: publish-valid-reviewer-score-evidence +- Audit digest: sha256:806cf8166a6f1d58824929c4964ec009b6d6a8ce4d613b9e357a1e0e684fda69 + ## Safety All fixtures are synthetic. The guard does not call payment processors, identity providers, private workspaces, sponsor systems, or external APIs. diff --git a/challenge-prequalification-fairness-guard/reports/summary.svg b/challenge-prequalification-fairness-guard/reports/summary.svg index 9a21934d..228fc711 100644 --- a/challenge-prequalification-fairness-guard/reports/summary.svg +++ b/challenge-prequalification-fairness-guard/reports/summary.svg @@ -5,7 +5,7 @@ Accepted applicants: 1 Held for fairness review: 3 Remediation actions: 3 - Checks: complete/unique criteria, reviewer identity/quorum, thresholds, anonymity, conflicts, appeals + Checks: complete/unique criteria, valid scores, reviewer identity/quorum, thresholds, anonymity, conflicts, appeals Unfair screening decisions are held before applicants are accepted or rejected. sha256:5ec541696fa83ff5f5ac49891dbb4bdf0e1174638039f2a2e7b6e65f2df49d16 diff --git a/challenge-prequalification-fairness-guard/requirements-map.md b/challenge-prequalification-fairness-guard/requirements-map.md index cfecf24b..a8fc796c 100644 --- a/challenge-prequalification-fairness-guard/requirements-map.md +++ b/challenge-prequalification-fairness-guard/requirements-map.md @@ -10,13 +10,14 @@ - Protects anonymous or named participation settings during prequalification review. - Requires distinct reviewer quorum before a solver team is accepted or rejected, and excludes missing or blank reviewer identities from quorum until reviewer evidence is completed. -- Holds incomplete reviewer score packets for evidence completion instead of letting malformed review records crash or drive decisions. +- Holds incomplete reviewer score packets and invalid finite 0-100 score values for evidence completion instead of letting malformed review records crash or drive decisions. - Preserves audit evidence for each applicant before access to private challenge workspaces changes. ## Arbitration And Reward Distribution - Holds inconsistent threshold decisions for fairness review before a solver is excluded. - Holds invalid pass thresholds for fairness review before sponsor accept/reject decisions can take effect. +- Holds invalid reviewer score values for fairness review before malformed score evidence can drive sponsor decisions. - Holds duplicate published criterion identifiers for fairness review before ambiguous rubric evidence can drive sponsor decisions. - Holds missing or blank published criterion identifiers for fairness review before unauditable rubric evidence can drive sponsor decisions. - Flags reviewer conflicts, missing or omitted rejection reason lists, and invalid appeal-window evidence for arbitration-ready remediation. diff --git a/challenge-prequalification-fairness-guard/test.js b/challenge-prequalification-fairness-guard/test.js index 93801408..97f9c72e 100644 --- a/challenge-prequalification-fairness-guard/test.js +++ b/challenge-prequalification-fairness-guard/test.js @@ -419,6 +419,55 @@ function testInvalidPassThresholdHoldsPrequalificationRound() { assert.equal(action.priority, 'high'); } +function testInvalidReviewerScoreValuesHoldPrequalificationRound() { + const round = buildSampleRound(); + round.applicants = [ + { + id: 'applicant-invalid-reviewer-score', + sponsorDecision: 'accept', + rejectionReasons: [], + appealDueAt: null + } + ]; + round.reviews = [ + { + applicantId: 'applicant-invalid-reviewer-score', + reviewerId: 'reviewer-independent-a', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 140, + 'data-readiness': 96, + 'safety-plan': 94 + } + }, + { + applicantId: 'applicant-invalid-reviewer-score', + reviewerId: 'reviewer-independent-b', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 92, + 'data-readiness': 94, + 'safety-plan': 93 + } + } + ]; + + const result = evaluatePrequalificationRound(round); + const decision = byId(result.decisions, 'applicant-invalid-reviewer-score'); + const action = byId(result.remediationActions, 'remediate-applicant-invalid-reviewer-score'); + + assert.equal(decision.decision, 'hold-for-fairness-review'); + assert.equal(decision.reasons.includes('reviewer-score-value-invalid'), true); + assert.equal(action.action, 'publish-valid-reviewer-score-evidence'); + assert.equal(action.priority, 'high'); +} + function testMissingRejectionReasonListHoldsWithoutCrashing() { const round = buildSampleRound(); round.applicants = [ @@ -636,6 +685,7 @@ const tests = [ testDuplicatePublishedCriterionIdsHoldPrequalificationRound, testMissingPublishedCriterionIdsHoldPrequalificationRound, testInvalidPassThresholdHoldsPrequalificationRound, + testInvalidReviewerScoreValuesHoldPrequalificationRound, testMissingRejectionReasonListHoldsWithoutCrashing, testIncompleteReviewerScoreEvidenceHoldsWithoutCrashing, testDuplicateReviewerScoreEvidenceDoesNotSatisfyQuorum, From a4d96c0206c6430fdc51005397c35028f7b42148 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Sat, 30 May 2026 08:50:37 +0200 Subject: [PATCH 15/28] Validate prequalification reviewer quorum --- .../README.md | 3 +- .../acceptance-notes.md | 1 + .../demo.js | 41 +++++++++++++++++ .../index.js | 13 ++++++ .../invalid-reviewer-quorum-packet.json | 46 +++++++++++++++++++ .../prequalification-fairness-report.md | 8 ++++ .../requirements-map.md | 5 +- .../test.js | 38 +++++++++++++++ 8 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 challenge-prequalification-fairness-guard/reports/invalid-reviewer-quorum-packet.json diff --git a/challenge-prequalification-fairness-guard/README.md b/challenge-prequalification-fairness-guard/README.md index 6eb17947..057227c9 100644 --- a/challenge-prequalification-fairness-guard/README.md +++ b/challenge-prequalification-fairness-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Bounty System slice for SCIBASE issue #18. It evaluates sponsor-side prequalification rounds before solver teams are accepted or rejected from a challenge. -The guard checks published screening criteria, complete and unique criterion identifiers, valid criterion weight values and totals, valid pass thresholds, complete reviewer score evidence, valid finite 0-100 reviewer score values, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, missing reviewer identity evidence, duplicate reviewer score evidence, missing or empty rejection reason lists, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, repeated reviewer identities are deduplicated before quorum or threshold scoring, and missing reviewer identities or invalid score values are excluded from scoring until the evidence is completed. Unfair or incomplete screening decisions are held for remediation before challenge access changes. +The guard checks published screening criteria, complete and unique criterion identifiers, valid criterion weight values and totals, valid pass thresholds, valid reviewer quorum requirements, complete reviewer score evidence, valid finite 0-100 reviewer score values, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, missing reviewer identity evidence, duplicate reviewer score evidence, missing or empty rejection reason lists, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, repeated reviewer identities are deduplicated before quorum or threshold scoring, and missing reviewer identities or invalid score values are excluded from scoring until the evidence is completed. Unfair or incomplete screening decisions are held for remediation before challenge access changes. ## Run @@ -18,6 +18,7 @@ npm run check - `reports/prequalification-fairness-packet.json` - `reports/missing-criterion-id-packet.json` - `reports/invalid-reviewer-score-packet.json` +- `reports/invalid-reviewer-quorum-packet.json` - `reports/prequalification-fairness-report.md` - `reports/summary.svg` - `reports/demo.mp4` diff --git a/challenge-prequalification-fairness-guard/acceptance-notes.md b/challenge-prequalification-fairness-guard/acceptance-notes.md index 887ea290..4b57575e 100644 --- a/challenge-prequalification-fairness-guard/acceptance-notes.md +++ b/challenge-prequalification-fairness-guard/acceptance-notes.md @@ -23,6 +23,7 @@ Validation coverage: - duplicate published criterion IDs are held before ambiguous rubric evidence can drive acceptance or rejection - missing or blank published criterion IDs are held before unauditable rubric evidence can drive acceptance or rejection - invalid pass thresholds are held before sponsor accept/reject decisions can take effect +- invalid reviewer quorum requirements are held before sponsor accept/reject decisions can take effect - invalid reviewer score values outside the finite 0-100 range are held before malformed scoring evidence can drive acceptance or rejection - missing rejection reason lists are normalized to an auditable fairness hold instead of crashing the prequalification packet - incomplete reviewer score evidence is held for completion without crashing the prequalification packet diff --git a/challenge-prequalification-fairness-guard/demo.js b/challenge-prequalification-fairness-guard/demo.js index fc260ed5..c7fe0c14 100644 --- a/challenge-prequalification-fairness-guard/demo.js +++ b/challenge-prequalification-fairness-guard/demo.js @@ -8,16 +8,19 @@ fs.mkdirSync(reportsDir, { recursive: true }); const result = evaluatePrequalificationRound(buildSampleRound()); const missingCriterionResult = evaluatePrequalificationRound(buildMissingCriterionIdRound()); const invalidScoreResult = evaluatePrequalificationRound(buildInvalidReviewerScoreRound()); +const invalidQuorumResult = evaluatePrequalificationRound(buildInvalidReviewerQuorumRound()); const packetPath = path.join(reportsDir, 'prequalification-fairness-packet.json'); const missingCriterionPacketPath = path.join(reportsDir, 'missing-criterion-id-packet.json'); const invalidScorePacketPath = path.join(reportsDir, 'invalid-reviewer-score-packet.json'); +const invalidQuorumPacketPath = path.join(reportsDir, 'invalid-reviewer-quorum-packet.json'); const reportPath = path.join(reportsDir, 'prequalification-fairness-report.md'); const svgPath = path.join(reportsDir, 'summary.svg'); fs.writeFileSync(packetPath, `${JSON.stringify(result, null, 2)}\n`); fs.writeFileSync(missingCriterionPacketPath, `${JSON.stringify(missingCriterionResult, null, 2)}\n`); fs.writeFileSync(invalidScorePacketPath, `${JSON.stringify(invalidScoreResult, null, 2)}\n`); +fs.writeFileSync(invalidQuorumPacketPath, `${JSON.stringify(invalidQuorumResult, null, 2)}\n`); const decisions = result.decisions .map( @@ -70,6 +73,14 @@ ${actions} - Remediation: ${invalidScoreResult.remediationActions[0].action} - Audit digest: ${invalidScoreResult.auditDigest} +## Invalid Reviewer Quorum Packet + +- Applicant: ${invalidQuorumResult.decisions[0].applicantId} +- Decision: ${invalidQuorumResult.decisions[0].decision} +- Reasons: ${invalidQuorumResult.decisions[0].reasons.join(', ')} +- Remediation: ${invalidQuorumResult.remediationActions[0].action} +- Audit digest: ${invalidQuorumResult.auditDigest} + ## Safety All fixtures are synthetic. The guard does not call payment processors, identity providers, private workspaces, sponsor systems, or external APIs. @@ -95,6 +106,7 @@ fs.writeFileSync(svgPath, svg); console.log(`Wrote ${path.relative(__dirname, packetPath)}`); console.log(`Wrote ${path.relative(__dirname, missingCriterionPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, invalidScorePacketPath)}`); +console.log(`Wrote ${path.relative(__dirname, invalidQuorumPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, reportPath)}`); console.log(`Wrote ${path.relative(__dirname, svgPath)}`); console.log(`Accepted applicants: ${result.summary.accepted}`); @@ -198,3 +210,32 @@ function buildInvalidReviewerScoreRound() { ]; return round; } + +function buildInvalidReviewerQuorumRound() { + const round = buildSampleRound(); + round.minReviewers = 0; + round.applicants = [ + { + id: 'applicant-invalid-reviewer-quorum', + sponsorDecision: 'accept', + rejectionReasons: [], + appealDueAt: null + } + ]; + round.reviews = [ + { + applicantId: 'applicant-invalid-reviewer-quorum', + reviewerId: 'reviewer-independent-a', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 94, + 'data-readiness': 96, + 'safety-plan': 93 + } + } + ]; + return round; +} diff --git a/challenge-prequalification-fairness-guard/index.js b/challenge-prequalification-fairness-guard/index.js index 57c15fb3..22c71aa4 100644 --- a/challenge-prequalification-fairness-guard/index.js +++ b/challenge-prequalification-fairness-guard/index.js @@ -165,6 +165,10 @@ function passThresholdIsInvalid(round) { ); } +function reviewerQuorumIsInvalid(round) { + return !Number.isInteger(round.minReviewers) || round.minReviewers < 1; +} + function reviewUsesHiddenCriteria(review, criteriaIds) { return Object.keys(reviewScores(review)).some((criterionId) => !criteriaIds.includes(criterionId)); } @@ -240,6 +244,10 @@ function reasonsForApplicant(applicant, reviews, round) { reasons.push('pass-threshold-invalid'); } + if (reviewerQuorumIsInvalid(round)) { + reasons.push('reviewer-quorum-invalid'); + } + if (nonConflictedReviews.length < round.minReviewers) { reasons.push('reviewer-quorum-shortfall'); } @@ -318,6 +326,10 @@ function remediationAction(applicant, reasons) { return 'publish-valid-prequalification-threshold'; } + if (reasons.includes('reviewer-quorum-invalid')) { + return 'publish-valid-reviewer-quorum'; + } + if (reasons.includes('reviewer-score-value-invalid')) { return 'publish-valid-reviewer-score-evidence'; } @@ -414,6 +426,7 @@ function evaluatePrequalificationRound(round) { decision.reasons.includes('criteria-weight-total-invalid') || decision.reasons.includes('criteria-weight-value-invalid') || decision.reasons.includes('pass-threshold-invalid') || + decision.reasons.includes('reviewer-quorum-invalid') || decision.reasons.includes('reviewer-score-value-invalid') ? 'high' : 'normal', diff --git a/challenge-prequalification-fairness-guard/reports/invalid-reviewer-quorum-packet.json b/challenge-prequalification-fairness-guard/reports/invalid-reviewer-quorum-packet.json new file mode 100644 index 00000000..3352ae1a --- /dev/null +++ b/challenge-prequalification-fairness-guard/reports/invalid-reviewer-quorum-packet.json @@ -0,0 +1,46 @@ +{ + "challengeId": "challenge-18-prequalification-rna-biomarker", + "generatedAt": "2026-05-28T08:00:00Z", + "criteriaDigest": "sha256:d643b033793917b9d0488787518a11e97094e671d52b86b69a6153375d726721", + "decisions": [ + { + "id": "applicant-invalid-reviewer-quorum", + "applicantId": "applicant-invalid-reviewer-quorum", + "challengeId": "challenge-18-prequalification-rna-biomarker", + "decision": "hold-for-fairness-review", + "sponsorDecision": "accept", + "weightedScore": 94, + "passThreshold": 75, + "reviewersCounted": 1, + "criteriaApplied": [ + "domain-fit", + "data-readiness", + "safety-plan" + ], + "reasons": [ + "reviewer-quorum-invalid" + ], + "rejectionReasons": [], + "appealStatus": "not-required", + "auditDigest": "sha256:d622405e7189eef202d2ccf557217bd26357f6249c7adf908537aa089bb8e24f" + } + ], + "remediationActions": [ + { + "id": "remediate-applicant-invalid-reviewer-quorum", + "applicantId": "applicant-invalid-reviewer-quorum", + "action": "publish-valid-reviewer-quorum", + "priority": "high", + "reasons": [ + "reviewer-quorum-invalid" + ] + } + ], + "summary": { + "accepted": 0, + "held": 1, + "rejectedWithAudit": 0, + "remediationActions": 1 + }, + "auditDigest": "sha256:61801b0cd7cf7c62fc38b0f6e62415e770d388bf3844e2132b8a379183e74ca1" +} diff --git a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md index ac636990..62ada382 100644 --- a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md +++ b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md @@ -41,6 +41,14 @@ Generated: 2026-05-28T08:00:00Z - Remediation: publish-valid-reviewer-score-evidence - Audit digest: sha256:806cf8166a6f1d58824929c4964ec009b6d6a8ce4d613b9e357a1e0e684fda69 +## Invalid Reviewer Quorum Packet + +- Applicant: applicant-invalid-reviewer-quorum +- Decision: hold-for-fairness-review +- Reasons: reviewer-quorum-invalid +- Remediation: publish-valid-reviewer-quorum +- Audit digest: sha256:61801b0cd7cf7c62fc38b0f6e62415e770d388bf3844e2132b8a379183e74ca1 + ## Safety All fixtures are synthetic. The guard does not call payment processors, identity providers, private workspaces, sponsor systems, or external APIs. diff --git a/challenge-prequalification-fairness-guard/requirements-map.md b/challenge-prequalification-fairness-guard/requirements-map.md index a8fc796c..65af0c9d 100644 --- a/challenge-prequalification-fairness-guard/requirements-map.md +++ b/challenge-prequalification-fairness-guard/requirements-map.md @@ -2,14 +2,14 @@ ## Challenge Posting Portal -- Verifies that prequalification rounds use published criteria, complete and unique criterion identifiers, nonnegative weights, valid weight totals, and valid 0-100 pass thresholds. +- Verifies that prequalification rounds use published criteria, complete and unique criterion identifiers, nonnegative weights, valid weight totals, valid 0-100 pass thresholds, and valid positive reviewer quorum requirements. - Blocks unpublished sponsor preferences from entering solver-screening decisions. - Keeps prequalification decisions tied to challenge timelines and parseable appeal windows. ## Submission Engine - Protects anonymous or named participation settings during prequalification review. -- Requires distinct reviewer quorum before a solver team is accepted or rejected, and excludes missing or blank reviewer identities from quorum until reviewer evidence is completed. +- Requires a valid positive reviewer quorum before a solver team is accepted or rejected, and excludes missing or blank reviewer identities from quorum until reviewer evidence is completed. - Holds incomplete reviewer score packets and invalid finite 0-100 score values for evidence completion instead of letting malformed review records crash or drive decisions. - Preserves audit evidence for each applicant before access to private challenge workspaces changes. @@ -17,6 +17,7 @@ - Holds inconsistent threshold decisions for fairness review before a solver is excluded. - Holds invalid pass thresholds for fairness review before sponsor accept/reject decisions can take effect. +- Holds invalid reviewer quorum requirements for fairness review before sponsor accept/reject decisions can take effect. - Holds invalid reviewer score values for fairness review before malformed score evidence can drive sponsor decisions. - Holds duplicate published criterion identifiers for fairness review before ambiguous rubric evidence can drive sponsor decisions. - Holds missing or blank published criterion identifiers for fairness review before unauditable rubric evidence can drive sponsor decisions. diff --git a/challenge-prequalification-fairness-guard/test.js b/challenge-prequalification-fairness-guard/test.js index 97f9c72e..ad1ed73b 100644 --- a/challenge-prequalification-fairness-guard/test.js +++ b/challenge-prequalification-fairness-guard/test.js @@ -419,6 +419,43 @@ function testInvalidPassThresholdHoldsPrequalificationRound() { assert.equal(action.priority, 'high'); } +function testInvalidReviewerQuorumHoldsPrequalificationRound() { + const round = buildSampleRound(); + round.minReviewers = 0; + round.applicants = [ + { + id: 'applicant-invalid-reviewer-quorum', + sponsorDecision: 'accept', + rejectionReasons: [], + appealDueAt: null + } + ]; + round.reviews = [ + { + applicantId: 'applicant-invalid-reviewer-quorum', + reviewerId: 'reviewer-independent-a', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 94, + 'data-readiness': 96, + 'safety-plan': 93 + } + } + ]; + + const result = evaluatePrequalificationRound(round); + const decision = byId(result.decisions, 'applicant-invalid-reviewer-quorum'); + const action = byId(result.remediationActions, 'remediate-applicant-invalid-reviewer-quorum'); + + assert.equal(decision.decision, 'hold-for-fairness-review'); + assert.equal(decision.reasons.includes('reviewer-quorum-invalid'), true); + assert.equal(action.action, 'publish-valid-reviewer-quorum'); + assert.equal(action.priority, 'high'); +} + function testInvalidReviewerScoreValuesHoldPrequalificationRound() { const round = buildSampleRound(); round.applicants = [ @@ -685,6 +722,7 @@ const tests = [ testDuplicatePublishedCriterionIdsHoldPrequalificationRound, testMissingPublishedCriterionIdsHoldPrequalificationRound, testInvalidPassThresholdHoldsPrequalificationRound, + testInvalidReviewerQuorumHoldsPrequalificationRound, testInvalidReviewerScoreValuesHoldPrequalificationRound, testMissingRejectionReasonListHoldsWithoutCrashing, testIncompleteReviewerScoreEvidenceHoldsWithoutCrashing, From 4b0283e5c261754863668e189ac7410ff28cb7ef Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Sat, 30 May 2026 09:54:17 +0200 Subject: [PATCH 16/28] Normalize prequalification criterion identifiers --- .../README.md | 3 +- .../acceptance-notes.md | 1 + .../demo.js | 70 +++++++++++++++++++ .../index.js | 7 +- .../normalized-criterion-id-packet.json | 46 ++++++++++++ .../prequalification-fairness-report.md | 8 +++ .../requirements-map.md | 3 +- .../test.js | 67 ++++++++++++++++++ 8 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 challenge-prequalification-fairness-guard/reports/normalized-criterion-id-packet.json diff --git a/challenge-prequalification-fairness-guard/README.md b/challenge-prequalification-fairness-guard/README.md index 057227c9..45707745 100644 --- a/challenge-prequalification-fairness-guard/README.md +++ b/challenge-prequalification-fairness-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Bounty System slice for SCIBASE issue #18. It evaluates sponsor-side prequalification rounds before solver teams are accepted or rejected from a challenge. -The guard checks published screening criteria, complete and unique criterion identifiers, valid criterion weight values and totals, valid pass thresholds, valid reviewer quorum requirements, complete reviewer score evidence, valid finite 0-100 reviewer score values, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, missing reviewer identity evidence, duplicate reviewer score evidence, missing or empty rejection reason lists, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, repeated reviewer identities are deduplicated before quorum or threshold scoring, and missing reviewer identities or invalid score values are excluded from scoring until the evidence is completed. Unfair or incomplete screening decisions are held for remediation before challenge access changes. +The guard checks published screening criteria, complete and unique criterion identifiers after trimming, valid criterion weight values and totals, valid pass thresholds, valid reviewer quorum requirements, complete reviewer score evidence, valid finite 0-100 reviewer score values, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, missing reviewer identity evidence, duplicate reviewer score evidence, missing or empty rejection reason lists, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, repeated reviewer identities are deduplicated before quorum or threshold scoring, and missing reviewer identities or invalid score values are excluded from scoring until the evidence is completed. Unfair or incomplete screening decisions are held for remediation before challenge access changes. ## Run @@ -17,6 +17,7 @@ npm run check - `reports/prequalification-fairness-packet.json` - `reports/missing-criterion-id-packet.json` +- `reports/normalized-criterion-id-packet.json` - `reports/invalid-reviewer-score-packet.json` - `reports/invalid-reviewer-quorum-packet.json` - `reports/prequalification-fairness-report.md` diff --git a/challenge-prequalification-fairness-guard/acceptance-notes.md b/challenge-prequalification-fairness-guard/acceptance-notes.md index 4b57575e..b6222110 100644 --- a/challenge-prequalification-fairness-guard/acceptance-notes.md +++ b/challenge-prequalification-fairness-guard/acceptance-notes.md @@ -21,6 +21,7 @@ Validation coverage: - invalid appeal-window timestamps hold rejected applicants before rejection packets are published - invalid individual criterion weights are held even when the total still sums to 100 - duplicate published criterion IDs are held before ambiguous rubric evidence can drive acceptance or rejection +- whitespace-variant published criterion IDs such as `domain-fit` and ` domain-fit ` are treated as duplicates before ambiguous rubric evidence can drive acceptance or rejection - missing or blank published criterion IDs are held before unauditable rubric evidence can drive acceptance or rejection - invalid pass thresholds are held before sponsor accept/reject decisions can take effect - invalid reviewer quorum requirements are held before sponsor accept/reject decisions can take effect diff --git a/challenge-prequalification-fairness-guard/demo.js b/challenge-prequalification-fairness-guard/demo.js index c7fe0c14..0287a1b9 100644 --- a/challenge-prequalification-fairness-guard/demo.js +++ b/challenge-prequalification-fairness-guard/demo.js @@ -7,11 +7,13 @@ fs.mkdirSync(reportsDir, { recursive: true }); const result = evaluatePrequalificationRound(buildSampleRound()); const missingCriterionResult = evaluatePrequalificationRound(buildMissingCriterionIdRound()); +const normalizedCriterionResult = evaluatePrequalificationRound(buildNormalizedCriterionIdRound()); const invalidScoreResult = evaluatePrequalificationRound(buildInvalidReviewerScoreRound()); const invalidQuorumResult = evaluatePrequalificationRound(buildInvalidReviewerQuorumRound()); const packetPath = path.join(reportsDir, 'prequalification-fairness-packet.json'); const missingCriterionPacketPath = path.join(reportsDir, 'missing-criterion-id-packet.json'); +const normalizedCriterionPacketPath = path.join(reportsDir, 'normalized-criterion-id-packet.json'); const invalidScorePacketPath = path.join(reportsDir, 'invalid-reviewer-score-packet.json'); const invalidQuorumPacketPath = path.join(reportsDir, 'invalid-reviewer-quorum-packet.json'); const reportPath = path.join(reportsDir, 'prequalification-fairness-report.md'); @@ -19,6 +21,7 @@ const svgPath = path.join(reportsDir, 'summary.svg'); fs.writeFileSync(packetPath, `${JSON.stringify(result, null, 2)}\n`); fs.writeFileSync(missingCriterionPacketPath, `${JSON.stringify(missingCriterionResult, null, 2)}\n`); +fs.writeFileSync(normalizedCriterionPacketPath, `${JSON.stringify(normalizedCriterionResult, null, 2)}\n`); fs.writeFileSync(invalidScorePacketPath, `${JSON.stringify(invalidScoreResult, null, 2)}\n`); fs.writeFileSync(invalidQuorumPacketPath, `${JSON.stringify(invalidQuorumResult, null, 2)}\n`); @@ -65,6 +68,14 @@ ${actions} - Remediation: ${missingCriterionResult.remediationActions[0].action} - Audit digest: ${missingCriterionResult.auditDigest} +## Normalized Criterion Identifier Packet + +- Applicant: ${normalizedCriterionResult.decisions[0].applicantId} +- Decision: ${normalizedCriterionResult.decisions[0].decision} +- Reasons: ${normalizedCriterionResult.decisions[0].reasons.join(', ')} +- Remediation: ${normalizedCriterionResult.remediationActions[0].action} +- Audit digest: ${normalizedCriterionResult.auditDigest} + ## Invalid Reviewer Score Packet - Applicant: ${invalidScoreResult.decisions[0].applicantId} @@ -105,6 +116,7 @@ fs.writeFileSync(svgPath, svg); console.log(`Wrote ${path.relative(__dirname, packetPath)}`); console.log(`Wrote ${path.relative(__dirname, missingCriterionPacketPath)}`); +console.log(`Wrote ${path.relative(__dirname, normalizedCriterionPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, invalidScorePacketPath)}`); console.log(`Wrote ${path.relative(__dirname, invalidQuorumPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, reportPath)}`); @@ -170,6 +182,64 @@ function buildMissingCriterionIdRound() { return round; } +function buildNormalizedCriterionIdRound() { + const round = buildSampleRound(); + round.criteria = [ + { + id: 'domain-fit', + label: 'Domain fit for the scientific challenge', + weight: 50 + }, + { + id: ' domain-fit ', + label: 'Whitespace-padded duplicate sponsor rubric identifier', + weight: 25 + }, + { + id: 'safety-plan', + label: 'Risk, NDA, and responsible-use plan', + weight: 25 + } + ]; + round.applicants = [ + { + id: 'applicant-normalized-criterion-id', + sponsorDecision: 'accept', + rejectionReasons: [], + appealDueAt: null + } + ]; + round.reviews = [ + { + applicantId: 'applicant-normalized-criterion-id', + reviewerId: 'reviewer-independent-a', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 94, + ' domain-fit ': 95, + 'safety-plan': 92 + } + }, + { + applicantId: 'applicant-normalized-criterion-id', + reviewerId: 'reviewer-independent-b', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 96, + ' domain-fit ': 94, + 'safety-plan': 94 + } + } + ]; + return round; +} + function buildInvalidReviewerScoreRound() { const round = buildSampleRound(); round.applicants = [ diff --git a/challenge-prequalification-fairness-guard/index.js b/challenge-prequalification-fairness-guard/index.js index 22c71aa4..84b1c286 100644 --- a/challenge-prequalification-fairness-guard/index.js +++ b/challenge-prequalification-fairness-guard/index.js @@ -58,9 +58,14 @@ function uniqueSorted(values) { return Array.from(new Set(values)).sort(); } +function criterionIdFor(criterion) { + return typeof criterion.id === 'string' ? criterion.id.trim() : criterion.id; +} + function duplicatePublishedCriterionIds(round) { const criterionCounts = round.criteria.reduce((counts, criterion) => { - counts[criterion.id] = (counts[criterion.id] || 0) + 1; + const criterionId = criterionIdFor(criterion); + counts[criterionId] = (counts[criterionId] || 0) + 1; return counts; }, Object.create(null)); diff --git a/challenge-prequalification-fairness-guard/reports/normalized-criterion-id-packet.json b/challenge-prequalification-fairness-guard/reports/normalized-criterion-id-packet.json new file mode 100644 index 00000000..e03cfcd1 --- /dev/null +++ b/challenge-prequalification-fairness-guard/reports/normalized-criterion-id-packet.json @@ -0,0 +1,46 @@ +{ + "challengeId": "challenge-18-prequalification-rna-biomarker", + "generatedAt": "2026-05-28T08:00:00Z", + "criteriaDigest": "sha256:2a1b7cf1647fefb5c70a5fa5ec271e5e0195c54bca77fcc3b54eb912092b2f8d", + "decisions": [ + { + "id": "applicant-normalized-criterion-id", + "applicantId": "applicant-normalized-criterion-id", + "challengeId": "challenge-18-prequalification-rna-biomarker", + "decision": "hold-for-fairness-review", + "sponsorDecision": "accept", + "weightedScore": 94, + "passThreshold": 75, + "reviewersCounted": 2, + "criteriaApplied": [ + "domain-fit", + " domain-fit ", + "safety-plan" + ], + "reasons": [ + "duplicate-published-criterion" + ], + "rejectionReasons": [], + "appealStatus": "not-required", + "auditDigest": "sha256:ffd439a9c5cce5c2c8223654147ab3a7db7e3ac741d76ecc626766dee821634e" + } + ], + "remediationActions": [ + { + "id": "remediate-applicant-normalized-criterion-id", + "applicantId": "applicant-normalized-criterion-id", + "action": "publish-unique-screening-criteria", + "priority": "high", + "reasons": [ + "duplicate-published-criterion" + ] + } + ], + "summary": { + "accepted": 0, + "held": 1, + "rejectedWithAudit": 0, + "remediationActions": 1 + }, + "auditDigest": "sha256:fdfd47231c8acfdad73947f357080e3e6967bd11dd622ee60d0ebebb40688ee6" +} diff --git a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md index 62ada382..b09739eb 100644 --- a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md +++ b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md @@ -33,6 +33,14 @@ Generated: 2026-05-28T08:00:00Z - Remediation: publish-complete-screening-criteria - Audit digest: sha256:b9df8bf4b4cf0ab259501a17673e27b1537153778f87a19190f026759151b27e +## Normalized Criterion Identifier Packet + +- Applicant: applicant-normalized-criterion-id +- Decision: hold-for-fairness-review +- Reasons: duplicate-published-criterion +- Remediation: publish-unique-screening-criteria +- Audit digest: sha256:fdfd47231c8acfdad73947f357080e3e6967bd11dd622ee60d0ebebb40688ee6 + ## Invalid Reviewer Score Packet - Applicant: applicant-invalid-reviewer-score diff --git a/challenge-prequalification-fairness-guard/requirements-map.md b/challenge-prequalification-fairness-guard/requirements-map.md index 65af0c9d..89173756 100644 --- a/challenge-prequalification-fairness-guard/requirements-map.md +++ b/challenge-prequalification-fairness-guard/requirements-map.md @@ -2,7 +2,7 @@ ## Challenge Posting Portal -- Verifies that prequalification rounds use published criteria, complete and unique criterion identifiers, nonnegative weights, valid weight totals, valid 0-100 pass thresholds, and valid positive reviewer quorum requirements. +- Verifies that prequalification rounds use published criteria, complete and unique criterion identifiers after trimming, nonnegative weights, valid weight totals, valid 0-100 pass thresholds, and valid positive reviewer quorum requirements. - Blocks unpublished sponsor preferences from entering solver-screening decisions. - Keeps prequalification decisions tied to challenge timelines and parseable appeal windows. @@ -20,6 +20,7 @@ - Holds invalid reviewer quorum requirements for fairness review before sponsor accept/reject decisions can take effect. - Holds invalid reviewer score values for fairness review before malformed score evidence can drive sponsor decisions. - Holds duplicate published criterion identifiers for fairness review before ambiguous rubric evidence can drive sponsor decisions. +- Holds whitespace-variant duplicate published criterion identifiers for fairness review before ambiguous rubric evidence can drive sponsor decisions. - Holds missing or blank published criterion identifiers for fairness review before unauditable rubric evidence can drive sponsor decisions. - Flags reviewer conflicts, missing or omitted rejection reason lists, and invalid appeal-window evidence for arbitration-ready remediation. - Excludes conflicted reviewer scores from weighted threshold evidence while retaining the conflict finding. diff --git a/challenge-prequalification-fairness-guard/test.js b/challenge-prequalification-fairness-guard/test.js index ad1ed73b..fc143fbf 100644 --- a/challenge-prequalification-fairness-guard/test.js +++ b/challenge-prequalification-fairness-guard/test.js @@ -339,6 +339,72 @@ function testDuplicatePublishedCriterionIdsHoldPrequalificationRound() { assert.equal(action.priority, 'high'); } +function testWhitespaceVariantCriterionIdsHoldPrequalificationRound() { + const round = buildSampleRound(); + round.criteria = [ + { + id: 'domain-fit', + label: 'Domain fit for the scientific challenge', + weight: 50 + }, + { + id: ' domain-fit ', + label: 'Whitespace-padded duplicate sponsor rubric identifier', + weight: 25 + }, + { + id: 'safety-plan', + label: 'Risk, NDA, and responsible-use plan', + weight: 25 + } + ]; + round.applicants = [ + { + id: 'applicant-normalized-criterion-id', + sponsorDecision: 'accept', + rejectionReasons: [], + appealDueAt: null + } + ]; + round.reviews = [ + { + applicantId: 'applicant-normalized-criterion-id', + reviewerId: 'reviewer-independent-a', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 94, + ' domain-fit ': 95, + 'safety-plan': 92 + } + }, + { + applicantId: 'applicant-normalized-criterion-id', + reviewerId: 'reviewer-independent-b', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 96, + ' domain-fit ': 94, + 'safety-plan': 94 + } + } + ]; + + const result = evaluatePrequalificationRound(round); + const decision = byId(result.decisions, 'applicant-normalized-criterion-id'); + const action = byId(result.remediationActions, 'remediate-applicant-normalized-criterion-id'); + + assert.equal(decision.decision, 'hold-for-fairness-review'); + assert.equal(decision.reasons.includes('duplicate-published-criterion'), true); + assert.equal(action.action, 'publish-unique-screening-criteria'); + assert.equal(action.priority, 'high'); +} + function testMissingPublishedCriterionIdsHoldPrequalificationRound() { const round = buildSampleRound(); round.criteria = [ @@ -720,6 +786,7 @@ const tests = [ testInvalidCriterionWeightsHoldPrequalificationRound, testInvalidIndividualCriterionWeightsHoldPrequalificationRound, testDuplicatePublishedCriterionIdsHoldPrequalificationRound, + testWhitespaceVariantCriterionIdsHoldPrequalificationRound, testMissingPublishedCriterionIdsHoldPrequalificationRound, testInvalidPassThresholdHoldsPrequalificationRound, testInvalidReviewerQuorumHoldsPrequalificationRound, From cd790ec15d756f4d4ee0410635fed77aef9d7834 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Sat, 30 May 2026 11:08:36 +0200 Subject: [PATCH 17/28] Reject blank rejection reasons --- .../README.md | 3 +- .../acceptance-notes.md | 1 + .../demo.js | 53 +++++++++++++++++++ .../index.js | 9 +++- .../blank-rejection-reason-packet.json | 46 ++++++++++++++++ .../prequalification-fairness-report.md | 8 +++ .../requirements-map.md | 2 +- .../test.js | 50 +++++++++++++++++ 8 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 challenge-prequalification-fairness-guard/reports/blank-rejection-reason-packet.json diff --git a/challenge-prequalification-fairness-guard/README.md b/challenge-prequalification-fairness-guard/README.md index 45707745..8b89bcab 100644 --- a/challenge-prequalification-fairness-guard/README.md +++ b/challenge-prequalification-fairness-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Bounty System slice for SCIBASE issue #18. It evaluates sponsor-side prequalification rounds before solver teams are accepted or rejected from a challenge. -The guard checks published screening criteria, complete and unique criterion identifiers after trimming, valid criterion weight values and totals, valid pass thresholds, valid reviewer quorum requirements, complete reviewer score evidence, valid finite 0-100 reviewer score values, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, missing reviewer identity evidence, duplicate reviewer score evidence, missing or empty rejection reason lists, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, repeated reviewer identities are deduplicated before quorum or threshold scoring, and missing reviewer identities or invalid score values are excluded from scoring until the evidence is completed. Unfair or incomplete screening decisions are held for remediation before challenge access changes. +The guard checks published screening criteria, complete and unique criterion identifiers after trimming, valid criterion weight values and totals, valid pass thresholds, valid reviewer quorum requirements, complete reviewer score evidence, valid finite 0-100 reviewer score values, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, missing reviewer identity evidence, duplicate reviewer score evidence, missing, empty, or blank rejection reason evidence, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, repeated reviewer identities are deduplicated before quorum or threshold scoring, and missing reviewer identities or invalid score values are excluded from scoring until the evidence is completed. Unfair or incomplete screening decisions are held for remediation before challenge access changes. ## Run @@ -20,6 +20,7 @@ npm run check - `reports/normalized-criterion-id-packet.json` - `reports/invalid-reviewer-score-packet.json` - `reports/invalid-reviewer-quorum-packet.json` +- `reports/blank-rejection-reason-packet.json` - `reports/prequalification-fairness-report.md` - `reports/summary.svg` - `reports/demo.mp4` diff --git a/challenge-prequalification-fairness-guard/acceptance-notes.md b/challenge-prequalification-fairness-guard/acceptance-notes.md index b6222110..3e88dda3 100644 --- a/challenge-prequalification-fairness-guard/acceptance-notes.md +++ b/challenge-prequalification-fairness-guard/acceptance-notes.md @@ -27,6 +27,7 @@ Validation coverage: - invalid reviewer quorum requirements are held before sponsor accept/reject decisions can take effect - invalid reviewer score values outside the finite 0-100 range are held before malformed scoring evidence can drive acceptance or rejection - missing rejection reason lists are normalized to an auditable fairness hold instead of crashing the prequalification packet +- blank rejection reason text is normalized away and held as missing applicant-facing rejection evidence - incomplete reviewer score evidence is held for completion without crashing the prequalification packet - duplicate reviewer score evidence is held and deduplicated before quorum or weighted threshold scoring - missing or blank reviewer identities are held and excluded from reviewer quorum until evidence is completed diff --git a/challenge-prequalification-fairness-guard/demo.js b/challenge-prequalification-fairness-guard/demo.js index 0287a1b9..e339de29 100644 --- a/challenge-prequalification-fairness-guard/demo.js +++ b/challenge-prequalification-fairness-guard/demo.js @@ -10,12 +10,14 @@ const missingCriterionResult = evaluatePrequalificationRound(buildMissingCriteri const normalizedCriterionResult = evaluatePrequalificationRound(buildNormalizedCriterionIdRound()); const invalidScoreResult = evaluatePrequalificationRound(buildInvalidReviewerScoreRound()); const invalidQuorumResult = evaluatePrequalificationRound(buildInvalidReviewerQuorumRound()); +const blankRejectionReasonResult = evaluatePrequalificationRound(buildBlankRejectionReasonRound()); const packetPath = path.join(reportsDir, 'prequalification-fairness-packet.json'); const missingCriterionPacketPath = path.join(reportsDir, 'missing-criterion-id-packet.json'); const normalizedCriterionPacketPath = path.join(reportsDir, 'normalized-criterion-id-packet.json'); const invalidScorePacketPath = path.join(reportsDir, 'invalid-reviewer-score-packet.json'); const invalidQuorumPacketPath = path.join(reportsDir, 'invalid-reviewer-quorum-packet.json'); +const blankRejectionReasonPacketPath = path.join(reportsDir, 'blank-rejection-reason-packet.json'); const reportPath = path.join(reportsDir, 'prequalification-fairness-report.md'); const svgPath = path.join(reportsDir, 'summary.svg'); @@ -24,6 +26,7 @@ fs.writeFileSync(missingCriterionPacketPath, `${JSON.stringify(missingCriterionR fs.writeFileSync(normalizedCriterionPacketPath, `${JSON.stringify(normalizedCriterionResult, null, 2)}\n`); fs.writeFileSync(invalidScorePacketPath, `${JSON.stringify(invalidScoreResult, null, 2)}\n`); fs.writeFileSync(invalidQuorumPacketPath, `${JSON.stringify(invalidQuorumResult, null, 2)}\n`); +fs.writeFileSync(blankRejectionReasonPacketPath, `${JSON.stringify(blankRejectionReasonResult, null, 2)}\n`); const decisions = result.decisions .map( @@ -92,6 +95,14 @@ ${actions} - Remediation: ${invalidQuorumResult.remediationActions[0].action} - Audit digest: ${invalidQuorumResult.auditDigest} +## Blank Rejection Reason Packet + +- Applicant: ${blankRejectionReasonResult.decisions[0].applicantId} +- Decision: ${blankRejectionReasonResult.decisions[0].decision} +- Reasons: ${blankRejectionReasonResult.decisions[0].reasons.join(', ')} +- Remediation: ${blankRejectionReasonResult.remediationActions[0].action} +- Audit digest: ${blankRejectionReasonResult.auditDigest} + ## Safety All fixtures are synthetic. The guard does not call payment processors, identity providers, private workspaces, sponsor systems, or external APIs. @@ -119,6 +130,7 @@ console.log(`Wrote ${path.relative(__dirname, missingCriterionPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, normalizedCriterionPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, invalidScorePacketPath)}`); console.log(`Wrote ${path.relative(__dirname, invalidQuorumPacketPath)}`); +console.log(`Wrote ${path.relative(__dirname, blankRejectionReasonPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, reportPath)}`); console.log(`Wrote ${path.relative(__dirname, svgPath)}`); console.log(`Accepted applicants: ${result.summary.accepted}`); @@ -309,3 +321,44 @@ function buildInvalidReviewerQuorumRound() { ]; return round; } + +function buildBlankRejectionReasonRound() { + const round = buildSampleRound(); + round.applicants = [ + { + id: 'applicant-blank-rejection-reason', + sponsorDecision: 'reject', + rejectionReasons: [' '], + appealDueAt: '2026-06-04T08:00:00Z' + } + ]; + round.reviews = [ + { + applicantId: 'applicant-blank-rejection-reason', + reviewerId: 'reviewer-independent-a', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'reject', + rejectionReasons: ['insufficient validation plan'], + scores: { + 'domain-fit': 58, + 'data-readiness': 60, + 'safety-plan': 62 + } + }, + { + applicantId: 'applicant-blank-rejection-reason', + reviewerId: 'reviewer-independent-b', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'reject', + rejectionReasons: ['insufficient validation plan'], + scores: { + 'domain-fit': 59, + 'data-readiness': 61, + 'safety-plan': 60 + } + } + ]; + return round; +} diff --git a/challenge-prequalification-fairness-guard/index.js b/challenge-prequalification-fairness-guard/index.js index 84b1c286..a4295f21 100644 --- a/challenge-prequalification-fairness-guard/index.js +++ b/challenge-prequalification-fairness-guard/index.js @@ -144,7 +144,14 @@ function isValidReviewerScore(score) { } function applicantRejectionReasons(applicant) { - return Array.isArray(applicant.rejectionReasons) ? applicant.rejectionReasons : []; + if (!Array.isArray(applicant.rejectionReasons)) { + return []; + } + + return applicant.rejectionReasons + .filter((reason) => typeof reason === 'string') + .map((reason) => reason.trim()) + .filter(Boolean); } function criteriaWeightTotal(round) { diff --git a/challenge-prequalification-fairness-guard/reports/blank-rejection-reason-packet.json b/challenge-prequalification-fairness-guard/reports/blank-rejection-reason-packet.json new file mode 100644 index 00000000..909cbf56 --- /dev/null +++ b/challenge-prequalification-fairness-guard/reports/blank-rejection-reason-packet.json @@ -0,0 +1,46 @@ +{ + "challengeId": "challenge-18-prequalification-rna-biomarker", + "generatedAt": "2026-05-28T08:00:00Z", + "criteriaDigest": "sha256:d643b033793917b9d0488787518a11e97094e671d52b86b69a6153375d726721", + "decisions": [ + { + "id": "applicant-blank-rejection-reason", + "applicantId": "applicant-blank-rejection-reason", + "challengeId": "challenge-18-prequalification-rna-biomarker", + "decision": "hold-for-fairness-review", + "sponsorDecision": "reject", + "weightedScore": 60, + "passThreshold": 75, + "reviewersCounted": 2, + "criteriaApplied": [ + "domain-fit", + "data-readiness", + "safety-plan" + ], + "reasons": [ + "missing-rejection-reason" + ], + "rejectionReasons": [], + "appealStatus": "open", + "auditDigest": "sha256:500be54f555dd977c9a3cfb05ab5e9a7bfbfb7217b7a484f5193fd00b7030c61" + } + ], + "remediationActions": [ + { + "id": "remediate-applicant-blank-rejection-reason", + "applicantId": "applicant-blank-rejection-reason", + "action": "publish-rejection-reasons-and-appeal-window", + "priority": "normal", + "reasons": [ + "missing-rejection-reason" + ] + } + ], + "summary": { + "accepted": 0, + "held": 1, + "rejectedWithAudit": 0, + "remediationActions": 1 + }, + "auditDigest": "sha256:bbd51d47794aadc8faa0eda8231781f66d0e4eacde31bd0362b6e723834a444c" +} diff --git a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md index b09739eb..2ccc914f 100644 --- a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md +++ b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md @@ -57,6 +57,14 @@ Generated: 2026-05-28T08:00:00Z - Remediation: publish-valid-reviewer-quorum - Audit digest: sha256:61801b0cd7cf7c62fc38b0f6e62415e770d388bf3844e2132b8a379183e74ca1 +## Blank Rejection Reason Packet + +- Applicant: applicant-blank-rejection-reason +- Decision: hold-for-fairness-review +- Reasons: missing-rejection-reason +- Remediation: publish-rejection-reasons-and-appeal-window +- Audit digest: sha256:bbd51d47794aadc8faa0eda8231781f66d0e4eacde31bd0362b6e723834a444c + ## Safety All fixtures are synthetic. The guard does not call payment processors, identity providers, private workspaces, sponsor systems, or external APIs. diff --git a/challenge-prequalification-fairness-guard/requirements-map.md b/challenge-prequalification-fairness-guard/requirements-map.md index 89173756..2284b050 100644 --- a/challenge-prequalification-fairness-guard/requirements-map.md +++ b/challenge-prequalification-fairness-guard/requirements-map.md @@ -22,7 +22,7 @@ - Holds duplicate published criterion identifiers for fairness review before ambiguous rubric evidence can drive sponsor decisions. - Holds whitespace-variant duplicate published criterion identifiers for fairness review before ambiguous rubric evidence can drive sponsor decisions. - Holds missing or blank published criterion identifiers for fairness review before unauditable rubric evidence can drive sponsor decisions. -- Flags reviewer conflicts, missing or omitted rejection reason lists, and invalid appeal-window evidence for arbitration-ready remediation. +- Flags reviewer conflicts, missing, omitted, or blank rejection reason evidence, and invalid appeal-window evidence for arbitration-ready remediation. - Excludes conflicted reviewer scores from weighted threshold evidence while retaining the conflict finding. - Deduplicates repeated reviewer identities before quorum and weighted threshold scoring while retaining the duplicate-evidence finding. - Holds missing reviewer identity evidence before anonymous or malformed reviewer rows can satisfy quorum. diff --git a/challenge-prequalification-fairness-guard/test.js b/challenge-prequalification-fairness-guard/test.js index fc143fbf..4ef2bb95 100644 --- a/challenge-prequalification-fairness-guard/test.js +++ b/challenge-prequalification-fairness-guard/test.js @@ -619,6 +619,55 @@ function testMissingRejectionReasonListHoldsWithoutCrashing() { assert.equal(action.action, 'publish-rejection-reasons-and-appeal-window'); } +function testBlankRejectionReasonTextHoldsRejectedApplicant() { + const round = buildSampleRound(); + round.applicants = [ + { + id: 'applicant-blank-rejection-reason', + sponsorDecision: 'reject', + rejectionReasons: [' '], + appealDueAt: '2026-06-04T08:00:00Z' + } + ]; + round.reviews = [ + { + applicantId: 'applicant-blank-rejection-reason', + reviewerId: 'reviewer-independent-a', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'reject', + rejectionReasons: ['insufficient validation plan'], + scores: { + 'domain-fit': 58, + 'data-readiness': 60, + 'safety-plan': 62 + } + }, + { + applicantId: 'applicant-blank-rejection-reason', + reviewerId: 'reviewer-independent-b', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'reject', + rejectionReasons: ['insufficient validation plan'], + scores: { + 'domain-fit': 59, + 'data-readiness': 61, + 'safety-plan': 60 + } + } + ]; + + const result = evaluatePrequalificationRound(round); + const decision = byId(result.decisions, 'applicant-blank-rejection-reason'); + const action = byId(result.remediationActions, 'remediate-applicant-blank-rejection-reason'); + + assert.equal(decision.decision, 'hold-for-fairness-review'); + assert.deepEqual(decision.rejectionReasons, []); + assert.equal(decision.reasons.includes('missing-rejection-reason'), true); + assert.equal(action.action, 'publish-rejection-reasons-and-appeal-window'); +} + function testIncompleteReviewerScoreEvidenceHoldsWithoutCrashing() { const round = buildSampleRound(); round.applicants = [ @@ -792,6 +841,7 @@ const tests = [ testInvalidReviewerQuorumHoldsPrequalificationRound, testInvalidReviewerScoreValuesHoldPrequalificationRound, testMissingRejectionReasonListHoldsWithoutCrashing, + testBlankRejectionReasonTextHoldsRejectedApplicant, testIncompleteReviewerScoreEvidenceHoldsWithoutCrashing, testDuplicateReviewerScoreEvidenceDoesNotSatisfyQuorum, testMissingReviewerIdentityDoesNotSatisfyQuorum, From 71109b6c5a1de270df5fdf93ee3d874c12d37bf2 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Sat, 30 May 2026 12:41:09 +0200 Subject: [PATCH 18/28] Harden prequalification sponsor decisions --- .../README.md | 3 +- .../acceptance-notes.md | 1 + .../demo.js | 64 +++++++++++++++++- .../index.js | 13 ++++ .../make-demo-video.py | 4 +- .../reports/demo.mp4 | Bin 44989 -> 46713 bytes .../invalid-sponsor-decision-packet.json | 46 +++++++++++++ .../prequalification-fairness-report.md | 8 +++ .../reports/summary.svg | 5 +- .../requirements-map.md | 3 +- .../test.js | 50 ++++++++++++++ 11 files changed, 189 insertions(+), 8 deletions(-) create mode 100644 challenge-prequalification-fairness-guard/reports/invalid-sponsor-decision-packet.json diff --git a/challenge-prequalification-fairness-guard/README.md b/challenge-prequalification-fairness-guard/README.md index 8b89bcab..ca04f34d 100644 --- a/challenge-prequalification-fairness-guard/README.md +++ b/challenge-prequalification-fairness-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Bounty System slice for SCIBASE issue #18. It evaluates sponsor-side prequalification rounds before solver teams are accepted or rejected from a challenge. -The guard checks published screening criteria, complete and unique criterion identifiers after trimming, valid criterion weight values and totals, valid pass thresholds, valid reviewer quorum requirements, complete reviewer score evidence, valid finite 0-100 reviewer score values, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, missing reviewer identity evidence, duplicate reviewer score evidence, missing, empty, or blank rejection reason evidence, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, repeated reviewer identities are deduplicated before quorum or threshold scoring, and missing reviewer identities or invalid score values are excluded from scoring until the evidence is completed. Unfair or incomplete screening decisions are held for remediation before challenge access changes. +The guard checks published screening criteria, complete and unique criterion identifiers after trimming, valid criterion weight values and totals, valid pass thresholds, valid reviewer quorum requirements, valid sponsor accept/reject decisions, complete reviewer score evidence, valid finite 0-100 reviewer score values, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, missing reviewer identity evidence, duplicate reviewer score evidence, missing, empty, or blank rejection reason evidence, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, repeated reviewer identities are deduplicated before quorum or threshold scoring, and missing reviewer identities, invalid sponsor decisions, or invalid score values are excluded from taking effect until the evidence is completed. Unfair or incomplete screening decisions are held for remediation before challenge access changes. ## Run @@ -20,6 +20,7 @@ npm run check - `reports/normalized-criterion-id-packet.json` - `reports/invalid-reviewer-score-packet.json` - `reports/invalid-reviewer-quorum-packet.json` +- `reports/invalid-sponsor-decision-packet.json` - `reports/blank-rejection-reason-packet.json` - `reports/prequalification-fairness-report.md` - `reports/summary.svg` diff --git a/challenge-prequalification-fairness-guard/acceptance-notes.md b/challenge-prequalification-fairness-guard/acceptance-notes.md index 3e88dda3..7510c5ee 100644 --- a/challenge-prequalification-fairness-guard/acceptance-notes.md +++ b/challenge-prequalification-fairness-guard/acceptance-notes.md @@ -25,6 +25,7 @@ Validation coverage: - missing or blank published criterion IDs are held before unauditable rubric evidence can drive acceptance or rejection - invalid pass thresholds are held before sponsor accept/reject decisions can take effect - invalid reviewer quorum requirements are held before sponsor accept/reject decisions can take effect +- invalid sponsor decision values are held before malformed accept/reject evidence can change solver access - invalid reviewer score values outside the finite 0-100 range are held before malformed scoring evidence can drive acceptance or rejection - missing rejection reason lists are normalized to an auditable fairness hold instead of crashing the prequalification packet - blank rejection reason text is normalized away and held as missing applicant-facing rejection evidence diff --git a/challenge-prequalification-fairness-guard/demo.js b/challenge-prequalification-fairness-guard/demo.js index e339de29..a1b6a89f 100644 --- a/challenge-prequalification-fairness-guard/demo.js +++ b/challenge-prequalification-fairness-guard/demo.js @@ -10,6 +10,7 @@ const missingCriterionResult = evaluatePrequalificationRound(buildMissingCriteri const normalizedCriterionResult = evaluatePrequalificationRound(buildNormalizedCriterionIdRound()); const invalidScoreResult = evaluatePrequalificationRound(buildInvalidReviewerScoreRound()); const invalidQuorumResult = evaluatePrequalificationRound(buildInvalidReviewerQuorumRound()); +const invalidSponsorDecisionResult = evaluatePrequalificationRound(buildInvalidSponsorDecisionRound()); const blankRejectionReasonResult = evaluatePrequalificationRound(buildBlankRejectionReasonRound()); const packetPath = path.join(reportsDir, 'prequalification-fairness-packet.json'); @@ -17,6 +18,10 @@ const missingCriterionPacketPath = path.join(reportsDir, 'missing-criterion-id-p const normalizedCriterionPacketPath = path.join(reportsDir, 'normalized-criterion-id-packet.json'); const invalidScorePacketPath = path.join(reportsDir, 'invalid-reviewer-score-packet.json'); const invalidQuorumPacketPath = path.join(reportsDir, 'invalid-reviewer-quorum-packet.json'); +const invalidSponsorDecisionPacketPath = path.join( + reportsDir, + 'invalid-sponsor-decision-packet.json' +); const blankRejectionReasonPacketPath = path.join(reportsDir, 'blank-rejection-reason-packet.json'); const reportPath = path.join(reportsDir, 'prequalification-fairness-report.md'); const svgPath = path.join(reportsDir, 'summary.svg'); @@ -26,6 +31,10 @@ fs.writeFileSync(missingCriterionPacketPath, `${JSON.stringify(missingCriterionR fs.writeFileSync(normalizedCriterionPacketPath, `${JSON.stringify(normalizedCriterionResult, null, 2)}\n`); fs.writeFileSync(invalidScorePacketPath, `${JSON.stringify(invalidScoreResult, null, 2)}\n`); fs.writeFileSync(invalidQuorumPacketPath, `${JSON.stringify(invalidQuorumResult, null, 2)}\n`); +fs.writeFileSync( + invalidSponsorDecisionPacketPath, + `${JSON.stringify(invalidSponsorDecisionResult, null, 2)}\n` +); fs.writeFileSync(blankRejectionReasonPacketPath, `${JSON.stringify(blankRejectionReasonResult, null, 2)}\n`); const decisions = result.decisions @@ -95,6 +104,14 @@ ${actions} - Remediation: ${invalidQuorumResult.remediationActions[0].action} - Audit digest: ${invalidQuorumResult.auditDigest} +## Invalid Sponsor Decision Packet + +- Applicant: ${invalidSponsorDecisionResult.decisions[0].applicantId} +- Decision: ${invalidSponsorDecisionResult.decisions[0].decision} +- Reasons: ${invalidSponsorDecisionResult.decisions[0].reasons.join(', ')} +- Remediation: ${invalidSponsorDecisionResult.remediationActions[0].action} +- Audit digest: ${invalidSponsorDecisionResult.auditDigest} + ## Blank Rejection Reason Packet - Applicant: ${blankRejectionReasonResult.decisions[0].applicantId} @@ -117,8 +134,9 @@ const svg = `Accepted applicants: ${result.summary.accepted} Held for fairness review: ${result.summary.held} Remediation actions: ${result.summary.remediationActions} - Checks: complete/unique criteria, valid scores, reviewer identity/quorum, thresholds, anonymity, conflicts, appeals - Unfair screening decisions are held before applicants are accepted or rejected. + Checks: criteria, sponsor decisions, valid scores, reviewer identity/quorum + thresholds, anonymity, conflicts, appeals + Unfair screening decisions are held before applicants are accepted or rejected. ${result.auditDigest} `; @@ -130,6 +148,7 @@ console.log(`Wrote ${path.relative(__dirname, missingCriterionPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, normalizedCriterionPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, invalidScorePacketPath)}`); console.log(`Wrote ${path.relative(__dirname, invalidQuorumPacketPath)}`); +console.log(`Wrote ${path.relative(__dirname, invalidSponsorDecisionPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, blankRejectionReasonPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, reportPath)}`); console.log(`Wrote ${path.relative(__dirname, svgPath)}`); @@ -322,6 +341,47 @@ function buildInvalidReviewerQuorumRound() { return round; } +function buildInvalidSponsorDecisionRound() { + const round = buildSampleRound(); + round.applicants = [ + { + id: 'applicant-invalid-sponsor-decision', + sponsorDecision: 'waitlist', + rejectionReasons: [], + appealDueAt: null + } + ]; + round.reviews = [ + { + applicantId: 'applicant-invalid-sponsor-decision', + reviewerId: 'reviewer-independent-a', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 94, + 'data-readiness': 96, + 'safety-plan': 94 + } + }, + { + applicantId: 'applicant-invalid-sponsor-decision', + reviewerId: 'reviewer-independent-b', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 92, + 'data-readiness': 94, + 'safety-plan': 93 + } + } + ]; + return round; +} + function buildBlankRejectionReasonRound() { const round = buildSampleRound(); round.applicants = [ diff --git a/challenge-prequalification-fairness-guard/index.js b/challenge-prequalification-fairness-guard/index.js index a4295f21..b19897be 100644 --- a/challenge-prequalification-fairness-guard/index.js +++ b/challenge-prequalification-fairness-guard/index.js @@ -181,6 +181,10 @@ function reviewerQuorumIsInvalid(round) { return !Number.isInteger(round.minReviewers) || round.minReviewers < 1; } +function sponsorDecisionIsInvalid(applicant) { + return !['accept', 'reject'].includes(applicant.sponsorDecision); +} + function reviewUsesHiddenCriteria(review, criteriaIds) { return Object.keys(reviewScores(review)).some((criterionId) => !criteriaIds.includes(criterionId)); } @@ -264,6 +268,10 @@ function reasonsForApplicant(applicant, reviews, round) { reasons.push('reviewer-quorum-shortfall'); } + if (sponsorDecisionIsInvalid(applicant)) { + reasons.push('sponsor-decision-invalid'); + } + if (reviews.some((review) => reviewUsesHiddenCriteria(review, criteriaIds))) { reasons.push('unpublished-screening-criterion'); } @@ -342,6 +350,10 @@ function remediationAction(applicant, reasons) { return 'publish-valid-reviewer-quorum'; } + if (reasons.includes('sponsor-decision-invalid')) { + return 'publish-valid-sponsor-decision'; + } + if (reasons.includes('reviewer-score-value-invalid')) { return 'publish-valid-reviewer-score-evidence'; } @@ -439,6 +451,7 @@ function evaluatePrequalificationRound(round) { decision.reasons.includes('criteria-weight-value-invalid') || decision.reasons.includes('pass-threshold-invalid') || decision.reasons.includes('reviewer-quorum-invalid') || + decision.reasons.includes('sponsor-decision-invalid') || decision.reasons.includes('reviewer-score-value-invalid') ? 'high' : 'normal', diff --git a/challenge-prequalification-fairness-guard/make-demo-video.py b/challenge-prequalification-fairness-guard/make-demo-video.py index 4afb386c..afc32309 100644 --- a/challenge-prequalification-fairness-guard/make-demo-video.py +++ b/challenge-prequalification-fairness-guard/make-demo-video.py @@ -27,8 +27,8 @@ def draw_frame_with_pillow(): draw.text((96, 102), "Challenge Prequalification Fairness Guard", fill="white", font=title_font) draw.text((96, 190), "Published criteria plus weighted threshold checks", fill="#dff5d5", font=body_font) - draw.text((96, 248), "Anonymous screening leaks and conflicts are held", fill="#dff5d5", font=body_font) - draw.text((96, 306), "Missing reviewer identities cannot satisfy quorum", fill="#dff5d5", font=body_font) + draw.text((96, 248), "Invalid sponsor decisions cannot change access", fill="#dff5d5", font=body_font) + draw.text((96, 306), "Anonymous leaks, conflicts, and missing reviewer IDs are held", fill="#dff5d5", font=body_font) draw.text((96, 402), "Synthetic data only. No sponsor, solver, payout, or identity systems are called.", fill="#ffd37a", font=note_font) image.save(FRAME) diff --git a/challenge-prequalification-fairness-guard/reports/demo.mp4 b/challenge-prequalification-fairness-guard/reports/demo.mp4 index 8a0f322fef3e899044301f584846fbe0f87b81a5..d1edacbd5da68d6773b6b7cee4cb63a0cd2d5444 100644 GIT binary patch delta 24801 zcmbrlQ>-sO(D%D++qP}*W!u_&**1R5wr$(CZQHiJ&i_fy#mTvQ-{<0!w4KaM+Haan z)3$pT|H1tq?f)VDA58zj_#aOH!Sf&V z|M5R#7XQKf9}NHTKlcCWbN#>V|1&$_e;eR`>HlB;|K$w+cm99>ZbE2XmVZV)F8QZQ8&*_s&E*6ssjHEes z9;V5)d3bvQeX9@r5IJ1*ymS_{nx{%zo};O$e%_44__Fh;(W{?!QRouA+{_GzbgJrz zk4amdaH{qQHkN{#wY3(BACY9V4!)oujmo-L;jn#Uj>p*C&0f1Bi)1pU3qk?8{%IFEYaCc2s}luOX9Rgy-om5Q3?TDv~RLEYY3 z*iciksBY=T(s+7cn}|;n-DS2YkQ!I^m2IxURgJrG5^8Gdsu$WPp}U$%s6?TGr35qL96*;j+A}Oo-HH z;~tPG?heQUJK$(&)j4%8A;beTf2RO#?@dy@sCU2v7bvf)<4cP~!QOMac85zwdlRNp zJDm=kUNgQnds34 znY5Pb0-0o7qlPkwp6Chd2O0D#I%*!B-3ycl+pF{`gj+2m)>Hu{DZkpSjfc8=o}A!@B{sg=u&ELGVmu*}LCl z{=mgyT0UozPUhx7HmD!Xy}x zfGf{}h?~R)P3H46inA<~!Ffes=e6Q8URF#Hj><3wT18DpQ}fqzAW@{xrGv0f@D`ib zGJ^gdK~^te0%x$FlkNHK(c$ zUBnYLO{sON495tgdSsqEI&+7k!Kuw9P<0XD#+W7?-*l|67jR6U=)|{f!?7eiKN~>% z4+~ur5HZrT3kaiyHm=^-p%o=)g{q+~dnLHye7)ye+bn=UehfjLPrHU2WZADD8n1D~ zF}f^Z-w#zh5ALh_?_w|A8V%7HoBJ6Uyzr@f`46J!7>vu!%yC+E2tUu ztiE;bEvGVp;Hw$uv0}IgT{tmu%Fm^q`#UcAMl_bG_?)5y7OknA5(yW1fLTPYIGZOp zy~JwwAmjW!q#H->gdAb4Qj-2CMW30EWK3*YJltM`y>Nj8cK;`Wivw>G-b(j)4Di$9 zLp6Y7v;Z)Z;-A)_#T2>}MNJF2OSWI1Cc~cj=ILYo)@rM(FD7eiBhVEjZPArEEZw5a z5~0I3bZuAKTm$uWHS!*+y#@t0{CJy zEUXEqh)g-u{40y*PZrU4<>@wTZgB2r3I~8sf?Pr9UvxV1Yh2#p6qeFWXym1Li;IW# z-;5K59T|dpfeFId4U%RN@ZR{3GR#TzIC{3NA^Dekja)5-5mX16!n+)(~Ly@Y`g@Up%L+q@#p|T z4vE>noDSpRV#U;HMm@NoyySi+1L}mrSOL)E_8@f@%Gdgh_|nd`Y-u0g+Kp*jsq1@h zugy~?Y}C46f3R&)+bl3)XN#CIkR5d_pfgG}!h3MjBce8+N@{?BoO4}1rmNjVAKHJh4Pov z=(T%!)nfq_H4^ELqYea^T#82qe|E69n3dpfnoL$n=*Y%-YNH$JveZ43Q5(DhBGU{9 zC0q2o|CqXV)=rO;)P;hvWZT{diBM&lkQG$)!yQ;jA6tr_RV zAu<5wZB7mt%r z_kNab&x$&{qK=p40~cHdiG^Vg2C+6bja?8I+buM2Cb8;;C7^fB%1*W(q<^` zicPx)_$iyp z^J= z^i?ke+CghP_QlHgP%MnaDR^l8kw(Pj@X;Y7l0d4~0Sg+fNlZ=-;vPo+)PMRBF9|*G zC8o%Rof+cM{Z|1o{E~#a>AVDzhb(bVs_`o7>U@a7){uXqY)pLG3T?MT+6nfgt-Saa z4T11ByK`1*nDi?WBcD+UzNaG;&Z_YM0xO6nWzUFVM%mel%9uZvK8ugV*xQL`Q%e9hNw1CwOsFFA%5< zI5=6bCS<~1K_X8i9%Ki6+(~+M!5zTOB9@%?5ygB<0M+QqX~!$bbd8+%WR}Y< z9BL|xUc%)hon+iOy-|sX^c4YjglNJyy-k@76anI^SjqUDUP(6Qe)HyHviJ79<%;TQ zK_Fm2f++ND(UxP`CY%$YMcinQI0b4O%$&b&2n zH{|qfh_}v+gelaM2NolYTMuDlbY}Ftb!xqh#)}ICAh$zaw^W$@V`R~Fku0PG_)V!?YUz9Rq*%Lx)jCr>RZ~X))Y&) zsP9|^xD;(R8EXhh-odH9n3!E1uy2qU0`sYcOypeE&vY5{5$`F?t4H2{0` zV2OEeh>d{_fZeY`3S>hn3FN~m$^e|fq~yepr;o?Rnu{=|uTStdVi}Lj3PCaMn8Kwh z#-GCW)RF8o;KZYMJHa#Czy!T|@{jX7lZ0h`t9OWaSQAlHUT+f=-!ISy`4%*;V-WgV zv-@S@{d+H1b&Hwf=8ZE>n3=MX{xzi6A~T!z->Qt|CYtpJuju8KhE8DG!~m=dgQ}EF zWJ=2I5tl{bM`g9m62Y%>5cr7Sq&#S7aNm?dGI*Jmc-CL9I@(NKF%%gK=@~SWHD&+Y z4#_|k@)Xyeu?egGBZ4+hM<+C_2qzX&!R1ssY6XR}!Wtt**j6v}o+&^$uZ6=hfpb_# zxgo#Ju4A~`=S>!Wu-iY|90sgru#~PgFUN7q72*n6{LYYs@v_0eCSba!>;thqFWI0< zkBPQ3?)4Kyrmf+${#C1l zs}VIcnz*;rHiu(8DF$o`8A9llSAdEu2TUaAEdO)wzk6yZeVlHup86Yg=}=m>t97ok zDcu~cG`*t&Pr~h<8E&pmi!rrrt5dEXt0XatZ@UI}XQToVLBHwA6+k3Z+&v1X((!rn zI^l7EFMuHx4L6QO6A|6_+FaQAhVU&5hk;rio9#&j^VGyK1_qE8)Fsz;W)zQEE?gGb zRNn&1SF#uk1@z21mQA}NeLZM7(Q&^B|J=>#DB}iuPsl>!FiGCiw3(%6o8mPrOBebh({x%)|q+5FeQ)JW7_csevP_m2&>^ zAY>A_>}Y2 z*jwr`QjB{Gij0^1ltQ_znjn4L2a*;rduo)}q1w|i%LdaHzJnZ}n?OTjtNxuNu8NjN zUDfDaQhB@1(DiN;x$lukGsDtg>yW zBVGWco~dK?e<`TnP8ZOGOnezb+Y>f z8{qYZ+yQz*>FGT*`DXOLc<3SY*k9cl%CtHdGxZ6_`!|5pSb~&8KQ4vqB zSy;I@po7zole8G=mPtwVVl~oAmGyM9_3b145QaH68mSXXv+!;*odwz^&Nw?*B*D}6 zKHsD&G2jl`E9gF#t}c^m7pitXryfLR^G2=6z4ev$0Vv~## z4uZ_3D5XuK5e_+4LU*_Fv&Uoj2-(%%HC1yL_n=_aQeuQ?r(#h6Ov3@1!!3$y(hHt= zxn`|`&;@kl#hlHbqXcE?1?NIC<+BJH?$532b&H~Z9;T^4@%w%h@zfG5 zNB|QtvMMVf818u(ai#uvNv4O%c26 zC^%jX$yJ8lU%HO>U0FISq1FV8qSqB61vV#tE{61hzyObbUVcP$gL1cpI=eynII@p? zeR&rQ$l-VD&uR3EOEcT=4lCJJqTKwvG=Q@zE!*OZ=KH_CGPUb!+GHcXS2*mB#Gwyu zn5FO~O2h^xAt^~`^Q0P;fm1Tgf1i6S6%CPjY0+E>mnGU1t6wL^lXFBXz9L#;RA@B6 zH1)$XBgxa?354HJfW_va3$xH&G>Af-2Ot!K*;d7DGsdXtRVTIGgg;>%&`M}yt(i0^1kD27j0wf*om-$F6QLQa$^RY48eOi&iqf@r~vW058 z2o?TYhdmolH#EoL-6mo=$$C@31@7DhBd+maa=&BHwqF`opH^^iuVN8 z^KON@k?Cg{Yy?(;B7`}1^Wm!EC8{7ac@SU57)KfD+9^ip2bJzJ3_i-&1Yp*EN2SkH zO>MeXRT0Hk*~rdQB8;zstTTu@8B0z5!k5+`O_48C;k z-eH;F{*QyCeDFgBJVEv-;7H>uuKqxQ98B*P24 z#&0M?f*5^GcDdt}d9izeK-?mfn($WUSb#pXuw(;%~EE$Xg9`` zc4PC!d@8Z)G4VBa2Oo$*xjuE@i*qBV(&|M3Pb6?{zF16D)TY%-*m$MU)wMfz>EDrY z+bhu;B?YDjgIEkMqsQBBcqv3LmbA3GIPNu7R-;yz&JV!j+W6%Hzn_>x7x%fFu?O9q zCZE$1dXOpi#*{)<2S6Cr70|uySFOS=BaX6)Rd$FLU8&0?NG={iRoS%E7EPPy^)U(N zjk1H)?>p$Fu=w<@vHO}lLUfaa%)K=3=20ESD?paOJYI0X+Sb<*E!Q^U$nRt!m}qWC zVZKh%Y~M0m+h66)*6PC|shGNRxSab&_vJnlEg+rN0;p?y1vt$zOW{SF3SBNXkj(=9 zRSh{Y_gFKrN;fBKYsq_=>p90bvaW;%+#y4`hBT783xgsP0!6FOxTP>$>m^+_tviHG z{l;w;4~Z&dAqr-F+vL&1kj8zG)SVW#jKL_CM-jWY03X@w!y*;;1B}vJ$j% z){fF~)%|Jy1=QIpH&w2`S)~okDVa%@;MsZOQVHAXrMFVwE-77UPG;xwmhACN;#gzO zUxy6d*hcd6)nA4S`0}3}-*jB4m)WXl=}NBFTm+CBX?}?QURT7*)J#MPx5+-W==3+Y$u~5B$m( zgx;r2vO5m~UlG3q?vFKp*h%{wbw<$DRr2AASEPGYXOKDMASzLn1jW+uw}q1`A|ZX* z9lu^*0QfX<0q4w=F36X@UHUY3SfP1pHSgDr^a}m>XEVU;bVW8L(khu(Wg;W09Djw@ z{dcQ|ohZAHXQtv3Vp`{D#QdV*9B|aZwZ&<=i{3oJA~=+nQ1ZA&eTC64@qp|*Ds!3b zFxSIidO0+<0+yz}vT*~3TrPTE~66Ylb=-wL;{mB_Ms}r_$6e6 zhI$(1xMJp+e5bt0`4LOMIK$PDjkK&rwcF z0A_zHx=AXt8?^YeG(Qe%hz14t@ss%cp|wn*LT!AW_4$kuuF1)B!JzS#%;stzZ1Qmk z(dz){k^;>pb2Cz{Vq|UPs=tvcI>G)wHO-RfzH4K~f#X zeD2L=`I)U62TQTp`5ViYzz=UicUm-te$)d@z0KQ{k6r|tZ+ zi2@`(X>skw9S^*+IA40hZV;b>Hj!w~hb-+~?UDS%daRivu`0qzcNe?eTa~LIz_itn z$4+Tmmqav8Jz$hYYDh8e=>x+6HeH@I3$=|bjaoTCi4_K<)%dr5k$^Nuw6!d5@-T!@ zxA9{!pLWMfS`6yMXAuS@uI>3XF5V|%S9qK#1zo&NG^Sr$1^&F#0G2v*tJ(kk3w%l? zRmhv|%n$gLe_4!i?ccjJ()0yA;QFvnqfIh)+=gfeV3L`sOQ#`}zn)DntiMyy_>ZnA zW!ZPER#L){$XAw5>6UxrlXUx7p6<^NZw}_%c3`6|Y2DYD;Pn_UxJ)Y2neWDMV8R5D zicCMQQR|(VMZKsGR(+5h7+M#iu60?jZ81}>BH3Gul>wYc<0F2 zf#Y!k69Wj+t(~y-kSu(p&62UuhjeLgymI&&gf9NWxXE-5OqTCf>DPhL@jiWSNDaaz2 zSjknBCh$4~ioZ_`Ze>pv=ym;!dD5E{Ei|x?bq~5A)tRfaSe@z_py|DiZ-V}YDyL~k zCzvI8;BQQ{?-%^#4v(R+y9X8DN$#PBoWR#tI7LY6Nrs*zas24}yiq=@{3uDISmQJ$ zMaRpJ>6IVc{fBu{;y}urYQ0X-#WU6rs8zKxQuUf&9*N6evDiS_WYutGzv#7|mWg6H zl|KBg+$gJ}Qu0t!z{M~{cIS8@CV&Oh-(Ci1w@xpCy={jn0(t^ss|XqcK%rXUD`sPU z4KTf^XUa4QPC{6dQa^G7%D;8fIg2!;ZBN_mH*aPAo9BPzsxG)CWj`39A*6^R%FTa4PO0E*g zZp5>kcW=v$Xht0_xkqRp7xr7Zed&@v<1>?^t+-j2J|CCyIAE`Z>~HAKx;@8bk2zj# zb*`Eve%9Fpq_2rZy#E?7VOP|i)_A+l-YoFb@H2+&dw*9jf%pYGEiHG7R!6(sRefoP z0K{qTxD=dEEL75;T|r7JE6==!1;`FNKzpX*TlwS9){&ak^xPbdBa9ehgO1m~Z(M5o zRJj;kK~2W|lGH6iZzc2dwTC1Wr-wSg{k+^x&v2IjJn+-3owFgcTcUo89LX$!p>;Ri zGhKWq%s?|6?um|WIg{ler-vo3m5J78{L~1Mbbo?B?Gt?yaq$^%aAxie7)S(zEQ71+ zRIz$KOk|-!#XNvbaw$Z~40J9b=?FX8Y=+YRfZ09utMta^N(g=_-OR_-@d%Z4csJ#5 zJEXD$^3u)A>*9f5mqz%qR=*);hY+r7x_S%AP7PREk!(@|gKmF!E>yqcnJT0ZJm z*!pq+B48MAM9{@4v~f(kuJl~N2+@8r^Xa93e?^U5<%6Het!lS*)qHmP9a{S& zMd4;teqEgZUG>zU@dQazK&?LdKc}pTVK{e|?_Wmzq3B@m*yDvlg@#Els(i2lwE9YO z7j~aAdt=9&sMFOMsf`xJR?}s=C5#%@X)Ph4WcT@uu(^}W_Bb=u--;+=TZWcI<22I% z5H$}y84;pxUD92^_Yw_9I4MG)sQPt$HFg=9S6_y$yv z>(^Q@*sdBJt<-u{Ip zVfb51&Bvh4cocJ7t6`QcXhd}Y=X{FEE5_h@(0ckPp55$srWRO8C(zdzgD7YLz%juS zy=XBABKb`UeUTbdu05=Y$+=t7`RX%HBb%&pHSUvHzD~HRxRUqyJ6HR5k<#G_dKw^F zoDG*b5R`&sK12uZPv8&{a*5%5Gc055pNSZen@+^cIyt;Ih|Io*={AsHDr#chXi3BH_YDb^J*x4lGkEn0-wIsSi5*LTvk;1EqSc|uIhSz5R}Lo5 zD$!2y-sMd%nOU6*P_Q>V0FzBzCh=3#!Ht0|5_ZR7y_njCv#>s3j}PVqG&dFTZv<*} zK;)$%ra3<%mto17n)Or66Ueiv2RdBxojLFw9zM z;m;)Dw6cpC+BT8$#FAD}(dtvu7ONakH=e}lE;N#LLmE*I`38(czwl-(`15(helLd%5OBh(!wC9)vp3-PFsW1HY1NJ!J)w~+_{|M-5|h~HN39Yt|4 z=URAKN6}|iJq6{zVJJd(&BJVXG>elIaZ3$JwAH{;%Capj%<+hfC9c32J;QbH$mZL*k0zO6I&r}GX0<$sgJ&uz@QV;VPzH9(S8)Q3*nbIxwO(m&}b zt?WC*v!r~MG84q$>nJ0aFLy?H$2~E$f1e9jh3spqZM)$Ct^JiI{Gmk*dYo(hSSa{` zK~~J?_h=;0@lueEJ5o_|9T5Ey@jl_{pZopy!rE2h0T==ve>etE()d;zq`Jm7py|f& z5@wlRGRSsyqhTX_o2l9E6L_+Wzo{tRz-+uJof3QQwxjn0CJDO{Ai=3Qo=b2J1WU}N z=X$Ro{?h0G4o&c?%>AWW4D5GNlb-G{N^(1~i-Js@kqhIxK=Qs8F6Oa-;!gjG;F2XyNQ)6oy0PnU6D`Ar?|BJ*v5Ib;54ss`F;yT!q-@gCB!^Jud0uXTJEKuc%RxzLFq_lZkYDSVc42{w-Y z#vq6Im7{llgdP$DWQ| z1R5dBtzJwQ*Yq7Z(#1FdfP(0Zzn)(e&Oh;y=2>0#qZrsiH`?Ci z(EureXz$PxeDyl#r4&UQA&OJdNLD2_Nir5o@-^WmQx&r4PSj*~cd9vLH`jE$bFkt| zew2O31Wc!I0@Ko^OcL|7J%^_u?Jb~+?^xgvBah--Z6CdZn$iyEe=|| zy=jH>tW~;rKnWZW1^LjlH^3St9#lx{%feN_u@W}*qdJn|pje?_@Re);3i74@ zl|M6YKj5KKju>KaU15BOoy+U`)@kOcvG^^k!&U)f9T|wg!%J23CP;)Y#)HLxjQYp& zce5ud0Xn~jaY5j~6|t*w54Gld!%)mSN97!uc#tACpCav z)XSoy2DB*{v$_)%j=(GMvx$|ubLIOk18vsG;|RdJDT?YyyB@LO`A;%MJL!S;C^+L` z8f8BhrYe32V7FaR>vmLrB7+h@PBN~QmWCYP`qWN)pYh-cPQ*Z5avKcn!OVw)3Z8E3 z9s_yi6CL}+W>3ldELR4KDdw&#i>H)@ZK$8E7&C6g$nLXotxwmR;d3t*9By5~3VP>$ zb71_(5ys?q)Hp5=hI6zO8IRGs{Z9su1xYDtA(``#u)6w{`Ihm)^iCb%&6|fydobhd zRH$W)i}z^XJB~kJ$m75LYikQ_G7WSAM$yz&1by1%E}3lAy#Ga%C}o#%0Y~?Rl%}4M ztFRMJwB4t#q|&}wZP4H`<2xv^YEp9wk_GXUOB9I`M6&!fxj%5?Um-m(iqcNtWQJ(3 zx;5THN{T`37u!k~q*emRbbnv4P3}tB#q}{H_m#ZBUd7UIFFX_xDOT0lU{|}Pf4u|Dc3rz+jtAD+P)-W? z5R|;0W>sTt(jzo-)DRPQ|p0b$Fl{^cY6}-UjfSuKHGhQEmkY`5qsSg5^eu z2Bxc#5pYCeaOj`9BM)22yVdUdRap2!3L4>d+>1e~x&y^?NCDg)RJ>jVK~DaJpH*%F zq@Sam_0(_loa_NThKwVZAO{P?)W9K$TLDsD1Y<4j{V3m@SM{;{85^;@lEAvY#|F5o z(onHB*}f#)ZW$!=rcdgcdHnJhOk}hZexkwZ$b#dF*{q-vZ2nJKe~HsfeuC^X(kNLX zh~jWErPNjr;~nTQhQXZH=7ULPbCB~D`P{vU9(P+bRm1_zG>%A-<+@wYYKY2LiS0}t z2BMl}rE#Y!O&T54s(heBHEL*)LDmIe^9ux9T)rY%#qvD!m%UOrGU?Vj2)8O9PlUv( z(JF8Q{6rLibx7yXMVF-ao zvLTHKmh^C!E%x>$X!9S7%)VWLB3s20#TW14w(p^E#zC-oX2c7N6-Cih=0JOv7x=E+ zf39Mj)Yr=Dl4loueiVckwFUuGez@+WrW0CQ*^V!0UlR9h7byQSmcqus%$>2{))R48 z*@FSX!#MEhJ^%dmBn$Md%M@#Q?Opp(D3b{iLoOHj?rGH^g+d+j$x|Q5A^&iQ3|U2( ztgejFa6{wuQ_zl6W~G`L8#+V7?OTvq@61n2f9-^MK$b3Hny*wIH!U@J0)rCCZ(ic>JrHC%iteYW{+fCkRXAMh*Ln7rvfi;vMtQ7^(VW&Fk+dSvU>qk zrv&HN&(GPqUq!!!_*QN&czax4d1o30#ruHd^YtxU%lrIvfQ>3Q{rmIevXI2}yrKgb zFT!r}fWV(0sLz;{$s6{$1|O+fj$G2a7JI;HyTIs!Q+`NnfDoF7U}$-5pi$ zD2EitLN!kL1gv!YfPi9zO`=VtV>eIWLly+9Cj(PC~!iM5!H+cm~xs?FccXvO#5i7sd6XwvAmN$dFey>eIXT@Fxl()fK$@ zUm`fWvnf;-b_l16g(mbzK3tDqOiub!-WY|+Z0+L$srihfaIbF>+iwkz zMgxjRf*^U5+#_sAvt2N^8o!06aOM;|;nB_eU1a)hs&@`5(Q=(DS37~(*`JmP4e{Be zWBEKRXXEc4P)hnvtQ=XDxZp zhOYP!KFj=qK^8isRj4=AqL{z=xHQ1^NP3<1=UK;BcXAYZ#6d;~G8rHS3FDyNc8+RQ zB809y$YgcC2Fk)H8&A<%+%>Q$`30H5V)_o(m1ZZ{umAmt=6Dtm>j4}hT(5y|coaZ~ z04p7oo1I-y{@(-iZZs1c)Hkbz+>AA6w+f2<0Z9ite~EnbT!Ku5eQyiwFr*28t5)nS z@?RT)N92_GW7VY`6k@>JQSXa$;d^nS?`0I7O@a|xu<%sHsMZOLw>}zUP`ry#EqAmyhs~KQM3R(N4|fgni0PA zix;80oWVo5gns1TE_JFW>Bky($!1tf?9Mgh8-(AD=lw&~%(j@FNA-Uc(sn z4T%Y)%8?M=_{~{QCgN71-?Qh>uJQ-3;X0*CjX@?`#PGu-29@7lFBXH@p)$} z`oTXA_>P*O*zak(s-9lw_pDi7Tw0;pkne3NR=;{dBMP7+>1EE@Av4W}(uDtNk{`#i zWsvOrcMJfO!-7N81QaBBb}bjwQ3D&EdON8!s1&YnwCpIT9}xM&tIM-em$8OsHH_Oy zYcLZ=xo^u#)ygwAnenJOMq3Now$@^JY4Tdyz>E5otc;Ail9~LGcLLd>EfynnKxtmA zq?n%B@^)LCqoj*|OJ68&1<{{7B2VL~N6G`NMpHmdl8$>~Q352aE;s9|ah>*O?zM!U z5_;SBa_`ADDA0BlNodXaT}ShxyLh8liA3X?N-W*KGmcBtgxOkit^?2{#PKrZqij4- z{s3TI;?2rpl#K<#W72Es3|@EEZ}}nKgAoMjKP!7%^|QMoT~_H@A8g^4X7+hR>&ox2 zk3E1xrjMlfu$nc3Qdzr*m0?ep9J9IVRtq=%xwp!jd(rH~=P?FnPSw3x0ZVdY6q6;w$Afi-+N zkIDtL%kvubX;={sJNqRM`xe;PC(AJ+hsGK*i`=X@4aAJ2)=#zyx6~RDJ>%!IpnUP)Y2&M96jx`>HTlv7 z({EIii?MW7JXRSqo7<~D>h8HF+!$-g_8wy$$a%qtn6y|9Zf-{ulfEA8S3_O&vRga9)d!EwQixX*6BY1-CUt0;(^R22~%*n6`)><%){ARg4CLA4i#3U z4U&=*^`^}fR1&u=ATk#Nn=aLZr_k*B{L}r!ii&oSI>FpAi`+g~vpDM5cV4IZqrb3g zRHumIQ~(NvX8&);t%isb)yjG#n?ORNQNy;i0NAk5xAz#M_*d}A#Z~kA zPCZPh@MCSLr#pmCr?1da1T~47o8oyJW3zUvq z_azFqWN@St-AJw?SUr$`ps6S>7=TJ$83^L_hlRUgV5FpDl-R1-87Y(# zOzeuAfVu@_Qk1K7WP$evm1#1F+-NElmRfxevI6#WTtgfi0K3Un{}{`o{8+ zL8m!Rl_@arv0&{oPE;*2Kz^@WM8&JM`v8aEoNo5f4Q>xWS)MCWf2zh{_DvzQKGPXOA^N?&Q)~d{_rj=><78)!`xizx9Fckj!{1 zjNqc9R58dEB(Vi?ySRM73*-!u+PsenYcTlK3L|S4)@i4fBMGAR>t3-8FcmsdF#lK? z!St3IQsLyGrHe5)pT#556XZS1P%%J*q9oho(?_*6AYEiWR4Xq@(a)b%3qHT(cC;FR zu_uH(^zb(UE@VF`4#n@q@`Vm;GLL@CqJX|kX|IaiZU=c=?`37Sk+K3k3)tWt2=Boz zYO5%Fjz<9q^9tnrpKayepLwnUa_rR3+KT8y662cH1%~Za8EJzMdxTCzr_W7uOHlE? zs>q+Zzcodb>1~sa_RhZsGCp<^9#(^A2LGr}SLcj?qetdn zK0M7L5YtfBXr-Q}+ps*u5}DB1-pRHvB630Gf%Ddhw&C68)%(*f12+~!J1(7urv%8EStz< zs3%3aS-r6y*8E65Af9V#Y3w*)qC`-45|RzNw|I8uFv$?d>2IEKCigKSjI`>T>y?!emzQ!IuqX-Y=!Q*jS5|@0h5$SAL;o82vA6*TY`2;7GA%hJ7_!%8ocLJrp zhfjmI<)dFIq8{XttsLLCH%fR|>d#hNnP>|txTir{jpRb@OvDoL2|0f%`mt;BpKo&V zC3ZQ!{ciOQS`0DFOlooT+M~#ls=BihX&b0%b5H+(u@|UQVibYa>3txRDnUT+Jnbe$ zO~_@z9UPYb#g&>=(U7nL5VYmyYI zS(nnT2V@@`q0jViFc4p2jQzz`##XZ8P*;E-9!d1auqs`O;3XGkjP#%;00NfxPFBn+ znEr{8+Rr9aXH^s7*a9VuU1Dixq)Il{>a3I#P_g)qWvjeWZ)}nTOhC%zi`I^wyy5{P zpMV{lq>J?Ci~(f^uVkodR(ca6Hp$Z$P{3w!8A8h5BvuLQciXT0WUNk=Mv#xr4*Q(c zw#YrftD<0GJlFOP-|Yz@x}|`A$(LW$^&{3TeFw(aO6hn6|0^9X(9cJ0r=ONeoQnoC z8I4LXU)i`}!Af`sa8B3kqLM}1&wE~g4L>30$5x0qpT9%RiY9rG9~Mhx?|D6a28%dx zV2DLtLt4t`kx$%Xq}uJlXjuA+`64T@@8iI61uL3?eQZ0+LJUXmvz@fpi^lJf_z~;d zd8go3Fn6Br7cgQTMcjpTeJ?#@+k3?x8@1KJPMxz(CgS4h^W^ z!sR&yjbn&`Kx>%gYnC=K%tz3V?vpH?-@-VJoWH;rqH%#};3GWUcHZWRsUecm{*m2U z3OO&HzV8Prs++M7v~iDHx8}|hr|bXT_YPZmXuGb>U(t}|z4K2Pfck>91y=b6d`b{^ z*P2?X9)b%3{wlh`$($Fn47<#loU7H75HQ9z%CGY}n@ECD-&{bRgImO>R|rZ3;7=h)C3BS`4*M^KPp1k`1bU514FBEXlKlOi zn$x1tGw>G$3o$4oxS>)_H0o9YIZ7{{*0wc*$@7KkZy+ zP!wCYogrrg$ypGQ43a@$$V!$h8Oeww$!Q1z5(S1JLxv&eG)j~pX~;Q9kPHHnLGl~# zz25hJeBOFhuj>7}Rr8~(_d2`wo^@8A-QB16=rT;*3T=0X-~){$D`Ejo$DUvXNNfb{ zZzs!xjr^XIr4SN@hdTsudv2D;ex{`Oh^4ejF(6kuyzq^7M9eMvnYvm(1fMzLDbG3qmDC80GZs!AzCkr-{Qixl%u9@%jk1_@K5O zcT$do?4+dGuOrgGhu-viYCY6g_EDlw@{(l2@^;$V6Ox)Z;?Ln5u6(l~MY$pjU{pT`<&nyff*X>30^*cmk6pCS2PxTey{q9Pt396H_k(e{vh>LmGEu*0uj z5?ta&+o`QXY`P#dE_bxn1VvEQdK~U+&F?7Z>)d2|9k{-9#OtL~div4}SmGNAHGj+J zLbaw1Ol`&-p^-_~f8lU;7)=RA6x8&#u^TV1&WyA=!7`L3H?d+KeCv7rVULJ}BUbuc z6Ff?+#()-!azF6M^o1e?exXXH@s_%j0r&~=j_(~2tZg6vnLRvJt%F*jAne_f#NeE1 z(+N1W-TBlhVp|;^JR960`y5{W8WhiP>d+{Trk9beTI6lE^U;%Ns3w^-GJ9?Z<>SU> z2HVQCvR$KNHAcrHW)FU#q_u&!shUS@{QzH4*LqejHJ_w=8EmuGtuc{~tadP^0o*U) zu?cs*F1K_( zZ4|V*Fa29_zHh{AC%Wz@zwg1_wK@uAr=8n`mr(6+?@Ff$QUeX!>>h*@#n&L^nXkv}#RA_Q{7^%av0Ka`j2KU4y%i*v!wDZ9ymiPCpoNH`Z@bAtZPJ1`{ zF4PbnUnmn-@qoMeAXh1Ox+MIgb>wFabipHkg#~5kUcYwP}cyaoD7^K=C~kw|$U2pHiqxcP7` zp_*~eEfcLGGksSt9$uS#O!MZa#-YxdM$AnSowxX(=VX!^I#xYuy&( zGJY&yL8dD>-P!7^$~9kfVlYLmL@1>*1HoXNKu8%XSM%eQkqh)42l`yo^S0)sgfz@u z?H4FRPwcElvkPpn3K*}8sVwQRV{RuyIph}R1#l@-)zi-w_3H07f*(}dmyAu&NMc6> z1|CV@09)u`o_sgMBd+QRlxYTAIl)LA0}u9)T*YT!Dj$GvW1qCGP7 z`j!Q=MTr4>&*_uHyn>pi*J-kaImm~6E9A#pI+8a|vV2>yy}-7YLORrJp(666Avlo* z4VenHF4>QKnsyzjd9;eG0`*tK1P~r;2aN}a+FJ3{m23rZ!;W>}1_ummy0|W&(HOV^ zg)%ssw6llQDRS4N_YL7v-n+IM5@y1hOV$y#|5@iEP;k zKZ+f{b;s^yrpI&~6d3f9N|}B62v*GSC1UR}lIRI@megytFdO%#LNKWqvp*Li>ZBBH zVpBxvLL2rbtuwuCk8LC}Tpn6kNV-FN0^aA|w?(%%u>8uQHz}jxnQdP&K4P*Q}H7G?6g=LbV$cZ<+}yi*f! zUv_E%dq6_~{P$8mJk2>_M-ht)1cBnfE4Oyi23f|#%vW=Q);3$(8vM~XXJK~pwCv~A zSo!yFX_pyZ)F|6=DRspaQ{@R}43OsAo4yPN&k-kk9cvn63E+sq*+nfOV+UZqG)q|= zcE`cVTBldRYP|FI>OK_wLmBrzJofx_wG*lt6wV}_&V};SK=UDfVyY=6hBUUq-s85@ zTNZE)Q_FmQ@9DKLI^)9vhXY?0Xf4b9h03^^9JYO&CoAljy@M&9A%jiyXj2R;W$_>a zZ01ourzqKK0}d+RRM2}LBf&V!IeOr;m|M~f&F<;Kn z4ATeuRTStHj`)^7t1)zhX5@CyQ}gE}ecK*&1;~eZOvH>PJsl2DhuwCmU?1njOlZNl zhNapHiC=M)l7wySSU7~DYhz2aF66e9f<-wImYD4ejnnT>W-9q55IWX1gle`Vl>xF@ zqsL<2Q9IU2T&LK>%-t5bMk`;Tr`hBiC@nIbT2pk~UDhyrt_cqO@x`9oOzpxa4mH)E z5lKT4+7e;Go90-|1D5nBx}NxJga8{7yrULx?M&4V%O5k|ty&D&|L7wkAlhcs20J}V zo$kh9RCXD)$Oi{e2VJ&t6vOF~+ThUL> z?)O6ihwv^&38HPQdVXw0bR`XJWV$S4y}sLQw~m!24!Tex-~~vEb_?P6a(FToalKPMq|{ar(D=^KbU%NW8Mb7c@93{eKB#> z*%)QjLto^5K;v_?G02qRgRgy7WTtp(&x;>b-nqC4WWpt&pjPQ$(=I=LsKx-b|CZ&0ml@1!4-lnj&-W= z(Mx?yO;T;WSRrY6b7O={l9N*dv`q1u3zQ#BKSfeyf9BSg_`MeD$Av1udgt``cA$oK zrO{bPESXjSE%y1ig3M~=gVu~U19-=`JF|Hl?iNeb)=m5sY_(?v{@P$eOTe5z2WdiF z%`1zqyLGi_vB$P7oMc!6&sA`ZI*FNstWUR-jSgV?nqgoK&0w(wv0PiTCJD34DG?#O zBDy`8PpuyBE7hbO&Q95I46FmO`>3RJ$tbB7w1^x_r=PuTIJ(yn; zjU?RMj>t~_F^S(s3AZnQ?{UsBfbA*6?vnGw-k>Wo-~VWCm#$E7-bm3_S~U+~Wpegb zCX&ZIC^N{zgHLtjF>EU}fz7yMt!D(3beTTlmFw_f25k$r38ZD47a5Af&&^1C)$pK* zRuqN*R8K&-(zs1!7uN<6OTE1iEpeu+2HACZ>;(@yXqb&WYQc(GHSkAe>GHs)SAq_a zoDpx2fyNPi$`J5!+Rp8hz)Zd^gEJj))lo_X#$|lsvxe;TG>w!mt^7o2jocq_s-70I zON;7sZoh2*fU8O%x#VjkuDrp$>5trWYHR0=hSPLGJ5|5Fb#Rxcup(cm)IoIJY@#F_ zdX~VD_v4sMWbmE2aCQeklsh~V9+I`b*;WKujL=3mwtzk^E)2|bD6a#(H8cSud)Lf!g6KS5K zxw9g4wQUwqKF2m0s8f5`7;JcIsJ1yjDz{M_qjAQp(B+LWkJ7{I|DiN5?AgnQ7+lvL zRgg#Q7UtZ9%);00ZXu}}ti@&DB1b3$5IlnOoki%C-R4T|TcHo`gNNF_RPhHehJ7!W zRL`@XqF9eXvmIRm-d8~so|w=LNw_qN*jQ|h+%J4C{*p>ZWrMLxRCp?7;4O$d8HEg# zSJEz;$H${-LdD(wX?NhUC4c%!;t!*-6dZavQh&FZ$)1!R1^=+2R;ykSg%sn}_3syY zH|#+O&FeQB^;Yf0BVfM}gNx6R*uoLT70T zy}3Yt#c}*m7@p(!1V2e_i~;^9IQCev3Gp%E`L=Hv0Hm}cZ?7x#vg`l=n*hEFj?f4U zW~Drv#<9bFd?wO)WtYpi@wN-(dDE1}F-EmuggBnPx^L>xGzWTk3 zG#O9yJ^oKQ?<6+B0vBz$N|DuXIYTDj6VQ`L?N8xq!Lb#pq@WNf>>%CIc<&~OfRMp5 z(par?=9}^?8k(e?p=3K%T39P$@=yTCtDK{ku5h>J!c;RmT#6X^&`WbkRLBu@GT2Geq;QiJk1*c=D*AOAir*E-4kveL69~O z(jezh`L^I;RSQCWSRpfB@5K=8Hg9t) zopndbTm|iX0EiLwFy3{LMPVARvg)w5OD{DG0AL&gm;xvcig*C4@_~T6 zaL`v2gX<@D0JlIY0Ob)V0K;!M0GJ6E23S}61L)4A0x&Yr=*B<6DaL(GPDn zTiEXl4v%K^NL9F~E|ctzZWe^^X`ENOJqIj{dK9M|O-viJ*e7vyW;?OA=H3F5uia$> z06^Y{gPgB0$RT)fEa1IsmbMOwS(nYdph=@2it!Qp=?Wv8c_dI!cxg6oeAP^`linaB z29fsg`{NU>F1K$qs9y&;Q~IRx?P^i+mmvZa!af^yRk=JB#4Hu=9h{I?2eKP#QFE!> zn4gmK?xj%k&)m%=Tj@CY+5Qh8Y`rFyoaIQ)a(PJb%-WAQT|3j(`;iJBwBzG=(Qa__ z<3sYyQ7$wN?@(~3(j;eFNJ>1plJ#cOA_?%oB*7+xF{>|)kj@BWIMHIj(DVYTgulnYifk&GybCm2oPPx%hlRWI+xsi(Tg>{7uz}wnA zOPXmrZ|e0`jTTs<)=>vududWrU#$K^VVSGI))Ia@zP9OOWF6Gr!h6?rX8x)2jLUM| z#gm^Ip%uB$>d<21vdY-6%)YoG`u1`&zFG)90RRA)L6#wGV)wci!|Id!4Fww~ZJK1> z(d+4)p*Fq9)sk2$P0PYCCYsRo?XYmQuxHGd)(-gT5wG$Ii0dA5yc$F_feuUrNl>pU zV_rKwmUt`oBn0mBzCJ8C1>zXlV?zNDEbNqEy)%BHnhE;jpH2%Fc|HU04@w;QiNP(J&i@}+GrFWMzp~NyC2|008_=q=_RwpPW=WY@LjT5*ZUxIe-0tnUMV?do&@bOae$1 zjG7jojVu{68UCm#v>gC+ee($! zq0b_xDAmQ|?0=N<%YqCV?az*<74LtS4q>4^Pzdrhi|)6%AcH~XQdvkw@w;3glG$XW z|0#4S2qNWY=#E8nw7(A>n0XFaDku4U^n*pei@pW=lZ*_ye_Fzvtu6x#eH_4}2gM^dI>EL553z zMvoN6KTRd1EPhMIzry&XF2{d`@$0e(0NDL2jDJ^*zox^#!uWNGKrX=l3gh=H#^*Ag z|L;=Dr=0u$UP^)gV`ccWOp;3VK$h6{$P)Y0h2H;|1tiU1-yR~1<6o7eZW|IUER~rk zN6KOH^K>`X2lZPD?8Iw6uV=v6piedcz+J{|w`k_%cQVIY*8u=)%3q^7vwn8Ae~7#{ f{i|j{jx|)}0P&AhSE9BNXl;>}{DLBj&9VOi2sLk3 delta 22899 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 JSI1Qxi diff --git a/challenge-prequalification-fairness-guard/reports/invalid-sponsor-decision-packet.json b/challenge-prequalification-fairness-guard/reports/invalid-sponsor-decision-packet.json new file mode 100644 index 00000000..f8687340 --- /dev/null +++ b/challenge-prequalification-fairness-guard/reports/invalid-sponsor-decision-packet.json @@ -0,0 +1,46 @@ +{ + "challengeId": "challenge-18-prequalification-rna-biomarker", + "generatedAt": "2026-05-28T08:00:00Z", + "criteriaDigest": "sha256:d643b033793917b9d0488787518a11e97094e671d52b86b69a6153375d726721", + "decisions": [ + { + "id": "applicant-invalid-sponsor-decision", + "applicantId": "applicant-invalid-sponsor-decision", + "challengeId": "challenge-18-prequalification-rna-biomarker", + "decision": "hold-for-fairness-review", + "sponsorDecision": "waitlist", + "weightedScore": 94, + "passThreshold": 75, + "reviewersCounted": 2, + "criteriaApplied": [ + "domain-fit", + "data-readiness", + "safety-plan" + ], + "reasons": [ + "sponsor-decision-invalid" + ], + "rejectionReasons": [], + "appealStatus": "not-required", + "auditDigest": "sha256:479b6f96e9e11bce1a330628272c24df445b8ca0bbc93de32748df3c240aca70" + } + ], + "remediationActions": [ + { + "id": "remediate-applicant-invalid-sponsor-decision", + "applicantId": "applicant-invalid-sponsor-decision", + "action": "publish-valid-sponsor-decision", + "priority": "high", + "reasons": [ + "sponsor-decision-invalid" + ] + } + ], + "summary": { + "accepted": 0, + "held": 1, + "rejectedWithAudit": 0, + "remediationActions": 1 + }, + "auditDigest": "sha256:030c1d99cb355c21fb3d67e9876997fd4a8bb2042a76d205fe3c3b79beaeccc3" +} diff --git a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md index 2ccc914f..0f00b9cf 100644 --- a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md +++ b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md @@ -57,6 +57,14 @@ Generated: 2026-05-28T08:00:00Z - Remediation: publish-valid-reviewer-quorum - Audit digest: sha256:61801b0cd7cf7c62fc38b0f6e62415e770d388bf3844e2132b8a379183e74ca1 +## Invalid Sponsor Decision Packet + +- Applicant: applicant-invalid-sponsor-decision +- Decision: hold-for-fairness-review +- Reasons: sponsor-decision-invalid +- Remediation: publish-valid-sponsor-decision +- Audit digest: sha256:030c1d99cb355c21fb3d67e9876997fd4a8bb2042a76d205fe3c3b79beaeccc3 + ## Blank Rejection Reason Packet - Applicant: applicant-blank-rejection-reason diff --git a/challenge-prequalification-fairness-guard/reports/summary.svg b/challenge-prequalification-fairness-guard/reports/summary.svg index 228fc711..434339a3 100644 --- a/challenge-prequalification-fairness-guard/reports/summary.svg +++ b/challenge-prequalification-fairness-guard/reports/summary.svg @@ -5,7 +5,8 @@ Accepted applicants: 1 Held for fairness review: 3 Remediation actions: 3 - Checks: complete/unique criteria, valid scores, reviewer identity/quorum, thresholds, anonymity, conflicts, appeals - Unfair screening decisions are held before applicants are accepted or rejected. + Checks: criteria, sponsor decisions, valid scores, reviewer identity/quorum + thresholds, anonymity, conflicts, appeals + Unfair screening decisions are held before applicants are accepted or rejected. sha256:5ec541696fa83ff5f5ac49891dbb4bdf0e1174638039f2a2e7b6e65f2df49d16 diff --git a/challenge-prequalification-fairness-guard/requirements-map.md b/challenge-prequalification-fairness-guard/requirements-map.md index 2284b050..1f105bf6 100644 --- a/challenge-prequalification-fairness-guard/requirements-map.md +++ b/challenge-prequalification-fairness-guard/requirements-map.md @@ -2,7 +2,7 @@ ## Challenge Posting Portal -- Verifies that prequalification rounds use published criteria, complete and unique criterion identifiers after trimming, nonnegative weights, valid weight totals, valid 0-100 pass thresholds, and valid positive reviewer quorum requirements. +- Verifies that prequalification rounds use published criteria, complete and unique criterion identifiers after trimming, nonnegative weights, valid weight totals, valid 0-100 pass thresholds, valid positive reviewer quorum requirements, and explicit sponsor accept/reject decisions. - Blocks unpublished sponsor preferences from entering solver-screening decisions. - Keeps prequalification decisions tied to challenge timelines and parseable appeal windows. @@ -18,6 +18,7 @@ - Holds inconsistent threshold decisions for fairness review before a solver is excluded. - Holds invalid pass thresholds for fairness review before sponsor accept/reject decisions can take effect. - Holds invalid reviewer quorum requirements for fairness review before sponsor accept/reject decisions can take effect. +- Holds invalid sponsor decision values for fairness review before malformed accept/reject evidence can change solver access. - Holds invalid reviewer score values for fairness review before malformed score evidence can drive sponsor decisions. - Holds duplicate published criterion identifiers for fairness review before ambiguous rubric evidence can drive sponsor decisions. - Holds whitespace-variant duplicate published criterion identifiers for fairness review before ambiguous rubric evidence can drive sponsor decisions. diff --git a/challenge-prequalification-fairness-guard/test.js b/challenge-prequalification-fairness-guard/test.js index 4ef2bb95..f4848615 100644 --- a/challenge-prequalification-fairness-guard/test.js +++ b/challenge-prequalification-fairness-guard/test.js @@ -571,6 +571,55 @@ function testInvalidReviewerScoreValuesHoldPrequalificationRound() { assert.equal(action.priority, 'high'); } +function testInvalidSponsorDecisionHoldsPrequalificationRound() { + const round = buildSampleRound(); + round.applicants = [ + { + id: 'applicant-invalid-sponsor-decision', + sponsorDecision: 'waitlist', + rejectionReasons: [], + appealDueAt: null + } + ]; + round.reviews = [ + { + applicantId: 'applicant-invalid-sponsor-decision', + reviewerId: 'reviewer-independent-a', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 94, + 'data-readiness': 96, + 'safety-plan': 94 + } + }, + { + applicantId: 'applicant-invalid-sponsor-decision', + reviewerId: 'reviewer-independent-b', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 92, + 'data-readiness': 94, + 'safety-plan': 93 + } + } + ]; + + const result = evaluatePrequalificationRound(round); + const decision = byId(result.decisions, 'applicant-invalid-sponsor-decision'); + const action = byId(result.remediationActions, 'remediate-applicant-invalid-sponsor-decision'); + + assert.equal(decision.decision, 'hold-for-fairness-review'); + assert.equal(decision.reasons.includes('sponsor-decision-invalid'), true); + assert.equal(action.action, 'publish-valid-sponsor-decision'); + assert.equal(action.priority, 'high'); +} + function testMissingRejectionReasonListHoldsWithoutCrashing() { const round = buildSampleRound(); round.applicants = [ @@ -840,6 +889,7 @@ const tests = [ testInvalidPassThresholdHoldsPrequalificationRound, testInvalidReviewerQuorumHoldsPrequalificationRound, testInvalidReviewerScoreValuesHoldPrequalificationRound, + testInvalidSponsorDecisionHoldsPrequalificationRound, testMissingRejectionReasonListHoldsWithoutCrashing, testBlankRejectionReasonTextHoldsRejectedApplicant, testIncompleteReviewerScoreEvidenceHoldsWithoutCrashing, From 3647ad0e2d866bc91e4ae457057386c8048ca481 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Sat, 30 May 2026 13:10:52 +0200 Subject: [PATCH 19/28] Harden prequalification applicant identity --- .../README.md | 3 +- .../acceptance-notes.md | 1 + .../demo.js | 61 +++++++++++++++++- .../index.js | 36 +++++++++-- .../make-demo-video.py | 2 +- .../reports/demo.mp4 | Bin 46713 -> 45050 bytes .../missing-applicant-identity-packet.json | 46 +++++++++++++ .../prequalification-fairness-report.md | 8 +++ .../reports/summary.svg | 2 +- .../requirements-map.md | 4 +- .../test.js | 51 +++++++++++++++ 11 files changed, 204 insertions(+), 10 deletions(-) create mode 100644 challenge-prequalification-fairness-guard/reports/missing-applicant-identity-packet.json diff --git a/challenge-prequalification-fairness-guard/README.md b/challenge-prequalification-fairness-guard/README.md index ca04f34d..a662d60c 100644 --- a/challenge-prequalification-fairness-guard/README.md +++ b/challenge-prequalification-fairness-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Bounty System slice for SCIBASE issue #18. It evaluates sponsor-side prequalification rounds before solver teams are accepted or rejected from a challenge. -The guard checks published screening criteria, complete and unique criterion identifiers after trimming, valid criterion weight values and totals, valid pass thresholds, valid reviewer quorum requirements, valid sponsor accept/reject decisions, complete reviewer score evidence, valid finite 0-100 reviewer score values, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, missing reviewer identity evidence, duplicate reviewer score evidence, missing, empty, or blank rejection reason evidence, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, repeated reviewer identities are deduplicated before quorum or threshold scoring, and missing reviewer identities, invalid sponsor decisions, or invalid score values are excluded from taking effect until the evidence is completed. Unfair or incomplete screening decisions are held for remediation before challenge access changes. +The guard checks published screening criteria, complete and unique criterion identifiers after trimming, valid criterion weight values and totals, valid pass thresholds, valid reviewer quorum requirements, valid sponsor accept/reject decisions, complete applicant identity evidence, complete reviewer score evidence, valid finite 0-100 reviewer score values, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, missing reviewer identity evidence, duplicate reviewer score evidence, missing, empty, or blank rejection reason evidence, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, repeated reviewer identities are deduplicated before quorum or threshold scoring, and missing applicant identities, missing reviewer identities, invalid sponsor decisions, or invalid score values are excluded from taking effect until the evidence is completed. Unfair or incomplete screening decisions are held for remediation before challenge access changes. ## Run @@ -21,6 +21,7 @@ npm run check - `reports/invalid-reviewer-score-packet.json` - `reports/invalid-reviewer-quorum-packet.json` - `reports/invalid-sponsor-decision-packet.json` +- `reports/missing-applicant-identity-packet.json` - `reports/blank-rejection-reason-packet.json` - `reports/prequalification-fairness-report.md` - `reports/summary.svg` diff --git a/challenge-prequalification-fairness-guard/acceptance-notes.md b/challenge-prequalification-fairness-guard/acceptance-notes.md index 7510c5ee..9dccce06 100644 --- a/challenge-prequalification-fairness-guard/acceptance-notes.md +++ b/challenge-prequalification-fairness-guard/acceptance-notes.md @@ -26,6 +26,7 @@ Validation coverage: - invalid pass thresholds are held before sponsor accept/reject decisions can take effect - invalid reviewer quorum requirements are held before sponsor accept/reject decisions can take effect - invalid sponsor decision values are held before malformed accept/reject evidence can change solver access +- missing or blank applicant identities are held before anonymous or malformed applicant rows can change solver access - invalid reviewer score values outside the finite 0-100 range are held before malformed scoring evidence can drive acceptance or rejection - missing rejection reason lists are normalized to an auditable fairness hold instead of crashing the prequalification packet - blank rejection reason text is normalized away and held as missing applicant-facing rejection evidence diff --git a/challenge-prequalification-fairness-guard/demo.js b/challenge-prequalification-fairness-guard/demo.js index a1b6a89f..0c6caa9a 100644 --- a/challenge-prequalification-fairness-guard/demo.js +++ b/challenge-prequalification-fairness-guard/demo.js @@ -11,6 +11,7 @@ const normalizedCriterionResult = evaluatePrequalificationRound(buildNormalizedC const invalidScoreResult = evaluatePrequalificationRound(buildInvalidReviewerScoreRound()); const invalidQuorumResult = evaluatePrequalificationRound(buildInvalidReviewerQuorumRound()); const invalidSponsorDecisionResult = evaluatePrequalificationRound(buildInvalidSponsorDecisionRound()); +const missingApplicantIdentityResult = evaluatePrequalificationRound(buildMissingApplicantIdentityRound()); const blankRejectionReasonResult = evaluatePrequalificationRound(buildBlankRejectionReasonRound()); const packetPath = path.join(reportsDir, 'prequalification-fairness-packet.json'); @@ -22,6 +23,10 @@ const invalidSponsorDecisionPacketPath = path.join( reportsDir, 'invalid-sponsor-decision-packet.json' ); +const missingApplicantIdentityPacketPath = path.join( + reportsDir, + 'missing-applicant-identity-packet.json' +); const blankRejectionReasonPacketPath = path.join(reportsDir, 'blank-rejection-reason-packet.json'); const reportPath = path.join(reportsDir, 'prequalification-fairness-report.md'); const svgPath = path.join(reportsDir, 'summary.svg'); @@ -35,6 +40,10 @@ fs.writeFileSync( invalidSponsorDecisionPacketPath, `${JSON.stringify(invalidSponsorDecisionResult, null, 2)}\n` ); +fs.writeFileSync( + missingApplicantIdentityPacketPath, + `${JSON.stringify(missingApplicantIdentityResult, null, 2)}\n` +); fs.writeFileSync(blankRejectionReasonPacketPath, `${JSON.stringify(blankRejectionReasonResult, null, 2)}\n`); const decisions = result.decisions @@ -112,6 +121,14 @@ ${actions} - Remediation: ${invalidSponsorDecisionResult.remediationActions[0].action} - Audit digest: ${invalidSponsorDecisionResult.auditDigest} +## Missing Applicant Identity Packet + +- Applicant: ${JSON.stringify(missingApplicantIdentityResult.decisions[0].applicantId)} +- Decision: ${missingApplicantIdentityResult.decisions[0].decision} +- Reasons: ${missingApplicantIdentityResult.decisions[0].reasons.join(', ')} +- Remediation: ${missingApplicantIdentityResult.remediationActions[0].action} +- Audit digest: ${missingApplicantIdentityResult.auditDigest} + ## Blank Rejection Reason Packet - Applicant: ${blankRejectionReasonResult.decisions[0].applicantId} @@ -134,7 +151,7 @@ const svg = `Accepted applicants: ${result.summary.accepted} Held for fairness review: ${result.summary.held} Remediation actions: ${result.summary.remediationActions} - Checks: criteria, sponsor decisions, valid scores, reviewer identity/quorum + Checks: criteria, applicant identities, sponsor decisions, valid scores thresholds, anonymity, conflicts, appeals Unfair screening decisions are held before applicants are accepted or rejected. ${result.auditDigest} @@ -149,6 +166,7 @@ console.log(`Wrote ${path.relative(__dirname, normalizedCriterionPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, invalidScorePacketPath)}`); console.log(`Wrote ${path.relative(__dirname, invalidQuorumPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, invalidSponsorDecisionPacketPath)}`); +console.log(`Wrote ${path.relative(__dirname, missingApplicantIdentityPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, blankRejectionReasonPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, reportPath)}`); console.log(`Wrote ${path.relative(__dirname, svgPath)}`); @@ -382,6 +400,47 @@ function buildInvalidSponsorDecisionRound() { return round; } +function buildMissingApplicantIdentityRound() { + const round = buildSampleRound(); + round.applicants = [ + { + id: ' ', + sponsorDecision: 'accept', + rejectionReasons: [], + appealDueAt: null + } + ]; + round.reviews = [ + { + applicantId: ' ', + reviewerId: 'reviewer-independent-a', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 94, + 'data-readiness': 96, + 'safety-plan': 94 + } + }, + { + applicantId: ' ', + reviewerId: 'reviewer-independent-b', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 92, + 'data-readiness': 94, + 'safety-plan': 93 + } + } + ]; + return round; +} + function buildBlankRejectionReasonRound() { const round = buildSampleRound(); round.applicants = [ diff --git a/challenge-prequalification-fairness-guard/index.js b/challenge-prequalification-fairness-guard/index.js index b19897be..e51e5bcc 100644 --- a/challenge-prequalification-fairness-guard/index.js +++ b/challenge-prequalification-fairness-guard/index.js @@ -86,6 +86,22 @@ function reviewerIdFor(review) { return typeof review.reviewerId === 'string' ? review.reviewerId.trim() : ''; } +function applicantIdFor(applicant) { + return typeof applicant.id === 'string' ? applicant.id.trim() : ''; +} + +function applicantIdentityIsMissing(applicant) { + return !applicantIdFor(applicant); +} + +function reviewApplicantIdFor(review) { + return typeof review.applicantId === 'string' ? review.applicantId.trim() : review.applicantId; +} + +function outputApplicantIdFor(applicant) { + return applicantIdFor(applicant) || 'unidentified-applicant'; +} + function duplicateNonConflictedReviewerIds(reviews) { const counts = reviews .filter((review) => !review.conflict) @@ -248,6 +264,10 @@ function reasonsForApplicant(applicant, reviews, round) { reasons.push('missing-reviewer-identity'); } + if (applicantIdentityIsMissing(applicant)) { + reasons.push('missing-applicant-identity'); + } + if (criteriaWeightTotal(round) !== 100) { reasons.push('criteria-weight-total-invalid'); } @@ -383,6 +403,10 @@ function remediationAction(applicant, reasons) { return 'complete-prequalification-evidence'; } + if (reasons.includes('missing-applicant-identity')) { + return 'complete-prequalification-evidence'; + } + if (reasons.includes('inconsistent-threshold-decision')) { return 'reconcile-score-threshold-decision'; } @@ -391,10 +415,11 @@ function remediationAction(applicant, reasons) { } function evaluatePrequalificationRound(round) { - const reviewsByApplicant = groupBy(round.reviews, (review) => review.applicantId); + const reviewsByApplicant = groupBy(round.reviews, reviewApplicantIdFor); const decisions = round.applicants.map((applicant) => { - const reviews = reviewsByApplicant[applicant.id] || []; + const reviews = reviewsByApplicant[applicantIdFor(applicant)] || []; + const applicantId = outputApplicantIdFor(applicant); const nonConflictedReviews = countableNonConflictedReviews(reviews); const reasons = reasonsForApplicant(applicant, reviews, round); const score = weightedScore(round.criteria, nonConflictedReviews); @@ -406,8 +431,8 @@ function evaluatePrequalificationRound(round) { : 'reject-with-audit'; return { - id: applicant.id, - applicantId: applicant.id, + id: applicantId, + applicantId, challengeId: round.challengeId, decision, sponsorDecision: applicant.sponsorDecision, @@ -419,7 +444,7 @@ function evaluatePrequalificationRound(round) { rejectionReasons: applicantRejectionReasons(applicant), appealStatus: appealStatus(applicant, round), auditDigest: digest({ - applicantId: applicant.id, + applicantId, challengeId: round.challengeId, score, reasons, @@ -446,6 +471,7 @@ function evaluatePrequalificationRound(round) { decision.reasons.includes('missing-published-criterion-id') || decision.reasons.includes('duplicate-reviewer-score-evidence') || decision.reasons.includes('missing-reviewer-identity') || + decision.reasons.includes('missing-applicant-identity') || decision.reasons.includes('unpublished-screening-criterion') || decision.reasons.includes('criteria-weight-total-invalid') || decision.reasons.includes('criteria-weight-value-invalid') || diff --git a/challenge-prequalification-fairness-guard/make-demo-video.py b/challenge-prequalification-fairness-guard/make-demo-video.py index afc32309..cd42a6db 100644 --- a/challenge-prequalification-fairness-guard/make-demo-video.py +++ b/challenge-prequalification-fairness-guard/make-demo-video.py @@ -28,7 +28,7 @@ def draw_frame_with_pillow(): draw.text((96, 102), "Challenge Prequalification Fairness Guard", fill="white", font=title_font) draw.text((96, 190), "Published criteria plus weighted threshold checks", fill="#dff5d5", font=body_font) draw.text((96, 248), "Invalid sponsor decisions cannot change access", fill="#dff5d5", font=body_font) - draw.text((96, 306), "Anonymous leaks, conflicts, and missing reviewer IDs are held", fill="#dff5d5", font=body_font) + draw.text((96, 306), "Missing applicant or reviewer identities are held", fill="#dff5d5", font=body_font) draw.text((96, 402), "Synthetic data only. No sponsor, solver, payout, or identity systems are called.", fill="#ffd37a", font=note_font) image.save(FRAME) diff --git a/challenge-prequalification-fairness-guard/reports/demo.mp4 b/challenge-prequalification-fairness-guard/reports/demo.mp4 index d1edacbd5da68d6773b6b7cee4cb63a0cd2d5444..283428f1f0a16f17a17266cc03f1e6e201e4cc86 100644 GIT binary patch delta 18745 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 applicants: 1 Held for fairness review: 3 Remediation actions: 3 - Checks: criteria, sponsor decisions, valid scores, reviewer identity/quorum + Checks: criteria, applicant identities, sponsor decisions, valid scores thresholds, anonymity, conflicts, appeals Unfair screening decisions are held before applicants are accepted or rejected. sha256:5ec541696fa83ff5f5ac49891dbb4bdf0e1174638039f2a2e7b6e65f2df49d16 diff --git a/challenge-prequalification-fairness-guard/requirements-map.md b/challenge-prequalification-fairness-guard/requirements-map.md index 1f105bf6..927ca604 100644 --- a/challenge-prequalification-fairness-guard/requirements-map.md +++ b/challenge-prequalification-fairness-guard/requirements-map.md @@ -2,7 +2,7 @@ ## Challenge Posting Portal -- Verifies that prequalification rounds use published criteria, complete and unique criterion identifiers after trimming, nonnegative weights, valid weight totals, valid 0-100 pass thresholds, valid positive reviewer quorum requirements, and explicit sponsor accept/reject decisions. +- Verifies that prequalification rounds use published criteria, complete and unique criterion identifiers after trimming, nonnegative weights, valid weight totals, valid 0-100 pass thresholds, valid positive reviewer quorum requirements, explicit sponsor accept/reject decisions, and complete applicant identities. - Blocks unpublished sponsor preferences from entering solver-screening decisions. - Keeps prequalification decisions tied to challenge timelines and parseable appeal windows. @@ -10,6 +10,7 @@ - Protects anonymous or named participation settings during prequalification review. - Requires a valid positive reviewer quorum before a solver team is accepted or rejected, and excludes missing or blank reviewer identities from quorum until reviewer evidence is completed. +- Holds missing or blank applicant identities before malformed applicant rows can change solver-team access. - Holds incomplete reviewer score packets and invalid finite 0-100 score values for evidence completion instead of letting malformed review records crash or drive decisions. - Preserves audit evidence for each applicant before access to private challenge workspaces changes. @@ -19,6 +20,7 @@ - Holds invalid pass thresholds for fairness review before sponsor accept/reject decisions can take effect. - Holds invalid reviewer quorum requirements for fairness review before sponsor accept/reject decisions can take effect. - Holds invalid sponsor decision values for fairness review before malformed accept/reject evidence can change solver access. +- Holds missing applicant identity evidence for fairness review before anonymous or malformed applicant rows can change solver access. - Holds invalid reviewer score values for fairness review before malformed score evidence can drive sponsor decisions. - Holds duplicate published criterion identifiers for fairness review before ambiguous rubric evidence can drive sponsor decisions. - Holds whitespace-variant duplicate published criterion identifiers for fairness review before ambiguous rubric evidence can drive sponsor decisions. diff --git a/challenge-prequalification-fairness-guard/test.js b/challenge-prequalification-fairness-guard/test.js index f4848615..c6065fd5 100644 --- a/challenge-prequalification-fairness-guard/test.js +++ b/challenge-prequalification-fairness-guard/test.js @@ -620,6 +620,56 @@ function testInvalidSponsorDecisionHoldsPrequalificationRound() { assert.equal(action.priority, 'high'); } +function testMissingApplicantIdentityHoldsPrequalificationRound() { + const round = buildSampleRound(); + round.applicants = [ + { + id: ' ', + sponsorDecision: 'accept', + rejectionReasons: [], + appealDueAt: null + } + ]; + round.reviews = [ + { + applicantId: ' ', + reviewerId: 'reviewer-independent-a', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 94, + 'data-readiness': 96, + 'safety-plan': 94 + } + }, + { + applicantId: ' ', + reviewerId: 'reviewer-independent-b', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 92, + 'data-readiness': 94, + 'safety-plan': 93 + } + } + ]; + + const result = evaluatePrequalificationRound(round); + const decision = byId(result.decisions, 'unidentified-applicant'); + const action = byId(result.remediationActions, 'remediate-unidentified-applicant'); + + assert.equal(decision.applicantId, 'unidentified-applicant'); + assert.equal(decision.decision, 'hold-for-fairness-review'); + assert.equal(decision.reasons.includes('missing-applicant-identity'), true); + assert.equal(action.action, 'complete-prequalification-evidence'); + assert.equal(action.priority, 'high'); +} + function testMissingRejectionReasonListHoldsWithoutCrashing() { const round = buildSampleRound(); round.applicants = [ @@ -890,6 +940,7 @@ const tests = [ testInvalidReviewerQuorumHoldsPrequalificationRound, testInvalidReviewerScoreValuesHoldPrequalificationRound, testInvalidSponsorDecisionHoldsPrequalificationRound, + testMissingApplicantIdentityHoldsPrequalificationRound, testMissingRejectionReasonListHoldsWithoutCrashing, testBlankRejectionReasonTextHoldsRejectedApplicant, testIncompleteReviewerScoreEvidenceHoldsWithoutCrashing, From f24fb2eca56a46e76bc635c9bb07be8c9fa33540 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Sat, 30 May 2026 13:41:58 +0200 Subject: [PATCH 20/28] Harden prequalification review list evidence --- .../README.md | 3 +- .../acceptance-notes.md | 1 + .../demo.js | 26 +++++++++ .../index.js | 19 ++++++- .../make-demo-video.py | 2 +- .../reports/demo.mp4 | Bin 45050 -> 44663 bytes .../reports/missing-review-list-packet.json | 50 ++++++++++++++++++ .../prequalification-fairness-report.md | 8 +++ .../requirements-map.md | 2 + .../test.js | 24 +++++++++ 10 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 challenge-prequalification-fairness-guard/reports/missing-review-list-packet.json diff --git a/challenge-prequalification-fairness-guard/README.md b/challenge-prequalification-fairness-guard/README.md index a662d60c..6eb7e9af 100644 --- a/challenge-prequalification-fairness-guard/README.md +++ b/challenge-prequalification-fairness-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Bounty System slice for SCIBASE issue #18. It evaluates sponsor-side prequalification rounds before solver teams are accepted or rejected from a challenge. -The guard checks published screening criteria, complete and unique criterion identifiers after trimming, valid criterion weight values and totals, valid pass thresholds, valid reviewer quorum requirements, valid sponsor accept/reject decisions, complete applicant identity evidence, complete reviewer score evidence, valid finite 0-100 reviewer score values, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, missing reviewer identity evidence, duplicate reviewer score evidence, missing, empty, or blank rejection reason evidence, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, repeated reviewer identities are deduplicated before quorum or threshold scoring, and missing applicant identities, missing reviewer identities, invalid sponsor decisions, or invalid score values are excluded from taking effect until the evidence is completed. Unfair or incomplete screening decisions are held for remediation before challenge access changes. +The guard checks published screening criteria, complete and unique criterion identifiers after trimming, valid criterion weight values and totals, valid pass thresholds, valid reviewer quorum requirements, valid sponsor accept/reject decisions, complete applicant identity evidence, complete round-level review-list evidence, complete reviewer score evidence, valid finite 0-100 reviewer score values, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, missing reviewer identity evidence, duplicate reviewer score evidence, missing, empty, or blank rejection reason evidence, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, repeated reviewer identities are deduplicated before quorum or threshold scoring, and missing applicant identities, missing review lists, missing reviewer identities, invalid sponsor decisions, or invalid score values are excluded from taking effect until the evidence is completed. Unfair or incomplete screening decisions are held for remediation before challenge access changes. ## Run @@ -22,6 +22,7 @@ npm run check - `reports/invalid-reviewer-quorum-packet.json` - `reports/invalid-sponsor-decision-packet.json` - `reports/missing-applicant-identity-packet.json` +- `reports/missing-review-list-packet.json` - `reports/blank-rejection-reason-packet.json` - `reports/prequalification-fairness-report.md` - `reports/summary.svg` diff --git a/challenge-prequalification-fairness-guard/acceptance-notes.md b/challenge-prequalification-fairness-guard/acceptance-notes.md index 9dccce06..626b8434 100644 --- a/challenge-prequalification-fairness-guard/acceptance-notes.md +++ b/challenge-prequalification-fairness-guard/acceptance-notes.md @@ -31,6 +31,7 @@ Validation coverage: - missing rejection reason lists are normalized to an auditable fairness hold instead of crashing the prequalification packet - blank rejection reason text is normalized away and held as missing applicant-facing rejection evidence - incomplete reviewer score evidence is held for completion without crashing the prequalification packet +- missing review lists are held for evidence completion instead of crashing sparse prequalification packets - duplicate reviewer score evidence is held and deduplicated before quorum or weighted threshold scoring - missing or blank reviewer identities are held and excluded from reviewer quorum until evidence is completed - audit digests are deterministic and private-data free diff --git a/challenge-prequalification-fairness-guard/demo.js b/challenge-prequalification-fairness-guard/demo.js index 0c6caa9a..153a80d6 100644 --- a/challenge-prequalification-fairness-guard/demo.js +++ b/challenge-prequalification-fairness-guard/demo.js @@ -12,6 +12,7 @@ const invalidScoreResult = evaluatePrequalificationRound(buildInvalidReviewerSco const invalidQuorumResult = evaluatePrequalificationRound(buildInvalidReviewerQuorumRound()); const invalidSponsorDecisionResult = evaluatePrequalificationRound(buildInvalidSponsorDecisionRound()); const missingApplicantIdentityResult = evaluatePrequalificationRound(buildMissingApplicantIdentityRound()); +const missingReviewListResult = evaluatePrequalificationRound(buildMissingReviewListRound()); const blankRejectionReasonResult = evaluatePrequalificationRound(buildBlankRejectionReasonRound()); const packetPath = path.join(reportsDir, 'prequalification-fairness-packet.json'); @@ -27,6 +28,7 @@ const missingApplicantIdentityPacketPath = path.join( reportsDir, 'missing-applicant-identity-packet.json' ); +const missingReviewListPacketPath = path.join(reportsDir, 'missing-review-list-packet.json'); const blankRejectionReasonPacketPath = path.join(reportsDir, 'blank-rejection-reason-packet.json'); const reportPath = path.join(reportsDir, 'prequalification-fairness-report.md'); const svgPath = path.join(reportsDir, 'summary.svg'); @@ -44,6 +46,7 @@ fs.writeFileSync( missingApplicantIdentityPacketPath, `${JSON.stringify(missingApplicantIdentityResult, null, 2)}\n` ); +fs.writeFileSync(missingReviewListPacketPath, `${JSON.stringify(missingReviewListResult, null, 2)}\n`); fs.writeFileSync(blankRejectionReasonPacketPath, `${JSON.stringify(blankRejectionReasonResult, null, 2)}\n`); const decisions = result.decisions @@ -129,6 +132,14 @@ ${actions} - Remediation: ${missingApplicantIdentityResult.remediationActions[0].action} - Audit digest: ${missingApplicantIdentityResult.auditDigest} +## Missing Review List Packet + +- Applicant: ${missingReviewListResult.decisions[0].applicantId} +- Decision: ${missingReviewListResult.decisions[0].decision} +- Reasons: ${missingReviewListResult.decisions[0].reasons.join(', ')} +- Remediation: ${missingReviewListResult.remediationActions[0].action} +- Audit digest: ${missingReviewListResult.auditDigest} + ## Blank Rejection Reason Packet - Applicant: ${blankRejectionReasonResult.decisions[0].applicantId} @@ -167,6 +178,7 @@ console.log(`Wrote ${path.relative(__dirname, invalidScorePacketPath)}`); console.log(`Wrote ${path.relative(__dirname, invalidQuorumPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, invalidSponsorDecisionPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, missingApplicantIdentityPacketPath)}`); +console.log(`Wrote ${path.relative(__dirname, missingReviewListPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, blankRejectionReasonPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, reportPath)}`); console.log(`Wrote ${path.relative(__dirname, svgPath)}`); @@ -441,6 +453,20 @@ function buildMissingApplicantIdentityRound() { return round; } +function buildMissingReviewListRound() { + const round = buildSampleRound(); + round.applicants = [ + { + id: 'applicant-missing-review-list', + sponsorDecision: 'accept', + rejectionReasons: [], + appealDueAt: null + } + ]; + delete round.reviews; + return round; +} + function buildBlankRejectionReasonRound() { const round = buildSampleRound(); round.applicants = [ diff --git a/challenge-prequalification-fairness-guard/index.js b/challenge-prequalification-fairness-guard/index.js index e51e5bcc..a8197f7e 100644 --- a/challenge-prequalification-fairness-guard/index.js +++ b/challenge-prequalification-fairness-guard/index.js @@ -98,6 +98,14 @@ function reviewApplicantIdFor(review) { return typeof review.applicantId === 'string' ? review.applicantId.trim() : review.applicantId; } +function reviewListFor(round) { + return Array.isArray(round.reviews) ? round.reviews : []; +} + +function reviewListIsMissing(round) { + return !Array.isArray(round.reviews); +} + function outputApplicantIdFor(applicant) { return applicantIdFor(applicant) || 'unidentified-applicant'; } @@ -264,6 +272,10 @@ function reasonsForApplicant(applicant, reviews, round) { reasons.push('missing-reviewer-identity'); } + if (reviewListIsMissing(round)) { + reasons.push('missing-review-list'); + } + if (applicantIdentityIsMissing(applicant)) { reasons.push('missing-applicant-identity'); } @@ -403,6 +415,10 @@ function remediationAction(applicant, reasons) { return 'complete-prequalification-evidence'; } + if (reasons.includes('missing-review-list')) { + return 'complete-prequalification-evidence'; + } + if (reasons.includes('missing-applicant-identity')) { return 'complete-prequalification-evidence'; } @@ -415,7 +431,7 @@ function remediationAction(applicant, reasons) { } function evaluatePrequalificationRound(round) { - const reviewsByApplicant = groupBy(round.reviews, reviewApplicantIdFor); + const reviewsByApplicant = groupBy(reviewListFor(round), reviewApplicantIdFor); const decisions = round.applicants.map((applicant) => { const reviews = reviewsByApplicant[applicantIdFor(applicant)] || []; @@ -471,6 +487,7 @@ function evaluatePrequalificationRound(round) { decision.reasons.includes('missing-published-criterion-id') || decision.reasons.includes('duplicate-reviewer-score-evidence') || decision.reasons.includes('missing-reviewer-identity') || + decision.reasons.includes('missing-review-list') || decision.reasons.includes('missing-applicant-identity') || decision.reasons.includes('unpublished-screening-criterion') || decision.reasons.includes('criteria-weight-total-invalid') || diff --git a/challenge-prequalification-fairness-guard/make-demo-video.py b/challenge-prequalification-fairness-guard/make-demo-video.py index cd42a6db..11e281c2 100644 --- a/challenge-prequalification-fairness-guard/make-demo-video.py +++ b/challenge-prequalification-fairness-guard/make-demo-video.py @@ -28,7 +28,7 @@ def draw_frame_with_pillow(): draw.text((96, 102), "Challenge Prequalification Fairness Guard", fill="white", font=title_font) draw.text((96, 190), "Published criteria plus weighted threshold checks", fill="#dff5d5", font=body_font) draw.text((96, 248), "Invalid sponsor decisions cannot change access", fill="#dff5d5", font=body_font) - draw.text((96, 306), "Missing applicant or reviewer identities are held", fill="#dff5d5", font=body_font) + draw.text((96, 306), "Missing review lists and identities are held", fill="#dff5d5", font=body_font) draw.text((96, 402), "Synthetic data only. No sponsor, solver, payout, or identity systems are called.", fill="#ffd37a", font=note_font) image.save(FRAME) diff --git a/challenge-prequalification-fairness-guard/reports/demo.mp4 b/challenge-prequalification-fairness-guard/reports/demo.mp4 index 283428f1f0a16f17a17266cc03f1e6e201e4cc86..f82bca135f87d4b26cd6eb08b52a2968347fd7ca 100644 GIT binary patch delta 18058 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 delta 18068 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 Rjb-z+d%FLygaV;E{V(aenO6V+ diff --git a/challenge-prequalification-fairness-guard/reports/missing-review-list-packet.json b/challenge-prequalification-fairness-guard/reports/missing-review-list-packet.json new file mode 100644 index 00000000..3a4a7f38 --- /dev/null +++ b/challenge-prequalification-fairness-guard/reports/missing-review-list-packet.json @@ -0,0 +1,50 @@ +{ + "challengeId": "challenge-18-prequalification-rna-biomarker", + "generatedAt": "2026-05-28T08:00:00Z", + "criteriaDigest": "sha256:d643b033793917b9d0488787518a11e97094e671d52b86b69a6153375d726721", + "decisions": [ + { + "id": "applicant-missing-review-list", + "applicantId": "applicant-missing-review-list", + "challengeId": "challenge-18-prequalification-rna-biomarker", + "decision": "hold-for-fairness-review", + "sponsorDecision": "accept", + "weightedScore": 0, + "passThreshold": 75, + "reviewersCounted": 0, + "criteriaApplied": [ + "domain-fit", + "data-readiness", + "safety-plan" + ], + "reasons": [ + "inconsistent-threshold-decision", + "missing-review-list", + "reviewer-quorum-shortfall" + ], + "rejectionReasons": [], + "appealStatus": "not-required", + "auditDigest": "sha256:792e589c9757b0b7d48f0f48fbc3863f5e42fb0926864968a582e47ebed09614" + } + ], + "remediationActions": [ + { + "id": "remediate-applicant-missing-review-list", + "applicantId": "applicant-missing-review-list", + "action": "complete-prequalification-evidence", + "priority": "high", + "reasons": [ + "inconsistent-threshold-decision", + "missing-review-list", + "reviewer-quorum-shortfall" + ] + } + ], + "summary": { + "accepted": 0, + "held": 1, + "rejectedWithAudit": 0, + "remediationActions": 1 + }, + "auditDigest": "sha256:205a2e89821f1f3e7c9b83e959f0b123967d8af29b659d89af898e473ed4033d" +} diff --git a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md index 9423d85d..98b2618c 100644 --- a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md +++ b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md @@ -73,6 +73,14 @@ Generated: 2026-05-28T08:00:00Z - Remediation: complete-prequalification-evidence - Audit digest: sha256:85aa774922f5707139444c13c705a88af31757014d98a8767b0cbe1725c4cf7c +## Missing Review List Packet + +- Applicant: applicant-missing-review-list +- Decision: hold-for-fairness-review +- Reasons: inconsistent-threshold-decision, missing-review-list, reviewer-quorum-shortfall +- Remediation: complete-prequalification-evidence +- Audit digest: sha256:205a2e89821f1f3e7c9b83e959f0b123967d8af29b659d89af898e473ed4033d + ## Blank Rejection Reason Packet - Applicant: applicant-blank-rejection-reason diff --git a/challenge-prequalification-fairness-guard/requirements-map.md b/challenge-prequalification-fairness-guard/requirements-map.md index 927ca604..5986f3ef 100644 --- a/challenge-prequalification-fairness-guard/requirements-map.md +++ b/challenge-prequalification-fairness-guard/requirements-map.md @@ -11,6 +11,7 @@ - Protects anonymous or named participation settings during prequalification review. - Requires a valid positive reviewer quorum before a solver team is accepted or rejected, and excludes missing or blank reviewer identities from quorum until reviewer evidence is completed. - Holds missing or blank applicant identities before malformed applicant rows can change solver-team access. +- Holds missing round-level review lists before sparse prequalification packets can crash or change solver-team access. - Holds incomplete reviewer score packets and invalid finite 0-100 score values for evidence completion instead of letting malformed review records crash or drive decisions. - Preserves audit evidence for each applicant before access to private challenge workspaces changes. @@ -29,6 +30,7 @@ - Excludes conflicted reviewer scores from weighted threshold evidence while retaining the conflict finding. - Deduplicates repeated reviewer identities before quorum and weighted threshold scoring while retaining the duplicate-evidence finding. - Holds missing reviewer identity evidence before anonymous or malformed reviewer rows can satisfy quorum. +- Holds missing review-list evidence for fairness review before applicant decisions can take effect. - Produces deterministic digests for challenge administrators and third-party reviewers. ## Safety And Scope diff --git a/challenge-prequalification-fairness-guard/test.js b/challenge-prequalification-fairness-guard/test.js index c6065fd5..0c450107 100644 --- a/challenge-prequalification-fairness-guard/test.js +++ b/challenge-prequalification-fairness-guard/test.js @@ -809,6 +809,29 @@ function testIncompleteReviewerScoreEvidenceHoldsWithoutCrashing() { assert.equal(action.action, 'complete-prequalification-evidence'); } +function testMissingReviewListHoldsPrequalificationRoundWithoutCrashing() { + const round = buildSampleRound(); + delete round.reviews; + round.applicants = [ + { + id: 'applicant-missing-review-list', + sponsorDecision: 'accept', + rejectionReasons: [], + appealDueAt: null + } + ]; + + const result = evaluatePrequalificationRound(round); + const decision = byId(result.decisions, 'applicant-missing-review-list'); + const action = byId(result.remediationActions, 'remediate-applicant-missing-review-list'); + + assert.equal(decision.decision, 'hold-for-fairness-review'); + assert.equal(decision.reasons.includes('missing-review-list'), true); + assert.equal(decision.reviewersCounted, 0); + assert.equal(action.action, 'complete-prequalification-evidence'); + assert.equal(action.priority, 'high'); +} + function testDuplicateReviewerScoreEvidenceDoesNotSatisfyQuorum() { const round = buildSampleRound(); round.minReviewers = 2; @@ -944,6 +967,7 @@ const tests = [ testMissingRejectionReasonListHoldsWithoutCrashing, testBlankRejectionReasonTextHoldsRejectedApplicant, testIncompleteReviewerScoreEvidenceHoldsWithoutCrashing, + testMissingReviewListHoldsPrequalificationRoundWithoutCrashing, testDuplicateReviewerScoreEvidenceDoesNotSatisfyQuorum, testMissingReviewerIdentityDoesNotSatisfyQuorum, testAuditDigestIsDeterministicAndPrivateFree From 4cafdffecec48c30207af16a013aa7fbb10047f4 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Sat, 30 May 2026 15:19:27 +0200 Subject: [PATCH 21/28] Harden missing prequalification criteria evidence --- .../README.md | 3 +- .../acceptance-notes.md | 1 + .../demo.js | 57 +++++++++++++++++++ .../index.js | 33 ++++++++--- .../reports/missing-criteria-list-packet.json | 48 ++++++++++++++++ .../prequalification-fairness-report.md | 8 +++ .../requirements-map.md | 5 +- .../test.js | 54 ++++++++++++++++++ 8 files changed, 198 insertions(+), 11 deletions(-) create mode 100644 challenge-prequalification-fairness-guard/reports/missing-criteria-list-packet.json diff --git a/challenge-prequalification-fairness-guard/README.md b/challenge-prequalification-fairness-guard/README.md index 6eb7e9af..7e0b6c72 100644 --- a/challenge-prequalification-fairness-guard/README.md +++ b/challenge-prequalification-fairness-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Bounty System slice for SCIBASE issue #18. It evaluates sponsor-side prequalification rounds before solver teams are accepted or rejected from a challenge. -The guard checks published screening criteria, complete and unique criterion identifiers after trimming, valid criterion weight values and totals, valid pass thresholds, valid reviewer quorum requirements, valid sponsor accept/reject decisions, complete applicant identity evidence, complete round-level review-list evidence, complete reviewer score evidence, valid finite 0-100 reviewer score values, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, missing reviewer identity evidence, duplicate reviewer score evidence, missing, empty, or blank rejection reason evidence, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, repeated reviewer identities are deduplicated before quorum or threshold scoring, and missing applicant identities, missing review lists, missing reviewer identities, invalid sponsor decisions, or invalid score values are excluded from taking effect until the evidence is completed. Unfair or incomplete screening decisions are held for remediation before challenge access changes. +The guard checks published screening criteria, complete criteria-list evidence, complete and unique criterion identifiers after trimming, valid criterion weight values and totals, valid pass thresholds, valid reviewer quorum requirements, valid sponsor accept/reject decisions, complete applicant identity evidence, complete round-level review-list evidence, complete reviewer score evidence, valid finite 0-100 reviewer score values, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, missing reviewer identity evidence, duplicate reviewer score evidence, missing, empty, or blank rejection reason evidence, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, repeated reviewer identities are deduplicated before quorum or threshold scoring, and missing applicant identities, missing criteria lists, missing review lists, missing reviewer identities, invalid sponsor decisions, or invalid score values are excluded from taking effect until the evidence is completed. Unfair or incomplete screening decisions are held for remediation before challenge access changes. ## Run @@ -23,6 +23,7 @@ npm run check - `reports/invalid-sponsor-decision-packet.json` - `reports/missing-applicant-identity-packet.json` - `reports/missing-review-list-packet.json` +- `reports/missing-criteria-list-packet.json` - `reports/blank-rejection-reason-packet.json` - `reports/prequalification-fairness-report.md` - `reports/summary.svg` diff --git a/challenge-prequalification-fairness-guard/acceptance-notes.md b/challenge-prequalification-fairness-guard/acceptance-notes.md index 626b8434..7b83ef4e 100644 --- a/challenge-prequalification-fairness-guard/acceptance-notes.md +++ b/challenge-prequalification-fairness-guard/acceptance-notes.md @@ -32,6 +32,7 @@ Validation coverage: - blank rejection reason text is normalized away and held as missing applicant-facing rejection evidence - incomplete reviewer score evidence is held for completion without crashing the prequalification packet - missing review lists are held for evidence completion instead of crashing sparse prequalification packets +- missing published criteria lists are held for evidence completion instead of crashing sparse prequalification packets - duplicate reviewer score evidence is held and deduplicated before quorum or weighted threshold scoring - missing or blank reviewer identities are held and excluded from reviewer quorum until evidence is completed - audit digests are deterministic and private-data free diff --git a/challenge-prequalification-fairness-guard/demo.js b/challenge-prequalification-fairness-guard/demo.js index 153a80d6..fdf020cc 100644 --- a/challenge-prequalification-fairness-guard/demo.js +++ b/challenge-prequalification-fairness-guard/demo.js @@ -13,6 +13,7 @@ const invalidQuorumResult = evaluatePrequalificationRound(buildInvalidReviewerQu const invalidSponsorDecisionResult = evaluatePrequalificationRound(buildInvalidSponsorDecisionRound()); const missingApplicantIdentityResult = evaluatePrequalificationRound(buildMissingApplicantIdentityRound()); const missingReviewListResult = evaluatePrequalificationRound(buildMissingReviewListRound()); +const missingCriteriaListResult = evaluatePrequalificationRound(buildMissingCriteriaListRound()); const blankRejectionReasonResult = evaluatePrequalificationRound(buildBlankRejectionReasonRound()); const packetPath = path.join(reportsDir, 'prequalification-fairness-packet.json'); @@ -29,6 +30,7 @@ const missingApplicantIdentityPacketPath = path.join( 'missing-applicant-identity-packet.json' ); const missingReviewListPacketPath = path.join(reportsDir, 'missing-review-list-packet.json'); +const missingCriteriaListPacketPath = path.join(reportsDir, 'missing-criteria-list-packet.json'); const blankRejectionReasonPacketPath = path.join(reportsDir, 'blank-rejection-reason-packet.json'); const reportPath = path.join(reportsDir, 'prequalification-fairness-report.md'); const svgPath = path.join(reportsDir, 'summary.svg'); @@ -47,6 +49,10 @@ fs.writeFileSync( `${JSON.stringify(missingApplicantIdentityResult, null, 2)}\n` ); fs.writeFileSync(missingReviewListPacketPath, `${JSON.stringify(missingReviewListResult, null, 2)}\n`); +fs.writeFileSync( + missingCriteriaListPacketPath, + `${JSON.stringify(missingCriteriaListResult, null, 2)}\n` +); fs.writeFileSync(blankRejectionReasonPacketPath, `${JSON.stringify(blankRejectionReasonResult, null, 2)}\n`); const decisions = result.decisions @@ -140,6 +146,14 @@ ${actions} - Remediation: ${missingReviewListResult.remediationActions[0].action} - Audit digest: ${missingReviewListResult.auditDigest} +## Missing Criteria List Packet + +- Applicant: ${missingCriteriaListResult.decisions[0].applicantId} +- Decision: ${missingCriteriaListResult.decisions[0].decision} +- Reasons: ${missingCriteriaListResult.decisions[0].reasons.join(', ')} +- Remediation: ${missingCriteriaListResult.remediationActions[0].action} +- Audit digest: ${missingCriteriaListResult.auditDigest} + ## Blank Rejection Reason Packet - Applicant: ${blankRejectionReasonResult.decisions[0].applicantId} @@ -179,6 +193,7 @@ console.log(`Wrote ${path.relative(__dirname, invalidQuorumPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, invalidSponsorDecisionPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, missingApplicantIdentityPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, missingReviewListPacketPath)}`); +console.log(`Wrote ${path.relative(__dirname, missingCriteriaListPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, blankRejectionReasonPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, reportPath)}`); console.log(`Wrote ${path.relative(__dirname, svgPath)}`); @@ -467,6 +482,48 @@ function buildMissingReviewListRound() { return round; } +function buildMissingCriteriaListRound() { + const round = buildSampleRound(); + round.applicants = [ + { + id: 'applicant-missing-criteria-list', + sponsorDecision: 'accept', + rejectionReasons: [], + appealDueAt: null + } + ]; + round.reviews = [ + { + applicantId: 'applicant-missing-criteria-list', + reviewerId: 'reviewer-independent-a', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 94, + 'data-readiness': 96, + 'safety-plan': 93 + } + }, + { + applicantId: 'applicant-missing-criteria-list', + reviewerId: 'reviewer-independent-b', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 92, + 'data-readiness': 94, + 'safety-plan': 91 + } + } + ]; + delete round.criteria; + return round; +} + function buildBlankRejectionReasonRound() { const round = buildSampleRound(); round.applicants = [ diff --git a/challenge-prequalification-fairness-guard/index.js b/challenge-prequalification-fairness-guard/index.js index a8197f7e..0348a530 100644 --- a/challenge-prequalification-fairness-guard/index.js +++ b/challenge-prequalification-fairness-guard/index.js @@ -63,7 +63,7 @@ function criterionIdFor(criterion) { } function duplicatePublishedCriterionIds(round) { - const criterionCounts = round.criteria.reduce((counts, criterion) => { + const criterionCounts = criteriaListFor(round).reduce((counts, criterion) => { const criterionId = criterionIdFor(criterion); counts[criterionId] = (counts[criterionId] || 0) + 1; return counts; @@ -77,7 +77,7 @@ function duplicatePublishedCriterionIds(round) { } function hasMissingPublishedCriterionIds(round) { - return round.criteria.some( + return criteriaListFor(round).some( (criterion) => typeof criterion.id !== 'string' || criterion.id.trim().length === 0 ); } @@ -106,6 +106,14 @@ function reviewListIsMissing(round) { return !Array.isArray(round.reviews); } +function criteriaListFor(round) { + return Array.isArray(round.criteria) ? round.criteria : []; +} + +function criteriaListIsMissing(round) { + return !Array.isArray(round.criteria); +} + function outputApplicantIdFor(applicant) { return applicantIdFor(applicant) || 'unidentified-applicant'; } @@ -156,7 +164,7 @@ function countableNonConflictedReviews(reviews) { } function publicCriteriaIds(round) { - return round.criteria.map((criterion) => criterion.id); + return criteriaListFor(round).map((criterion) => criterion.id); } function reviewScores(review) { @@ -179,11 +187,11 @@ function applicantRejectionReasons(applicant) { } function criteriaWeightTotal(round) { - return round.criteria.reduce((total, criterion) => total + criterion.weight, 0); + return criteriaListFor(round).reduce((total, criterion) => total + criterion.weight, 0); } function criteriaWeightsHaveInvalidValues(round) { - return round.criteria.some( + return criteriaListFor(round).some( (criterion) => typeof criterion.weight !== 'number' || !Number.isFinite(criterion.weight) || @@ -264,6 +272,10 @@ function reasonsForApplicant(applicant, reviews, round) { reasons.push('missing-published-criterion-id'); } + if (criteriaListIsMissing(round)) { + reasons.push('missing-published-criteria-list'); + } + if (duplicateReviewerIds.length > 0) { reasons.push('duplicate-reviewer-score-evidence'); } @@ -320,7 +332,7 @@ function reasonsForApplicant(applicant, reviews, round) { reasons.push('reviewer-score-value-invalid'); } - const score = weightedScore(round.criteria, nonConflictedReviews); + const score = weightedScore(criteriaListFor(round), nonConflictedReviews); const passesThreshold = score >= round.passThreshold; if ( @@ -354,6 +366,10 @@ function remediationAction(applicant, reasons) { return 'rerun-blinded-prequalification-review'; } + if (reasons.includes('missing-published-criteria-list')) { + return 'publish-complete-screening-criteria'; + } + if (reasons.includes('unpublished-screening-criterion')) { return 'remove-unpublished-criterion-and-rescore'; } @@ -438,7 +454,7 @@ function evaluatePrequalificationRound(round) { const applicantId = outputApplicantIdFor(applicant); const nonConflictedReviews = countableNonConflictedReviews(reviews); const reasons = reasonsForApplicant(applicant, reviews, round); - const score = weightedScore(round.criteria, nonConflictedReviews); + const score = weightedScore(criteriaListFor(round), nonConflictedReviews); const decision = reasons.length > 0 ? 'hold-for-fairness-review' @@ -485,6 +501,7 @@ function evaluatePrequalificationRound(round) { decision.reasons.includes('reviewer-conflict') || decision.reasons.includes('duplicate-published-criterion') || decision.reasons.includes('missing-published-criterion-id') || + decision.reasons.includes('missing-published-criteria-list') || decision.reasons.includes('duplicate-reviewer-score-evidence') || decision.reasons.includes('missing-reviewer-identity') || decision.reasons.includes('missing-review-list') || @@ -512,7 +529,7 @@ function evaluatePrequalificationRound(round) { return { challengeId: round.challengeId, generatedAt: round.generatedAt, - criteriaDigest: digest(round.criteria), + criteriaDigest: digest(criteriaListFor(round)), decisions, remediationActions, summary, diff --git a/challenge-prequalification-fairness-guard/reports/missing-criteria-list-packet.json b/challenge-prequalification-fairness-guard/reports/missing-criteria-list-packet.json new file mode 100644 index 00000000..6af8a3e0 --- /dev/null +++ b/challenge-prequalification-fairness-guard/reports/missing-criteria-list-packet.json @@ -0,0 +1,48 @@ +{ + "challengeId": "challenge-18-prequalification-rna-biomarker", + "generatedAt": "2026-05-28T08:00:00Z", + "criteriaDigest": "sha256:4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "decisions": [ + { + "id": "applicant-missing-criteria-list", + "applicantId": "applicant-missing-criteria-list", + "challengeId": "challenge-18-prequalification-rna-biomarker", + "decision": "hold-for-fairness-review", + "sponsorDecision": "accept", + "weightedScore": 0, + "passThreshold": 75, + "reviewersCounted": 2, + "criteriaApplied": [], + "reasons": [ + "criteria-weight-total-invalid", + "inconsistent-threshold-decision", + "missing-published-criteria-list", + "unpublished-screening-criterion" + ], + "rejectionReasons": [], + "appealStatus": "not-required", + "auditDigest": "sha256:21d6fa1aa15c41e101dd8914c23858396adfea0699a700a91fcb7501bef9da78" + } + ], + "remediationActions": [ + { + "id": "remediate-applicant-missing-criteria-list", + "applicantId": "applicant-missing-criteria-list", + "action": "publish-complete-screening-criteria", + "priority": "high", + "reasons": [ + "criteria-weight-total-invalid", + "inconsistent-threshold-decision", + "missing-published-criteria-list", + "unpublished-screening-criterion" + ] + } + ], + "summary": { + "accepted": 0, + "held": 1, + "rejectedWithAudit": 0, + "remediationActions": 1 + }, + "auditDigest": "sha256:c2d8b60e1ca8cc50e8b8c072ee2a22a9a832df09b59e63d60dd21b41b1f17fd3" +} diff --git a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md index 98b2618c..b1d67054 100644 --- a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md +++ b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md @@ -81,6 +81,14 @@ Generated: 2026-05-28T08:00:00Z - Remediation: complete-prequalification-evidence - Audit digest: sha256:205a2e89821f1f3e7c9b83e959f0b123967d8af29b659d89af898e473ed4033d +## Missing Criteria List Packet + +- Applicant: applicant-missing-criteria-list +- Decision: hold-for-fairness-review +- Reasons: criteria-weight-total-invalid, inconsistent-threshold-decision, missing-published-criteria-list, unpublished-screening-criterion +- Remediation: publish-complete-screening-criteria +- Audit digest: sha256:c2d8b60e1ca8cc50e8b8c072ee2a22a9a832df09b59e63d60dd21b41b1f17fd3 + ## Blank Rejection Reason Packet - Applicant: applicant-blank-rejection-reason diff --git a/challenge-prequalification-fairness-guard/requirements-map.md b/challenge-prequalification-fairness-guard/requirements-map.md index 5986f3ef..d5137a22 100644 --- a/challenge-prequalification-fairness-guard/requirements-map.md +++ b/challenge-prequalification-fairness-guard/requirements-map.md @@ -2,7 +2,7 @@ ## Challenge Posting Portal -- Verifies that prequalification rounds use published criteria, complete and unique criterion identifiers after trimming, nonnegative weights, valid weight totals, valid 0-100 pass thresholds, valid positive reviewer quorum requirements, explicit sponsor accept/reject decisions, and complete applicant identities. +- Verifies that prequalification rounds use complete published criteria lists, complete and unique criterion identifiers after trimming, nonnegative weights, valid weight totals, valid 0-100 pass thresholds, valid positive reviewer quorum requirements, explicit sponsor accept/reject decisions, and complete applicant identities. - Blocks unpublished sponsor preferences from entering solver-screening decisions. - Keeps prequalification decisions tied to challenge timelines and parseable appeal windows. @@ -11,7 +11,7 @@ - Protects anonymous or named participation settings during prequalification review. - Requires a valid positive reviewer quorum before a solver team is accepted or rejected, and excludes missing or blank reviewer identities from quorum until reviewer evidence is completed. - Holds missing or blank applicant identities before malformed applicant rows can change solver-team access. -- Holds missing round-level review lists before sparse prequalification packets can crash or change solver-team access. +- Holds missing round-level criteria lists and review lists before sparse prequalification packets can crash or change solver-team access. - Holds incomplete reviewer score packets and invalid finite 0-100 score values for evidence completion instead of letting malformed review records crash or drive decisions. - Preserves audit evidence for each applicant before access to private challenge workspaces changes. @@ -31,6 +31,7 @@ - Deduplicates repeated reviewer identities before quorum and weighted threshold scoring while retaining the duplicate-evidence finding. - Holds missing reviewer identity evidence before anonymous or malformed reviewer rows can satisfy quorum. - Holds missing review-list evidence for fairness review before applicant decisions can take effect. +- Holds missing criteria-list evidence for fairness review before applicant decisions can take effect. - Produces deterministic digests for challenge administrators and third-party reviewers. ## Safety And Scope diff --git a/challenge-prequalification-fairness-guard/test.js b/challenge-prequalification-fairness-guard/test.js index 0c450107..0fa66700 100644 --- a/challenge-prequalification-fairness-guard/test.js +++ b/challenge-prequalification-fairness-guard/test.js @@ -832,6 +832,59 @@ function testMissingReviewListHoldsPrequalificationRoundWithoutCrashing() { assert.equal(action.priority, 'high'); } +function testMissingCriteriaListHoldsPrequalificationRoundWithoutCrashing() { + const round = buildSampleRound(); + delete round.criteria; + round.applicants = [ + { + id: 'applicant-missing-criteria-list', + sponsorDecision: 'accept', + rejectionReasons: [], + appealDueAt: null + } + ]; + round.reviews = [ + { + applicantId: 'applicant-missing-criteria-list', + reviewerId: 'reviewer-independent-a', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 94, + 'data-readiness': 96, + 'safety-plan': 93 + } + }, + { + applicantId: 'applicant-missing-criteria-list', + reviewerId: 'reviewer-independent-b', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 92, + 'data-readiness': 94, + 'safety-plan': 91 + } + } + ]; + + const result = evaluatePrequalificationRound(round); + const decision = byId(result.decisions, 'applicant-missing-criteria-list'); + const action = byId(result.remediationActions, 'remediate-applicant-missing-criteria-list'); + + assert.equal(decision.decision, 'hold-for-fairness-review'); + assert.equal(decision.reasons.includes('missing-published-criteria-list'), true); + assert.deepEqual(decision.criteriaApplied, []); + assert.equal(decision.weightedScore, 0); + assert.equal(action.action, 'publish-complete-screening-criteria'); + assert.equal(action.priority, 'high'); + assert.ok(result.criteriaDigest.startsWith('sha256:')); +} + function testDuplicateReviewerScoreEvidenceDoesNotSatisfyQuorum() { const round = buildSampleRound(); round.minReviewers = 2; @@ -968,6 +1021,7 @@ const tests = [ testBlankRejectionReasonTextHoldsRejectedApplicant, testIncompleteReviewerScoreEvidenceHoldsWithoutCrashing, testMissingReviewListHoldsPrequalificationRoundWithoutCrashing, + testMissingCriteriaListHoldsPrequalificationRoundWithoutCrashing, testDuplicateReviewerScoreEvidenceDoesNotSatisfyQuorum, testMissingReviewerIdentityDoesNotSatisfyQuorum, testAuditDigestIsDeterministicAndPrivateFree From 92ba303f38df415c268d29242198b99788b85647 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Sat, 30 May 2026 21:15:53 +0200 Subject: [PATCH 22/28] Harden missing applicant list prequalification packets --- .../README.md | 3 +- .../acceptance-notes.md | 1 + .../demo.js | 21 +++++++++ .../index.js | 30 +++++++++++- .../missing-applicant-list-packet.json | 46 +++++++++++++++++++ .../prequalification-fairness-report.md | 8 ++++ .../requirements-map.md | 4 +- .../test.js | 17 +++++++ 8 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 challenge-prequalification-fairness-guard/reports/missing-applicant-list-packet.json diff --git a/challenge-prequalification-fairness-guard/README.md b/challenge-prequalification-fairness-guard/README.md index 7e0b6c72..cb5b9673 100644 --- a/challenge-prequalification-fairness-guard/README.md +++ b/challenge-prequalification-fairness-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Bounty System slice for SCIBASE issue #18. It evaluates sponsor-side prequalification rounds before solver teams are accepted or rejected from a challenge. -The guard checks published screening criteria, complete criteria-list evidence, complete and unique criterion identifiers after trimming, valid criterion weight values and totals, valid pass thresholds, valid reviewer quorum requirements, valid sponsor accept/reject decisions, complete applicant identity evidence, complete round-level review-list evidence, complete reviewer score evidence, valid finite 0-100 reviewer score values, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, missing reviewer identity evidence, duplicate reviewer score evidence, missing, empty, or blank rejection reason evidence, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, repeated reviewer identities are deduplicated before quorum or threshold scoring, and missing applicant identities, missing criteria lists, missing review lists, missing reviewer identities, invalid sponsor decisions, or invalid score values are excluded from taking effect until the evidence is completed. Unfair or incomplete screening decisions are held for remediation before challenge access changes. +The guard checks published screening criteria, complete criteria-list evidence, complete and unique criterion identifiers after trimming, valid criterion weight values and totals, valid pass thresholds, valid reviewer quorum requirements, valid sponsor accept/reject decisions, complete applicant-list and applicant identity evidence, complete round-level review-list evidence, complete reviewer score evidence, valid finite 0-100 reviewer score values, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, missing reviewer identity evidence, duplicate reviewer score evidence, missing, empty, or blank rejection reason evidence, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, repeated reviewer identities are deduplicated before quorum or threshold scoring, and missing applicant lists, missing applicant identities, missing criteria lists, missing review lists, missing reviewer identities, invalid sponsor decisions, or invalid score values are excluded from taking effect until the evidence is completed. Unfair or incomplete screening decisions are held for remediation before challenge access changes. ## Run @@ -24,6 +24,7 @@ npm run check - `reports/missing-applicant-identity-packet.json` - `reports/missing-review-list-packet.json` - `reports/missing-criteria-list-packet.json` +- `reports/missing-applicant-list-packet.json` - `reports/blank-rejection-reason-packet.json` - `reports/prequalification-fairness-report.md` - `reports/summary.svg` diff --git a/challenge-prequalification-fairness-guard/acceptance-notes.md b/challenge-prequalification-fairness-guard/acceptance-notes.md index 7b83ef4e..6f986daa 100644 --- a/challenge-prequalification-fairness-guard/acceptance-notes.md +++ b/challenge-prequalification-fairness-guard/acceptance-notes.md @@ -33,6 +33,7 @@ Validation coverage: - incomplete reviewer score evidence is held for completion without crashing the prequalification packet - missing review lists are held for evidence completion instead of crashing sparse prequalification packets - missing published criteria lists are held for evidence completion instead of crashing sparse prequalification packets +- missing applicant lists are held for evidence completion instead of crashing sparse prequalification packets - duplicate reviewer score evidence is held and deduplicated before quorum or weighted threshold scoring - missing or blank reviewer identities are held and excluded from reviewer quorum until evidence is completed - audit digests are deterministic and private-data free diff --git a/challenge-prequalification-fairness-guard/demo.js b/challenge-prequalification-fairness-guard/demo.js index fdf020cc..4e22fc33 100644 --- a/challenge-prequalification-fairness-guard/demo.js +++ b/challenge-prequalification-fairness-guard/demo.js @@ -14,6 +14,7 @@ const invalidSponsorDecisionResult = evaluatePrequalificationRound(buildInvalidS const missingApplicantIdentityResult = evaluatePrequalificationRound(buildMissingApplicantIdentityRound()); const missingReviewListResult = evaluatePrequalificationRound(buildMissingReviewListRound()); const missingCriteriaListResult = evaluatePrequalificationRound(buildMissingCriteriaListRound()); +const missingApplicantListResult = evaluatePrequalificationRound(buildMissingApplicantListRound()); const blankRejectionReasonResult = evaluatePrequalificationRound(buildBlankRejectionReasonRound()); const packetPath = path.join(reportsDir, 'prequalification-fairness-packet.json'); @@ -31,6 +32,7 @@ const missingApplicantIdentityPacketPath = path.join( ); const missingReviewListPacketPath = path.join(reportsDir, 'missing-review-list-packet.json'); const missingCriteriaListPacketPath = path.join(reportsDir, 'missing-criteria-list-packet.json'); +const missingApplicantListPacketPath = path.join(reportsDir, 'missing-applicant-list-packet.json'); const blankRejectionReasonPacketPath = path.join(reportsDir, 'blank-rejection-reason-packet.json'); const reportPath = path.join(reportsDir, 'prequalification-fairness-report.md'); const svgPath = path.join(reportsDir, 'summary.svg'); @@ -53,6 +55,10 @@ fs.writeFileSync( missingCriteriaListPacketPath, `${JSON.stringify(missingCriteriaListResult, null, 2)}\n` ); +fs.writeFileSync( + missingApplicantListPacketPath, + `${JSON.stringify(missingApplicantListResult, null, 2)}\n` +); fs.writeFileSync(blankRejectionReasonPacketPath, `${JSON.stringify(blankRejectionReasonResult, null, 2)}\n`); const decisions = result.decisions @@ -154,6 +160,14 @@ ${actions} - Remediation: ${missingCriteriaListResult.remediationActions[0].action} - Audit digest: ${missingCriteriaListResult.auditDigest} +## Missing Applicant List Packet + +- Applicant: ${missingApplicantListResult.decisions[0].applicantId} +- Decision: ${missingApplicantListResult.decisions[0].decision} +- Reasons: ${missingApplicantListResult.decisions[0].reasons.join(', ')} +- Remediation: ${missingApplicantListResult.remediationActions[0].action} +- Audit digest: ${missingApplicantListResult.auditDigest} + ## Blank Rejection Reason Packet - Applicant: ${blankRejectionReasonResult.decisions[0].applicantId} @@ -194,6 +208,7 @@ console.log(`Wrote ${path.relative(__dirname, invalidSponsorDecisionPacketPath)} console.log(`Wrote ${path.relative(__dirname, missingApplicantIdentityPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, missingReviewListPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, missingCriteriaListPacketPath)}`); +console.log(`Wrote ${path.relative(__dirname, missingApplicantListPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, blankRejectionReasonPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, reportPath)}`); console.log(`Wrote ${path.relative(__dirname, svgPath)}`); @@ -524,6 +539,12 @@ function buildMissingCriteriaListRound() { return round; } +function buildMissingApplicantListRound() { + const round = buildSampleRound(); + delete round.applicants; + return round; +} + function buildBlankRejectionReasonRound() { const round = buildSampleRound(); round.applicants = [ diff --git a/challenge-prequalification-fairness-guard/index.js b/challenge-prequalification-fairness-guard/index.js index 0348a530..0c69c30d 100644 --- a/challenge-prequalification-fairness-guard/index.js +++ b/challenge-prequalification-fairness-guard/index.js @@ -114,6 +114,14 @@ function criteriaListIsMissing(round) { return !Array.isArray(round.criteria); } +function applicantListFor(round) { + return Array.isArray(round.applicants) ? round.applicants : []; +} + +function applicantListIsMissing(round) { + return !Array.isArray(round.applicants); +} + function outputApplicantIdFor(applicant) { return applicantIdFor(applicant) || 'unidentified-applicant'; } @@ -256,6 +264,10 @@ function reasonsForApplicant(applicant, reviews, round) { const applicantAppealStatus = appealStatus(applicant, round); const reasons = []; + if (applicant.missingApplicantList) { + return ['missing-applicant-list']; + } + if (round.anonymousScreeningRequired && reviews.some((review) => !review.anonymousScreeningObserved)) { reasons.push('anonymous-screening-leak'); } @@ -435,6 +447,10 @@ function remediationAction(applicant, reasons) { return 'complete-prequalification-evidence'; } + if (reasons.includes('missing-applicant-list')) { + return 'complete-prequalification-evidence'; + } + if (reasons.includes('missing-applicant-identity')) { return 'complete-prequalification-evidence'; } @@ -448,8 +464,19 @@ function remediationAction(applicant, reasons) { function evaluatePrequalificationRound(round) { const reviewsByApplicant = groupBy(reviewListFor(round), reviewApplicantIdFor); + const applicants = applicantListIsMissing(round) + ? [ + { + id: '', + sponsorDecision: null, + rejectionReasons: [], + appealDueAt: null, + missingApplicantList: true + } + ] + : applicantListFor(round); - const decisions = round.applicants.map((applicant) => { + const decisions = applicants.map((applicant) => { const reviews = reviewsByApplicant[applicantIdFor(applicant)] || []; const applicantId = outputApplicantIdFor(applicant); const nonConflictedReviews = countableNonConflictedReviews(reviews); @@ -505,6 +532,7 @@ function evaluatePrequalificationRound(round) { decision.reasons.includes('duplicate-reviewer-score-evidence') || decision.reasons.includes('missing-reviewer-identity') || decision.reasons.includes('missing-review-list') || + decision.reasons.includes('missing-applicant-list') || decision.reasons.includes('missing-applicant-identity') || decision.reasons.includes('unpublished-screening-criterion') || decision.reasons.includes('criteria-weight-total-invalid') || diff --git a/challenge-prequalification-fairness-guard/reports/missing-applicant-list-packet.json b/challenge-prequalification-fairness-guard/reports/missing-applicant-list-packet.json new file mode 100644 index 00000000..615d69f6 --- /dev/null +++ b/challenge-prequalification-fairness-guard/reports/missing-applicant-list-packet.json @@ -0,0 +1,46 @@ +{ + "challengeId": "challenge-18-prequalification-rna-biomarker", + "generatedAt": "2026-05-28T08:00:00Z", + "criteriaDigest": "sha256:d643b033793917b9d0488787518a11e97094e671d52b86b69a6153375d726721", + "decisions": [ + { + "id": "unidentified-applicant", + "applicantId": "unidentified-applicant", + "challengeId": "challenge-18-prequalification-rna-biomarker", + "decision": "hold-for-fairness-review", + "sponsorDecision": null, + "weightedScore": 0, + "passThreshold": 75, + "reviewersCounted": 0, + "criteriaApplied": [ + "domain-fit", + "data-readiness", + "safety-plan" + ], + "reasons": [ + "missing-applicant-list" + ], + "rejectionReasons": [], + "appealStatus": "not-required", + "auditDigest": "sha256:4de0629dfb7646c44064dfaad44ba573fc82612aba864d6f643989a0f69d4d0f" + } + ], + "remediationActions": [ + { + "id": "remediate-unidentified-applicant", + "applicantId": "unidentified-applicant", + "action": "complete-prequalification-evidence", + "priority": "high", + "reasons": [ + "missing-applicant-list" + ] + } + ], + "summary": { + "accepted": 0, + "held": 1, + "rejectedWithAudit": 0, + "remediationActions": 1 + }, + "auditDigest": "sha256:c77ccb7f1875504454711c406569be8b6b9b34789239e956635850890ecf1a90" +} diff --git a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md index b1d67054..fbc754fe 100644 --- a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md +++ b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md @@ -89,6 +89,14 @@ Generated: 2026-05-28T08:00:00Z - Remediation: publish-complete-screening-criteria - Audit digest: sha256:c2d8b60e1ca8cc50e8b8c072ee2a22a9a832df09b59e63d60dd21b41b1f17fd3 +## Missing Applicant List Packet + +- Applicant: unidentified-applicant +- Decision: hold-for-fairness-review +- Reasons: missing-applicant-list +- Remediation: complete-prequalification-evidence +- Audit digest: sha256:c77ccb7f1875504454711c406569be8b6b9b34789239e956635850890ecf1a90 + ## Blank Rejection Reason Packet - Applicant: applicant-blank-rejection-reason diff --git a/challenge-prequalification-fairness-guard/requirements-map.md b/challenge-prequalification-fairness-guard/requirements-map.md index d5137a22..517c7de7 100644 --- a/challenge-prequalification-fairness-guard/requirements-map.md +++ b/challenge-prequalification-fairness-guard/requirements-map.md @@ -2,7 +2,7 @@ ## Challenge Posting Portal -- Verifies that prequalification rounds use complete published criteria lists, complete and unique criterion identifiers after trimming, nonnegative weights, valid weight totals, valid 0-100 pass thresholds, valid positive reviewer quorum requirements, explicit sponsor accept/reject decisions, and complete applicant identities. +- Verifies that prequalification rounds use complete published criteria lists, complete and unique criterion identifiers after trimming, nonnegative weights, valid weight totals, valid 0-100 pass thresholds, valid positive reviewer quorum requirements, explicit sponsor accept/reject decisions, complete applicant lists, and complete applicant identities. - Blocks unpublished sponsor preferences from entering solver-screening decisions. - Keeps prequalification decisions tied to challenge timelines and parseable appeal windows. @@ -12,6 +12,7 @@ - Requires a valid positive reviewer quorum before a solver team is accepted or rejected, and excludes missing or blank reviewer identities from quorum until reviewer evidence is completed. - Holds missing or blank applicant identities before malformed applicant rows can change solver-team access. - Holds missing round-level criteria lists and review lists before sparse prequalification packets can crash or change solver-team access. +- Holds missing applicant lists before sparse prequalification packets can crash or change solver-team access. - Holds incomplete reviewer score packets and invalid finite 0-100 score values for evidence completion instead of letting malformed review records crash or drive decisions. - Preserves audit evidence for each applicant before access to private challenge workspaces changes. @@ -22,6 +23,7 @@ - Holds invalid reviewer quorum requirements for fairness review before sponsor accept/reject decisions can take effect. - Holds invalid sponsor decision values for fairness review before malformed accept/reject evidence can change solver access. - Holds missing applicant identity evidence for fairness review before anonymous or malformed applicant rows can change solver access. +- Holds missing applicant-list evidence for fairness review before malformed challenge rounds can change solver access. - Holds invalid reviewer score values for fairness review before malformed score evidence can drive sponsor decisions. - Holds duplicate published criterion identifiers for fairness review before ambiguous rubric evidence can drive sponsor decisions. - Holds whitespace-variant duplicate published criterion identifiers for fairness review before ambiguous rubric evidence can drive sponsor decisions. diff --git a/challenge-prequalification-fairness-guard/test.js b/challenge-prequalification-fairness-guard/test.js index 0fa66700..3011bb26 100644 --- a/challenge-prequalification-fairness-guard/test.js +++ b/challenge-prequalification-fairness-guard/test.js @@ -885,6 +885,22 @@ function testMissingCriteriaListHoldsPrequalificationRoundWithoutCrashing() { assert.ok(result.criteriaDigest.startsWith('sha256:')); } +function testMissingApplicantListHoldsPrequalificationRoundWithoutCrashing() { + const round = buildSampleRound(); + delete round.applicants; + + const result = evaluatePrequalificationRound(round); + const decision = byId(result.decisions, 'unidentified-applicant'); + const action = byId(result.remediationActions, 'remediate-unidentified-applicant'); + + assert.equal(decision.decision, 'hold-for-fairness-review'); + assert.equal(decision.reasons.includes('missing-applicant-list'), true); + assert.deepEqual(decision.rejectionReasons, []); + assert.equal(action.action, 'complete-prequalification-evidence'); + assert.equal(action.priority, 'high'); + assert.equal(result.summary.held, 1); +} + function testDuplicateReviewerScoreEvidenceDoesNotSatisfyQuorum() { const round = buildSampleRound(); round.minReviewers = 2; @@ -1022,6 +1038,7 @@ const tests = [ testIncompleteReviewerScoreEvidenceHoldsWithoutCrashing, testMissingReviewListHoldsPrequalificationRoundWithoutCrashing, testMissingCriteriaListHoldsPrequalificationRoundWithoutCrashing, + testMissingApplicantListHoldsPrequalificationRoundWithoutCrashing, testDuplicateReviewerScoreEvidenceDoesNotSatisfyQuorum, testMissingReviewerIdentityDoesNotSatisfyQuorum, testAuditDigestIsDeterministicAndPrivateFree From 303f18fa878bd072f5e725ea8316ebefe504b46b Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Sun, 31 May 2026 01:25:53 +0200 Subject: [PATCH 23/28] Harden prequalification applicant identity guard --- .../README.md | 3 +- .../acceptance-notes.md | 1 + .../demo.js | 65 +++++++++++++++ .../index.js | 27 +++++++ .../duplicate-applicant-identity-packet.json | 80 +++++++++++++++++++ .../prequalification-fairness-report.md | 8 ++ .../requirements-map.md | 6 +- .../test.js | 59 ++++++++++++++ 8 files changed, 245 insertions(+), 4 deletions(-) create mode 100644 challenge-prequalification-fairness-guard/reports/duplicate-applicant-identity-packet.json diff --git a/challenge-prequalification-fairness-guard/README.md b/challenge-prequalification-fairness-guard/README.md index cb5b9673..fb9a1194 100644 --- a/challenge-prequalification-fairness-guard/README.md +++ b/challenge-prequalification-fairness-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Bounty System slice for SCIBASE issue #18. It evaluates sponsor-side prequalification rounds before solver teams are accepted or rejected from a challenge. -The guard checks published screening criteria, complete criteria-list evidence, complete and unique criterion identifiers after trimming, valid criterion weight values and totals, valid pass thresholds, valid reviewer quorum requirements, valid sponsor accept/reject decisions, complete applicant-list and applicant identity evidence, complete round-level review-list evidence, complete reviewer score evidence, valid finite 0-100 reviewer score values, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, missing reviewer identity evidence, duplicate reviewer score evidence, missing, empty, or blank rejection reason evidence, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, repeated reviewer identities are deduplicated before quorum or threshold scoring, and missing applicant lists, missing applicant identities, missing criteria lists, missing review lists, missing reviewer identities, invalid sponsor decisions, or invalid score values are excluded from taking effect until the evidence is completed. Unfair or incomplete screening decisions are held for remediation before challenge access changes. +The guard checks published screening criteria, complete criteria-list evidence, complete and unique criterion identifiers after trimming, valid criterion weight values and totals, valid pass thresholds, valid reviewer quorum requirements, valid sponsor accept/reject decisions, complete applicant-list and unique applicant identity evidence after trimming, complete round-level review-list evidence, complete reviewer score evidence, valid finite 0-100 reviewer score values, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, missing reviewer identity evidence, duplicate reviewer score evidence, missing, empty, or blank rejection reason evidence, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, repeated reviewer identities are deduplicated before quorum or threshold scoring, and missing applicant lists, missing or duplicate applicant identities, missing criteria lists, missing review lists, missing reviewer identities, invalid sponsor decisions, or invalid score values are excluded from taking effect until the evidence is completed. Unfair or incomplete screening decisions are held for remediation before challenge access changes. ## Run @@ -22,6 +22,7 @@ npm run check - `reports/invalid-reviewer-quorum-packet.json` - `reports/invalid-sponsor-decision-packet.json` - `reports/missing-applicant-identity-packet.json` +- `reports/duplicate-applicant-identity-packet.json` - `reports/missing-review-list-packet.json` - `reports/missing-criteria-list-packet.json` - `reports/missing-applicant-list-packet.json` diff --git a/challenge-prequalification-fairness-guard/acceptance-notes.md b/challenge-prequalification-fairness-guard/acceptance-notes.md index 6f986daa..17bb2f2a 100644 --- a/challenge-prequalification-fairness-guard/acceptance-notes.md +++ b/challenge-prequalification-fairness-guard/acceptance-notes.md @@ -27,6 +27,7 @@ Validation coverage: - invalid reviewer quorum requirements are held before sponsor accept/reject decisions can take effect - invalid sponsor decision values are held before malformed accept/reject evidence can change solver access - missing or blank applicant identities are held before anonymous or malformed applicant rows can change solver access +- duplicate applicant identities after trimming are held before conflicting prequalification rows can change solver access - invalid reviewer score values outside the finite 0-100 range are held before malformed scoring evidence can drive acceptance or rejection - missing rejection reason lists are normalized to an auditable fairness hold instead of crashing the prequalification packet - blank rejection reason text is normalized away and held as missing applicant-facing rejection evidence diff --git a/challenge-prequalification-fairness-guard/demo.js b/challenge-prequalification-fairness-guard/demo.js index 4e22fc33..7bd17c2c 100644 --- a/challenge-prequalification-fairness-guard/demo.js +++ b/challenge-prequalification-fairness-guard/demo.js @@ -12,6 +12,7 @@ const invalidScoreResult = evaluatePrequalificationRound(buildInvalidReviewerSco const invalidQuorumResult = evaluatePrequalificationRound(buildInvalidReviewerQuorumRound()); const invalidSponsorDecisionResult = evaluatePrequalificationRound(buildInvalidSponsorDecisionRound()); const missingApplicantIdentityResult = evaluatePrequalificationRound(buildMissingApplicantIdentityRound()); +const duplicateApplicantIdentityResult = evaluatePrequalificationRound(buildDuplicateApplicantIdentityRound()); const missingReviewListResult = evaluatePrequalificationRound(buildMissingReviewListRound()); const missingCriteriaListResult = evaluatePrequalificationRound(buildMissingCriteriaListRound()); const missingApplicantListResult = evaluatePrequalificationRound(buildMissingApplicantListRound()); @@ -30,6 +31,10 @@ const missingApplicantIdentityPacketPath = path.join( reportsDir, 'missing-applicant-identity-packet.json' ); +const duplicateApplicantIdentityPacketPath = path.join( + reportsDir, + 'duplicate-applicant-identity-packet.json' +); const missingReviewListPacketPath = path.join(reportsDir, 'missing-review-list-packet.json'); const missingCriteriaListPacketPath = path.join(reportsDir, 'missing-criteria-list-packet.json'); const missingApplicantListPacketPath = path.join(reportsDir, 'missing-applicant-list-packet.json'); @@ -50,6 +55,10 @@ fs.writeFileSync( missingApplicantIdentityPacketPath, `${JSON.stringify(missingApplicantIdentityResult, null, 2)}\n` ); +fs.writeFileSync( + duplicateApplicantIdentityPacketPath, + `${JSON.stringify(duplicateApplicantIdentityResult, null, 2)}\n` +); fs.writeFileSync(missingReviewListPacketPath, `${JSON.stringify(missingReviewListResult, null, 2)}\n`); fs.writeFileSync( missingCriteriaListPacketPath, @@ -144,6 +153,14 @@ ${actions} - Remediation: ${missingApplicantIdentityResult.remediationActions[0].action} - Audit digest: ${missingApplicantIdentityResult.auditDigest} +## Duplicate Applicant Identity Packet + +- Applicant: ${duplicateApplicantIdentityResult.decisions[0].applicantId} +- Decision: ${duplicateApplicantIdentityResult.decisions[0].decision} +- Reasons: ${duplicateApplicantIdentityResult.decisions[0].reasons.join(', ')} +- Remediation: ${duplicateApplicantIdentityResult.remediationActions[0].action} +- Audit digest: ${duplicateApplicantIdentityResult.auditDigest} + ## Missing Review List Packet - Applicant: ${missingReviewListResult.decisions[0].applicantId} @@ -206,6 +223,7 @@ console.log(`Wrote ${path.relative(__dirname, invalidScorePacketPath)}`); console.log(`Wrote ${path.relative(__dirname, invalidQuorumPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, invalidSponsorDecisionPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, missingApplicantIdentityPacketPath)}`); +console.log(`Wrote ${path.relative(__dirname, duplicateApplicantIdentityPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, missingReviewListPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, missingCriteriaListPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, missingApplicantListPacketPath)}`); @@ -483,6 +501,53 @@ function buildMissingApplicantIdentityRound() { return round; } +function buildDuplicateApplicantIdentityRound() { + const round = buildSampleRound(); + round.applicants = [ + { + id: ' applicant-duplicate ', + sponsorDecision: 'accept', + rejectionReasons: [], + appealDueAt: null + }, + { + id: 'applicant-duplicate', + sponsorDecision: 'reject', + rejectionReasons: ['duplicate entry should be resolved before screening'], + appealDueAt: '2026-06-04T08:00:00Z' + } + ]; + round.reviews = [ + { + applicantId: 'applicant-duplicate', + reviewerId: 'reviewer-independent-a', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 94, + 'data-readiness': 96, + 'safety-plan': 94 + } + }, + { + applicantId: 'applicant-duplicate', + reviewerId: 'reviewer-independent-b', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 92, + 'data-readiness': 94, + 'safety-plan': 93 + } + } + ]; + return round; +} + function buildMissingReviewListRound() { const round = buildSampleRound(); round.applicants = [ diff --git a/challenge-prequalification-fairness-guard/index.js b/challenge-prequalification-fairness-guard/index.js index 0c69c30d..7378e708 100644 --- a/challenge-prequalification-fairness-guard/index.js +++ b/challenge-prequalification-fairness-guard/index.js @@ -94,6 +94,23 @@ function applicantIdentityIsMissing(applicant) { return !applicantIdFor(applicant); } +function duplicateApplicantIds(round) { + const applicantCounts = applicantListFor(round).reduce((counts, applicant) => { + const applicantId = applicantIdFor(applicant); + if (!applicantId) { + return counts; + } + counts[applicantId] = (counts[applicantId] || 0) + 1; + return counts; + }, Object.create(null)); + + return uniqueSorted( + Object.entries(applicantCounts) + .filter(([, count]) => count > 1) + .map(([applicantId]) => applicantId) + ); +} + function reviewApplicantIdFor(review) { return typeof review.applicantId === 'string' ? review.applicantId.trim() : review.applicantId; } @@ -259,6 +276,7 @@ function appealStatus(applicant, round) { function reasonsForApplicant(applicant, reviews, round) { const criteriaIds = publicCriteriaIds(round); const duplicateCriterionIds = duplicatePublishedCriterionIds(round); + const duplicateApplicantIdentities = duplicateApplicantIds(round); const nonConflictedReviews = countableNonConflictedReviews(reviews); const duplicateReviewerIds = duplicateNonConflictedReviewerIds(reviews); const applicantAppealStatus = appealStatus(applicant, round); @@ -304,6 +322,10 @@ function reasonsForApplicant(applicant, reviews, round) { reasons.push('missing-applicant-identity'); } + if (duplicateApplicantIdentities.includes(applicantIdFor(applicant))) { + reasons.push('duplicate-applicant-identity'); + } + if (criteriaWeightTotal(round) !== 100) { reasons.push('criteria-weight-total-invalid'); } @@ -455,6 +477,10 @@ function remediationAction(applicant, reasons) { return 'complete-prequalification-evidence'; } + if (reasons.includes('duplicate-applicant-identity')) { + return 'complete-prequalification-evidence'; + } + if (reasons.includes('inconsistent-threshold-decision')) { return 'reconcile-score-threshold-decision'; } @@ -534,6 +560,7 @@ function evaluatePrequalificationRound(round) { decision.reasons.includes('missing-review-list') || decision.reasons.includes('missing-applicant-list') || decision.reasons.includes('missing-applicant-identity') || + decision.reasons.includes('duplicate-applicant-identity') || decision.reasons.includes('unpublished-screening-criterion') || decision.reasons.includes('criteria-weight-total-invalid') || decision.reasons.includes('criteria-weight-value-invalid') || diff --git a/challenge-prequalification-fairness-guard/reports/duplicate-applicant-identity-packet.json b/challenge-prequalification-fairness-guard/reports/duplicate-applicant-identity-packet.json new file mode 100644 index 00000000..a08b024c --- /dev/null +++ b/challenge-prequalification-fairness-guard/reports/duplicate-applicant-identity-packet.json @@ -0,0 +1,80 @@ +{ + "challengeId": "challenge-18-prequalification-rna-biomarker", + "generatedAt": "2026-05-28T08:00:00Z", + "criteriaDigest": "sha256:d643b033793917b9d0488787518a11e97094e671d52b86b69a6153375d726721", + "decisions": [ + { + "id": "applicant-duplicate", + "applicantId": "applicant-duplicate", + "challengeId": "challenge-18-prequalification-rna-biomarker", + "decision": "hold-for-fairness-review", + "sponsorDecision": "accept", + "weightedScore": 94, + "passThreshold": 75, + "reviewersCounted": 2, + "criteriaApplied": [ + "domain-fit", + "data-readiness", + "safety-plan" + ], + "reasons": [ + "duplicate-applicant-identity" + ], + "rejectionReasons": [], + "appealStatus": "not-required", + "auditDigest": "sha256:1b3676776e5454bab52424913826cb5714e450f80ff2c48a16008d85000ac89d" + }, + { + "id": "applicant-duplicate", + "applicantId": "applicant-duplicate", + "challengeId": "challenge-18-prequalification-rna-biomarker", + "decision": "hold-for-fairness-review", + "sponsorDecision": "reject", + "weightedScore": 94, + "passThreshold": 75, + "reviewersCounted": 2, + "criteriaApplied": [ + "domain-fit", + "data-readiness", + "safety-plan" + ], + "reasons": [ + "duplicate-applicant-identity", + "inconsistent-threshold-decision" + ], + "rejectionReasons": [ + "duplicate entry should be resolved before screening" + ], + "appealStatus": "open", + "auditDigest": "sha256:77ce84425359c61ac9ef3741dcb4e71b530bd8c8b547cb209f9e1a8125446fee" + } + ], + "remediationActions": [ + { + "id": "remediate-applicant-duplicate", + "applicantId": "applicant-duplicate", + "action": "complete-prequalification-evidence", + "priority": "high", + "reasons": [ + "duplicate-applicant-identity" + ] + }, + { + "id": "remediate-applicant-duplicate", + "applicantId": "applicant-duplicate", + "action": "complete-prequalification-evidence", + "priority": "high", + "reasons": [ + "duplicate-applicant-identity", + "inconsistent-threshold-decision" + ] + } + ], + "summary": { + "accepted": 0, + "held": 2, + "rejectedWithAudit": 0, + "remediationActions": 2 + }, + "auditDigest": "sha256:3f23ed40ab5aa7c06e33ca3dc3b149bae1d550f3764425c6955cedf04beb01e0" +} diff --git a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md index fbc754fe..3b87fe5c 100644 --- a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md +++ b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md @@ -73,6 +73,14 @@ Generated: 2026-05-28T08:00:00Z - Remediation: complete-prequalification-evidence - Audit digest: sha256:85aa774922f5707139444c13c705a88af31757014d98a8767b0cbe1725c4cf7c +## Duplicate Applicant Identity Packet + +- Applicant: applicant-duplicate +- Decision: hold-for-fairness-review +- Reasons: duplicate-applicant-identity +- Remediation: complete-prequalification-evidence +- Audit digest: sha256:3f23ed40ab5aa7c06e33ca3dc3b149bae1d550f3764425c6955cedf04beb01e0 + ## Missing Review List Packet - Applicant: applicant-missing-review-list diff --git a/challenge-prequalification-fairness-guard/requirements-map.md b/challenge-prequalification-fairness-guard/requirements-map.md index 517c7de7..3e4c5eda 100644 --- a/challenge-prequalification-fairness-guard/requirements-map.md +++ b/challenge-prequalification-fairness-guard/requirements-map.md @@ -2,7 +2,7 @@ ## Challenge Posting Portal -- Verifies that prequalification rounds use complete published criteria lists, complete and unique criterion identifiers after trimming, nonnegative weights, valid weight totals, valid 0-100 pass thresholds, valid positive reviewer quorum requirements, explicit sponsor accept/reject decisions, complete applicant lists, and complete applicant identities. +- Verifies that prequalification rounds use complete published criteria lists, complete and unique criterion identifiers after trimming, nonnegative weights, valid weight totals, valid 0-100 pass thresholds, valid positive reviewer quorum requirements, explicit sponsor accept/reject decisions, complete applicant lists, and complete unique applicant identities after trimming. - Blocks unpublished sponsor preferences from entering solver-screening decisions. - Keeps prequalification decisions tied to challenge timelines and parseable appeal windows. @@ -10,7 +10,7 @@ - Protects anonymous or named participation settings during prequalification review. - Requires a valid positive reviewer quorum before a solver team is accepted or rejected, and excludes missing or blank reviewer identities from quorum until reviewer evidence is completed. -- Holds missing or blank applicant identities before malformed applicant rows can change solver-team access. +- Holds missing, blank, or duplicate applicant identities before malformed applicant rows can change solver-team access. - Holds missing round-level criteria lists and review lists before sparse prequalification packets can crash or change solver-team access. - Holds missing applicant lists before sparse prequalification packets can crash or change solver-team access. - Holds incomplete reviewer score packets and invalid finite 0-100 score values for evidence completion instead of letting malformed review records crash or drive decisions. @@ -22,7 +22,7 @@ - Holds invalid pass thresholds for fairness review before sponsor accept/reject decisions can take effect. - Holds invalid reviewer quorum requirements for fairness review before sponsor accept/reject decisions can take effect. - Holds invalid sponsor decision values for fairness review before malformed accept/reject evidence can change solver access. -- Holds missing applicant identity evidence for fairness review before anonymous or malformed applicant rows can change solver access. +- Holds missing or duplicate applicant identity evidence for fairness review before anonymous or malformed applicant rows can change solver access. - Holds missing applicant-list evidence for fairness review before malformed challenge rounds can change solver access. - Holds invalid reviewer score values for fairness review before malformed score evidence can drive sponsor decisions. - Holds duplicate published criterion identifiers for fairness review before ambiguous rubric evidence can drive sponsor decisions. diff --git a/challenge-prequalification-fairness-guard/test.js b/challenge-prequalification-fairness-guard/test.js index 3011bb26..e55eebb4 100644 --- a/challenge-prequalification-fairness-guard/test.js +++ b/challenge-prequalification-fairness-guard/test.js @@ -670,6 +670,64 @@ function testMissingApplicantIdentityHoldsPrequalificationRound() { assert.equal(action.priority, 'high'); } +function testDuplicateApplicantIdentitiesHoldPrequalificationRound() { + const round = buildSampleRound(); + round.applicants = [ + { + id: ' applicant-duplicate ', + sponsorDecision: 'accept', + rejectionReasons: [], + appealDueAt: null + }, + { + id: 'applicant-duplicate', + sponsorDecision: 'reject', + rejectionReasons: ['duplicate entry should be resolved before screening'], + appealDueAt: '2026-06-04T08:00:00Z' + } + ]; + round.reviews = [ + { + applicantId: 'applicant-duplicate', + reviewerId: 'reviewer-independent-a', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 94, + 'data-readiness': 96, + 'safety-plan': 94 + } + }, + { + applicantId: 'applicant-duplicate', + reviewerId: 'reviewer-independent-b', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 92, + 'data-readiness': 94, + 'safety-plan': 93 + } + } + ]; + + const result = evaluatePrequalificationRound(round); + const decisions = result.decisions.filter( + (decision) => decision.applicantId === 'applicant-duplicate' + ); + const action = byId(result.remediationActions, 'remediate-applicant-duplicate'); + + assert.equal(decisions.length, 2); + assert.equal(decisions.every((decision) => decision.decision === 'hold-for-fairness-review'), true); + assert.equal(decisions.every((decision) => decision.reasons.includes('duplicate-applicant-identity')), true); + assert.equal(action.action, 'complete-prequalification-evidence'); + assert.equal(action.priority, 'high'); +} + function testMissingRejectionReasonListHoldsWithoutCrashing() { const round = buildSampleRound(); round.applicants = [ @@ -1033,6 +1091,7 @@ const tests = [ testInvalidReviewerScoreValuesHoldPrequalificationRound, testInvalidSponsorDecisionHoldsPrequalificationRound, testMissingApplicantIdentityHoldsPrequalificationRound, + testDuplicateApplicantIdentitiesHoldPrequalificationRound, testMissingRejectionReasonListHoldsWithoutCrashing, testBlankRejectionReasonTextHoldsRejectedApplicant, testIncompleteReviewerScoreEvidenceHoldsWithoutCrashing, From d154a75ecd981d47de7185d76a1072d4a313c5f0 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Sun, 31 May 2026 20:50:02 +0200 Subject: [PATCH 24/28] Harden malformed applicant entries --- .../README.md | 3 +- .../acceptance-notes.md | 1 + .../demo.js | 25 ++++++++++ .../index.js | 33 ++++++++++++- .../malformed-applicant-entry-packet.json | 46 +++++++++++++++++++ .../prequalification-fairness-report.md | 8 ++++ .../requirements-map.md | 4 +- .../test.js | 17 +++++++ 8 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 challenge-prequalification-fairness-guard/reports/malformed-applicant-entry-packet.json diff --git a/challenge-prequalification-fairness-guard/README.md b/challenge-prequalification-fairness-guard/README.md index fb9a1194..7ebabacb 100644 --- a/challenge-prequalification-fairness-guard/README.md +++ b/challenge-prequalification-fairness-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Bounty System slice for SCIBASE issue #18. It evaluates sponsor-side prequalification rounds before solver teams are accepted or rejected from a challenge. -The guard checks published screening criteria, complete criteria-list evidence, complete and unique criterion identifiers after trimming, valid criterion weight values and totals, valid pass thresholds, valid reviewer quorum requirements, valid sponsor accept/reject decisions, complete applicant-list and unique applicant identity evidence after trimming, complete round-level review-list evidence, complete reviewer score evidence, valid finite 0-100 reviewer score values, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, missing reviewer identity evidence, duplicate reviewer score evidence, missing, empty, or blank rejection reason evidence, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, repeated reviewer identities are deduplicated before quorum or threshold scoring, and missing applicant lists, missing or duplicate applicant identities, missing criteria lists, missing review lists, missing reviewer identities, invalid sponsor decisions, or invalid score values are excluded from taking effect until the evidence is completed. Unfair or incomplete screening decisions are held for remediation before challenge access changes. +The guard checks published screening criteria, complete criteria-list evidence, complete and unique criterion identifiers after trimming, valid criterion weight values and totals, valid pass thresholds, valid reviewer quorum requirements, valid sponsor accept/reject decisions, complete applicant-list and unique applicant identity evidence after trimming, well-formed applicant entries, complete round-level review-list evidence, complete reviewer score evidence, valid finite 0-100 reviewer score values, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, missing reviewer identity evidence, duplicate reviewer score evidence, missing, empty, or blank rejection reason evidence, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, repeated reviewer identities are deduplicated before quorum or threshold scoring, and missing applicant lists, malformed applicant entries, missing or duplicate applicant identities, missing criteria lists, missing review lists, missing reviewer identities, invalid sponsor decisions, or invalid score values are excluded from taking effect until the evidence is completed. Unfair or incomplete screening decisions are held for remediation before challenge access changes. ## Run @@ -26,6 +26,7 @@ npm run check - `reports/missing-review-list-packet.json` - `reports/missing-criteria-list-packet.json` - `reports/missing-applicant-list-packet.json` +- `reports/malformed-applicant-entry-packet.json` - `reports/blank-rejection-reason-packet.json` - `reports/prequalification-fairness-report.md` - `reports/summary.svg` diff --git a/challenge-prequalification-fairness-guard/acceptance-notes.md b/challenge-prequalification-fairness-guard/acceptance-notes.md index 17bb2f2a..28ef8d3e 100644 --- a/challenge-prequalification-fairness-guard/acceptance-notes.md +++ b/challenge-prequalification-fairness-guard/acceptance-notes.md @@ -28,6 +28,7 @@ Validation coverage: - invalid sponsor decision values are held before malformed accept/reject evidence can change solver access - missing or blank applicant identities are held before anonymous or malformed applicant rows can change solver access - duplicate applicant identities after trimming are held before conflicting prequalification rows can change solver access +- malformed applicant entries such as `null` are held for evidence completion instead of crashing sparse prequalification packets - invalid reviewer score values outside the finite 0-100 range are held before malformed scoring evidence can drive acceptance or rejection - missing rejection reason lists are normalized to an auditable fairness hold instead of crashing the prequalification packet - blank rejection reason text is normalized away and held as missing applicant-facing rejection evidence diff --git a/challenge-prequalification-fairness-guard/demo.js b/challenge-prequalification-fairness-guard/demo.js index 7bd17c2c..24678573 100644 --- a/challenge-prequalification-fairness-guard/demo.js +++ b/challenge-prequalification-fairness-guard/demo.js @@ -16,6 +16,7 @@ const duplicateApplicantIdentityResult = evaluatePrequalificationRound(buildDupl const missingReviewListResult = evaluatePrequalificationRound(buildMissingReviewListRound()); const missingCriteriaListResult = evaluatePrequalificationRound(buildMissingCriteriaListRound()); const missingApplicantListResult = evaluatePrequalificationRound(buildMissingApplicantListRound()); +const malformedApplicantEntryResult = evaluatePrequalificationRound(buildMalformedApplicantEntryRound()); const blankRejectionReasonResult = evaluatePrequalificationRound(buildBlankRejectionReasonRound()); const packetPath = path.join(reportsDir, 'prequalification-fairness-packet.json'); @@ -38,6 +39,10 @@ const duplicateApplicantIdentityPacketPath = path.join( const missingReviewListPacketPath = path.join(reportsDir, 'missing-review-list-packet.json'); const missingCriteriaListPacketPath = path.join(reportsDir, 'missing-criteria-list-packet.json'); const missingApplicantListPacketPath = path.join(reportsDir, 'missing-applicant-list-packet.json'); +const malformedApplicantEntryPacketPath = path.join( + reportsDir, + 'malformed-applicant-entry-packet.json' +); const blankRejectionReasonPacketPath = path.join(reportsDir, 'blank-rejection-reason-packet.json'); const reportPath = path.join(reportsDir, 'prequalification-fairness-report.md'); const svgPath = path.join(reportsDir, 'summary.svg'); @@ -68,6 +73,10 @@ fs.writeFileSync( missingApplicantListPacketPath, `${JSON.stringify(missingApplicantListResult, null, 2)}\n` ); +fs.writeFileSync( + malformedApplicantEntryPacketPath, + `${JSON.stringify(malformedApplicantEntryResult, null, 2)}\n` +); fs.writeFileSync(blankRejectionReasonPacketPath, `${JSON.stringify(blankRejectionReasonResult, null, 2)}\n`); const decisions = result.decisions @@ -185,6 +194,14 @@ ${actions} - Remediation: ${missingApplicantListResult.remediationActions[0].action} - Audit digest: ${missingApplicantListResult.auditDigest} +## Malformed Applicant Entry Packet + +- Applicant: ${malformedApplicantEntryResult.decisions[0].applicantId} +- Decision: ${malformedApplicantEntryResult.decisions[0].decision} +- Reasons: ${malformedApplicantEntryResult.decisions[0].reasons.join(', ')} +- Remediation: ${malformedApplicantEntryResult.remediationActions[0].action} +- Audit digest: ${malformedApplicantEntryResult.auditDigest} + ## Blank Rejection Reason Packet - Applicant: ${blankRejectionReasonResult.decisions[0].applicantId} @@ -227,6 +244,7 @@ console.log(`Wrote ${path.relative(__dirname, duplicateApplicantIdentityPacketPa console.log(`Wrote ${path.relative(__dirname, missingReviewListPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, missingCriteriaListPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, missingApplicantListPacketPath)}`); +console.log(`Wrote ${path.relative(__dirname, malformedApplicantEntryPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, blankRejectionReasonPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, reportPath)}`); console.log(`Wrote ${path.relative(__dirname, svgPath)}`); @@ -610,6 +628,13 @@ function buildMissingApplicantListRound() { return round; } +function buildMalformedApplicantEntryRound() { + const round = buildSampleRound(); + round.applicants = [null]; + round.reviews = []; + return round; +} + function buildBlankRejectionReasonRound() { const round = buildSampleRound(); round.applicants = [ diff --git a/challenge-prequalification-fairness-guard/index.js b/challenge-prequalification-fairness-guard/index.js index 7378e708..2216a166 100644 --- a/challenge-prequalification-fairness-guard/index.js +++ b/challenge-prequalification-fairness-guard/index.js @@ -86,7 +86,29 @@ function reviewerIdFor(review) { return typeof review.reviewerId === 'string' ? review.reviewerId.trim() : ''; } +function applicantIsRecord(applicant) { + return Boolean(applicant && typeof applicant === 'object' && !Array.isArray(applicant)); +} + +function applicantRecordFor(applicant) { + if (applicantIsRecord(applicant)) { + return applicant; + } + + return { + id: '', + sponsorDecision: null, + rejectionReasons: [], + appealDueAt: null, + malformedApplicantEntry: true + }; +} + function applicantIdFor(applicant) { + if (!applicantIsRecord(applicant)) { + return ''; + } + return typeof applicant.id === 'string' ? applicant.id.trim() : ''; } @@ -286,6 +308,10 @@ function reasonsForApplicant(applicant, reviews, round) { return ['missing-applicant-list']; } + if (applicant.malformedApplicantEntry) { + return ['malformed-applicant-entry']; + } + if (round.anonymousScreeningRequired && reviews.some((review) => !review.anonymousScreeningObserved)) { reasons.push('anonymous-screening-leak'); } @@ -473,6 +499,10 @@ function remediationAction(applicant, reasons) { return 'complete-prequalification-evidence'; } + if (reasons.includes('malformed-applicant-entry')) { + return 'complete-prequalification-evidence'; + } + if (reasons.includes('missing-applicant-identity')) { return 'complete-prequalification-evidence'; } @@ -500,7 +530,7 @@ function evaluatePrequalificationRound(round) { missingApplicantList: true } ] - : applicantListFor(round); + : applicantListFor(round).map(applicantRecordFor); const decisions = applicants.map((applicant) => { const reviews = reviewsByApplicant[applicantIdFor(applicant)] || []; @@ -559,6 +589,7 @@ function evaluatePrequalificationRound(round) { decision.reasons.includes('missing-reviewer-identity') || decision.reasons.includes('missing-review-list') || decision.reasons.includes('missing-applicant-list') || + decision.reasons.includes('malformed-applicant-entry') || decision.reasons.includes('missing-applicant-identity') || decision.reasons.includes('duplicate-applicant-identity') || decision.reasons.includes('unpublished-screening-criterion') || diff --git a/challenge-prequalification-fairness-guard/reports/malformed-applicant-entry-packet.json b/challenge-prequalification-fairness-guard/reports/malformed-applicant-entry-packet.json new file mode 100644 index 00000000..46c87782 --- /dev/null +++ b/challenge-prequalification-fairness-guard/reports/malformed-applicant-entry-packet.json @@ -0,0 +1,46 @@ +{ + "challengeId": "challenge-18-prequalification-rna-biomarker", + "generatedAt": "2026-05-28T08:00:00Z", + "criteriaDigest": "sha256:d643b033793917b9d0488787518a11e97094e671d52b86b69a6153375d726721", + "decisions": [ + { + "id": "unidentified-applicant", + "applicantId": "unidentified-applicant", + "challengeId": "challenge-18-prequalification-rna-biomarker", + "decision": "hold-for-fairness-review", + "sponsorDecision": null, + "weightedScore": 0, + "passThreshold": 75, + "reviewersCounted": 0, + "criteriaApplied": [ + "domain-fit", + "data-readiness", + "safety-plan" + ], + "reasons": [ + "malformed-applicant-entry" + ], + "rejectionReasons": [], + "appealStatus": "not-required", + "auditDigest": "sha256:fb51cfbd84ab96e5595f67cb28b427e84f73a592276ba2866afb43fac47541e9" + } + ], + "remediationActions": [ + { + "id": "remediate-unidentified-applicant", + "applicantId": "unidentified-applicant", + "action": "complete-prequalification-evidence", + "priority": "high", + "reasons": [ + "malformed-applicant-entry" + ] + } + ], + "summary": { + "accepted": 0, + "held": 1, + "rejectedWithAudit": 0, + "remediationActions": 1 + }, + "auditDigest": "sha256:f186f54dec87f06c4cc6c61d8de82dfc74749a0ce103446d2084e1fb0f3b6de4" +} diff --git a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md index 3b87fe5c..ba8fc376 100644 --- a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md +++ b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md @@ -105,6 +105,14 @@ Generated: 2026-05-28T08:00:00Z - Remediation: complete-prequalification-evidence - Audit digest: sha256:c77ccb7f1875504454711c406569be8b6b9b34789239e956635850890ecf1a90 +## Malformed Applicant Entry Packet + +- Applicant: unidentified-applicant +- Decision: hold-for-fairness-review +- Reasons: malformed-applicant-entry +- Remediation: complete-prequalification-evidence +- Audit digest: sha256:f186f54dec87f06c4cc6c61d8de82dfc74749a0ce103446d2084e1fb0f3b6de4 + ## Blank Rejection Reason Packet - Applicant: applicant-blank-rejection-reason diff --git a/challenge-prequalification-fairness-guard/requirements-map.md b/challenge-prequalification-fairness-guard/requirements-map.md index 3e4c5eda..609678d5 100644 --- a/challenge-prequalification-fairness-guard/requirements-map.md +++ b/challenge-prequalification-fairness-guard/requirements-map.md @@ -10,7 +10,7 @@ - Protects anonymous or named participation settings during prequalification review. - Requires a valid positive reviewer quorum before a solver team is accepted or rejected, and excludes missing or blank reviewer identities from quorum until reviewer evidence is completed. -- Holds missing, blank, or duplicate applicant identities before malformed applicant rows can change solver-team access. +- Holds missing, blank, duplicate, or malformed applicant identity entries before malformed applicant rows can change solver-team access. - Holds missing round-level criteria lists and review lists before sparse prequalification packets can crash or change solver-team access. - Holds missing applicant lists before sparse prequalification packets can crash or change solver-team access. - Holds incomplete reviewer score packets and invalid finite 0-100 score values for evidence completion instead of letting malformed review records crash or drive decisions. @@ -22,7 +22,7 @@ - Holds invalid pass thresholds for fairness review before sponsor accept/reject decisions can take effect. - Holds invalid reviewer quorum requirements for fairness review before sponsor accept/reject decisions can take effect. - Holds invalid sponsor decision values for fairness review before malformed accept/reject evidence can change solver access. -- Holds missing or duplicate applicant identity evidence for fairness review before anonymous or malformed applicant rows can change solver access. +- Holds missing, duplicate, or malformed applicant identity evidence for fairness review before anonymous or malformed applicant rows can change solver access. - Holds missing applicant-list evidence for fairness review before malformed challenge rounds can change solver access. - Holds invalid reviewer score values for fairness review before malformed score evidence can drive sponsor decisions. - Holds duplicate published criterion identifiers for fairness review before ambiguous rubric evidence can drive sponsor decisions. diff --git a/challenge-prequalification-fairness-guard/test.js b/challenge-prequalification-fairness-guard/test.js index e55eebb4..fabf1ae0 100644 --- a/challenge-prequalification-fairness-guard/test.js +++ b/challenge-prequalification-fairness-guard/test.js @@ -728,6 +728,22 @@ function testDuplicateApplicantIdentitiesHoldPrequalificationRound() { assert.equal(action.priority, 'high'); } +function testMalformedApplicantEntryHoldsPrequalificationRoundWithoutCrashing() { + const round = buildSampleRound(); + round.applicants = [null]; + round.reviews = []; + + const result = evaluatePrequalificationRound(round); + const decision = byId(result.decisions, 'unidentified-applicant'); + const action = byId(result.remediationActions, 'remediate-unidentified-applicant'); + + assert.equal(decision.applicantId, 'unidentified-applicant'); + assert.equal(decision.decision, 'hold-for-fairness-review'); + assert.equal(decision.reasons.includes('malformed-applicant-entry'), true); + assert.equal(action.action, 'complete-prequalification-evidence'); + assert.equal(action.priority, 'high'); +} + function testMissingRejectionReasonListHoldsWithoutCrashing() { const round = buildSampleRound(); round.applicants = [ @@ -1092,6 +1108,7 @@ const tests = [ testInvalidSponsorDecisionHoldsPrequalificationRound, testMissingApplicantIdentityHoldsPrequalificationRound, testDuplicateApplicantIdentitiesHoldPrequalificationRound, + testMalformedApplicantEntryHoldsPrequalificationRoundWithoutCrashing, testMissingRejectionReasonListHoldsWithoutCrashing, testBlankRejectionReasonTextHoldsRejectedApplicant, testIncompleteReviewerScoreEvidenceHoldsWithoutCrashing, From 530f27f4a01f3cd148c776f3706b930c46e6aa07 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Mon, 1 Jun 2026 02:56:00 +0200 Subject: [PATCH 25/28] Harden malformed review entries --- .../README.md | 3 +- .../acceptance-notes.md | 1 + .../demo.js | 61 +++++++++++++++++++ .../index.js | 29 ++++++++- .../malformed-review-entry-packet.json | 46 ++++++++++++++ .../prequalification-fairness-report.md | 9 +++ .../requirements-map.md | 2 + .../test.js | 52 ++++++++++++++++ 8 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 challenge-prequalification-fairness-guard/reports/malformed-review-entry-packet.json diff --git a/challenge-prequalification-fairness-guard/README.md b/challenge-prequalification-fairness-guard/README.md index 7ebabacb..ad461084 100644 --- a/challenge-prequalification-fairness-guard/README.md +++ b/challenge-prequalification-fairness-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Bounty System slice for SCIBASE issue #18. It evaluates sponsor-side prequalification rounds before solver teams are accepted or rejected from a challenge. -The guard checks published screening criteria, complete criteria-list evidence, complete and unique criterion identifiers after trimming, valid criterion weight values and totals, valid pass thresholds, valid reviewer quorum requirements, valid sponsor accept/reject decisions, complete applicant-list and unique applicant identity evidence after trimming, well-formed applicant entries, complete round-level review-list evidence, complete reviewer score evidence, valid finite 0-100 reviewer score values, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, missing reviewer identity evidence, duplicate reviewer score evidence, missing, empty, or blank rejection reason evidence, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, repeated reviewer identities are deduplicated before quorum or threshold scoring, and missing applicant lists, malformed applicant entries, missing or duplicate applicant identities, missing criteria lists, missing review lists, missing reviewer identities, invalid sponsor decisions, or invalid score values are excluded from taking effect until the evidence is completed. Unfair or incomplete screening decisions are held for remediation before challenge access changes. +The guard checks published screening criteria, complete criteria-list evidence, complete and unique criterion identifiers after trimming, valid criterion weight values and totals, valid pass thresholds, valid reviewer quorum requirements, valid sponsor accept/reject decisions, complete applicant-list and unique applicant identity evidence after trimming, well-formed applicant entries, complete round-level review-list evidence, well-formed review entries, complete reviewer score evidence, valid finite 0-100 reviewer score values, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, missing reviewer identity evidence, duplicate reviewer score evidence, missing, empty, or blank rejection reason evidence, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, repeated reviewer identities are deduplicated before quorum or threshold scoring, and missing applicant lists, malformed applicant entries, missing or duplicate applicant identities, missing criteria lists, missing review lists, malformed review entries, missing reviewer identities, invalid sponsor decisions, or invalid score values are excluded from taking effect until the evidence is completed. Unfair or incomplete screening decisions are held for remediation before challenge access changes. ## Run @@ -24,6 +24,7 @@ npm run check - `reports/missing-applicant-identity-packet.json` - `reports/duplicate-applicant-identity-packet.json` - `reports/missing-review-list-packet.json` +- `reports/malformed-review-entry-packet.json` - `reports/missing-criteria-list-packet.json` - `reports/missing-applicant-list-packet.json` - `reports/malformed-applicant-entry-packet.json` diff --git a/challenge-prequalification-fairness-guard/acceptance-notes.md b/challenge-prequalification-fairness-guard/acceptance-notes.md index 28ef8d3e..f9701205 100644 --- a/challenge-prequalification-fairness-guard/acceptance-notes.md +++ b/challenge-prequalification-fairness-guard/acceptance-notes.md @@ -34,6 +34,7 @@ Validation coverage: - blank rejection reason text is normalized away and held as missing applicant-facing rejection evidence - incomplete reviewer score evidence is held for completion without crashing the prequalification packet - missing review lists are held for evidence completion instead of crashing sparse prequalification packets +- malformed review entries such as `null` are held for evidence completion instead of crashing sparse prequalification packets - missing published criteria lists are held for evidence completion instead of crashing sparse prequalification packets - missing applicant lists are held for evidence completion instead of crashing sparse prequalification packets - duplicate reviewer score evidence is held and deduplicated before quorum or weighted threshold scoring diff --git a/challenge-prequalification-fairness-guard/demo.js b/challenge-prequalification-fairness-guard/demo.js index 24678573..f714218f 100644 --- a/challenge-prequalification-fairness-guard/demo.js +++ b/challenge-prequalification-fairness-guard/demo.js @@ -14,6 +14,7 @@ const invalidSponsorDecisionResult = evaluatePrequalificationRound(buildInvalidS const missingApplicantIdentityResult = evaluatePrequalificationRound(buildMissingApplicantIdentityRound()); const duplicateApplicantIdentityResult = evaluatePrequalificationRound(buildDuplicateApplicantIdentityRound()); const missingReviewListResult = evaluatePrequalificationRound(buildMissingReviewListRound()); +const malformedReviewEntryResult = evaluatePrequalificationRound(buildMalformedReviewEntryRound()); const missingCriteriaListResult = evaluatePrequalificationRound(buildMissingCriteriaListRound()); const missingApplicantListResult = evaluatePrequalificationRound(buildMissingApplicantListRound()); const malformedApplicantEntryResult = evaluatePrequalificationRound(buildMalformedApplicantEntryRound()); @@ -37,6 +38,10 @@ const duplicateApplicantIdentityPacketPath = path.join( 'duplicate-applicant-identity-packet.json' ); const missingReviewListPacketPath = path.join(reportsDir, 'missing-review-list-packet.json'); +const malformedReviewEntryPacketPath = path.join( + reportsDir, + 'malformed-review-entry-packet.json' +); const missingCriteriaListPacketPath = path.join(reportsDir, 'missing-criteria-list-packet.json'); const missingApplicantListPacketPath = path.join(reportsDir, 'missing-applicant-list-packet.json'); const malformedApplicantEntryPacketPath = path.join( @@ -65,6 +70,10 @@ fs.writeFileSync( `${JSON.stringify(duplicateApplicantIdentityResult, null, 2)}\n` ); fs.writeFileSync(missingReviewListPacketPath, `${JSON.stringify(missingReviewListResult, null, 2)}\n`); +fs.writeFileSync( + malformedReviewEntryPacketPath, + `${JSON.stringify(malformedReviewEntryResult, null, 2)}\n` +); fs.writeFileSync( missingCriteriaListPacketPath, `${JSON.stringify(missingCriteriaListResult, null, 2)}\n` @@ -178,6 +187,15 @@ ${actions} - Remediation: ${missingReviewListResult.remediationActions[0].action} - Audit digest: ${missingReviewListResult.auditDigest} +## Malformed Review Entry Packet + +- Applicant: ${malformedReviewEntryResult.decisions[0].applicantId} +- Decision: ${malformedReviewEntryResult.decisions[0].decision} +- Reviewers counted: ${malformedReviewEntryResult.decisions[0].reviewersCounted} +- Reasons: ${malformedReviewEntryResult.decisions[0].reasons.join(', ')} +- Remediation: ${malformedReviewEntryResult.remediationActions[0].action} +- Audit digest: ${malformedReviewEntryResult.auditDigest} + ## Missing Criteria List Packet - Applicant: ${missingCriteriaListResult.decisions[0].applicantId} @@ -242,6 +260,7 @@ console.log(`Wrote ${path.relative(__dirname, invalidSponsorDecisionPacketPath)} console.log(`Wrote ${path.relative(__dirname, missingApplicantIdentityPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, duplicateApplicantIdentityPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, missingReviewListPacketPath)}`); +console.log(`Wrote ${path.relative(__dirname, malformedReviewEntryPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, missingCriteriaListPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, missingApplicantListPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, malformedApplicantEntryPacketPath)}`); @@ -580,6 +599,48 @@ function buildMissingReviewListRound() { return round; } +function buildMalformedReviewEntryRound() { + const round = buildSampleRound(); + round.applicants = [ + { + id: 'applicant-malformed-review-entry', + sponsorDecision: 'accept', + rejectionReasons: [], + appealDueAt: null + } + ]; + round.reviews = [ + null, + { + applicantId: 'applicant-malformed-review-entry', + reviewerId: 'reviewer-independent-a', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 94, + 'data-readiness': 96, + 'safety-plan': 93 + } + }, + { + applicantId: 'applicant-malformed-review-entry', + reviewerId: 'reviewer-independent-b', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 92, + 'data-readiness': 94, + 'safety-plan': 91 + } + } + ]; + return round; +} + function buildMissingCriteriaListRound() { const round = buildSampleRound(); round.applicants = [ diff --git a/challenge-prequalification-fairness-guard/index.js b/challenge-prequalification-fairness-guard/index.js index 2216a166..e9c8ad50 100644 --- a/challenge-prequalification-fairness-guard/index.js +++ b/challenge-prequalification-fairness-guard/index.js @@ -82,7 +82,15 @@ function hasMissingPublishedCriterionIds(round) { ); } +function reviewIsRecord(review) { + return Boolean(review && typeof review === 'object' && !Array.isArray(review)); +} + function reviewerIdFor(review) { + if (!reviewIsRecord(review)) { + return ''; + } + return typeof review.reviewerId === 'string' ? review.reviewerId.trim() : ''; } @@ -134,17 +142,25 @@ function duplicateApplicantIds(round) { } function reviewApplicantIdFor(review) { + if (!reviewIsRecord(review)) { + return ''; + } + return typeof review.applicantId === 'string' ? review.applicantId.trim() : review.applicantId; } function reviewListFor(round) { - return Array.isArray(round.reviews) ? round.reviews : []; + return Array.isArray(round.reviews) ? round.reviews.filter(reviewIsRecord) : []; } function reviewListIsMissing(round) { return !Array.isArray(round.reviews); } +function reviewListHasMalformedEntries(round) { + return Array.isArray(round.reviews) && round.reviews.some((review) => !reviewIsRecord(review)); +} + function criteriaListFor(round) { return Array.isArray(round.criteria) ? round.criteria : []; } @@ -215,7 +231,7 @@ function publicCriteriaIds(round) { } function reviewScores(review) { - return review.scores && typeof review.scores === 'object' ? review.scores : {}; + return reviewIsRecord(review) && review.scores && typeof review.scores === 'object' ? review.scores : {}; } function isValidReviewerScore(score) { @@ -340,6 +356,10 @@ function reasonsForApplicant(applicant, reviews, round) { reasons.push('missing-reviewer-identity'); } + if (reviewListHasMalformedEntries(round)) { + reasons.push('malformed-review-entry'); + } + if (reviewListIsMissing(round)) { reasons.push('missing-review-list'); } @@ -491,6 +511,10 @@ function remediationAction(applicant, reasons) { return 'complete-prequalification-evidence'; } + if (reasons.includes('malformed-review-entry')) { + return 'complete-prequalification-evidence'; + } + if (reasons.includes('missing-review-list')) { return 'complete-prequalification-evidence'; } @@ -587,6 +611,7 @@ function evaluatePrequalificationRound(round) { decision.reasons.includes('missing-published-criteria-list') || decision.reasons.includes('duplicate-reviewer-score-evidence') || decision.reasons.includes('missing-reviewer-identity') || + decision.reasons.includes('malformed-review-entry') || decision.reasons.includes('missing-review-list') || decision.reasons.includes('missing-applicant-list') || decision.reasons.includes('malformed-applicant-entry') || diff --git a/challenge-prequalification-fairness-guard/reports/malformed-review-entry-packet.json b/challenge-prequalification-fairness-guard/reports/malformed-review-entry-packet.json new file mode 100644 index 00000000..43db53f6 --- /dev/null +++ b/challenge-prequalification-fairness-guard/reports/malformed-review-entry-packet.json @@ -0,0 +1,46 @@ +{ + "challengeId": "challenge-18-prequalification-rna-biomarker", + "generatedAt": "2026-05-28T08:00:00Z", + "criteriaDigest": "sha256:d643b033793917b9d0488787518a11e97094e671d52b86b69a6153375d726721", + "decisions": [ + { + "id": "applicant-malformed-review-entry", + "applicantId": "applicant-malformed-review-entry", + "challengeId": "challenge-18-prequalification-rna-biomarker", + "decision": "hold-for-fairness-review", + "sponsorDecision": "accept", + "weightedScore": 93, + "passThreshold": 75, + "reviewersCounted": 2, + "criteriaApplied": [ + "domain-fit", + "data-readiness", + "safety-plan" + ], + "reasons": [ + "malformed-review-entry" + ], + "rejectionReasons": [], + "appealStatus": "not-required", + "auditDigest": "sha256:212227740c1b3a1d0dcdc877a8ef7923dc919892d25c7cf0da7b10c9cae600e9" + } + ], + "remediationActions": [ + { + "id": "remediate-applicant-malformed-review-entry", + "applicantId": "applicant-malformed-review-entry", + "action": "complete-prequalification-evidence", + "priority": "high", + "reasons": [ + "malformed-review-entry" + ] + } + ], + "summary": { + "accepted": 0, + "held": 1, + "rejectedWithAudit": 0, + "remediationActions": 1 + }, + "auditDigest": "sha256:92f8e6dbb21bc4f859670a8add42b7e265487aec1eead2c48a87adc717d95976" +} diff --git a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md index ba8fc376..147c9ff0 100644 --- a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md +++ b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md @@ -89,6 +89,15 @@ Generated: 2026-05-28T08:00:00Z - Remediation: complete-prequalification-evidence - Audit digest: sha256:205a2e89821f1f3e7c9b83e959f0b123967d8af29b659d89af898e473ed4033d +## Malformed Review Entry Packet + +- Applicant: applicant-malformed-review-entry +- Decision: hold-for-fairness-review +- Reviewers counted: 2 +- Reasons: malformed-review-entry +- Remediation: complete-prequalification-evidence +- Audit digest: sha256:92f8e6dbb21bc4f859670a8add42b7e265487aec1eead2c48a87adc717d95976 + ## Missing Criteria List Packet - Applicant: applicant-missing-criteria-list diff --git a/challenge-prequalification-fairness-guard/requirements-map.md b/challenge-prequalification-fairness-guard/requirements-map.md index 609678d5..8fedaaeb 100644 --- a/challenge-prequalification-fairness-guard/requirements-map.md +++ b/challenge-prequalification-fairness-guard/requirements-map.md @@ -12,6 +12,7 @@ - Requires a valid positive reviewer quorum before a solver team is accepted or rejected, and excludes missing or blank reviewer identities from quorum until reviewer evidence is completed. - Holds missing, blank, duplicate, or malformed applicant identity entries before malformed applicant rows can change solver-team access. - Holds missing round-level criteria lists and review lists before sparse prequalification packets can crash or change solver-team access. +- Holds malformed review entries before sparse reviewer evidence can crash or change solver-team access. - Holds missing applicant lists before sparse prequalification packets can crash or change solver-team access. - Holds incomplete reviewer score packets and invalid finite 0-100 score values for evidence completion instead of letting malformed review records crash or drive decisions. - Preserves audit evidence for each applicant before access to private challenge workspaces changes. @@ -33,6 +34,7 @@ - Deduplicates repeated reviewer identities before quorum and weighted threshold scoring while retaining the duplicate-evidence finding. - Holds missing reviewer identity evidence before anonymous or malformed reviewer rows can satisfy quorum. - Holds missing review-list evidence for fairness review before applicant decisions can take effect. +- Holds malformed review-entry evidence for fairness review before applicant decisions can take effect. - Holds missing criteria-list evidence for fairness review before applicant decisions can take effect. - Produces deterministic digests for challenge administrators and third-party reviewers. diff --git a/challenge-prequalification-fairness-guard/test.js b/challenge-prequalification-fairness-guard/test.js index fabf1ae0..9da38e32 100644 --- a/challenge-prequalification-fairness-guard/test.js +++ b/challenge-prequalification-fairness-guard/test.js @@ -906,6 +906,57 @@ function testMissingReviewListHoldsPrequalificationRoundWithoutCrashing() { assert.equal(action.priority, 'high'); } +function testMalformedReviewEntryHoldsPrequalificationRoundWithoutCrashing() { + const round = buildSampleRound(); + round.applicants = [ + { + id: 'applicant-malformed-review-entry', + sponsorDecision: 'accept', + rejectionReasons: [], + appealDueAt: null + } + ]; + round.reviews = [ + null, + { + applicantId: 'applicant-malformed-review-entry', + reviewerId: 'reviewer-independent-a', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 94, + 'data-readiness': 96, + 'safety-plan': 93 + } + }, + { + applicantId: 'applicant-malformed-review-entry', + reviewerId: 'reviewer-independent-b', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 92, + 'data-readiness': 94, + 'safety-plan': 91 + } + } + ]; + + const result = evaluatePrequalificationRound(round); + const decision = byId(result.decisions, 'applicant-malformed-review-entry'); + const action = byId(result.remediationActions, 'remediate-applicant-malformed-review-entry'); + + assert.equal(decision.decision, 'hold-for-fairness-review'); + assert.equal(decision.reviewersCounted, 2); + assert.equal(decision.reasons.includes('malformed-review-entry'), true); + assert.equal(action.action, 'complete-prequalification-evidence'); + assert.equal(action.priority, 'high'); +} + function testMissingCriteriaListHoldsPrequalificationRoundWithoutCrashing() { const round = buildSampleRound(); delete round.criteria; @@ -1113,6 +1164,7 @@ const tests = [ testBlankRejectionReasonTextHoldsRejectedApplicant, testIncompleteReviewerScoreEvidenceHoldsWithoutCrashing, testMissingReviewListHoldsPrequalificationRoundWithoutCrashing, + testMalformedReviewEntryHoldsPrequalificationRoundWithoutCrashing, testMissingCriteriaListHoldsPrequalificationRoundWithoutCrashing, testMissingApplicantListHoldsPrequalificationRoundWithoutCrashing, testDuplicateReviewerScoreEvidenceDoesNotSatisfyQuorum, From b2c3a66921210946f4cdbb802c601e4c2850664a Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Mon, 1 Jun 2026 04:58:27 +0200 Subject: [PATCH 26/28] Harden malformed criteria entries --- .../README.md | 3 +- .../acceptance-notes.md | 1 + .../demo.js | 32 +++++++++++ .../index.js | 31 ++++++++++- .../malformed-criterion-entry-packet.json | 54 +++++++++++++++++++ .../prequalification-fairness-report.md | 8 +++ .../test.js | 24 +++++++++ 7 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 challenge-prequalification-fairness-guard/reports/malformed-criterion-entry-packet.json diff --git a/challenge-prequalification-fairness-guard/README.md b/challenge-prequalification-fairness-guard/README.md index ad461084..180d9962 100644 --- a/challenge-prequalification-fairness-guard/README.md +++ b/challenge-prequalification-fairness-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Bounty System slice for SCIBASE issue #18. It evaluates sponsor-side prequalification rounds before solver teams are accepted or rejected from a challenge. -The guard checks published screening criteria, complete criteria-list evidence, complete and unique criterion identifiers after trimming, valid criterion weight values and totals, valid pass thresholds, valid reviewer quorum requirements, valid sponsor accept/reject decisions, complete applicant-list and unique applicant identity evidence after trimming, well-formed applicant entries, complete round-level review-list evidence, well-formed review entries, complete reviewer score evidence, valid finite 0-100 reviewer score values, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, missing reviewer identity evidence, duplicate reviewer score evidence, missing, empty, or blank rejection reason evidence, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, repeated reviewer identities are deduplicated before quorum or threshold scoring, and missing applicant lists, malformed applicant entries, missing or duplicate applicant identities, missing criteria lists, missing review lists, malformed review entries, missing reviewer identities, invalid sponsor decisions, or invalid score values are excluded from taking effect until the evidence is completed. Unfair or incomplete screening decisions are held for remediation before challenge access changes. +The guard checks published screening criteria, complete criteria-list evidence, well-formed criteria entries, complete and unique criterion identifiers after trimming, valid criterion weight values and totals, valid pass thresholds, valid reviewer quorum requirements, valid sponsor accept/reject decisions, complete applicant-list and unique applicant identity evidence after trimming, well-formed applicant entries, complete round-level review-list evidence, well-formed review entries, complete reviewer score evidence, valid finite 0-100 reviewer score values, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, missing reviewer identity evidence, duplicate reviewer score evidence, missing, empty, or blank rejection reason evidence, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, repeated reviewer identities are deduplicated before quorum or threshold scoring, and missing applicant lists, malformed applicant entries, missing or duplicate applicant identities, missing criteria lists, malformed criteria entries, missing review lists, malformed review entries, missing reviewer identities, invalid sponsor decisions, or invalid score values are excluded from taking effect until the evidence is completed. Unfair or incomplete screening decisions are held for remediation before challenge access changes. ## Run @@ -26,6 +26,7 @@ npm run check - `reports/missing-review-list-packet.json` - `reports/malformed-review-entry-packet.json` - `reports/missing-criteria-list-packet.json` +- `reports/malformed-criterion-entry-packet.json` - `reports/missing-applicant-list-packet.json` - `reports/malformed-applicant-entry-packet.json` - `reports/blank-rejection-reason-packet.json` diff --git a/challenge-prequalification-fairness-guard/acceptance-notes.md b/challenge-prequalification-fairness-guard/acceptance-notes.md index f9701205..d3e5619a 100644 --- a/challenge-prequalification-fairness-guard/acceptance-notes.md +++ b/challenge-prequalification-fairness-guard/acceptance-notes.md @@ -36,6 +36,7 @@ Validation coverage: - missing review lists are held for evidence completion instead of crashing sparse prequalification packets - malformed review entries such as `null` are held for evidence completion instead of crashing sparse prequalification packets - missing published criteria lists are held for evidence completion instead of crashing sparse prequalification packets +- malformed published criteria entries such as `null` are held for evidence completion instead of crashing sparse prequalification packets - missing applicant lists are held for evidence completion instead of crashing sparse prequalification packets - duplicate reviewer score evidence is held and deduplicated before quorum or weighted threshold scoring - missing or blank reviewer identities are held and excluded from reviewer quorum until evidence is completed diff --git a/challenge-prequalification-fairness-guard/demo.js b/challenge-prequalification-fairness-guard/demo.js index f714218f..446cc319 100644 --- a/challenge-prequalification-fairness-guard/demo.js +++ b/challenge-prequalification-fairness-guard/demo.js @@ -16,6 +16,7 @@ const duplicateApplicantIdentityResult = evaluatePrequalificationRound(buildDupl const missingReviewListResult = evaluatePrequalificationRound(buildMissingReviewListRound()); const malformedReviewEntryResult = evaluatePrequalificationRound(buildMalformedReviewEntryRound()); const missingCriteriaListResult = evaluatePrequalificationRound(buildMissingCriteriaListRound()); +const malformedCriterionEntryResult = evaluatePrequalificationRound(buildMalformedCriterionEntryRound()); const missingApplicantListResult = evaluatePrequalificationRound(buildMissingApplicantListRound()); const malformedApplicantEntryResult = evaluatePrequalificationRound(buildMalformedApplicantEntryRound()); const blankRejectionReasonResult = evaluatePrequalificationRound(buildBlankRejectionReasonRound()); @@ -43,6 +44,10 @@ const malformedReviewEntryPacketPath = path.join( 'malformed-review-entry-packet.json' ); const missingCriteriaListPacketPath = path.join(reportsDir, 'missing-criteria-list-packet.json'); +const malformedCriterionEntryPacketPath = path.join( + reportsDir, + 'malformed-criterion-entry-packet.json' +); const missingApplicantListPacketPath = path.join(reportsDir, 'missing-applicant-list-packet.json'); const malformedApplicantEntryPacketPath = path.join( reportsDir, @@ -78,6 +83,10 @@ fs.writeFileSync( missingCriteriaListPacketPath, `${JSON.stringify(missingCriteriaListResult, null, 2)}\n` ); +fs.writeFileSync( + malformedCriterionEntryPacketPath, + `${JSON.stringify(malformedCriterionEntryResult, null, 2)}\n` +); fs.writeFileSync( missingApplicantListPacketPath, `${JSON.stringify(missingApplicantListResult, null, 2)}\n` @@ -204,6 +213,14 @@ ${actions} - Remediation: ${missingCriteriaListResult.remediationActions[0].action} - Audit digest: ${missingCriteriaListResult.auditDigest} +## Malformed Criterion Entry Packet + +- Applicant: ${malformedCriterionEntryResult.decisions[0].applicantId} +- Decision: ${malformedCriterionEntryResult.decisions[0].decision} +- Reasons: ${malformedCriterionEntryResult.decisions[0].reasons.join(', ')} +- Remediation: ${malformedCriterionEntryResult.remediationActions[0].action} +- Audit digest: ${malformedCriterionEntryResult.auditDigest} + ## Missing Applicant List Packet - Applicant: ${missingApplicantListResult.decisions[0].applicantId} @@ -262,6 +279,7 @@ console.log(`Wrote ${path.relative(__dirname, duplicateApplicantIdentityPacketPa console.log(`Wrote ${path.relative(__dirname, missingReviewListPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, malformedReviewEntryPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, missingCriteriaListPacketPath)}`); +console.log(`Wrote ${path.relative(__dirname, malformedCriterionEntryPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, missingApplicantListPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, malformedApplicantEntryPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, blankRejectionReasonPacketPath)}`); @@ -683,6 +701,20 @@ function buildMissingCriteriaListRound() { return round; } +function buildMalformedCriterionEntryRound() { + const round = buildSampleRound(); + round.criteria = [null]; + round.applicants = [ + { + id: 'applicant-malformed-criterion-entry', + sponsorDecision: 'accept', + rejectionReasons: [], + appealDueAt: null + } + ]; + return round; +} + function buildMissingApplicantListRound() { const round = buildSampleRound(); delete round.applicants; diff --git a/challenge-prequalification-fairness-guard/index.js b/challenge-prequalification-fairness-guard/index.js index e9c8ad50..20dafdfc 100644 --- a/challenge-prequalification-fairness-guard/index.js +++ b/challenge-prequalification-fairness-guard/index.js @@ -62,6 +62,22 @@ function criterionIdFor(criterion) { return typeof criterion.id === 'string' ? criterion.id.trim() : criterion.id; } +function criterionIsRecord(criterion) { + return Boolean(criterion && typeof criterion === 'object' && !Array.isArray(criterion)); +} + +function criterionRecordFor(criterion) { + if (criterionIsRecord(criterion)) { + return criterion; + } + + return { + id: '', + weight: null, + malformedCriterionEntry: true + }; +} + function duplicatePublishedCriterionIds(round) { const criterionCounts = criteriaListFor(round).reduce((counts, criterion) => { const criterionId = criterionIdFor(criterion); @@ -162,13 +178,17 @@ function reviewListHasMalformedEntries(round) { } function criteriaListFor(round) { - return Array.isArray(round.criteria) ? round.criteria : []; + return Array.isArray(round.criteria) ? round.criteria.map(criterionRecordFor) : []; } function criteriaListIsMissing(round) { return !Array.isArray(round.criteria); } +function criteriaListHasMalformedEntries(round) { + return Array.isArray(round.criteria) && round.criteria.some((criterion) => !criterionIsRecord(criterion)); +} + function applicantListFor(round) { return Array.isArray(round.applicants) ? round.applicants : []; } @@ -348,6 +368,10 @@ function reasonsForApplicant(applicant, reviews, round) { reasons.push('missing-published-criteria-list'); } + if (criteriaListHasMalformedEntries(round)) { + reasons.push('malformed-published-criterion-entry'); + } + if (duplicateReviewerIds.length > 0) { reasons.push('duplicate-reviewer-score-evidence'); } @@ -450,6 +474,10 @@ function remediationAction(applicant, reasons) { return 'publish-complete-screening-criteria'; } + if (reasons.includes('malformed-published-criterion-entry')) { + return 'publish-complete-screening-criteria'; + } + if (reasons.includes('unpublished-screening-criterion')) { return 'remove-unpublished-criterion-and-rescore'; } @@ -609,6 +637,7 @@ function evaluatePrequalificationRound(round) { decision.reasons.includes('duplicate-published-criterion') || decision.reasons.includes('missing-published-criterion-id') || decision.reasons.includes('missing-published-criteria-list') || + decision.reasons.includes('malformed-published-criterion-entry') || decision.reasons.includes('duplicate-reviewer-score-evidence') || decision.reasons.includes('missing-reviewer-identity') || decision.reasons.includes('malformed-review-entry') || diff --git a/challenge-prequalification-fairness-guard/reports/malformed-criterion-entry-packet.json b/challenge-prequalification-fairness-guard/reports/malformed-criterion-entry-packet.json new file mode 100644 index 00000000..4ed8d050 --- /dev/null +++ b/challenge-prequalification-fairness-guard/reports/malformed-criterion-entry-packet.json @@ -0,0 +1,54 @@ +{ + "challengeId": "challenge-18-prequalification-rna-biomarker", + "generatedAt": "2026-05-28T08:00:00Z", + "criteriaDigest": "sha256:2e78a5062ffa5673f5eefb1185b8616fa56e435f3c4320476454b240d3355ab6", + "decisions": [ + { + "id": "applicant-malformed-criterion-entry", + "applicantId": "applicant-malformed-criterion-entry", + "challengeId": "challenge-18-prequalification-rna-biomarker", + "decision": "hold-for-fairness-review", + "sponsorDecision": "accept", + "weightedScore": 0, + "passThreshold": 75, + "reviewersCounted": 0, + "criteriaApplied": [ + "" + ], + "reasons": [ + "criteria-weight-total-invalid", + "criteria-weight-value-invalid", + "inconsistent-threshold-decision", + "malformed-published-criterion-entry", + "missing-published-criterion-id", + "reviewer-quorum-shortfall" + ], + "rejectionReasons": [], + "appealStatus": "not-required", + "auditDigest": "sha256:b2e87c7470068a0abe378189749a4d088d4cd7334bd2c769259d75b813bba848" + } + ], + "remediationActions": [ + { + "id": "remediate-applicant-malformed-criterion-entry", + "applicantId": "applicant-malformed-criterion-entry", + "action": "publish-complete-screening-criteria", + "priority": "high", + "reasons": [ + "criteria-weight-total-invalid", + "criteria-weight-value-invalid", + "inconsistent-threshold-decision", + "malformed-published-criterion-entry", + "missing-published-criterion-id", + "reviewer-quorum-shortfall" + ] + } + ], + "summary": { + "accepted": 0, + "held": 1, + "rejectedWithAudit": 0, + "remediationActions": 1 + }, + "auditDigest": "sha256:288e8b93e7b316689b59f0d21d90d99e579ad0231804b4d64291f20e66d7ec4a" +} diff --git a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md index 147c9ff0..46812801 100644 --- a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md +++ b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md @@ -106,6 +106,14 @@ Generated: 2026-05-28T08:00:00Z - Remediation: publish-complete-screening-criteria - Audit digest: sha256:c2d8b60e1ca8cc50e8b8c072ee2a22a9a832df09b59e63d60dd21b41b1f17fd3 +## Malformed Criterion Entry Packet + +- Applicant: applicant-malformed-criterion-entry +- Decision: hold-for-fairness-review +- Reasons: criteria-weight-total-invalid, criteria-weight-value-invalid, inconsistent-threshold-decision, malformed-published-criterion-entry, missing-published-criterion-id, reviewer-quorum-shortfall +- Remediation: publish-complete-screening-criteria +- Audit digest: sha256:288e8b93e7b316689b59f0d21d90d99e579ad0231804b4d64291f20e66d7ec4a + ## Missing Applicant List Packet - Applicant: unidentified-applicant diff --git a/challenge-prequalification-fairness-guard/test.js b/challenge-prequalification-fairness-guard/test.js index 9da38e32..f22ddeda 100644 --- a/challenge-prequalification-fairness-guard/test.js +++ b/challenge-prequalification-fairness-guard/test.js @@ -1010,6 +1010,29 @@ function testMissingCriteriaListHoldsPrequalificationRoundWithoutCrashing() { assert.ok(result.criteriaDigest.startsWith('sha256:')); } +function testMalformedCriterionEntryHoldsPrequalificationRoundWithoutCrashing() { + const round = buildSampleRound(); + round.criteria = [null]; + round.applicants = [ + { + id: 'applicant-malformed-criterion-entry', + sponsorDecision: 'accept', + rejectionReasons: [], + appealDueAt: null + } + ]; + + const result = evaluatePrequalificationRound(round); + const decision = byId(result.decisions, 'applicant-malformed-criterion-entry'); + const action = byId(result.remediationActions, 'remediate-applicant-malformed-criterion-entry'); + + assert.equal(decision.decision, 'hold-for-fairness-review'); + assert.equal(decision.reasons.includes('malformed-published-criterion-entry'), true); + assert.equal(action.action, 'publish-complete-screening-criteria'); + assert.equal(action.priority, 'high'); + assert.ok(result.criteriaDigest.startsWith('sha256:')); +} + function testMissingApplicantListHoldsPrequalificationRoundWithoutCrashing() { const round = buildSampleRound(); delete round.applicants; @@ -1166,6 +1189,7 @@ const tests = [ testMissingReviewListHoldsPrequalificationRoundWithoutCrashing, testMalformedReviewEntryHoldsPrequalificationRoundWithoutCrashing, testMissingCriteriaListHoldsPrequalificationRoundWithoutCrashing, + testMalformedCriterionEntryHoldsPrequalificationRoundWithoutCrashing, testMissingApplicantListHoldsPrequalificationRoundWithoutCrashing, testDuplicateReviewerScoreEvidenceDoesNotSatisfyQuorum, testMissingReviewerIdentityDoesNotSatisfyQuorum, From 4c74a9ace88ce375d355125e08347904e96ede02 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Mon, 1 Jun 2026 06:52:04 +0200 Subject: [PATCH 27/28] Harden missing challenge identity holds --- .../README.md | 3 +- .../acceptance-notes.md | 1 + .../demo.js | 61 +++++++++++++++++++ .../index.js | 28 ++++++++- .../missing-challenge-identity-packet.json | 46 ++++++++++++++ .../prequalification-fairness-report.md | 9 +++ .../test.js | 53 ++++++++++++++++ 7 files changed, 197 insertions(+), 4 deletions(-) create mode 100644 challenge-prequalification-fairness-guard/reports/missing-challenge-identity-packet.json diff --git a/challenge-prequalification-fairness-guard/README.md b/challenge-prequalification-fairness-guard/README.md index 180d9962..78540fc4 100644 --- a/challenge-prequalification-fairness-guard/README.md +++ b/challenge-prequalification-fairness-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Bounty System slice for SCIBASE issue #18. It evaluates sponsor-side prequalification rounds before solver teams are accepted or rejected from a challenge. -The guard checks published screening criteria, complete criteria-list evidence, well-formed criteria entries, complete and unique criterion identifiers after trimming, valid criterion weight values and totals, valid pass thresholds, valid reviewer quorum requirements, valid sponsor accept/reject decisions, complete applicant-list and unique applicant identity evidence after trimming, well-formed applicant entries, complete round-level review-list evidence, well-formed review entries, complete reviewer score evidence, valid finite 0-100 reviewer score values, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, missing reviewer identity evidence, duplicate reviewer score evidence, missing, empty, or blank rejection reason evidence, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, repeated reviewer identities are deduplicated before quorum or threshold scoring, and missing applicant lists, malformed applicant entries, missing or duplicate applicant identities, missing criteria lists, malformed criteria entries, missing review lists, malformed review entries, missing reviewer identities, invalid sponsor decisions, or invalid score values are excluded from taking effect until the evidence is completed. Unfair or incomplete screening decisions are held for remediation before challenge access changes. +The guard checks challenge identity, published screening criteria, complete criteria-list evidence, well-formed criteria entries, complete and unique criterion identifiers after trimming, valid criterion weight values and totals, valid pass thresholds, valid reviewer quorum requirements, valid sponsor accept/reject decisions, complete applicant-list and unique applicant identity evidence after trimming, well-formed applicant entries, complete round-level review-list evidence, well-formed review entries, complete reviewer score evidence, valid finite 0-100 reviewer score values, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, missing reviewer identity evidence, duplicate reviewer score evidence, missing, empty, or blank rejection reason evidence, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, repeated reviewer identities are deduplicated before quorum or threshold scoring, and missing challenge identities, missing applicant lists, malformed applicant entries, missing or duplicate applicant identities, missing criteria lists, malformed criteria entries, missing review lists, malformed review entries, missing reviewer identities, invalid sponsor decisions, or invalid score values are excluded from taking effect until the evidence is completed. Unfair or incomplete screening decisions are held for remediation before challenge access changes. ## Run @@ -28,6 +28,7 @@ npm run check - `reports/missing-criteria-list-packet.json` - `reports/malformed-criterion-entry-packet.json` - `reports/missing-applicant-list-packet.json` +- `reports/missing-challenge-identity-packet.json` - `reports/malformed-applicant-entry-packet.json` - `reports/blank-rejection-reason-packet.json` - `reports/prequalification-fairness-report.md` diff --git a/challenge-prequalification-fairness-guard/acceptance-notes.md b/challenge-prequalification-fairness-guard/acceptance-notes.md index d3e5619a..4ce13296 100644 --- a/challenge-prequalification-fairness-guard/acceptance-notes.md +++ b/challenge-prequalification-fairness-guard/acceptance-notes.md @@ -38,6 +38,7 @@ Validation coverage: - missing published criteria lists are held for evidence completion instead of crashing sparse prequalification packets - malformed published criteria entries such as `null` are held for evidence completion instead of crashing sparse prequalification packets - missing applicant lists are held for evidence completion instead of crashing sparse prequalification packets +- missing or blank challenge identities are held before solver access decisions can detach from a specific challenge audit trail - duplicate reviewer score evidence is held and deduplicated before quorum or weighted threshold scoring - missing or blank reviewer identities are held and excluded from reviewer quorum until evidence is completed - audit digests are deterministic and private-data free diff --git a/challenge-prequalification-fairness-guard/demo.js b/challenge-prequalification-fairness-guard/demo.js index 446cc319..4240a675 100644 --- a/challenge-prequalification-fairness-guard/demo.js +++ b/challenge-prequalification-fairness-guard/demo.js @@ -18,6 +18,7 @@ const malformedReviewEntryResult = evaluatePrequalificationRound(buildMalformedR const missingCriteriaListResult = evaluatePrequalificationRound(buildMissingCriteriaListRound()); const malformedCriterionEntryResult = evaluatePrequalificationRound(buildMalformedCriterionEntryRound()); const missingApplicantListResult = evaluatePrequalificationRound(buildMissingApplicantListRound()); +const missingChallengeIdentityResult = evaluatePrequalificationRound(buildMissingChallengeIdentityRound()); const malformedApplicantEntryResult = evaluatePrequalificationRound(buildMalformedApplicantEntryRound()); const blankRejectionReasonResult = evaluatePrequalificationRound(buildBlankRejectionReasonRound()); @@ -49,6 +50,10 @@ const malformedCriterionEntryPacketPath = path.join( 'malformed-criterion-entry-packet.json' ); const missingApplicantListPacketPath = path.join(reportsDir, 'missing-applicant-list-packet.json'); +const missingChallengeIdentityPacketPath = path.join( + reportsDir, + 'missing-challenge-identity-packet.json' +); const malformedApplicantEntryPacketPath = path.join( reportsDir, 'malformed-applicant-entry-packet.json' @@ -91,6 +96,10 @@ fs.writeFileSync( missingApplicantListPacketPath, `${JSON.stringify(missingApplicantListResult, null, 2)}\n` ); +fs.writeFileSync( + missingChallengeIdentityPacketPath, + `${JSON.stringify(missingChallengeIdentityResult, null, 2)}\n` +); fs.writeFileSync( malformedApplicantEntryPacketPath, `${JSON.stringify(malformedApplicantEntryResult, null, 2)}\n` @@ -229,6 +238,15 @@ ${actions} - Remediation: ${missingApplicantListResult.remediationActions[0].action} - Audit digest: ${missingApplicantListResult.auditDigest} +## Missing Challenge Identity Packet + +- Challenge: ${missingChallengeIdentityResult.challengeId} +- Applicant: ${missingChallengeIdentityResult.decisions[0].applicantId} +- Decision: ${missingChallengeIdentityResult.decisions[0].decision} +- Reasons: ${missingChallengeIdentityResult.decisions[0].reasons.join(', ')} +- Remediation: ${missingChallengeIdentityResult.remediationActions[0].action} +- Audit digest: ${missingChallengeIdentityResult.auditDigest} + ## Malformed Applicant Entry Packet - Applicant: ${malformedApplicantEntryResult.decisions[0].applicantId} @@ -281,6 +299,7 @@ console.log(`Wrote ${path.relative(__dirname, malformedReviewEntryPacketPath)}`) console.log(`Wrote ${path.relative(__dirname, missingCriteriaListPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, malformedCriterionEntryPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, missingApplicantListPacketPath)}`); +console.log(`Wrote ${path.relative(__dirname, missingChallengeIdentityPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, malformedApplicantEntryPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, blankRejectionReasonPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, reportPath)}`); @@ -721,6 +740,48 @@ function buildMissingApplicantListRound() { return round; } +function buildMissingChallengeIdentityRound() { + const round = buildSampleRound(); + round.challengeId = ' '; + round.applicants = [ + { + id: 'applicant-missing-challenge-identity', + sponsorDecision: 'accept', + rejectionReasons: [], + appealDueAt: null + } + ]; + round.reviews = [ + { + applicantId: 'applicant-missing-challenge-identity', + reviewerId: 'reviewer-independent-a', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 94, + 'data-readiness': 96, + 'safety-plan': 93 + } + }, + { + applicantId: 'applicant-missing-challenge-identity', + reviewerId: 'reviewer-independent-b', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 92, + 'data-readiness': 94, + 'safety-plan': 91 + } + } + ]; + return round; +} + function buildMalformedApplicantEntryRound() { const round = buildSampleRound(); round.applicants = [null]; diff --git a/challenge-prequalification-fairness-guard/index.js b/challenge-prequalification-fairness-guard/index.js index 20dafdfc..b54a795a 100644 --- a/challenge-prequalification-fairness-guard/index.js +++ b/challenge-prequalification-fairness-guard/index.js @@ -58,6 +58,18 @@ function uniqueSorted(values) { return Array.from(new Set(values)).sort(); } +function challengeIdFor(round) { + return typeof round.challengeId === 'string' ? round.challengeId.trim() : ''; +} + +function outputChallengeIdFor(round) { + return challengeIdFor(round) || 'unidentified-challenge'; +} + +function challengeIdentityIsMissing(round) { + return !challengeIdFor(round); +} + function criterionIdFor(criterion) { return typeof criterion.id === 'string' ? criterion.id.trim() : criterion.id; } @@ -348,6 +360,10 @@ function reasonsForApplicant(applicant, reviews, round) { return ['malformed-applicant-entry']; } + if (challengeIdentityIsMissing(round)) { + reasons.push('missing-challenge-identity'); + } + if (round.anonymousScreeningRequired && reviews.some((review) => !review.anonymousScreeningObserved)) { reasons.push('anonymous-screening-leak'); } @@ -466,6 +482,10 @@ function reasonsForApplicant(applicant, reviews, round) { } function remediationAction(applicant, reasons) { + if (reasons.includes('missing-challenge-identity')) { + return 'complete-challenge-identity-evidence'; + } + if (reasons.includes('anonymous-screening-leak')) { return 'rerun-blinded-prequalification-review'; } @@ -571,6 +591,7 @@ function remediationAction(applicant, reasons) { } function evaluatePrequalificationRound(round) { + const challengeId = outputChallengeIdFor(round); const reviewsByApplicant = groupBy(reviewListFor(round), reviewApplicantIdFor); const applicants = applicantListIsMissing(round) ? [ @@ -600,7 +621,7 @@ function evaluatePrequalificationRound(round) { return { id: applicantId, applicantId, - challengeId: round.challengeId, + challengeId, decision, sponsorDecision: applicant.sponsorDecision, weightedScore: score, @@ -612,7 +633,7 @@ function evaluatePrequalificationRound(round) { appealStatus: appealStatus(applicant, round), auditDigest: digest({ applicantId, - challengeId: round.challengeId, + challengeId, score, reasons, reviews: reviews.map((review) => ({ @@ -638,6 +659,7 @@ function evaluatePrequalificationRound(round) { decision.reasons.includes('missing-published-criterion-id') || decision.reasons.includes('missing-published-criteria-list') || decision.reasons.includes('malformed-published-criterion-entry') || + decision.reasons.includes('missing-challenge-identity') || decision.reasons.includes('duplicate-reviewer-score-evidence') || decision.reasons.includes('missing-reviewer-identity') || decision.reasons.includes('malformed-review-entry') || @@ -667,7 +689,7 @@ function evaluatePrequalificationRound(round) { }; return { - challengeId: round.challengeId, + challengeId, generatedAt: round.generatedAt, criteriaDigest: digest(criteriaListFor(round)), decisions, diff --git a/challenge-prequalification-fairness-guard/reports/missing-challenge-identity-packet.json b/challenge-prequalification-fairness-guard/reports/missing-challenge-identity-packet.json new file mode 100644 index 00000000..ec8fc16d --- /dev/null +++ b/challenge-prequalification-fairness-guard/reports/missing-challenge-identity-packet.json @@ -0,0 +1,46 @@ +{ + "challengeId": "unidentified-challenge", + "generatedAt": "2026-05-28T08:00:00Z", + "criteriaDigest": "sha256:d643b033793917b9d0488787518a11e97094e671d52b86b69a6153375d726721", + "decisions": [ + { + "id": "applicant-missing-challenge-identity", + "applicantId": "applicant-missing-challenge-identity", + "challengeId": "unidentified-challenge", + "decision": "hold-for-fairness-review", + "sponsorDecision": "accept", + "weightedScore": 93, + "passThreshold": 75, + "reviewersCounted": 2, + "criteriaApplied": [ + "domain-fit", + "data-readiness", + "safety-plan" + ], + "reasons": [ + "missing-challenge-identity" + ], + "rejectionReasons": [], + "appealStatus": "not-required", + "auditDigest": "sha256:91f727ed6c0f39a3fba57e3aff235d131b4e5a1944b1d8f2ede901bc1fa988a3" + } + ], + "remediationActions": [ + { + "id": "remediate-applicant-missing-challenge-identity", + "applicantId": "applicant-missing-challenge-identity", + "action": "complete-challenge-identity-evidence", + "priority": "high", + "reasons": [ + "missing-challenge-identity" + ] + } + ], + "summary": { + "accepted": 0, + "held": 1, + "rejectedWithAudit": 0, + "remediationActions": 1 + }, + "auditDigest": "sha256:dcd93740470a0b4a16d6e7913c2f62a81fc895d7cbf664b4317b239bddd1ae6d" +} diff --git a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md index 46812801..35ce1b3d 100644 --- a/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md +++ b/challenge-prequalification-fairness-guard/reports/prequalification-fairness-report.md @@ -122,6 +122,15 @@ Generated: 2026-05-28T08:00:00Z - Remediation: complete-prequalification-evidence - Audit digest: sha256:c77ccb7f1875504454711c406569be8b6b9b34789239e956635850890ecf1a90 +## Missing Challenge Identity Packet + +- Challenge: unidentified-challenge +- Applicant: applicant-missing-challenge-identity +- Decision: hold-for-fairness-review +- Reasons: missing-challenge-identity +- Remediation: complete-challenge-identity-evidence +- Audit digest: sha256:dcd93740470a0b4a16d6e7913c2f62a81fc895d7cbf664b4317b239bddd1ae6d + ## Malformed Applicant Entry Packet - Applicant: unidentified-applicant diff --git a/challenge-prequalification-fairness-guard/test.js b/challenge-prequalification-fairness-guard/test.js index f22ddeda..ed0dd4f3 100644 --- a/challenge-prequalification-fairness-guard/test.js +++ b/challenge-prequalification-fairness-guard/test.js @@ -1049,6 +1049,58 @@ function testMissingApplicantListHoldsPrequalificationRoundWithoutCrashing() { assert.equal(result.summary.held, 1); } +function testMissingChallengeIdentityHoldsPrequalificationRound() { + const round = buildSampleRound(); + round.challengeId = ' '; + round.applicants = [ + { + id: 'applicant-missing-challenge-identity', + sponsorDecision: 'accept', + rejectionReasons: [], + appealDueAt: null + } + ]; + round.reviews = [ + { + applicantId: 'applicant-missing-challenge-identity', + reviewerId: 'reviewer-independent-a', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 94, + 'data-readiness': 96, + 'safety-plan': 93 + } + }, + { + applicantId: 'applicant-missing-challenge-identity', + reviewerId: 'reviewer-independent-b', + anonymousScreeningObserved: true, + conflict: false, + recommendedDecision: 'accept', + rejectionReasons: [], + scores: { + 'domain-fit': 92, + 'data-readiness': 94, + 'safety-plan': 91 + } + } + ]; + + const result = evaluatePrequalificationRound(round); + const decision = byId(result.decisions, 'applicant-missing-challenge-identity'); + const action = byId(result.remediationActions, 'remediate-applicant-missing-challenge-identity'); + + assert.equal(result.challengeId, 'unidentified-challenge'); + assert.equal(decision.challengeId, 'unidentified-challenge'); + assert.equal(decision.decision, 'hold-for-fairness-review'); + assert.equal(decision.reasons.includes('missing-challenge-identity'), true); + assert.equal(action.action, 'complete-challenge-identity-evidence'); + assert.equal(action.priority, 'high'); +} + function testDuplicateReviewerScoreEvidenceDoesNotSatisfyQuorum() { const round = buildSampleRound(); round.minReviewers = 2; @@ -1191,6 +1243,7 @@ const tests = [ testMissingCriteriaListHoldsPrequalificationRoundWithoutCrashing, testMalformedCriterionEntryHoldsPrequalificationRoundWithoutCrashing, testMissingApplicantListHoldsPrequalificationRoundWithoutCrashing, + testMissingChallengeIdentityHoldsPrequalificationRound, testDuplicateReviewerScoreEvidenceDoesNotSatisfyQuorum, testMissingReviewerIdentityDoesNotSatisfyQuorum, testAuditDigestIsDeterministicAndPrivateFree From 5e5c287a562fde5bc3370ce84f0be85dbc6cd7f6 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Tue, 2 Jun 2026 07:07:45 +0200 Subject: [PATCH 28/28] Harden malformed prequalification round packets --- .../README.md | 3 +- .../acceptance-notes.md | 1 + .../demo.js | 19 +++++++ .../index.js | 53 +++++++++++++++++- .../make-demo-video.py | 2 +- .../reports/demo.mp4 | Bin 44663 -> 46164 bytes ...lformed-prequalification-round-packet.json | 50 +++++++++++++++++ .../prequalification-fairness-report.md | 9 +++ .../requirements-map.md | 2 + .../test.js | 18 ++++++ 10 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 challenge-prequalification-fairness-guard/reports/malformed-prequalification-round-packet.json diff --git a/challenge-prequalification-fairness-guard/README.md b/challenge-prequalification-fairness-guard/README.md index 78540fc4..82682f87 100644 --- a/challenge-prequalification-fairness-guard/README.md +++ b/challenge-prequalification-fairness-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Bounty System slice for SCIBASE issue #18. It evaluates sponsor-side prequalification rounds before solver teams are accepted or rejected from a challenge. -The guard checks challenge identity, published screening criteria, complete criteria-list evidence, well-formed criteria entries, complete and unique criterion identifiers after trimming, valid criterion weight values and totals, valid pass thresholds, valid reviewer quorum requirements, valid sponsor accept/reject decisions, complete applicant-list and unique applicant identity evidence after trimming, well-formed applicant entries, complete round-level review-list evidence, well-formed review entries, complete reviewer score evidence, valid finite 0-100 reviewer score values, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, missing reviewer identity evidence, duplicate reviewer score evidence, missing, empty, or blank rejection reason evidence, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, repeated reviewer identities are deduplicated before quorum or threshold scoring, and missing challenge identities, missing applicant lists, malformed applicant entries, missing or duplicate applicant identities, missing criteria lists, malformed criteria entries, missing review lists, malformed review entries, missing reviewer identities, invalid sponsor decisions, or invalid score values are excluded from taking effect until the evidence is completed. Unfair or incomplete screening decisions are held for remediation before challenge access changes. +The guard checks well-formed prequalification packets, challenge identity, published screening criteria, complete criteria-list evidence, well-formed criteria entries, complete and unique criterion identifiers after trimming, valid criterion weight values and totals, valid pass thresholds, valid reviewer quorum requirements, valid sponsor accept/reject decisions, complete applicant-list and unique applicant identity evidence after trimming, well-formed applicant entries, complete round-level review-list evidence, well-formed review entries, complete reviewer score evidence, valid finite 0-100 reviewer score values, weighted threshold consistency, anonymous-screening requirements, reviewer conflicts, distinct reviewer quorum, missing reviewer identity evidence, duplicate reviewer score evidence, missing, empty, or blank rejection reason evidence, parseable appeal windows, and audit evidence. Conflicted reviewer scores are excluded from threshold scoring while the conflict remains auditable, repeated reviewer identities are deduplicated before quorum or threshold scoring, and malformed top-level packets, missing challenge identities, missing applicant lists, malformed applicant entries, missing or duplicate applicant identities, missing criteria lists, malformed criteria entries, missing review lists, malformed review entries, missing reviewer identities, invalid sponsor decisions, or invalid score values are excluded from taking effect until the evidence is completed. Unfair or incomplete screening decisions are held for remediation before challenge access changes. ## Run @@ -28,6 +28,7 @@ npm run check - `reports/missing-criteria-list-packet.json` - `reports/malformed-criterion-entry-packet.json` - `reports/missing-applicant-list-packet.json` +- `reports/malformed-prequalification-round-packet.json` - `reports/missing-challenge-identity-packet.json` - `reports/malformed-applicant-entry-packet.json` - `reports/blank-rejection-reason-packet.json` diff --git a/challenge-prequalification-fairness-guard/acceptance-notes.md b/challenge-prequalification-fairness-guard/acceptance-notes.md index 4ce13296..1f77aa11 100644 --- a/challenge-prequalification-fairness-guard/acceptance-notes.md +++ b/challenge-prequalification-fairness-guard/acceptance-notes.md @@ -38,6 +38,7 @@ Validation coverage: - missing published criteria lists are held for evidence completion instead of crashing sparse prequalification packets - malformed published criteria entries such as `null` are held for evidence completion instead of crashing sparse prequalification packets - missing applicant lists are held for evidence completion instead of crashing sparse prequalification packets +- malformed top-level prequalification packets such as `null` are held for evidence completion instead of crashing before reviewer packets can be generated - missing or blank challenge identities are held before solver access decisions can detach from a specific challenge audit trail - duplicate reviewer score evidence is held and deduplicated before quorum or weighted threshold scoring - missing or blank reviewer identities are held and excluded from reviewer quorum until evidence is completed diff --git a/challenge-prequalification-fairness-guard/demo.js b/challenge-prequalification-fairness-guard/demo.js index 4240a675..0e1e7a40 100644 --- a/challenge-prequalification-fairness-guard/demo.js +++ b/challenge-prequalification-fairness-guard/demo.js @@ -18,6 +18,7 @@ const malformedReviewEntryResult = evaluatePrequalificationRound(buildMalformedR const missingCriteriaListResult = evaluatePrequalificationRound(buildMissingCriteriaListRound()); const malformedCriterionEntryResult = evaluatePrequalificationRound(buildMalformedCriterionEntryRound()); const missingApplicantListResult = evaluatePrequalificationRound(buildMissingApplicantListRound()); +const malformedPrequalificationRoundResult = evaluatePrequalificationRound(null); const missingChallengeIdentityResult = evaluatePrequalificationRound(buildMissingChallengeIdentityRound()); const malformedApplicantEntryResult = evaluatePrequalificationRound(buildMalformedApplicantEntryRound()); const blankRejectionReasonResult = evaluatePrequalificationRound(buildBlankRejectionReasonRound()); @@ -50,6 +51,10 @@ const malformedCriterionEntryPacketPath = path.join( 'malformed-criterion-entry-packet.json' ); const missingApplicantListPacketPath = path.join(reportsDir, 'missing-applicant-list-packet.json'); +const malformedPrequalificationRoundPacketPath = path.join( + reportsDir, + 'malformed-prequalification-round-packet.json' +); const missingChallengeIdentityPacketPath = path.join( reportsDir, 'missing-challenge-identity-packet.json' @@ -96,6 +101,10 @@ fs.writeFileSync( missingApplicantListPacketPath, `${JSON.stringify(missingApplicantListResult, null, 2)}\n` ); +fs.writeFileSync( + malformedPrequalificationRoundPacketPath, + `${JSON.stringify(malformedPrequalificationRoundResult, null, 2)}\n` +); fs.writeFileSync( missingChallengeIdentityPacketPath, `${JSON.stringify(missingChallengeIdentityResult, null, 2)}\n` @@ -238,6 +247,15 @@ ${actions} - Remediation: ${missingApplicantListResult.remediationActions[0].action} - Audit digest: ${missingApplicantListResult.auditDigest} +## Malformed Prequalification Round Packet + +- Challenge: ${malformedPrequalificationRoundResult.challengeId} +- Applicant: ${malformedPrequalificationRoundResult.decisions[0].applicantId} +- Decision: ${malformedPrequalificationRoundResult.decisions[0].decision} +- Reasons: ${malformedPrequalificationRoundResult.decisions[0].reasons.join(', ')} +- Remediation: ${malformedPrequalificationRoundResult.remediationActions[0].action} +- Audit digest: ${malformedPrequalificationRoundResult.auditDigest} + ## Missing Challenge Identity Packet - Challenge: ${missingChallengeIdentityResult.challengeId} @@ -299,6 +317,7 @@ console.log(`Wrote ${path.relative(__dirname, malformedReviewEntryPacketPath)}`) console.log(`Wrote ${path.relative(__dirname, missingCriteriaListPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, malformedCriterionEntryPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, missingApplicantListPacketPath)}`); +console.log(`Wrote ${path.relative(__dirname, malformedPrequalificationRoundPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, missingChallengeIdentityPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, malformedApplicantEntryPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, blankRejectionReasonPacketPath)}`); diff --git a/challenge-prequalification-fairness-guard/index.js b/challenge-prequalification-fairness-guard/index.js index b54a795a..d2b273dc 100644 --- a/challenge-prequalification-fairness-guard/index.js +++ b/challenge-prequalification-fairness-guard/index.js @@ -62,6 +62,28 @@ function challengeIdFor(round) { return typeof round.challengeId === 'string' ? round.challengeId.trim() : ''; } +function prequalificationRoundIsRecord(round) { + return Boolean(round && typeof round === 'object' && !Array.isArray(round)); +} + +function prequalificationRoundRecordFor(round) { + if (prequalificationRoundIsRecord(round)) { + return round; + } + + return { + challengeId: '', + generatedAt: null, + anonymousScreeningRequired: false, + minReviewers: null, + passThreshold: null, + criteria: null, + applicants: null, + reviews: null, + malformedPrequalificationRound: true + }; +} + function outputChallengeIdFor(round) { return challengeIdFor(round) || 'unidentified-challenge'; } @@ -353,13 +375,35 @@ function reasonsForApplicant(applicant, reviews, round) { const reasons = []; if (applicant.missingApplicantList) { - return ['missing-applicant-list']; + const missingApplicantReasons = ['missing-applicant-list']; + + if (round.malformedPrequalificationRound) { + missingApplicantReasons.push('malformed-prequalification-round'); + } + + if (challengeIdentityIsMissing(round)) { + missingApplicantReasons.push('missing-challenge-identity'); + } + + if (criteriaListIsMissing(round)) { + missingApplicantReasons.push('missing-published-criteria-list'); + } + + if (reviewListIsMissing(round)) { + missingApplicantReasons.push('missing-review-list'); + } + + return uniqueSorted(missingApplicantReasons); } if (applicant.malformedApplicantEntry) { return ['malformed-applicant-entry']; } + if (round.malformedPrequalificationRound) { + reasons.push('malformed-prequalification-round'); + } + if (challengeIdentityIsMissing(round)) { reasons.push('missing-challenge-identity'); } @@ -482,6 +526,10 @@ function reasonsForApplicant(applicant, reviews, round) { } function remediationAction(applicant, reasons) { + if (reasons.includes('malformed-prequalification-round')) { + return 'complete-prequalification-evidence'; + } + if (reasons.includes('missing-challenge-identity')) { return 'complete-challenge-identity-evidence'; } @@ -591,6 +639,8 @@ function remediationAction(applicant, reasons) { } function evaluatePrequalificationRound(round) { + round = prequalificationRoundRecordFor(round); + const challengeId = outputChallengeIdFor(round); const reviewsByApplicant = groupBy(reviewListFor(round), reviewApplicantIdFor); const applicants = applicantListIsMissing(round) @@ -659,6 +709,7 @@ function evaluatePrequalificationRound(round) { decision.reasons.includes('missing-published-criterion-id') || decision.reasons.includes('missing-published-criteria-list') || decision.reasons.includes('malformed-published-criterion-entry') || + decision.reasons.includes('malformed-prequalification-round') || decision.reasons.includes('missing-challenge-identity') || decision.reasons.includes('duplicate-reviewer-score-evidence') || decision.reasons.includes('missing-reviewer-identity') || diff --git a/challenge-prequalification-fairness-guard/make-demo-video.py b/challenge-prequalification-fairness-guard/make-demo-video.py index 11e281c2..da39b9d6 100644 --- a/challenge-prequalification-fairness-guard/make-demo-video.py +++ b/challenge-prequalification-fairness-guard/make-demo-video.py @@ -28,7 +28,7 @@ def draw_frame_with_pillow(): draw.text((96, 102), "Challenge Prequalification Fairness Guard", fill="white", font=title_font) draw.text((96, 190), "Published criteria plus weighted threshold checks", fill="#dff5d5", font=body_font) draw.text((96, 248), "Invalid sponsor decisions cannot change access", fill="#dff5d5", font=body_font) - draw.text((96, 306), "Missing review lists and identities are held", fill="#dff5d5", font=body_font) + draw.text((96, 306), "Malformed packets, review lists, and identities are held", fill="#dff5d5", font=body_font) draw.text((96, 402), "Synthetic data only. No sponsor, solver, payout, or identity systems are called.", fill="#ffd37a", font=note_font) image.save(FRAME) diff --git a/challenge-prequalification-fairness-guard/reports/demo.mp4 b/challenge-prequalification-fairness-guard/reports/demo.mp4 index f82bca135f87d4b26cd6eb08b52a2968347fd7ca..072857836357fc74a7c3fd421a71a1f4a322851d 100644 GIT binary patch delta 19622 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 O4{SX{nz}%eeJKEbGFqho delta 18088 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