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 00000000..35dd4eac Binary files /dev/null and b/scibase/__pycache__/__init__.cpython-314.pyc differ diff --git a/scibase/__pycache__/bounty.cpython-314.pyc b/scibase/__pycache__/bounty.cpython-314.pyc new file mode 100644 index 00000000..7537f563 Binary files /dev/null and b/scibase/__pycache__/bounty.cpython-314.pyc differ 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 00000000..81a664e6 Binary files /dev/null and b/tests/__pycache__/test_bounty.cpython-314-pytest-9.1.1.pyc differ diff --git a/tests/__pycache__/test_bounty.cpython-314.pyc b/tests/__pycache__/test_bounty.cpython-314.pyc new file mode 100644 index 00000000..2cb114c9 Binary files /dev/null and b/tests/__pycache__/test_bounty.cpython-314.pyc differ 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