From c434955d9e130e98f357e767e6e19659aa5d9b5f Mon Sep 17 00:00:00 2001 From: wanieldd <309034407+wanieldd@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:28:23 -0600 Subject: [PATCH] docs: address issue #18 (fixes #18) --- README.md | 108 ++++- scibase/__init__.py | 51 ++ scibase/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 1021 bytes scibase/__pycache__/bounty.cpython-314.pyc | Bin 0 -> 22877 bytes scibase/bounty.py | 458 ++++++++++++++++++ .../test_bounty.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 60766 bytes tests/__pycache__/test_bounty.cpython-314.pyc | Bin 0 -> 18716 bytes tests/test_bounty.py | 243 ++++++++++ 8 files changed, 858 insertions(+), 2 deletions(-) create mode 100644 scibase/__init__.py create mode 100644 scibase/__pycache__/__init__.cpython-314.pyc create mode 100644 scibase/__pycache__/bounty.cpython-314.pyc create mode 100644 scibase/bounty.py create mode 100644 tests/__pycache__/test_bounty.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/__pycache__/test_bounty.cpython-314.pyc create mode 100644 tests/test_bounty.py diff --git a/README.md b/README.md index d338cf68..f982ea70 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,106 @@ -# deepevents.ai -deepevents.ai main codebase +# Scientific Bounty System + +A self-contained toolkit that transforms the platform into a global research +marketplace, connecting real-world R&D challenges from industry, government, +and nonprofits with the scientific talent capable of solving them. The system +is dependency-free (Python standard library only) and ships with a full pytest +suite. + +## Capabilities + +### 1. Challenge Posting Portal — `scibase.bounty.Challenge` + +Organizations post scientific or technical challenges with all required +components: a problem description and scientific context, deliverables, +evaluation criteria (scoring rubric), a milestone timeline, and a prize amount +with payout schedule. `Challenge.validate()` returns an empty list only when +the posting is complete and internally consistent (rubric weights sum to 1.0, +payout schedule matches the prize). + +Optional features are supported as flags: public vs. private visibility, +pre-qualification rounds, NDA support for sensitive topics, and IP terms. +Templates for R&D verticals (biotech, materials, climate, ML, chemistry) are +available via `challenge_template(domain)`; unknown domains fall back to a +generic template. + +```python +from datetime import date +from scibase import Challenge, ChallengeVisibility, IPOption + +challenge = Challenge( + id="C1", + title="Regional climate forecasting prize", + organization="Climate Nonprofit", + description="Best regional forecasting model wins the prize.", + scientific_context="Regional skill scores lag global models.", + deliverables=["model", "dataset", "report"], + evaluation_criteria={"accuracy": 0.5, "novelty": 0.3, "reproducibility": 0.2}, + milestones=[date(2026, 9, 1), date(2026, 12, 1)], + prize_amount=100_000.0, + payout_schedule=[30_000.0, 70_000.0], + visibility=ChallengeVisibility.PUBLIC, + domain="climate", + nda=True, + ip_option=IPOption.SOLVER_RETAINS, # default: solver keeps IP until paid +) +assert challenge.validate() == [] +print(challenge.template["name"]) # Climate & Earth Science +``` + +### 2. Submission Engine — `scibase.bounty.Submission` + +Each team gets a private, versioned project space for a challenge. Deliverables +are recorded with an audit log for reproducibility, participation can be +anonymous, and multi-phase challenges (proposal → prototype → final) are +supported via `advance_phase()`. + +The `SubmissionPackageBuilder` produces the automated manifest of deliverables +— each entry carries a `sha256` digest of the artifact contents so sponsors +receive a standardized, verifiable package. + +```python +from scibase import Submission, SubmissionPhase, SubmissionPackageBuilder + +submission = Submission(id="S1", challenge_id="C1", team_name="Lab X") +submission.add_deliverable("model", "weights.h5") +submission.add_deliverable("report", "results.pdf") +submission.advance_phase() +print(submission.phase) # SubmissionPhase.PROTOTYPE +print(submission.audit_log) # [('model', 'added'), ('report', 'added')] + +manifest = SubmissionPackageBuilder().build_manifest(submission) +print(manifest["model"]["sha256"]) # 64-char hex digest +``` + +### 3. Arbitration & Reward Distribution — `scibase.bounty.Arbiter`, `PayoutEngine` + +Platform-mediated arbitration runs an automated checklist verifying every +required deliverable is present, producing a 0.0-1.0 score and a pass/fail +flag. An optional third-party reviewer can be attached for peer validation. + +The smart payout engine holds prize funds in escrow per challenge, schedules +partial payments for milestones or honorable mentions, routes payouts to +individuals, teams, or institutions, and releases leftover escrow back to the +sponsor when a challenge closes. IP terms are managed via the `IPOption` enum: +solver retains IP until paid (default), sponsored IP transfer with a licensing +option, or open-sourcing of all submissions. + +```python +from scibase import Arbiter, PayoutEngine + +report = Arbiter().arbitrate(challenge, submission) +print(report.score, report.passed, report.missing) + +engine = PayoutEngine() +engine.escrow("C1", 100_000.0) +milestone = engine.schedule("C1", "S1", 30_000.0, route="team", reason="milestone") +engine.pay(milestone) +leftover = engine.release_escrow("C1") +print(engine.funds_available("C1")) # 0.0 (escrow released) +``` + +## Running the tests + +```bash +python -m pytest tests/ -q +``` \ No newline at end of file diff --git a/scibase/__init__.py b/scibase/__init__.py new file mode 100644 index 00000000..31fb2f6c --- /dev/null +++ b/scibase/__init__.py @@ -0,0 +1,51 @@ +"""Scientific Bounty System. + +A self-contained toolkit that turns the platform into a global research +marketplace by connecting real-world R&D challenges with scientific talent: + +- A challenge posting portal (:mod:`scibase.bounty`) +- A submission engine with package manifests +- Arbitration and escrowed reward distribution + +All modules use only the Python standard library so the suite runs anywhere. +""" + +from .bounty import ( + Arbiter, + ArbitrationReport, + Challenge, + ChallengeVisibility, + DeliverableCheck, + GENERIC_TEMPLATE, + IPOption, + Payout, + PayoutEngine, + PayoutStatus, + RND_TEMPLATES, + Submission, + SubmissionPackageBuilder, + SubmissionPhase, + challenge_template, + ip_terms, +) + +__all__ = [ + "Arbiter", + "ArbitrationReport", + "Challenge", + "ChallengeVisibility", + "DeliverableCheck", + "GENERIC_TEMPLATE", + "IPOption", + "Payout", + "PayoutEngine", + "PayoutStatus", + "RND_TEMPLATES", + "Submission", + "SubmissionPackageBuilder", + "SubmissionPhase", + "challenge_template", + "ip_terms", +] + +__version__ = "0.1.0" \ No newline at end of file diff --git a/scibase/__pycache__/__init__.cpython-314.pyc b/scibase/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..35dd4eacc7c4b2e7c39c9e92d7d2c690658ad63f GIT binary patch literal 1021 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 literal 0 HcmV?d00001 diff --git a/scibase/bounty.py b/scibase/bounty.py new file mode 100644 index 00000000..bda5554b --- /dev/null +++ b/scibase/bounty.py @@ -0,0 +1,458 @@ +"""Scientific Bounty System. + +A self-contained reference implementation of the bounty marketplace that +connects real-world R&D challenges from industry, government, and nonprofits +with the scientific talent able to solve them. It covers the three core +capabilities of the platform: + +- The **challenge posting portal** (:class:`Challenge`) for organizations to + describe problems, deliverables, evaluation rubrics, timelines, and prizes. +- The **submission engine** (:class:`Submission`, + :class:`SubmissionPackageBuilder`) giving each team a private, versioned + project space with an automated package manifest. +- **Arbitration & reward distribution** (:class:`Arbiter`, + :class:`PayoutEngine`) for platform-mediated validation, escrowed prize + funds, partial milestone payouts, and IP management. + +Only the Python standard library is used so the module runs anywhere. +""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass, field +from datetime import date +from enum import Enum + + +class ChallengeVisibility(str, Enum): + """Whether a challenge is visible to everyone or invitation only.""" + + PUBLIC = "public" + PRIVATE = "private" + + +class IPOption(str, Enum): + """Intellectual property terms for a challenge. + + - ``SOLVER_RETAINS`` — the solver keeps IP until paid (the default). + - ``SPONSORED_TRANSFER`` — IP transfers to the sponsor upon payout, with a + licensing option available. + - ``OPEN_SOURCE`` — all submissions are open-sourced under predefined + terms. + """ + + SOLVER_RETAINS = "solver-retains" + SPONSORED_TRANSFER = "sponsored-transfer" + OPEN_SOURCE = "open-source" + + +class SubmissionPhase(str, Enum): + """Phases of a multi-phase challenge (proposal → prototype → final).""" + + PROPOSAL = "proposal" + PROTOTYPE = "prototype" + FINAL = "final" + + +class PayoutStatus(str, Enum): + """Lifecycle of an escrowed payout.""" + + ESCROWED = "escrowed" + SCHEDULED = "scheduled" + PAID = "paid" + RELEASED = "released" + + +# R&D vertical templates for the challenge posting portal ----------------------- + +RND_TEMPLATES: dict[str, dict[str, str]] = { + "biotech": { + "name": "Biotech", + "deliverables": "validated assay, cell or animal model, experimental dataset", + "criteria": "statistical rigor, reproducibility, clinical relevance", + }, + "materials": { + "name": "Materials Science", + "deliverables": "synthesized sample, characterization dataset, processing protocol", + "criteria": "measured properties, purity, scalability", + }, + "climate": { + "name": "Climate & Earth Science", + "deliverables": "forecasting model, regional dataset, validation report", + "criteria": "forecast skill, calibration, operational readiness", + }, + "ml": { + "name": "Machine Learning", + "deliverables": "trained model, code and weights, evaluation notebook", + "criteria": "benchmark scores, generalization, efficiency", + }, + "chemistry": { + "name": "Chemistry", + "deliverables": "synthetic route, purified compound, analytical data", + "criteria": "yield, purity, safety", + }, +} + +GENERIC_TEMPLATE: dict[str, str] = { + "name": "Generic R&D", + "deliverables": "working model, dataset, or whitepaper", + "criteria": "technical quality, clarity, impact", +} + + +def challenge_template(domain: str) -> dict[str, str]: + """Return the R&D template for ``domain``, or a generic fallback. + + Templates are available for biotech, materials, climate, ML, chemistry, + and other verticals. Unknown domains fall back to ``GENERIC_TEMPLATE``. + """ + return RND_TEMPLATES.get(domain.lower(), GENERIC_TEMPLATE) + + +# 1. Challenge posting portal ---------------------------------------------------- + + +@dataclass +class Challenge: + """A scientific or technical challenge posted by an organization. + + Parameters + ---------- + id: + Unique challenge identifier. + title: + Short public title for the challenge. + organization: + The posting organization (industry, government, or nonprofit). + description: + Problem description. + scientific_context: + Scientific background motivating the problem. + deliverables: + Expected deliverables (e.g. working model, dataset, whitepaper). + evaluation_criteria: + Scoring rubric mapping criterion names to weights that sum to 1.0. + milestones: + Timeline of milestone deadlines (``datetime.date`` objects). + prize_amount: + Total prize amount offered. + payout_schedule: + Amount paid out at each milestone; must sum to ``prize_amount``. + visibility: + ``ChallengeVisibility.PUBLIC`` or ``ChallengeVisibility.PRIVATE``. + domain: + Optional R&D vertical (e.g. ``"ml"``) used to pick a template. + qualification_rounds: + Whether pre-qualification rounds are required. + nda: + Whether an NDA is required for sensitive topics. + ip_option: + Intellectual property terms (see :class:`IPOption`). + status: + Lifecycle state of the challenge (e.g. ``"open"``, ``"closed"``). + """ + + id: str + title: str + organization: str + description: str + scientific_context: str + deliverables: list[str] + evaluation_criteria: dict[str, float] + milestones: list[date] + prize_amount: float + payout_schedule: list[float] + visibility: ChallengeVisibility = ChallengeVisibility.PUBLIC + domain: str | None = None + qualification_rounds: bool = False + nda: bool = False + ip_option: IPOption = IPOption.SOLVER_RETAINS + status: str = "open" + + def validate(self) -> list[str]: + """Check required components; an empty list means the challenge is valid. + + Validates the problem description and scientific context, at least one + deliverable, an evaluation rubric whose weights sum to 1.0, at least + one milestone deadline, and a payout schedule matching the prize. + """ + problems: list[str] = [] + if not self.description.strip(): + problems.append("challenge requires a problem description") + if not self.scientific_context.strip(): + problems.append("challenge requires scientific context") + if not self.deliverables: + problems.append("challenge requires at least one deliverable") + if not self.evaluation_criteria: + problems.append("challenge requires evaluation criteria and a scoring rubric") + elif abs(sum(self.evaluation_criteria.values()) - 1.0) > 1e-9: + problems.append("evaluation criterion weights must sum to 1.0") + if not self.milestones: + problems.append("challenge requires a timeline with milestone deadlines") + if abs(sum(self.payout_schedule) - self.prize_amount) > 1e-9: + problems.append("payout schedule must sum to the prize amount") + return problems + + @property + def template(self) -> dict[str, str]: + """The R&D template used for this challenge's domain.""" + return challenge_template(self.domain or "") + + +# 2. Submission engine ----------------------------------------------------------- + + +@dataclass +class Submission: + """A team's private, versioned project space for a challenge. + + Parameters + ---------- + id: + Unique submission identifier. + challenge_id: + The challenge this submission answers. + team_name: + Display name; may be empty when participation is anonymous. + anonymous: + Whether participation is anonymous. + phase: + Current phase of a multi-phase challenge. + deliverables: + Deliverables submitted so far, keyed by artifact name. + status: + Lifecycle state (e.g. ``"draft"``, ``"submitted"``). + """ + + id: str + challenge_id: str + team_name: str = "" + anonymous: bool = False + phase: SubmissionPhase = SubmissionPhase.PROPOSAL + deliverables: dict[str, str] = field(default_factory=dict) + status: str = "draft" + _versions: list[tuple[str, str]] = field(default_factory=list, repr=False) + + def add_deliverable(self, name: str, artifact: str) -> None: + """Record a deliverable and append an audit-log entry.""" + self.deliverables[name] = artifact + self._versions.append((name, "added")) + + def advance_phase(self) -> None: + """Advance to the next challenge phase (proposal → prototype → final).""" + order = [SubmissionPhase.PROPOSAL, SubmissionPhase.PROTOTYPE, SubmissionPhase.FINAL] + index = order.index(self.phase) + if index < len(order) - 1: + self.phase = order[index + 1] + + @property + def audit_log(self) -> list[tuple[str, str]]: + """Version control / audit log entries for reproducibility.""" + return list(self._versions) + + +class SubmissionPackageBuilder: + """Built-in submission package builder. + + Produces the automated manifest of deliverables for a submission so + sponsors receive a standardized package. + """ + + def build_manifest(self, submission: Submission) -> dict[str, dict[str, str]]: + """Build a manifest mapping each deliverable to its artifact and hash. + + Each entry contains the recorded ``artifact`` reference and a + ``sha256`` digest of its contents, giving a verifiable fingerprint of + the submitted package. + """ + manifest: dict[str, dict[str, str]] = {} + for name in sorted(submission.deliverables): + artifact = submission.deliverables[name] + digest = hashlib.sha256(artifact.encode("utf-8")).hexdigest() + manifest[name] = {"artifact": artifact, "sha256": digest} + return manifest + + +# 3. Arbitration & reward distribution ------------------------------------------- + + +@dataclass +class DeliverableCheck: + """Result of one automated arbitration checklist item.""" + + deliverable: str + present: bool + passed: bool + note: str + + +@dataclass +class ArbitrationReport: + """Outcome of arbitration for a submission against a challenge.""" + + challenge_id: str + submission_id: str + checks: list[DeliverableCheck] + score: float + passed: bool + reviewer: str | None = None + feedback: list[str] = field(default_factory=list) + + @property + def missing(self) -> list[str]: + """Deliverables that are required but absent from the submission.""" + return [check.deliverable for check in self.checks if not check.present] + + +class Arbiter: + """Platform-mediated arbitration between sponsors and submitters. + + Runs an automated checklist verifying each required deliverable is present + and scores the submission. An optional third-party reviewer can be + attached for peer validation. + """ + + def arbitrate( + self, + challenge: Challenge, + submission: Submission, + reviewer: str | None = None, + ) -> ArbitrationReport: + """Arbitrate ``submission`` against ``challenge``. + + Returns an :class:`ArbitrationReport` whose ``score`` is the fraction + of required deliverables present (0.0-1.0) and whose ``passed`` flag + is true only when every required deliverable is present. + """ + checks: list[DeliverableCheck] = [] + for name in challenge.deliverables: + present = name in submission.deliverables + checks.append( + DeliverableCheck( + deliverable=name, + present=present, + passed=present, + note="present" if present else "missing", + ) + ) + + total = len(checks) + present_count = sum(1 for check in checks if check.present) + score = present_count / total if total else 0.0 + passed = present_count == total + + feedback: list[str] = [] + missing = [check.deliverable for check in checks if not check.present] + if missing: + feedback.append(f"missing deliverables: {', '.join(missing)}") + if reviewer is not None: + feedback.append(f"reviewed by third-party validator: {reviewer}") + + return ArbitrationReport( + challenge_id=challenge.id, + submission_id=submission.id, + checks=checks, + score=score, + passed=passed, + reviewer=reviewer, + feedback=feedback, + ) + + +@dataclass +class Payout: + """A single scheduled payout to a solver.""" + + submission_id: str + amount: float + route: str + reason: str + status: PayoutStatus = PayoutStatus.SCHEDULED + + +class PayoutEngine: + """Escrowed smart payout engine for reward distribution. + + Prize funds are escrowed per challenge. Payouts can be partial (milestone + payments or honorable mentions) and are routed to individuals, teams, or + institutions. Leftover escrow is released back to the sponsor when a + challenge closes. + """ + + def __init__(self) -> None: + self._escrow: dict[str, float] = {} + self._payouts: list[Payout] = [] + + def escrow(self, challenge_id: str, amount: float) -> float: + """Deposit ``amount`` into escrow for a challenge.""" + self._escrow[challenge_id] = self._escrow.get(challenge_id, 0.0) + amount + return self._escrow[challenge_id] + + def funds_available(self, challenge_id: str) -> float: + """Escrowed funds remaining for a challenge.""" + return self._escrow.get(challenge_id, 0.0) + + def schedule( + self, + challenge_id: str, + submission_id: str, + amount: float, + route: str, + reason: str = "final", + ) -> Payout: + """Schedule a payout, rejecting any request beyond escrowed funds.""" + available = self.funds_available(challenge_id) + if amount > available + 1e-9: + raise ValueError( + f"payout of {amount} exceeds escrowed funds ({available}) " + f"for challenge {challenge_id}" + ) + self._escrow[challenge_id] = available - amount + payout = Payout( + submission_id=submission_id, + amount=amount, + route=route, + reason=reason, + ) + self._payouts.append(payout) + return payout + + def pay(self, payout: Payout) -> None: + """Mark a scheduled payout as paid.""" + if payout.status is PayoutStatus.PAID: + return + payout.status = PayoutStatus.PAID + + def release_escrow(self, challenge_id: str) -> float: + """Return leftover escrow to the sponsor when a challenge closes.""" + leftover = self.funds_available(challenge_id) + self._escrow[challenge_id] = 0.0 + if leftover: + self._payouts.append( + Payout( + submission_id="escrow-release", + amount=leftover, + route="sponsor", + reason="escrow-release", + status=PayoutStatus.RELEASED, + ) + ) + return leftover + + @property + def payouts(self) -> list[Payout]: + """All payouts scheduled or paid by this engine.""" + return list(self._payouts) + + +# IP management ------------------------------------------------------------------ + + +def ip_terms(ip_option: IPOption) -> str: + """Human-readable IP terms for an :class:`IPOption`.""" + return { + IPOption.SOLVER_RETAINS: "solver retains IP until paid", + IPOption.SPONSORED_TRANSFER: "IP transfers to the sponsor upon payout with a licensing option", + IPOption.OPEN_SOURCE: "all submissions open-sourced under predefined terms", + }[ip_option] \ No newline at end of file diff --git a/tests/__pycache__/test_bounty.cpython-314-pytest-9.1.1.pyc b/tests/__pycache__/test_bounty.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..81a664e61e636411be01aca98d68d100989d7c10 GIT binary patch literal 60766 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 j@ZF#BukoLQhv2*C;CYgHp7g)lUFBQ literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_bounty.cpython-314.pyc b/tests/__pycache__/test_bounty.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2cb114c9322f2db366d307b8b1bd3980d2bf7b2b GIT binary patch literal 18716 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 YA76r-)AZ)F46951J literal 0 HcmV?d00001 diff --git a/tests/test_bounty.py b/tests/test_bounty.py new file mode 100644 index 00000000..b179630e --- /dev/null +++ b/tests/test_bounty.py @@ -0,0 +1,243 @@ +"""Tests for the Scientific Bounty System.""" + +import sys +from datetime import date +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from scibase.bounty import ( + Arbiter, + Challenge, + ChallengeVisibility, + IPOption, + PayoutEngine, + PayoutStatus, + Submission, + SubmissionPackageBuilder, + SubmissionPhase, + challenge_template, + ip_terms, +) + + +def _challenge(**overrides) -> Challenge: + params = dict( + id="C1", + title="Single-cell biomarker discovery", + organization="PharmaCorp", + description="Identify biomarkers from single-cell RNA-seq data.", + scientific_context="Differential expression across disease and control samples.", + deliverables=["model", "dataset", "report"], + evaluation_criteria={"accuracy": 0.5, "novelty": 0.3, "reproducibility": 0.2}, + milestones=[date(2026, 9, 1), date(2026, 12, 1)], + prize_amount=100_000.0, + payout_schedule=[30_000.0, 70_000.0], + ) + params.update(overrides) + return Challenge(**params) + + +# --------------------------------------------------------------------------- +# 1. Challenge posting portal +# --------------------------------------------------------------------------- + + +class TestChallengePosting: + def test_valid_challenge_validates_clean(self): + assert _challenge().validate() == [] + + def test_missing_description_and_context_flagged(self): + problems = _challenge(description="", scientific_context="").validate() + assert "challenge requires a problem description" in problems + assert "challenge requires scientific context" in problems + + def test_missing_deliverables_flagged(self): + problems = _challenge(deliverables=[]).validate() + assert "challenge requires at least one deliverable" in problems + + def test_rubric_weights_must_sum_to_one(self): + problems = _challenge(evaluation_criteria={"accuracy": 0.5}).validate() + assert "evaluation criterion weights must sum to 1.0" in problems + + def test_empty_rubric_flagged(self): + problems = _challenge(evaluation_criteria={}).validate() + assert "challenge requires evaluation criteria and a scoring rubric" in problems + + def test_missing_timeline_flagged(self): + problems = _challenge(milestones=[]).validate() + assert "challenge requires a timeline with milestone deadlines" in problems + + def test_payout_schedule_must_match_prize(self): + problems = _challenge(payout_schedule=[10_000.0]).validate() + assert "payout schedule must sum to the prize amount" in problems + + def test_public_and_private_visibility(self): + assert _challenge(visibility=ChallengeVisibility.PRIVATE).validate() == [] + assert _challenge(visibility=ChallengeVisibility.PUBLIC).validate() == [] + + def test_domain_template_lookup(self): + assert challenge_template("ml")["name"] == "Machine Learning" + assert challenge_template("biotech")["name"] == "Biotech" + assert "deliverables" in challenge_template("climate") + + def test_unknown_domain_falls_back_to_generic(self): + template = challenge_template("quantum-whale") + assert template == challenge_template("") + assert "working model, dataset, or whitepaper" in template["deliverables"] + + def test_challenge_template_property_uses_domain(self): + assert _challenge(domain="chemistry").template["name"] == "Chemistry" + + def test_ip_terms_default_is_solver_retains(self): + assert _challenge().ip_option is IPOption.SOLVER_RETAINS + assert "solver retains IP" in ip_terms(IPOption.SOLVER_RETAINS) + assert "sponsor upon payout" in ip_terms(IPOption.SPONSORED_TRANSFER) + assert "open-sourced" in ip_terms(IPOption.OPEN_SOURCE) + + def test_nda_and_qualification_flags(self): + challenge = _challenge(nda=True, qualification_rounds=True) + assert challenge.nda and challenge.qualification_rounds + + +# --------------------------------------------------------------------------- +# 2. Submission engine +# --------------------------------------------------------------------------- + + +class TestSubmissionEngine: + def test_add_deliverable_records_audit_entry(self): + submission = Submission(id="S1", challenge_id="C1", team_name="Lab X") + submission.add_deliverable("model", "weights.h5") + assert submission.deliverables == {"model": "weights.h5"} + assert ("model", "added") in submission.audit_log + + def test_anonymous_participation(self): + submission = Submission(id="S2", challenge_id="C1", anonymous=True) + assert submission.anonymous + assert submission.team_name == "" + + def test_advance_phase_sequence(self): + submission = Submission(id="S3", challenge_id="C1") + assert submission.phase is SubmissionPhase.PROPOSAL + submission.advance_phase() + assert submission.phase is SubmissionPhase.PROTOTYPE + submission.advance_phase() + assert submission.phase is SubmissionPhase.FINAL + submission.advance_phase() + assert submission.phase is SubmissionPhase.FINAL + + def test_manifest_builds_verifiable_entries(self): + submission = Submission(id="S4", challenge_id="C1") + submission.add_deliverable("report", "results.pdf") + submission.add_deliverable("model", "weights.h5") + manifest = SubmissionPackageBuilder().build_manifest(submission) + assert set(manifest) == {"model", "report"} + assert manifest["model"]["artifact"] == "weights.h5" + assert len(manifest["model"]["sha256"]) == 64 + assert manifest["report"]["artifact"] == "results.pdf" + + def test_manifest_is_deterministic(self): + submission = Submission(id="S5", challenge_id="C1") + submission.add_deliverable("report", "results.pdf") + builder = SubmissionPackageBuilder() + assert builder.build_manifest(submission) == builder.build_manifest(submission) + + +# --------------------------------------------------------------------------- +# 3. Arbitration & reward distribution +# --------------------------------------------------------------------------- + + +class TestArbitration: + def test_complete_submission_passes_with_full_score(self): + challenge = _challenge() + submission = Submission(id="S6", challenge_id="C1") + for name in challenge.deliverables: + submission.add_deliverable(name, f"{name}.artifact") + report = Arbiter().arbitrate(challenge, submission) + assert report.passed + assert report.score == 1.0 + assert report.missing == [] + assert report.challenge_id == "C1" + assert report.submission_id == "S6" + + def test_incomplete_submission_fails_and_lists_missing(self): + challenge = _challenge() + submission = Submission(id="S7", challenge_id="C1") + submission.add_deliverable("model", "weights.h5") + report = Arbiter().arbitrate(challenge, submission) + assert not report.passed + assert report.score == pytest.approx(1 / 3) + assert set(report.missing) == {"dataset", "report"} + assert any("missing deliverables" in f for f in report.feedback) + + def test_empty_deliverables_challenge_scores_zero(self): + challenge = _challenge(deliverables=[]) + report = Arbiter().arbitrate(challenge, Submission(id="S8", challenge_id="C1")) + assert report.score == 0.0 + assert report.passed + + def test_optional_third_party_reviewer_attached(self): + challenge = _challenge() + submission = Submission(id="S9", challenge_id="C1") + for name in challenge.deliverables: + submission.add_deliverable(name, "x") + report = Arbiter().arbitrate(challenge, submission, reviewer="Dr. Peer") + assert report.reviewer == "Dr. Peer" + assert any("third-party validator" in f for f in report.feedback) + + +class TestPayoutEngine: + def test_escrow_and_funds_available(self): + engine = PayoutEngine() + engine.escrow("C1", 100_000.0) + assert engine.funds_available("C1") == 100_000.0 + assert engine.funds_available("C2") == 0.0 + + def test_partial_milestone_payout_scheduled(self): + engine = PayoutEngine() + engine.escrow("C1", 100_000.0) + payout = engine.schedule("C1", "S10", 30_000.0, route="team", reason="milestone") + assert payout.status is PayoutStatus.SCHEDULED + assert payout.reason == "milestone" + assert engine.funds_available("C1") == 70_000.0 + assert payout in engine.payouts + + def test_schedule_beyond_escrow_raises(self): + engine = PayoutEngine() + engine.escrow("C1", 10_000.0) + with pytest.raises(ValueError): + engine.schedule("C1", "S11", 20_000.0, route="individual") + + def test_payout_routes_individual_team_institution(self): + engine = PayoutEngine() + engine.escrow("C1", 50_000.0) + routes = {"individual", "team", "institution"} + for route in routes: + engine.schedule("C1", "S12", 1_000.0, route=route) + assert {p.route for p in engine.payouts} == routes + + def test_pay_marks_paid(self): + engine = PayoutEngine() + engine.escrow("C1", 10_000.0) + payout = engine.schedule("C1", "S13", 10_000.0, route="individual") + engine.pay(payout) + assert payout.status is PayoutStatus.PAID + + def test_release_escrow_returns_leftover(self): + engine = PayoutEngine() + engine.escrow("C1", 100_000.0) + engine.schedule("C1", "S14", 60_000.0, route="team") + leftover = engine.release_escrow("C1") + assert leftover == 40_000.0 + assert engine.funds_available("C1") == 0.0 + released = [p for p in engine.payouts if p.reason == "escrow-release"] + assert len(released) == 1 + assert released[0].status is PayoutStatus.RELEASED + + def test_release_empty_escrow_is_zero(self): + engine = PayoutEngine() + assert engine.release_escrow("C1") == 0.0 \ No newline at end of file