Skip to main content

nucleus_substrate_core/mechanism/
vcg.rs

1//! Pigouvian-VCG mechanism types.
2//!
3//! These are the wire shapes the auction-hub server emits and the
4//! mechanism-vcg lifter (`nucleus-mechanism-vcg`) consumes when
5//! constructing the body of a [`Projection::Economic`] variant.
6//!
7//! Lean theorems backing the mechanism live in `formal/Nucleus/Auctions/`:
8//!
9//!   * `IntegerVcgTruthful.vickrey_truthful` — single-good
10//!     strategy-proofness.
11//!   * `VcgPigouTruthful.pigou_vickrey_truthful` — Pigouvian-VCG
12//!     truthfulness.
13//!   * `PigouvianVcgSequential.sequential_welfare_bounded_above` —
14//!     welfare bound.
15//!
16//! Aeneas-extracted µUSD parity is enforced by the proptest in
17//! `crates/nucleus-econ-kernels/tests/pigou_parity.rs`.
18//!
19//! [`Projection::Economic`]: super::super::Projection::Economic
20
21use serde::{Deserialize, Serialize};
22use std::collections::{BTreeMap, BTreeSet};
23
24// ── Resource dimensions for Pigouvian externalities ───────────
25
26/// One axis of externality the auction internalizes. Each variant
27/// captures a measurable externality and an oracle that signs claims
28/// about it.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31#[non_exhaustive]
32pub enum ResourceDim {
33    /// GPU compute time consumed, in micro-seconds.
34    GpuSeconds,
35    /// Grid carbon intensity × electrical energy, in micro-grams CO₂-eq.
36    GridCarbonGramsCo2,
37    /// Peer-verifier CPU / I/O time imposed on the witness federation
38    /// when this call's lineage edges get verified, in milliseconds.
39    PeerVerifierMillis,
40    /// Bits added to the corpus when this call's artifact is recorded.
41    CorpusBitsAdded,
42    /// **Negative externality** (= positive spillover): knowledge
43    /// produced and made available to other agents.
44    KnowledgeSpillover,
45    /// FX volatility imposed on subsequent FX-denominated bids.
46    FxVolatilityDelta,
47    /// Auction-clearing delay imposed on downstream auctions,
48    /// in milliseconds.
49    AuctionDelay,
50}
51
52// ── Externality profile ───────────────────────────────────────
53
54/// One agent's claims about the externalities they would impose if
55/// awarded a given auction. Each claim is signed by a trusted oracle;
56/// the hub verifies signatures before the VCG path consumes them.
57///
58/// Profiles are **opaque to the SDK** — clients construct them from
59/// raw signed-claim bytes obtained out-of-band from an oracle
60/// service. The hub deserializes them via the same struct shape.
61#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
62pub struct ExternalityProfile {
63    /// Map from dimension to the signed claim for that dimension.
64    pub dimensions: BTreeMap<ResourceDim, OpaqueSignedClaim>,
65}
66
67/// A signed externality claim, opaque from the SDK's perspective.
68/// The hub deserializes the inner `signed_bytes` against the oracle's
69/// verifying key; SDK consumers pass through whatever bytes they
70/// received from their oracle.
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
72pub struct OpaqueSignedClaim {
73    /// Canonical bytes of the claim payload (oracle-defined shape).
74    pub signed_bytes: Vec<u8>,
75    /// Ed25519 signature over `signed_bytes`.
76    pub signature: Vec<u8>,
77}
78
79// ── Auction lifecycle ─────────────────────────────────────────
80
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
82pub struct PostedAuction {
83    pub auction_id: String,
84    pub required_capabilities: BTreeSet<String>,
85    pub reward_micro_usd: u64,
86    pub pigouvian_rates: Vec<(ResourceDim, u64)>,
87    pub scale: u64,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
91pub struct AgentBid {
92    pub agent_spiffe_id: String,
93    pub auction_id: String,
94    pub effective_value_micro_usd: u64,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub externality_profile: Option<ExternalityProfile>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
100pub struct MatchResult {
101    pub auction_id: String,
102    pub winner_spiffe_id: Option<String>,
103    pub clearing_price_micro_usd: u64,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
107pub struct VcgMatchResult {
108    /// (winner SPIFFE id, Clarke-pivot payment in µUSD)
109    pub winners: Vec<(String, u64)>,
110}
111
112// ── Helper: pack a cleared auction into an Economic projection ──
113
114/// Wire payload of the Economic projection variant when a single-good
115/// Vickrey match has cleared. Roughly mirrors the hub's `/match`
116/// response plus the bid set that produced it.
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
118pub struct VickreyPayload {
119    pub auction: PostedAuction,
120    pub bids: Vec<AgentBid>,
121    pub match_result: MatchResult,
122}
123
124/// Build a [`Projection::Economic`] body from a cleared single-good
125/// Vickrey auction. Callers will typically wrap this in a `Receipt`
126/// with the relevant `Session`.
127///
128/// [`Projection::Economic`]: crate::Projection::Economic
129pub fn vickrey_projection_body(
130    auction: PostedAuction,
131    bids: Vec<AgentBid>,
132    match_result: MatchResult,
133) -> serde_json::Value {
134    let payload = VickreyPayload {
135        auction,
136        bids,
137        match_result,
138    };
139    serde_json::to_value(payload).expect("VickreyPayload serializes deterministically")
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::{Projection, Receipt, Session};
146
147    fn dummy_auction() -> PostedAuction {
148        PostedAuction {
149            auction_id: "a1".into(),
150            required_capabilities: BTreeSet::new(),
151            reward_micro_usd: 1_000_000,
152            pigouvian_rates: vec![(ResourceDim::GpuSeconds, 100)],
153            scale: 1_000_000,
154        }
155    }
156
157    fn dummy_match() -> MatchResult {
158        MatchResult {
159            auction_id: "a1".into(),
160            winner_spiffe_id: Some("spiffe://test/agent".into()),
161            clearing_price_micro_usd: 250_000,
162        }
163    }
164
165    #[test]
166    fn vickrey_payload_round_trips_serde() {
167        let payload = VickreyPayload {
168            auction: dummy_auction(),
169            bids: vec![],
170            match_result: dummy_match(),
171        };
172        let json = serde_json::to_string(&payload).unwrap();
173        let back: VickreyPayload = serde_json::from_str(&json).unwrap();
174        assert_eq!(back.auction.auction_id, "a1");
175        assert_eq!(back.match_result.clearing_price_micro_usd, 250_000);
176    }
177
178    #[test]
179    fn projection_body_helper_packs_into_economic_variant() {
180        let body = vickrey_projection_body(dummy_auction(), vec![], dummy_match());
181        let projection = Projection::Economic(body);
182        assert_eq!(projection.kind(), "economic");
183    }
184
185    /// **End-to-end**: pack a cleared auction into the categorical
186    /// Receipt, sign it, verify offline. This is the canonical
187    /// "auction-hub emits a Receipt" code path.
188    #[test]
189    fn cleared_auction_round_trips_through_receipt() {
190        let sk = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
191        let session = Session {
192            session_id: "spiffe://test/auction-hub".into(),
193            issuer_kid: "test-kid".into(),
194            issued_at_micros: 1_717_000_000_000_000,
195            parent_chain: vec![],
196        };
197        let body = vickrey_projection_body(dummy_auction(), vec![], dummy_match());
198        let receipt = Receipt::sign(session, vec![Projection::Economic(body)], &sk);
199        let vk: [u8; 32] = sk.verifying_key().to_bytes();
200        receipt.verify(&vk).expect("cleared-auction Receipt verifies");
201    }
202
203    /// **Wire-shape regression** — assert the JSON layout downstream
204    /// consumers will see. If this ever changes, that's a SemVer
205    /// break for substrate-core.
206    #[test]
207    fn projection_economic_wire_format_includes_auction_id() {
208        let body = vickrey_projection_body(dummy_auction(), vec![], dummy_match());
209        let projection = Projection::Economic(body);
210        let v = serde_json::to_value(&projection).unwrap();
211        assert_eq!(v["kind"], "economic");
212        assert_eq!(v["body"]["auction"]["auction_id"], "a1");
213        assert_eq!(v["body"]["match_result"]["clearing_price_micro_usd"], 250_000);
214    }
215}