diff --git a/.greptile/config.json b/.greptile/config.json index 84cb5767f..b14f60c01 100644 --- a/.greptile/config.json +++ b/.greptile/config.json @@ -1,11 +1,7 @@ { - "labels": true, - "commentOnChanges": true, - "ignorePatterns": [ - "docs/BUNDLE_SPEC.md", - "docs/DESIGN_CHALLENGE.md", - "docs/PRISM.md", - "docs/external-miner/relearn*.md", - "docs/external-miner/proof-tbench.md" - ] + "triggerOnDrafts": true, + "autoReview": ["open", "push"], + "statusCheck": true, + "statusCommentsEnabled": true, + "ignorePatterns": "docs/BUNDLE_SPEC.md\ndocs/DESIGN_CHALLENGE.md\ndocs/PRISM.md\ndocs/external-miner/relearn*.md\ndocs/external-miner/proof-tbench.md" } diff --git a/.greptile/rules.md b/.greptile/rules.md index 57c6af402..52074a4a6 100644 --- a/.greptile/rules.md +++ b/.greptile/rules.md @@ -1,11 +1,18 @@ # Cortex review rules Cortex is a Python Bittensor research subnet with exactly two live challenges: -`bounty` at 2,000 basis points and `proof` at 8,000. The trust-root sum is always -10,000. Design, Prism and Relearn are historical only. +`bounty` at 3,000 basis points and `proof` at 7,000 under algorithm 2. The +owner-signed legacy 2,000/8,000 profile retains algorithm 1. The trust-root sum +is always 10,000; the new profile requires challenge-document version >=2. +Design, Prism and Relearn are historical only. - Preserve frozen SCALE encodings, Merkle construction, aggregation and every `base-*-v1` signature preimage. Cross-language vectors must remain green. + Algorithm 2 counts each valid Bounty report once, distributes proportionally, + scales its 30% share by min(total_valid/10, 1), and burns unused/quarantined + mass. The total includes only the signed expected participant population. + Never allow an algorithm 1 body under the new profile or activate before + the owner-signed epoch; historical seals and journals stay immutable. - Preserve existing `BASE_*` names and deployed compatibility paths. New master settings may add the matching `CORTEX_*` alias but conflicting values fail. - Missing or unknown image digests, offers, baselines, topics, keys, feeds and diff --git a/AGENTS.md b/AGENTS.md index 3f2df0062..72cb2ce1c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,13 +8,15 @@ canonical documentation instead of duplicating runbooks. Cortex is an autonomous research subnet on Bittensor. It has exactly two live challenge IDs: -| Challenge | Share | Purpose | +| Challenge | Algorithm 2 share | Purpose | | --- | ---: | --- | -| `bounty` | 2,000 bps | useful vulnerability reports, scored from the CortexLM/backend public feed | -| `proof` | 8,000 bps | operator-created research topics evaluated by Cortex's recursive language-model engine | +| `bounty` | 3,000 bps | useful vulnerability reports, scored from the CortexLM/backend public feed | +| `proof` | 7,000 bps | operator-created research topics evaluated by Cortex's recursive language-model engine | -The sum is always 10,000 basis points. Design, Prism and Relearn are retired -products. Their frozen specifications and historical miner pointers remain for +The sum is always 10,000 basis points. The owner-signed legacy 2,000/8,000 +profile retains algorithm 1. The 3,000/7,000 profile requires challenge-document +version >=2 and algorithm 2; activation is an offline owner ceremony. Design, +Prism and Relearn are retired products. Their frozen specifications and historical miner pointers remain for compatibility; no active code, service, trust-root row or leaf may register them. @@ -88,6 +90,11 @@ Follow [the trust-root ceremony](docs/how-to/trust-root.md). - Scores come only from `BOUNTY_BACKEND_PUBLIC_URL`. Never add an offline live scorer. On feed failure, cover every expected participant with `NoScore(ChallengeInternal)` so the Bounty share burns without blocking Proof. +- Under algorithm 2, each valid report contributes one point regardless of severity. + Reward authors proportionally; Bounty pays `0.30 * min(total_valid / 10, 1)`. + Count cumulative published reports only for the expected participant set. + Burn unused or unmapped mass to UID0; never increase Proof or surviving + challenge shares. Keep algorithm 1 and its frozen vectors unchanged. - A public API, quota or scoring change must update `docs/external-miner/bounty.md` in the same change. diff --git a/CHANGELOG.md b/CHANGELOG.md index 80f8f04cb..7a9831846 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Owner-activated algorithm 2: each valid Bounty report earns one point, all + authors share proportionally, and ten valid reports unlock its full 30% of + emission. Proof retains 70%; unused Bounty mass burns to UID0. The legacy + signed 20/80 profile, algorithm 1 and historical sealed bytes remain intact. + Activation requires coordinated gateway/validator upgrades and an offline + owner signature over the new profile and epoch. - Bounty-only launch mode with a revision-pinned, fully paginated CortexLM backend feed, bounded public writes and readiness checks that fail closed. - Validator production preflight, resilient bounded master/validator Bittensor RPC failover and @@ -48,6 +54,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Historical bundle-root lookup retains accepted trust profiles across rotation + and restart; current weights still require the current profile. The unsigned + activation template requires an explicit epoch before it can be signed. - Bounty reconstructs a backend-capped leaderboard from the complete report snapshot, avoiding a permanent burn after the public log exceeds 1,000 hotkeys. - Bounty adjudication rejects missing or misplaced severity, and external diff --git a/README.md b/README.md index 1827360ca..3db5b6c92 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ Cortex research subnet: Bounty and agentic Proof on Bittensor. The Python implementation runs the gateway and both challenges on a master, verifies sealed rewards in independent validators, and isolates research work -in Firecracker guests. Bounty receives 20% of emission; Proof receives 80%. +in Firecracker guests. Algorithm 2 assigns up to 30% of emission to Bounty +and 70% to Proof; activating it requires a new owner-signed trust root. Proof uses Cortex's own recursive language-model engine with persistent memory, context compaction and bounded tool execution. @@ -20,6 +21,11 @@ trust root still contains `bounty = 2000` and `proof = 8000`: Proof emits `ChallengeInternal` absences and its share burns to UID 0. Never renormalize Bounty to 100%. Production pairing, report intake and adjudication stay in `CortexLM/backend`; Cortex reads its immutable public scoring snapshots. +Algorithm 2 pays one point per valid report, proportionally across authors; +ten valid reports across expected participants unlock the full Bounty share. +The unsigned [30/70 template](config/challenges-v2.example.toml) changes nothing +until the [trust-root migration](docs/how-to/trust-root.md#activate-proportional-bounty) +is completed on the gateway and validators. ## Installation diff --git a/config/challenges-v2.example.toml b/config/challenges-v2.example.toml new file mode 100644 index 000000000..1ba940d7a --- /dev/null +++ b/config/challenges-v2.example.toml @@ -0,0 +1,17 @@ +# UNSIGNED activation template. Replace development public keys, choose the +# next owner document version and activation epoch, then sign offline using +# docs/how-to/trust-root.md. No production activation occurs from this file. +version = 2 +introduced_epoch = "CHOOSE_ACTIVATION_EPOCH" + +[[challenges]] +id = "bounty" +public_key = "743688a1e1b2848b309205706b4dcae54bffe4233a5d7018053471e1dce45c21" +emission_share_bps = 3000 +policy = "all_metagraph_hotkeys" + +[[challenges]] +id = "proof" +public_key = "3e7f70f09165e265ab89ab04a4fc91dc0531d54a100c538fb14c6f008421c375" +emission_share_bps = 7000 +policy = "all_metagraph_hotkeys" diff --git a/deploy/README.md b/deploy/README.md index e33b7e6fe..4eb77a67b 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -114,10 +114,14 @@ service writes is not a valid backup. ## Bounty-only launch Set `BOUNTY_BACKEND_PUBLIC_URL` to the reviewed HTTPS `CortexLM/backend` origin -and leave `PROOF_VM_ORCHESTRATOR_URL` empty. Keep `proof.key` mounted and keep the -owner-signed trust split at `bounty = 2000`, `proof = 8000`. With no open Proof -topic, the master signs `ChallengeInternal` Proof leaves and that 8000 bps burns -to UID 0; Bounty is never scaled to 100%. +and leave `PROOF_VM_ORCHESTRATOR_URL` empty. Keep `proof.key` mounted. Legacy +deployments retain the signed `bounty = 2000`, `proof = 8000` profile until +[algorithm 2 activation](../docs/how-to/trust-root.md#activate-proportional-bounty) +coordinates the gateway and validators with a signed 3000/7000 profile. +With no open Proof topic, its entire configured share burns to UID0 through +signed `ChallengeInternal` leaves. Algorithm 2 pays up to 30% for Bounty, +proportionally to valid reports with a global ten-report ramp; Bounty is never +scaled to 100%. Keep `BOUNTY_GATEWAY_URL` empty in CortexLM/backend unless an authenticated, idempotent delivery contract is deployed. The production dependency for this diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e61149d12..d00646704 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,8 @@ # Architecture -Cortex has two scoring products: Bounty (2,000 basis points) and Proof (8,000). +Cortex has two scoring products. Algorithm 2 assigns Bounty 3,000 basis points +and Proof 7,000. The owner-signed legacy 2,000/8,000 profile retains algorithm 1; +see [activation](how-to/trust-root.md#activate-proportional-bounty). The signed trust root fixes their shares. All challenge execution belongs to the master; validators independently verify sealed bundles and submit weights. diff --git a/docs/BOUNTY.md b/docs/BOUNTY.md index 186ec5677..870ac6e73 100644 --- a/docs/BOUNTY.md +++ b/docs/BOUNTY.md @@ -1,7 +1,10 @@ # Bounty operator reference Bounty rewards useful vulnerability reports about the CortexLM backend. It is -20% of subnet emission. Initial production pairing, intake and adjudication live +up to 30% of subnet emission after +[algorithm 2 activation](how-to/trust-root.md#activate-proportional-bounty). +The legacy owner-signed profile retains its 20% allocation and algorithm 1. +Initial production pairing, intake and adjudication live in CortexLM/backend. The Python subnet retains the compatibility intake below and emits signed leaves, but does not export those local rows; the external CortexLM/backend public feed is the sole scoring source. @@ -100,22 +103,37 @@ published report is treated as an unavailable scorer, not as a zero score. ## Score -Scoring uses integer arithmetic only. A contender needs at least three decided -valid/malicious reports, nonnegative net credit, no unpriced valid report, at -least 60% precision, no more than 50% duplicate/already-fixed triage noise, and -strictly better precision than the current champion. Severity weights are 6.25%, -25%, 50% and 100% for trivial through critical. +Algorithm 2 signs each expected hotkey's exact count of `valid` reports as its +raw score. One valid report is one point regardless of severity. There is no +champion, precision gate, minimum author count or triage-noise gate. Invalid, +duplicate and already-fixed reports contribute zero points. Severity remains +required evidence for a valid publication, with no effect on its point value. -An eligible champion receives: +Let `n_i` be author i's valid count and `N = sum(n_i)` over the epoch's expected +participants, selected by the owner-signed policy and sealed metagraph: ```text -1_000_000 * precision_bps * average_severity_bps / 100_000_000 +Bounty payout = 0.30 * min(N / 10, 1) +author i payout = 0.30 * n_i / max(10, N) ``` -Only one hotkey is champion for the snapshot. A miner with negative net credit -gets `InvalidResponse`; other expected hotkeys get `NotAttempted`. Feed failure -produces `ChallengeInternal` for every expected participant, so the Bounty mass -burns to UID 0 while the bundle remains complete. +Five valid reports distribute 15% of subnet emission; ten or more distribute +30%, proportionally across authors. The remaining Bounty mass burns to UID0; +it never increases Proof's 70%. UID0 and unmapped author allocations also burn +without increasing other authors' allocations. Existing owner/permit submission +constraints are unchanged. + +Counts use the complete cumulative report history in one pinned external +publication, with no epoch reset or new rolling window. Only expected hotkeys +enter `N`; historical authors outside the metagraph/policy are excluded. +Validated report IDs and rooted duplicate chains prevent duplicate credit. +An author with zero valid reports gets `NotAttempted`. Feed failure produces +`ChallengeInternal` for every expected participant and burns the Bounty share. + +The legacy 2,000/8,000 owner profile retains algorithm 1, including its champion, +precision/severity scoring and signed encodings. A 3,000/7,000 owner profile +requires challenge-document version >=2 and algorithm 2. An algorithm 1 body +under that profile is rejected, even with a valid gateway signature. ## Operational checks diff --git a/docs/OPERATOR_SECURITY.md b/docs/OPERATOR_SECURITY.md index 6f2be4512..3965cbd40 100644 --- a/docs/OPERATOR_SECURITY.md +++ b/docs/OPERATOR_SECURITY.md @@ -22,7 +22,10 @@ recovery. It complements the [threat model](THREAT_MODEL.md). documents at the deployment epoch. - [ ] Bounty and Proof public keys match their mounted signing seeds, and the gateway public key is different from both. -- [ ] The trust root contains only `bounty = 2000` and `proof = 8000`. +- [ ] The trust root contains only Bounty and Proof: legacy 2000/8000 with + algorithm 1, or 3000/7000 with algorithm 2 and challenge-document version >=2. + Complete [activation](how-to/trust-root.md#activate-proportional-bounty) before + switching profiles; preserve old journals and sealed bytes. - [ ] Runtime, kernel, rootfs, evaluator and experiment-pack references use verified SHA-256 digests. No production image uses a floating tag. - [ ] Empty or unknown pins remain fail-closed; no digest was copied from an @@ -51,7 +54,7 @@ recovery. It complements the [threat model](THREAT_MODEL.md). probe, and a report outage test returns 503 without a row. - [ ] In Bounty-only mode, `PROOF_VM_ORCHESTRATOR_URL` is empty, no Proof topic is open, and a completed epoch contains signed `ChallengeInternal` Proof - leaves whose 8000 bps burn to UID 0 without blocking Bounty. + leaves whose 7000 bps (8000 under algorithm 1) burn to UID 0 without blocking Bounty. - [ ] When Proof is enabled, `/v1/status` reports a valid topic, sealed baseline, registered runner, pinned image, open inference offer and compatible executor offer; its failure matrix returns 503 without a scored row. diff --git a/docs/PROOF.md b/docs/PROOF.md index 5f6ab848b..ada11d29f 100644 --- a/docs/PROOF.md +++ b/docs/PROOF.md @@ -1,6 +1,7 @@ # Proof operator reference -Proof is 80% of Cortex emission. An operator supplies a research objective; a +Proof is 70% of Cortex emission under algorithm 2 (80% under the legacy +owner-signed profile). An operator supplies a research objective; a topic-scoped recursive language-model agent installs its environment, proposes measurable rules, creates private evaluation material, measures a baseline and publishes miner documentation. Miners submit code and artifacts against the @@ -287,7 +288,8 @@ Proof score is the sum of its topic masses. When no topic is open, no baseline is sealed or scoring infrastructure is unavailable, Proof emits `NoScore(ChallengeInternal)` for the expected set. Its -8,000 basis points burn to UID 0 while preserving complete bundle coverage. +7,000 basis points (8,000 under algorithm 1) burn to UID 0 while preserving +complete bundle coverage. ## Readiness and verification diff --git a/docs/external-miner/README.md b/docs/external-miner/README.md index 6cdea4f02..c8aa16c81 100644 --- a/docs/external-miner/README.md +++ b/docs/external-miner/README.md @@ -10,9 +10,11 @@ reproduction or on-chain payment. | Challenge | Emission share | Guide | |-----------|----------------|-------| -| `bounty` | 2000 bps (20%) | [Pair an account and report bugs](bounty.md) | -| `proof` | 8000 bps (80%) | [Discover topics and submit research](proof.md) | +| `bounty` | up to 3000 bps (30%), algorithm 2 | [Pair an account and report bugs](bounty.md) | +| `proof` | 7000 bps (70%), algorithm 2 | [Discover topics and submit research](proof.md) | +The legacy owner-signed profile remains 2000/8000 until +[algorithm 2 activation](../how-to/trust-root.md#activate-proportional-bounty). These are the only live challenge ids. Proof topics are operator-published, signed documents discovered through the API, never a built-in catalog. No particular benchmark, runner, model or topic is promised by this repository. diff --git a/docs/external-miner/bounty.md b/docs/external-miner/bounty.md index 86a1cb29a..14612e994 100644 --- a/docs/external-miner/bounty.md +++ b/docs/external-miner/bounty.md @@ -2,8 +2,8 @@ # Bounty miner guide -Bounty (`bounty`, 2000 bps) accepts reproducible Cortex product and backend bug -reports associated with your Bittensor hotkey. Install the [Python CLI](README.md) +Bounty (`bounty`, up to 3000 bps under algorithm 2) accepts reproducible Cortex +product and backend bug reports associated with your Bittensor hotkey. Install the [Python CLI](README.md) and obtain the gateway URL from the subnet operator. The initial production miner flow pairs and files reports in CortexLM/backend. @@ -143,31 +143,43 @@ Stable adjudication, pricing and backlog gates are not retried. A waiting adjudication backlog with no published report also fails closed instead of scoring every miner as `NotAttempted`. -| Adjudication | Effect | -|--------------|--------| -| `valid` with severity | Eligible evidence, subject to the scoring gates | -| `valid` without severity | Not creditable; missing severity prevents eligibility | -| `already_fixed_not_prod` | No reward or direct penalty; counts as triage noise | -| `invalid_malicious` | Negative credit; may lead to a burn outcome | -| `duplicate` | No extra reward or direct penalty; counts as triage noise | - -Paid score is precision times mean severity impact, subject to champion -displacement and eligibility gates. Precision is priced valid reports divided -by priced valid plus malicious reports. The minimum precision is 6000 bps, -and at least three decided reports are required. Severity levels are -`trivial`, `minor`, `major` and `critical`. Unpriced valid rows cannot be used -to manufacture credit. - -The duplicate/already-fixed triage-noise ratio is an **off-score gate**. It is -not multiplied into the visible precision-times-severity score; exceeding -5000 bps rejects eligibility. A high report count is not a substitute for -precision and severity. +| Adjudication | Algorithm 2 points | +|--------------|--------------------| +| `valid` with severity | 1, regardless of severity | +| `valid` without severity | Invalid publication; scoring fails closed | +| `already_fixed_not_prod` | 0 | +| `invalid_malicious` | 0 | +| `duplicate` | 0; the original valid report is counted once | + +Every author with valid evidence participates proportionally. There is no +champion, precision gate, minimum of three reports, severity weighting or +triage-noise gate. Severity (`trivial`, `minor`, `major`, `critical`) remains +required publication evidence and does not affect point value. + +For `N` valid reports across the epoch's expected participants, the total Bounty +payout is `0.30 * min(N / 10, 1)` of subnet emission. An author with `n` valid +reports gets `0.30 * n / max(10, N)`. Thus five valid reports distribute 15%; +ten or more distribute 30%. Unused mass burns to UID0, never to Proof or other +authors. Proof retains its separate 70% share. An allocation to UID0 or an +unmapped author burns without increasing another author's allocation. + +Counts include cumulative published history at one immutable revision, without +an epoch reset or rolling window. The population is the sealed metagraph's +hotkeys selected by the owner-signed participant policy. Historical authors +outside that population do not enter the total. Duplicate and rejected reports +never add points. Chain weights retain the protocol's independent u16 rounding. + +`GET /v1/status` exposes the active `scoring_version`, `points_per_valid_report`, +`full_share_reports`, population and window. Algorithm 2 requires the owner-signed +3000/7000 profile and document version >=2. Until the gateway and validators +complete [activation](../how-to/trust-root.md#activate-proportional-bounty), +legacy 2000/8000 deployments retain algorithm 1 and its champion/precision rules. If the backend is unreadable, unconfigured or inconsistent, report intake returns `503` without storing a report. Emission pays nobody from Bounty and covers the expected participant set with `NoScore(ChallengeInternal)` leaves. -The 2000 bps share then burns to uid 0 through normal sealing. There is no -offline scorer or forced simulation path. +The configured Bounty share then burns to uid 0 through normal sealing. There +is no offline scorer or forced simulation path. Validators independently verify the [sealed bundle](validators.md); they do not rerun reports or fetch the Bounty feed. See [troubleshooting](troubleshoot.md) diff --git a/docs/external-miner/proof.md b/docs/external-miner/proof.md index a899fb8d8..4a9499520 100644 --- a/docs/external-miner/proof.md +++ b/docs/external-miner/proof.md @@ -2,8 +2,8 @@ # Proof miner guide -Proof (`proof`, 8000 bps) rewards reproducible submissions against signed, -operator-published research topics. Install the [Python CLI](README.md) and use +Proof (`proof`, 7000 bps under algorithm 2) rewards reproducible submissions +against signed, operator-published research topics. Install the [Python CLI](README.md) and use your Bittensor hotkey. The operator supplies the gateway URL and an independently pinned Proof public key. @@ -240,7 +240,9 @@ mass to the best eligible result, sharing exact ties. `discovery` divides a pass-floor pool among eligible miners and a novelty pool according to new improvement; duplicates receive no novelty pool. A miner's Proof score is the **sum of per-topic masses**, not a mean of binary passes. The fixed subnet -split remains Bounty 2000 / Proof 8000 bps. +split is Bounty 3000 / Proof 7000 bps under algorithm 2. Legacy owner-signed +2000/8000 deployments retain algorithm 1 until +[activation](../how-to/trust-root.md#activate-proportional-bounty). Actual payment additionally needs signed leaves, a valid gateway seal and validator submission on Bittensor. See [validators](validators.md) and diff --git a/docs/external-miner/validators.md b/docs/external-miner/validators.md index 71ec4b92c..8462cde4d 100644 --- a/docs/external-miner/validators.md +++ b/docs/external-miner/validators.md @@ -5,7 +5,16 @@ Python validators independently verify gateway bundles, compare authenticated peer roots and submit weights on Bittensor. They never run Bounty or Proof evaluation, rent GPUs or receive miner provider credentials. The only live -challenge shares are `bounty` 2000 bps and `proof` 8000 bps. +challenge shares are `bounty` 3000 bps and `proof` 7000 bps under algorithm 2. +The legacy 2000/8000 owner profile retains algorithm 1 until +[activation](../how-to/trust-root.md#activate-proportional-bounty). + +Sealed `GET /v1/weights/latest` responses expose `algorithm_version` from the +signed bundle. `emission_shares` contains the configured ceilings; each +`source_challenges[].emission_percent` reflects its allocated share, including +the ten-report Bounty ramp under algorithm 2. Author allocations to UID0 or +unmapped hotkeys still burn. Validators always recompute from signed leaves; +they never trust these display fields as consensus inputs. ## Prepare local trust and identity @@ -121,8 +130,10 @@ a reorg or a changed/unsealed latest response prevents submission. | A challenge has invalid leaf signatures, wrong leaf epoch or incomplete participants | Quarantine that challenge; submit only if surviving signed mass is at least 5000 bps | | Structural failure, unknown challenge, invalid Merkle root or peer disagreement | Refuse | -With the fixed split, quarantining Bounty can retain Proof's 8000 bps; -quarantining Proof leaves only 2000 bps and cannot be submitted. Valid +Under algorithm 2, quarantining Bounty retains Proof's absolute 7000 bps +and burns Bounty's 3000 bps. Quarantining Proof leaves only 3000 bps and +cannot be submitted. No quarantined share is reassigned. Legacy algorithm 1 +keeps its original 5000-bps gate and survivor renormalization. Valid `NoScore(ChallengeInternal)` leaves from an unavailable scorer still cover the expected participants and are not themselves a consensus fault. diff --git a/docs/how-to/trust-root.md b/docs/how-to/trust-root.md index 86fdaf9d8..cd9a99d93 100644 --- a/docs/how-to/trust-root.md +++ b/docs/how-to/trust-root.md @@ -26,9 +26,13 @@ cortex keygen \ `keygen` creates files exclusively and refuses to overwrite an existing path. Seeds are raw 32-byte sr25519 seeds with mode 0600. Copy only the public owner key into `config/owner.pubkey`. Put the Bounty and Proof public keys in the -matching rows of `config/challenges.toml`; their shares must remain exactly -2000 and 8000 basis points. The gateway public key is supplied to verification -and is never a challenge row. +matching rows of the selected challenges document. The preserved development +`config/challenges.toml` is the signed legacy 2000/8000 profile (algorithm 1). +The unsigned `config/challenges-v2.example.toml` is the 3000/7000 activation +template (algorithm 2, challenge-document version >=2). Replace its +`"CHOOSE_ACTIVATION_EPOCH"` placeholder with the coordinated integer epoch; +the unchanged template cannot be signed. The gateway public key +is supplied to verification and is never a challenge row. ## Sign both documents @@ -77,3 +81,46 @@ uv run python scripts/check_repo.py --final Install seeds and bearer tokens as private regular files. The master re-reads signing material from its configured paths and fails closed if a file is missing, group-readable, symlinked, or does not match the signed public key. + +## Activate proportional Bounty + +Code installation does not activate the reward change. The committed +`config/challenges.toml` and its detached signature remain the legacy development +fixture. `config/challenges-v2.example.toml` is unsigned and cannot authorize +production rewards. The new profile fixes Bounty/Proof at 3000/7000 bps and +requires bundle algorithm 2; changing only raw scores cannot implement it. + +1. Upgrade the gateway/master and every submitting validator to a release that + supports both algorithms. Keep the old signed profile while validating + historical chain snapshots and recomputation. The upgraded gateway persists + the accepted profile, including challenge keys and policies, for historical + root lookup after rotation and restart. Missing profiles fail closed; do not + skip this upgrade under the old trust document. Existing algorithm 1 signed + bytes and frozen vectors remain unchanged; never relax metagraph-root checks + to admit an incompatible historical implementation. +2. Choose the activation epoch and drain every pending emission epoch older than + it using the old profile. Pause the master scheduler for the coordinated + rotation. A new profile cannot sign or verify an older pending epoch; leaving + one behind blocks recovery. Back up the database, journals, old documents and + detached signatures privately. Never clear a journal or rewrite a seal. +3. Prepare the 3000/7000 template privately, preserving the actual production + challenge keys and participant policies. Set a monotonic challenge-document + `version` of at least 2 and the agreed `introduced_epoch`. Sign offline using + the existing owner. Retain the independently signed measurement document. +4. At activation, install the exact signed documents and corresponding minimum + version pins on the gateway and validators. Use the existing verification + command with the activation epoch. Resume the master scheduler. Missing + owner signatures, keys or compatible chain snapshots are explicit blockers. +5. Until a new completed epoch seals under algorithm 2, latest is unsealed; + validators must wait and must not reuse a legacy seal. Raw historical bundle + bytes remain retrievable by epoch and must be verified using their original + owner profile. Check `scoring_version: 2`, algorithm 2 signed leaves, a new + `sealed: true` latest response and independent validator recomputation before + claiming activation. A health response or local fake-provider test is not + proof of live chain submission. + +If Proof is unavailable, its 70% burns. Bounty pays at most 30%, with unused mass +burned according to [the report-count formula](../BOUNTY.md#score). Rollback must +respect persisted version watermarks: restoring an older trust file is rejected. +Stop submission and prepare an owner-authorized higher-version recovery profile +through the same ceremony rather than deleting watermarks or replaying epochs. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index d9053fe75..ad0a6b93b 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -40,9 +40,11 @@ credential vaults must be backed by durable private storage. For the Bounty-only launch, set `BOUNTY_BACKEND_PUBLIC_URL` to the HTTPS `CortexLM/backend` origin and leave `PROOF_VM_ORCHESTRATOR_URL` empty. The Proof seed remains required because every completed epoch still needs signed Proof -absence leaves. The owner-signed trust root remains exactly `bounty = 2000` and -`proof = 8000`; the unavailable Proof share burns and is never reassigned to -Bounty. +absence leaves. The legacy owner-signed trust root is `bounty = 2000` and +`proof = 8000`; algorithm 2 requires a signed `bounty = 3000`, `proof = 7000` +profile with challenge-document version >=2 (see +[activation](../how-to/trust-root.md#activate-proportional-bounty)). The unavailable +Proof share burns and is never reassigned to Bounty. One resource shape applies to the persistent topic VM and each fresh experiment VM. The examples explicitly request 1 vCPU, 1024 MiB RAM and 16384 MiB disk; diff --git a/scripts/check_repo.py b/scripts/check_repo.py index 8f3d68797..f1ece8f45 100644 --- a/scripts/check_repo.py +++ b/scripts/check_repo.py @@ -24,8 +24,9 @@ "docs/PRISM.md": "bce5789ac64cbc75e62ac78daa8452b8ae3a5eaff0b0134afb6f9ff9dd372925", } SHARES = {"bounty": 2000, "proof": 8000} +PROPORTIONAL_SHARES = {"bounty": 3000, "proof": 7000} DOC_CONTRACTS = { - "docs/external-miner/README.md": ("bounty", "proof", "2000", "8000"), + "docs/external-miner/README.md": ("bounty", "proof", "3000", "7000"), "docs/external-miner/bounty.md": ( "/v1/pair", "/v1/reports", @@ -54,8 +55,8 @@ "/v1/weights/latest", "sealed", "burn_outcome", - "2000", - "8000", + "3000", + "7000", ), } PUBLIC_ROUTES = { @@ -164,10 +165,15 @@ def check_trust_roots(root: Path) -> list[str]: if not re.fullmatch(r"[0-9a-f]{64}", row["public_key"]): raise ValueError("invalid challenge public key") actual[identifier] = share - if actual != SHARES: - raise ValueError("only bounty=2000 and proof=8000 are permitted") + version = document["version"] + if type(version) is not int or version < 1: + raise ValueError("invalid trust document version") + if actual != SHARES and not (actual == PROPORTIONAL_SHARES and version >= 2): + raise ValueError("unsupported shares or activation version") except (OSError, UnicodeError, ValueError, KeyError, TypeError): - failures.append(f"{name}: trust root must contain only bounty=2000 and proof=8000") + failures.append( + f"{name}: expected bounty/proof=2000/8000 or version >=2 with 3000/7000" + ) return failures diff --git a/src/cortex/bounty/api.py b/src/cortex/bounty/api.py index a70da3be0..5a92cbaa3 100644 --- a/src/cortex/bounty/api.py +++ b/src/cortex/bounty/api.py @@ -6,6 +6,8 @@ from fastapi.responses import JSONResponse from pydantic import BaseModel, ConfigDict, Field, ValidationError +from cortex.protocol.models import BOUNTY_FULL_SHARE_REPORTS + from .backend import BackendUnavailable, Severity, Verdict from .scoring import MAX_TRIAGE_NOISE_BPS, MIN_PRECISION_BPS, SCORE_MAX, SEVERITY_BPS from .service import PAIR_GRANT_MAX_TTL_SECONDS, TERMS_TEXT, BountyService @@ -85,10 +87,11 @@ def create_router(service: BountyService) -> APIRouter: @router.get("/health") async def health(): - return {"ok": True, "challenge_id": "bounty", "scoring_version": 1} + return {"ok": True, "challenge_id": "bounty", "scoring_version": service.scoring_version()} @router.get("/v1/status") async def status(): + version = service.scoring_version() reason = None try: await service.backend.probe() @@ -98,8 +101,8 @@ async def status(): reason = str(error) return { "challenge_id": "bounty", - "scoring_version": 1, - "score_max": SCORE_MAX, + "scoring_version": version, + "score_max": SCORE_MAX if version == 1 else 2**64 - 1, "champion_hotkey": None, "scoring_backend": "backend_public" if service.backend.configured else "unconfigured", "can_score": can_score, @@ -110,6 +113,17 @@ async def status(): "grant_max_ttl_secs": PAIR_GRANT_MAX_TTL_SECONDS, }, "scoring": { + "paid_on": ["valid_report_count"], + "points_per_valid_report": 1, + "full_share_reports": BOUNTY_FULL_SHARE_REPORTS, + "emission_share_bps": 3000, + "population": "expected_metagraph_hotkeys", + "window": "cumulative_published_history", + "off_score_gates": [], + "severities": list(SEVERITY_BPS), + } + if version == 2 + else { "paid_on": ["precision", "severity_impact"], "off_score_gates": ["triage_noise"], "min_precision_bps": MIN_PRECISION_BPS, diff --git a/src/cortex/bounty/backend.py b/src/cortex/bounty/backend.py index bf5085a76..121d8ef9a 100644 --- a/src/cortex/bounty/backend.py +++ b/src/cortex/bounty/backend.py @@ -221,9 +221,23 @@ def validate_publication(self) -> None: ): raise BackendUnavailable("backend public leaderboard and reports do not agree") - def score(self, expected: list[str]) -> dict[str, BountyScore]: + def score(self, expected: list[str], *, scoring_version: int = 1) -> dict[str, BountyScore]: self.validate_publication() expected_keys = {decode_hotkey(raw).hex() for raw in expected} + if scoring_version == 2: + counts = Counter( + decode_hotkey(report.hotkey).hex() + for report in self.reports + if report.status == "valid" + ) + return { + key: BountyScore(value=counts[key]) + if counts[key] + else BountyScore(reason="NotAttempted") + for key in sorted(expected_keys) + } + if scoring_version != 1: + raise BackendUnavailable("unsupported Bounty scoring version") holdouts: dict[str, Holdout] = {} for report in self.reports: hotkey = decode_hotkey(report.hotkey).hex() diff --git a/src/cortex/bounty/service.py b/src/cortex/bounty/service.py index 60f71a076..d823f90d9 100644 --- a/src/cortex/bounty/service.py +++ b/src/cortex/bounty/service.py @@ -47,10 +47,12 @@ def __init__( admin_tokens: Sequence[str] = (), admin_hashes: Sequence[str] = (), clock: Callable[[], float] = time.time, + scoring_version: Callable[[], int] = lambda: 1, ): if len(session_secret) < 32: raise ValueError("Bounty session secret must contain at least 32 bytes") self.store, self.backend, self.clock = store, backend, clock + self.scoring_version = scoring_version self._secret = session_secret self._admin_hashes = tuple(admin_hashes) + tuple( hashlib.sha256(t.encode()).hexdigest() for t in admin_tokens if t @@ -144,9 +146,10 @@ async def submit(self, body) -> dict: async def score(self, expected: list[str]) -> dict[str, BountyScore]: """Produce exact-E outcomes, including ChallengeInternal on feed failure.""" keys = sorted({decode_hotkey(raw).hex() for raw in expected}) + version = self.scoring_version() try: snapshot = await self.backend.fetch() - return snapshot.score(keys) + return snapshot.score(keys, scoring_version=version) except BackendUnavailable: return {key: BountyScore(reason="ChallengeInternal") for key in keys} diff --git a/src/cortex/gateway/projection.py b/src/cortex/gateway/projection.py index 172f55d1a..899dd17e2 100644 --- a/src/cortex/gateway/projection.py +++ b/src/cortex/gateway/projection.py @@ -7,6 +7,7 @@ from cortex.protocol import Bundle, Score, aggregate_leaves from cortex.protocol.crypto import encode_hotkey +from cortex.protocol.models import BOUNTY_FULL_SHARE_REPORTS def _identity(digest: str) -> str: @@ -50,13 +51,19 @@ def project(bundle: Bundle | None, *, netuid: int, now: datetime, chain_endpoint ) if bundle is not None: body = bundle.body - floats = aggregate_leaves(body.leaves, body.emission_shares, body.uid_map) + floats = aggregate_leaves( + body.leaves, + body.emission_shares, + body.uid_map, + algorithm_version=body.algorithm_version, + ) digest = sha256(body.encode()).hexdigest() view.update( vector_id=_identity(digest), vector_digest=digest, epoch=body.epoch, revision=1, + algorithm_version=body.algorithm_version, netuid=body.netuid, uids=list(floats.uids), weights=list(floats.weights), @@ -92,10 +99,16 @@ def project(bundle: Bundle | None, *, netuid: int, now: datetime, chain_endpoint hotkey = encode_hotkey(leaf.miner_hotkey) source_weights[hotkey] = source_weights.get(hotkey, 0.0) + leaf.score.value view["emission_shares"][slug] = bps / 10000 + emission_percent = bps / 100 + if body.algorithm_version == 2 and challenge == b"bounty": + emission_percent *= ( + min(sum(source_weights.values()), BOUNTY_FULL_SHARE_REPORTS) + / BOUNTY_FULL_SHARE_REPORTS + ) view["source_challenges"].append( dict( slug=slug, - emission_percent=bps / 100, + emission_percent=emission_percent, weights=source_weights, ok=bool(leaves), error=None if leaves else outcome, diff --git a/src/cortex/gateway/service.py b/src/cortex/gateway/service.py index 791e3d6aa..d3c69d2cc 100644 --- a/src/cortex/gateway/service.py +++ b/src/cortex/gateway/service.py @@ -45,7 +45,7 @@ def __init__( self.clock = clock or (lambda: datetime.now(UTC)) self.chain_endpoint = chain_endpoint self.trust_loader = trust_loader - self.store.trust_versions(trust.challenges_version, trust.measurements_version) + self.store.accept_trust(trust) def _chain_endpoint(self) -> str: try: @@ -59,7 +59,7 @@ def refresh_trust(self, epoch: int) -> None: return trust = self.trust_loader(epoch) trust.validate() - self.store.trust_versions(trust.challenges_version, trust.measurements_version) + self.store.accept_trust(trust) self.trust = trust def accept_leaf(self, leaf: Leaf) -> dict: @@ -125,7 +125,8 @@ def bundle_bytes(self, epoch: int) -> bytes: raise ServiceError(404, "bundle not found") return stored.encoded - def _decode_stored(self, stored: StoredBundle) -> Bundle: + def _decode_stored(self, stored: StoredBundle, trust: TrustRoot | None = None) -> Bundle: + trust = self.trust if trust is None else trust bundle = Bundle.decode(stored.encoded) if bundle.body.epoch != stored.epoch: raise ProtocolError("stored epoch mismatch") @@ -134,17 +135,34 @@ def _decode_stored(self, stored: StoredBundle) -> Bundle: body = bundle.body if ( body.protocol_version != 1 - or body.algorithm_version != 1 + or body.algorithm_version != trust.algorithm_version + or body.epoch < trust.introduced_epoch or body.netuid != self.netuid or body.gateway_hotkey != self.trust.gateway_hotkey - or body.emission_shares != self.trust.shares - or body.measurements_digest != self.trust.measurements_digest + or body.gateway_hotkey != trust.gateway_hotkey + or body.emission_shares != trust.shares + or body.measurements_digest != trust.measurements_digest or not verify_raw(body.gateway_hotkey, BUNDLE_DOMAIN, body.encode(), bundle.gateway_sig) ): raise ProtocolError("invalid stored seal") if merkle_root(leaf.encode() for leaf in body.leaves) != body.merkle_root: raise ProtocolError("invalid stored leaf root") - final = aggregate_leaves(body.leaves, body.emission_shares, body.uid_map) + keys = {entry.id: entry.public_key for entry in trust.challenges} + for leaf in body.leaves: + if ( + leaf.epoch != body.epoch + or leaf.challenge_id not in keys + or not verify_raw( + keys[leaf.challenge_id], RAW_WEIGHT_DOMAIN, leaf.payload(), leaf.challenge_sig + ) + ): + raise ProtocolError("invalid stored challenge signature") + final = aggregate_leaves( + body.leaves, + body.emission_shares, + body.uid_map, + algorithm_version=body.algorithm_version, + ) if final.final_vector != body.final_vector: raise ProtocolError("invalid stored final vector") return bundle @@ -160,11 +178,18 @@ def bundle_by_root(self, root: str) -> bytes: if stored is None: raise ServiceError(404, "bundle not found") try: - if self._decode_stored(stored).body.merkle_root != raw: - raise ProtocolError("indexed root mismatch") - except ProtocolError: + # Archived profiles never become the current profile or lower its watermarks. + for trust in self.store.trust_profiles(): + try: + bundle = self._decode_stored(stored, trust) + except ProtocolError: + continue + if bundle.body.merkle_root != raw: + raise ProtocolError("indexed root mismatch") + return stored.encoded + except (sqlite3.Error, ProtocolError): raise ServiceError(503, "stored bundle unavailable") from None - return stored.encoded + raise ServiceError(503, "stored bundle unavailable") def latest(self) -> dict: try: diff --git a/src/cortex/gateway/store.py b/src/cortex/gateway/store.py index b16ab4564..c8944b316 100644 --- a/src/cortex/gateway/store.py +++ b/src/cortex/gateway/store.py @@ -1,16 +1,26 @@ """SQLite durability for raw leaves and immutable epoch seals.""" +import json import sqlite3 import threading from collections.abc import Callable from contextlib import contextmanager -from dataclasses import dataclass +from dataclasses import asdict, dataclass from hashlib import sha256 from pathlib import Path from uuid import uuid4 from cortex.errors import ServiceError -from cortex.protocol import Bundle, Leaf, NoScore, ProtocolError, Score +from cortex.protocol import ( + Bundle, + ChallengeEntry, + Leaf, + NoScore, + ParticipantPolicy, + ProtocolError, + Score, + TrustRoot, +) from cortex.protocol.scale import Reader, uint from cortex.state import secure_sqlite_path @@ -81,6 +91,9 @@ def __init__(self, path: Path | str): CREATE TABLE IF NOT EXISTS gateway_bundle_roots ( root BLOB PRIMARY KEY, epoch TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS gateway_trust_profiles ( + digest BLOB PRIMARY KEY, profile TEXT NOT NULL + ); """) for epoch, encoded in self._connection.execute( "SELECT epoch,bundle_scale FROM gateway_bundles " @@ -118,11 +131,13 @@ def bind_netuid(self, netuid: int) -> None: if row[0] != str(netuid): raise ServiceError(503, "gateway database belongs to another subnet") - def trust_versions(self, challenges: int, measurements: int) -> None: + def accept_trust(self, trust: TrustRoot) -> None: + trust.validate() + profile = json.dumps(asdict(trust), default=bytes.hex, sort_keys=True) with self._transaction() as connection: for name, value in ( - ("challenges_version", challenges), - ("measurements_version", measurements), + ("challenges_version", trust.challenges_version), + ("measurements_version", trust.measurements_version), ): row = connection.execute( "SELECT value FROM gateway_metadata WHERE name=?", (name,) @@ -134,6 +149,44 @@ def trust_versions(self, challenges: int, measurements: int) -> None: "ON CONFLICT(name) DO UPDATE SET value=excluded.value", (name, str(value)), ) + connection.execute( + "INSERT OR IGNORE INTO gateway_trust_profiles VALUES (?,?)", + (sha256(profile.encode()).digest(), profile), + ) + + def trust_profiles(self) -> tuple[TrustRoot, ...]: + with self._lock: + rows = self._connection.execute( + "SELECT digest,profile FROM gateway_trust_profiles" + ).fetchall() + profiles = [] + try: + for digest, profile in rows: + if not isinstance(profile, str) or sha256(profile.encode()).digest() != digest: + raise ProtocolError("corrupt stored trust profile") + values = json.loads(profile) + if not isinstance(values, dict): + raise ProtocolError("corrupt stored trust profile") + challenges = [] + for entry in values.pop("challenges"): + policy = entry["policy"] + policy["hotkeys"] = tuple(bytes.fromhex(key) for key in policy["hotkeys"]) + challenges.append( + ChallengeEntry( + bytes.fromhex(entry["id"]), + bytes.fromhex(entry["public_key"]), + entry["emission_share_bps"], + ParticipantPolicy(**policy), + ) + ) + values["measurements_digest"] = bytes.fromhex(values["measurements_digest"]) + values["gateway_hotkey"] = bytes.fromhex(values["gateway_hotkey"]) + trust = TrustRoot(challenges=tuple(challenges), **values) + trust.validate() + profiles.append(trust) + except (KeyError, TypeError, ValueError) as error: + raise ProtocolError("corrupt stored trust profile") from error + return tuple(profiles) def put_leaf(self, leaf: Leaf) -> dict: encoded = leaf.encode() diff --git a/src/cortex/master.py b/src/cortex/master.py index f1393e98d..aabd4ae10 100644 --- a/src/cortex/master.py +++ b/src/cortex/master.py @@ -536,6 +536,7 @@ def chain_endpoint() -> str: bounty_backend or PublicBackend(config.bounty_backend_url), session_secret=read_seed(config.bounty_session_secret_file), token_file=config.operator_token_file, + scoring_version=lambda: gateway.trust.algorithm_version, ) proof = _EpochTrackedProof( store=proof_store, diff --git a/src/cortex/protocol/aggregate.py b/src/cortex/protocol/aggregate.py index 904ac9732..fd5138dbd 100644 --- a/src/cortex/protocol/aggregate.py +++ b/src/cortex/protocol/aggregate.py @@ -9,7 +9,7 @@ from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass -from .models import Leaf, Score +from .models import BOUNTY_FULL_SHARE_REPORTS, PROPORTIONAL_SHARES, Leaf, Score from .scale import ProtocolError, fixed, uint @@ -114,8 +114,12 @@ def aggregate_leaves( *, algorithm_version: int = 1, ) -> FinalWeights: - if algorithm_version != 1: + if algorithm_version not in (1, 2): raise ProtocolError("unsupported algorithm version") + if algorithm_version == 2 and shares != PROPORTIONAL_SHARES: + raise ProtocolError("algorithm 2 requires proportional shares") + if algorithm_version == 1 and shares == PROPORTIONAL_SHARES: + raise ProtocolError("proportional shares require algorithm version 2") if len(dict(shares)) != len(shares) or sum(bps for _, bps in shares) != 10000: raise ProtocolError("emission shares must be unique and sum to 10000") if len(dict(uid_map)) != len(uid_map) or len({uid for _, uid in uid_map}) != len(uid_map): @@ -135,5 +139,10 @@ def aggregate_leaves( uint(bps, 2) miners = scores.get(challenge, {}) weights = {key.hex(): float(value) for key, value in sorted(miners.items()) if value > 0} - results.append(ChallengeWeights(challenge.hex(), bps / 100.0, weights)) + emission_percent = bps / 100.0 + if algorithm_version == 2 and challenge == b"bounty": + emission_percent *= ( + min(sum(miners.values()), BOUNTY_FULL_SHARE_REPORTS) / BOUNTY_FULL_SHARE_REPORTS + ) + results.append(ChallengeWeights(challenge.hex(), emission_percent, weights)) return aggregate_challenge_weights(results, {key.hex(): uid for key, uid in sorted(uid_map)}) diff --git a/src/cortex/protocol/bundle.py b/src/cortex/protocol/bundle.py index d93c4b822..17c1841d1 100644 --- a/src/cortex/protocol/bundle.py +++ b/src/cortex/protocol/bundle.py @@ -7,7 +7,16 @@ from .aggregate import aggregate_leaves from .crypto import BUNDLE_DOMAIN, RAW_WEIGHT_DOMAIN, public_key, sign_raw, verify_raw from .merkle import canonical_rows, merkle_root, metagraph_root -from .models import Bundle, BundleBody, Leaf, MetagraphRow, NoScore, Score, TrustRoot +from .models import ( + PROPORTIONAL_SHARES, + Bundle, + BundleBody, + Leaf, + MetagraphRow, + NoScore, + Score, + TrustRoot, +) from .scale import ProtocolError, byte_vec @@ -71,12 +80,16 @@ def build_bundle( trust: TrustRoot, ) -> Bundle: trust.validate() + if epoch < trust.introduced_epoch: + raise ProtocolError("trust root is not active for bundle epoch") if public_key(gateway_seed) != trust.gateway_hotkey: raise ProtocolError("gateway key does not match local trust") ordered_rows = canonical_rows(rows) ordered_leaves = tuple(sorted(leaves, key=lambda leaf: leaf.sort_key)) uid_map = tuple((row.hotkey, row.uid) for row in ordered_rows) - final = aggregate_leaves(ordered_leaves, trust.shares, uid_map) + final = aggregate_leaves( + ordered_leaves, trust.shares, uid_map, algorithm_version=trust.algorithm_version + ) body = BundleBody( 1, epoch, @@ -84,7 +97,7 @@ def build_bundle( block_b, block_hash, metagraph_root(ordered_rows), - 1, + trust.algorithm_version, trust.shares, trust.measurements_digest, uid_map, @@ -109,7 +122,9 @@ def verify_bundle( body, _ = verify_inputs( bundle, rows=rows, block_hash=block_hash, trust=trust, netuid=netuid, epoch=epoch ) - computed = aggregate_leaves(body.leaves, body.emission_shares, body.uid_map).final_vector + computed = aggregate_leaves( + body.leaves, body.emission_shares, body.uid_map, algorithm_version=body.algorithm_version + ).final_vector if body.final_vector != computed: raise ProtocolError("final vector mismatch") return body @@ -128,8 +143,10 @@ def verify_inputs( """Rows/hash must come from the chain at body.block_b, never from the gateway.""" trust.validate() body = bundle.body - if body.protocol_version != 1 or body.algorithm_version != 1: + if body.protocol_version != 1 or body.algorithm_version != trust.algorithm_version: raise ProtocolError("unsupported bundle version") + if body.epoch < trust.introduced_epoch: + raise ProtocolError("trust root is not active for bundle epoch") if body.netuid != netuid or (epoch is not None and body.epoch != epoch): raise ProtocolError("bundle subnet/epoch mismatch") if body.gateway_hotkey != trust.gateway_hotkey: @@ -152,11 +169,16 @@ def verify_inputs( def recompute(body: BundleBody, quarantined: set[bytes], minimum_share_mass: int = 5000): + if body.emission_shares == PROPORTIONAL_SHARES and body.algorithm_version != 2: + raise ProtocolError("proportional shares require algorithm version 2") shares = tuple(pair for pair in body.emission_shares if pair[0] not in quarantined) mass = sum(value for _, value in shares) if mass < minimum_share_mass or not mass: raise ProtocolError("surviving share mass below threshold") - if quarantined: + if body.algorithm_version == 2: + # Quarantined mass burns; the surviving challenge cannot inherit its share. + shares = body.emission_shares + elif quarantined: apportioned = {name: value * 10000 // mass for name, value in shares} remaining = 10000 - sum(apportioned.values()) order = sorted(shares, key=lambda pair: (-(pair[1] * 10000 % mass), byte_vec(pair[0]))) @@ -167,6 +189,7 @@ def recompute(body: BundleBody, quarantined: set[bytes], minimum_share_mass: int tuple(leaf for leaf in body.leaves if leaf.challenge_id not in quarantined), shares, body.uid_map, + algorithm_version=body.algorithm_version, ) diff --git a/src/cortex/protocol/models.py b/src/cortex/protocol/models.py index 1fb966345..461b074e9 100644 --- a/src/cortex/protocol/models.py +++ b/src/cortex/protocol/models.py @@ -6,6 +6,8 @@ from .scale import ProtocolError, Reader, byte_vec, fixed, uint, vector LIVE_SHARES = ((b"bounty", 2000), (b"proof", 8000)) +PROPORTIONAL_SHARES = ((b"bounty", 3000), (b"proof", 7000)) +BOUNTY_FULL_SHARE_REPORTS = 10 class NoScoreReason(IntEnum): @@ -155,13 +157,20 @@ class TrustRoot: def shares(self) -> tuple[tuple[bytes, int], ...]: return tuple((entry.id, entry.emission_share_bps) for entry in self.challenges) + @property + def algorithm_version(self) -> int: + self.validate() + return 2 if self.shares == PROPORTIONAL_SHARES else 1 + def challenges_body(self) -> bytes: self.validate() return vector(self.challenges, ChallengeEntry.encode) def validate(self) -> None: - if self.shares != LIVE_SHARES: - raise ProtocolError("live shares must be bounty=2000, proof=8000") + if self.shares not in (LIVE_SHARES, PROPORTIONAL_SHARES): + raise ProtocolError("shares must be bounty/proof=2000/8000 or 3000/7000") + if self.shares == PROPORTIONAL_SHARES and self.challenges_version < 2: + raise ProtocolError("proportional shares require challenges version >= 2") fixed(self.measurements_digest, 32) fixed(self.gateway_hotkey, 32) uint(self.challenges_version, 4) diff --git a/tests/bounty/test_backend.py b/tests/bounty/test_backend.py index 8f69343a8..75ced9dc3 100644 --- a/tests/bounty/test_backend.py +++ b/tests/bounty/test_backend.py @@ -176,6 +176,36 @@ async def test_backend_public_leaderboard_valid_field_is_supported(): assert snapshot.leaderboard[0].valid_count == 1 +async def test_v2_counts_every_valid_report_without_precision_severity_or_champion_gates(): + other = "02" * 32 + historical = "03" * 32 + reports = [ + published_report(id="a", severity="trivial"), + published_report(id="b", hotkey=other, severity="critical"), + published_report(id="c", hotkey=other, severity="minor"), + published_report(id="old", hotkey=historical), + *[ + published_report(id=f"invalid-{index}", status="invalid_malicious", severity=None) + for index in range(5) + ], + published_report(id="duplicate", status="duplicate", severity=None, related_report_id="a"), + ] + backend = backend_for( + [ + {"hotkey": other, "valid": 2}, + {"hotkey": historical, "valid": 1}, + {"hotkey": HOTKEY, "valid": 1}, + ], + reports, + ) + snapshot = await backend.fetch() + scores = snapshot.score([HOTKEY, other, "04" * 32], scoring_version=2) + assert set(scores) == {HOTKEY, other, "04" * 32} + assert scores[HOTKEY].value == 1 and scores[HOTKEY].reason is None + assert scores[other].value == 2 and scores[other].reason is None + assert scores["04" * 32].value == 0 and scores["04" * 32].reason == "NotAttempted" + + async def test_backend_rejects_conflicting_leaderboard_count_aliases(): backend = backend_for( [{"hotkey": HOTKEY, "valid": 1, "valid_count": 2}], diff --git a/tests/gateway/test_gateway.py b/tests/gateway/test_gateway.py index 15f611656..1facf3a00 100644 --- a/tests/gateway/test_gateway.py +++ b/tests/gateway/test_gateway.py @@ -205,6 +205,7 @@ async def test_sealed_burn_exposes_the_public_weights_contract(network): bundle = Bundle.decode(network.service.bundle_bytes(12)) required = { "protocol_version", + "algorithm_version", "vector_id", "vector_digest", "epoch", @@ -236,6 +237,7 @@ async def test_sealed_burn_exposes_the_public_weights_contract(network): assert set(latest) == required assert latest["protocol_version"] == "1.0" + assert latest["algorithm_version"] == 1 assert UUID(latest["vector_id"]).version == 5 assert latest["vector_digest"] == sha256(bundle.body.encode()).hexdigest() assert latest["epoch"] == 12 and latest["revision"] == 1 @@ -521,6 +523,52 @@ async def test_seal_and_raw_rows_survive_restart_byte_identically(network): third.close() +async def test_profile_rotation_preserves_archives_and_waits_for_new_sealed_epoch(network): + await intake(network) + assert (await seal(network)).status_code == 200 + original = network.service.bundle_bytes(12) + original_root = Bundle.decode(original).body.merkle_root.hex() + trust = replace( + network.trust, + challenges=tuple( + replace(entry, emission_share_bps=3000 if entry.id == b"bounty" else 7000) + for entry in network.trust.challenges + ), + challenges_version=2, + introduced_epoch=13, + ) + network.service.trust_loader = lambda epoch: trust + network.service.refresh_trust(13) + assert network.service.latest()["sealed"] is False + assert (await network.client.get("/v1/bundle/12")).content == original + archived = await network.client.get(f"/v1/bundle/root/{original_root}") + assert archived.status_code == 200 + assert archived.content == original + validator = Validator( + gateway_url="https://master.invalid", + netuid=541, + trust=trust, + chain=network.chain, + journal=network.journal, + http=network.client, + ) + assert (await validator.run_once()).outcome == "unsealed" + assert network.chain.submissions == [] + await intake(network, epoch=13) + assert (await seal(network, epoch=13)).status_code == 200 + assert network.service.latest()["algorithm_version"] == 2 + assert (await validator.run_once()).outcome == "submitted" + second = GatewayStore(network.db_path) + try: + restarted = GatewayService(store=second, **{**network.settings, "trust": trust}) + assert restarted.latest()["sealed"] is True + assert restarted.latest()["algorithm_version"] == 2 + assert restarted.bundle_bytes(12) == original + assert restarted.bundle_by_root(original_root) == original + finally: + second.close() + + async def test_corrupt_latest_seal_burns_without_falling_back_to_older_seal(network): for epoch in (12, 13): await intake(network, epoch=epoch) @@ -570,10 +618,58 @@ async def test_corrupted_signature_in_latest_never_projects_as_a_sealed_vector(n await intake(network) assert (await seal(network)).status_code == 200 raw = bytearray(network.service.bundle_bytes(12)) + root = Bundle.decode(bytes(raw)).body.merkle_root.hex() raw[-1] ^= 1 with sqlite3.connect(network.db_path) as connection: connection.execute("UPDATE gateway_bundles SET bundle_scale=?", (bytes(raw),)) assert network.service.latest()["sealed"] is False + assert (await network.client.get(f"/v1/bundle/root/{root}")).status_code == 503 + + +@pytest.mark.parametrize("profile", [None, "null", "true", "3", '"text"', "[]", "{}", "{", b"{}"]) +async def test_historical_root_refuses_corrupt_or_missing_accepted_profile(network, profile): + await intake(network) + assert (await seal(network)).status_code == 200 + root = network.service.latest()["merkle_root"] + with sqlite3.connect(network.db_path) as connection: + if profile is None: + connection.execute("DELETE FROM gateway_trust_profiles") + else: + raw = profile.encode() if isinstance(profile, str) else profile + connection.execute( + "UPDATE gateway_trust_profiles SET profile=?,digest=?", + (profile, sha256(raw).digest()), + ) + + response = await network.client.get(f"/v1/bundle/root/{root}") + + assert response.status_code == 503 + + +async def test_historical_root_uses_accepted_challenge_keys_after_key_rotation(network): + await intake(network) + assert (await seal(network)).status_code == 200 + original = network.service.bundle_bytes(12) + root = Bundle.decode(original).body.merkle_root.hex() + trust = replace( + network.trust, + challenges=tuple( + replace(entry, public_key=public_key(bytes([70 + index]) * 32)) + for index, entry in enumerate(network.trust.challenges) + ), + challenges_version=2, + introduced_epoch=13, + ) + network.service.trust_loader = lambda epoch: trust + network.service.refresh_trust(13) + second = GatewayStore(network.db_path) + try: + restarted = GatewayService(store=second, **{**network.settings, "trust": trust}) + assert restarted.latest()["sealed"] is False + assert restarted.bundle_by_root(root) == original + assert restarted.trust == trust + finally: + second.close() async def test_duplicate_json_and_oversized_request_are_rejected_before_store(network): diff --git a/tests/protocol/test_aggregate.py b/tests/protocol/test_aggregate.py index 81287b247..cef1a34d1 100644 --- a/tests/protocol/test_aggregate.py +++ b/tests/protocol/test_aggregate.py @@ -68,3 +68,50 @@ def test_no_score_does_not_erase_other_miner_scores(): ) result = aggregate_leaves(leaves, LIVE_SHARES, ((miners[0], 1), (miners[1], 2))) assert result.final_vector == ((0, 13107), (1, 52428)) + + +@pytest.mark.parametrize("reports", [0, 1, 5, 9, 10, 20]) +def test_v2_bounty_pays_proportionally_up_to_thirty_percent(reports): + miners = (bytes([1]) * 32, bytes([2]) * 32, bytes([3]) * 32) + counts = (reports // 2, reports - reports // 2) + leaves = tuple( + Leaf(b"bounty", key, 1, Score(count), bytes(64)) + for key, count in zip(miners[:2], counts, strict=True) + ) + (Leaf(b"proof", miners[2], 1, Score(100), bytes(64)),) + result = aggregate_leaves( + leaves, + ((b"bounty", 3000), (b"proof", 7000)), + tuple((key, index + 1) for index, key in enumerate(miners)), + algorithm_version=2, + ) + weights = dict(zip(result.uids, result.weights, strict=True)) + assert weights[3] == pytest.approx(0.7) + assert weights.get(0, 0) == pytest.approx(0.3 * (1 - min(reports / 10, 1))) + for uid, count in enumerate(counts, 1): + assert weights.get(uid, 0) == pytest.approx(0.3 * count / max(10, reports)) + + +def test_v2_burns_uid0_and_unmapped_authors_without_transferring_their_mass(): + miners = tuple(bytes([index]) * 32 for index in range(3)) + result = aggregate_leaves( + tuple(Leaf(b"bounty", key, 1, Score(5), bytes(64)) for key in miners), + ((b"bounty", 3000), (b"proof", 7000)), + ((miners[0], 0), (miners[1], 1)), + algorithm_version=2, + ) + assert result.weights == pytest.approx((0.9, 0.1)) + + +def test_v2_rejects_other_share_profiles(): + with pytest.raises(ProtocolError, match="shares"): + aggregate_leaves((), LIVE_SHARES, (), algorithm_version=2) + + +def test_proportional_profile_cannot_silently_use_default_legacy_algorithm(): + miner = bytes([1]) * 32 + with pytest.raises(ProtocolError, match="version 2"): + aggregate_leaves( + (Leaf(b"bounty", miner, 1, Score(1), bytes(64)),), + ((b"bounty", 3000), (b"proof", 7000)), + ((miner, 1),), + ) diff --git a/tests/protocol/test_bundle.py b/tests/protocol/test_bundle.py index ef0c4d886..92044b661 100644 --- a/tests/protocol/test_bundle.py +++ b/tests/protocol/test_bundle.py @@ -17,6 +17,7 @@ sign_leaf, verify_bundle, ) +from cortex.protocol.bundle import recompute from cortex.protocol.crypto import BUNDLE_DOMAIN, public_key, sign_raw, verify_raw from cortex.protocol.merkle import merkle_root from cortex.protocol.scale import Reader @@ -60,6 +61,71 @@ def test_complete_signed_seal_roundtrip_and_independent_recompute(network): assert Bundle.decode(bundle.encode()) == bundle +@pytest.fixture +def proportional_network(network): + previous, arguments = network + trust = replace( + arguments["trust"], + challenges=tuple( + replace(entry, emission_share_bps=3000 if entry.id == b"bounty" else 7000) + for entry in arguments["trust"].challenges + ), + challenges_version=2, + introduced_epoch=12, + ) + bundle = build_bundle( + gateway_seed=bytes([7]) * 32, + epoch=12, + netuid=541, + block_b=99, + block_hash=arguments["block_hash"], + rows=arguments["rows"], + leaves=previous.body.leaves, + trust=trust, + ) + return bundle, {**arguments, "trust": trust} + + +def test_v2_owner_profile_selects_algorithm_and_cannot_downgrade(proportional_network): + bundle, arguments = proportional_network + assert verify_bundle(Bundle.decode(bundle.encode()), **arguments).algorithm_version == 2 + body = replace(bundle.body, algorithm_version=1) + downgraded = Bundle(body, sign_raw(bytes([7]) * 32, BUNDLE_DOMAIN, body.encode())) + with pytest.raises(ProtocolError, match="version"): + verify_bundle(downgraded, **arguments) + with pytest.raises(ProtocolError, match="version"): + recompute(body, {b"bounty"}) + + +def test_v2_quarantine_burns_mass_without_changing_the_other_share(proportional_network): + bundle, _ = proportional_network + result = recompute(bundle.body, {b"bounty"}) + assert result.weights == pytest.approx((0.3, 0.35, 0.35)) + with pytest.raises(ProtocolError, match="surviving share"): + recompute(bundle.body, {b"proof"}) + assert recompute(bundle.body, {b"proof"}, minimum_share_mass=0).weights == pytest.approx( + (0.7, 0.15, 0.15) + ) + + +def test_v2_profile_cannot_activate_before_its_owner_signed_epoch(proportional_network): + bundle, arguments = proportional_network + future_trust = replace(arguments["trust"], introduced_epoch=13) + with pytest.raises(ProtocolError, match="not active"): + verify_bundle(bundle, **{**arguments, "trust": future_trust}) + with pytest.raises(ProtocolError, match="not active"): + build_bundle( + gateway_seed=bytes([7]) * 32, + epoch=12, + netuid=541, + block_b=99, + block_hash=arguments["block_hash"], + rows=arguments["rows"], + leaves=bundle.body.leaves, + trust=future_trust, + ) + + def test_frozen_rust_wire_fixture_matches_exact_fields_and_signature(): reference = json.loads((Path(__file__).parent / "vectors/rust_wire.json").read_text()) raw = bytes.fromhex(reference["bundle_scale"]) diff --git a/tests/protocol/test_trust.py b/tests/protocol/test_trust.py index 3283b0842..ad25f2a83 100644 --- a/tests/protocol/test_trust.py +++ b/tests/protocol/test_trust.py @@ -3,8 +3,8 @@ import pytest from cortex.protocol import ProtocolError -from cortex.protocol.crypto import decode_hotkey, public_key -from cortex.protocol.trust import load_trust_root +from cortex.protocol.crypto import TRUST_ROOT_DOMAIN, decode_hotkey, public_key, sign_raw +from cortex.protocol.trust import load_trust_root, signing_payload REFERENCE = Path(__file__).parent / "vectors/trust" @@ -47,3 +47,31 @@ def test_cannot_resign_trust_by_substituting_challenge_key(tmp_path): def test_local_version_pin_prevents_rollback(): with pytest.raises(ProtocolError, match="rollback"): load_trust_root(**arguments(), minimum_challenges_version=2) + + +@pytest.mark.parametrize("version,epoch,valid", [(1, 123, False), (2, 122, False), (2, 123, True)]) +def test_owner_signed_proportional_profile_requires_version_and_activation( + tmp_path, version, epoch, valid +): + values = arguments() + seed = bytes([31]) * 32 + values.update(owner_public=public_key(seed), epoch=epoch) + for kind in ("challenges", "measurements"): + path = tmp_path / f"{kind}.toml" + source = values[f"{kind}_path"].read_text() + if kind == "challenges": + source = ( + source.replace("version = 1", f"version = {version}") + .replace("introduced_epoch = 0", "introduced_epoch = 123") + .replace("2000", "3000") + .replace("8000", "7000") + ) + path.write_text(source) + signature = path.with_suffix(".sig") + signature.write_bytes(sign_raw(seed, TRUST_ROOT_DOMAIN, signing_payload(path, kind))) + values.update({f"{kind}_path": path, f"{kind}_signature": signature}) + if valid: + assert load_trust_root(**values).algorithm_version == 2 + else: + with pytest.raises(ProtocolError, match="version|activation"): + load_trust_root(**values) diff --git a/tests/test_network_e2e.py b/tests/test_network_e2e.py index bf653291a..707efe5e2 100644 --- a/tests/test_network_e2e.py +++ b/tests/test_network_e2e.py @@ -9,6 +9,7 @@ from types import SimpleNamespace import httpx +import pytest from test_master import FakeEpochChain, master_config from vm.test_research import MemoryCallback from vm.test_setup_agent import ExecutingHypervisor, SetupProvider @@ -17,8 +18,8 @@ from cortex.master import EpochState, build_master from cortex.miner import MinerClient from cortex.proof.backend import VmBackend -from cortex.protocol import ChallengeEntry, NoScore, NoScoreReason, Score, TrustRoot -from cortex.protocol.crypto import public_key +from cortex.protocol import ChallengeEntry, MetagraphRow, NoScore, NoScoreReason, Score, TrustRoot +from cortex.protocol.crypto import encode_hotkey, public_key from cortex.rlm.offer import InferenceOffer, sign_offer from cortex.rlm.provider import Completion from cortex.validator import SubmissionJournal, Validator @@ -166,16 +167,34 @@ def handle(self, request): return httpx.Response(200, json=body) -async def test_bounty_compatibility_intake_external_feed_seal_and_validator_dispatch(tmp_path): +@pytest.mark.parametrize( + "version,counts,payouts", + [ + (1, (3, 0), (0.2, 0)), + (2, (0, 0), (0, 0)), + (2, (1, 0), (0.03, 0)), + (2, (2, 3), (0.06, 0.09)), + (2, (4, 5), (0.12, 0.15)), + (2, (4, 6), (0.12, 0.18)), + (2, (8, 12), (0.12, 0.18)), + ], +) +async def test_bounty_intake_external_feed_seal_and_validator_dispatch( + tmp_path, version, counts, payouts +): config = master_config(tmp_path) chain = FakeEpochChain() + second_key = public_key(bytes([22]) * 32) + chain.rows += (MetagraphRow(second_key, 2),) + bounty_share = 2000 if version == 1 else 3000 trust = TrustRoot( ( - ChallengeEntry(b"bounty", public_key(bytes([1]) * 32), 2000), - ChallengeEntry(b"proof", public_key(bytes([2]) * 32), 8000), + ChallengeEntry(b"bounty", public_key(bytes([1]) * 32), bounty_share), + ChallengeEntry(b"proof", public_key(bytes([2]) * 32), 10000 - bounty_share), ), hashlib.sha256(b"\x00").digest(), public_key(bytes([7]) * 32), + challenges_version=version, ) feed = BountyFeed() runtime = await build_master( @@ -244,20 +263,29 @@ async def test_bounty_compatibility_intake_external_feed_seal_and_validator_disp ), ) assert local_report["state"] == "pending" - feed.leaderboard = [{"hotkey": miner.hotkey, "valid": 3}] + authors = (miner.hotkey, encode_hotkey(second_key)) + feed.leaderboard = sorted( + [ + {"hotkey": author, "valid": count} + for author, count in zip(authors, counts, strict=True) + if count + ], + key=lambda row: (-row["valid"], row["hotkey"]), + ) feed.reports = [ { - "id": f"backend-{index}", - "hotkey": miner.hotkey, + "id": f"backend-{author}-{index}", + "hotkey": author, "status": "valid", - "severity": "critical", + "severity": "critical" if version == 1 else "trivial", "problem_found": "Unauthorized configuration mutation", "adjudicator": "fixture-adjudicator", "justification": "Reproduced from a clean unauthenticated client.", "adjudicated_at": "2026-09-18T01:00:00Z", "created_at": "2026-09-18T00:00:00Z", } - for index in range(3) + for author, count in zip(authors, counts, strict=True) + for index in range(count) ] feed.revision = "2" @@ -270,7 +298,11 @@ async def test_bounty_compatibility_intake_external_feed_seal_and_validator_disp leaf for leaf in leaves if leaf.challenge_id == b"bounty" and leaf.miner_hotkey == miner_key - ).score == Score(1_000_000) + ).score == ( + Score(1_000_000 if version == 1 else counts[0]) + if counts[0] + else NoScore(NoScoreReason.NOT_ATTEMPTED) + ) assert all( leaf.score == NoScore(NoScoreReason.CHALLENGE_INTERNAL) for leaf in leaves @@ -278,7 +310,18 @@ async def test_bounty_compatibility_intake_external_feed_seal_and_validator_disp ) latest = (await http.get("/v1/weights/latest")).json() assert latest["sealed"] is True - assert latest["final_vector"] == [[0, 52428], [1, 13107]] + assert latest["algorithm_version"] == version + assert latest["source_challenges"][0]["emission_percent"] == pytest.approx( + 20 if version == 1 else sum(payouts) * 100 + ) + weights = dict(zip(latest["uids"], latest["weights"], strict=True)) + assert weights[0] == pytest.approx(1 - sum(payouts)) + assert weights.get(1, 0) == pytest.approx(payouts[0]) + assert weights.get(2, 0) == pytest.approx(payouts[1]) + status = (await http.get("/challenge/bounty/v1/status")).json() + assert status["scoring_version"] == version + if version == 2: + assert status["scoring"]["paid_on"] == ["valid_report_count"] preflight = Validator( gateway_url="https://master.fixture", @@ -301,7 +344,7 @@ async def test_bounty_compatibility_intake_external_feed_seal_and_validator_disp http=http, ) assert (await validator.run_once()).outcome == "submitted" - assert chain.submissions == [((0, 52428), (1, 13107))] + assert chain.submissions == [tuple(tuple(pair) for pair in latest["final_vector"])] finally: journal.close() await runtime.close() diff --git a/tests/test_operator.py b/tests/test_operator.py index 2a280960d..9e7de69e6 100644 --- a/tests/test_operator.py +++ b/tests/test_operator.py @@ -4,6 +4,7 @@ from cortex.cli import main from cortex.operator import generate_key, sign_trust_document +from cortex.protocol import ProtocolError from cortex.protocol.crypto import decode_hotkey, public_key from cortex.protocol.trust import load_trust_root from cortex.validator import SubmissionJournal @@ -70,6 +71,34 @@ def test_key_generation_refuses_to_overwrite_operator_material(tmp_path): assert len(seed_path.read_bytes()) == 32 +def test_proportional_template_requires_an_explicit_activation_epoch_before_signing(tmp_path): + seed_path = tmp_path / "owner.seed" + generate_key(seed_path, tmp_path / "owner.pubkey") + template = Path(__file__).resolve().parents[1] / "config/challenges-v2.example.toml" + signature = tmp_path / "challenges.toml.sig" + + with pytest.raises(ProtocolError): + sign_trust_document( + input_path=template, + kind="challenges", + seed_path=seed_path, + signature_path=signature, + ) + + assert not signature.exists() + selected = _document( + tmp_path / "challenges.toml", + template.read_text().replace('"CHOOSE_ACTIVATION_EPOCH"', "123"), + ) + sign_trust_document( + input_path=selected, + kind="challenges", + seed_path=seed_path, + signature_path=signature, + ) + assert len(bytes.fromhex(signature.read_text())) == 64 + + def test_cli_verifies_the_signed_files_before_operator_install(tmp_path, capsys): seed_path, owner_path = tmp_path / "owner.seed", tmp_path / "owner.pubkey" generate_key(seed_path, owner_path) diff --git a/tests/test_repo_contract.py b/tests/test_repo_contract.py index 6f2cf071d..d861e0e96 100644 --- a/tests/test_repo_contract.py +++ b/tests/test_repo_contract.py @@ -59,6 +59,19 @@ def test_trust_root_rejects_emission_drift_or_extra_products(tmp_path, mutation) assert len(CHECK["check_trust_roots"](tmp_path)) == 1 +@pytest.mark.parametrize("version,accepted", [(1, False), (2, True), (3, True)]) +def test_proportional_trust_template_requires_new_owner_version(tmp_path, version, accepted): + put(tmp_path, "config/challenges.toml", trust_root()) + put( + tmp_path, + "config/challenges-v2.example.toml", + trust_root(proof_share=7000) + .replace("2000", "3000") + .replace("version = 1", f"version = {version}"), + ) + assert (CHECK["check_trust_roots"](tmp_path) == []) is accepted + + def test_public_api_must_be_an_actual_route_not_a_comment_or_mapping_access(): source = ( '# @router.post("/v1/submissions")\n'