Skip to main content

wowlab_common/
runtime.rs

1//! Runtime work-expansion protocol shared by sentinel and node; payloads serialize as camelCase JSON.
2
3use serde::{Deserialize, Serialize, Serializer};
4use wowlab_types::proto;
5
6const DIGEST_LEN: usize = 32;
7
8/// SHA-256 digest of `(base_sim_config, TournamentPayload, sentinel_config)`, serialized as lowercase hex.
9#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq)]
10#[serde(try_from = "String")]
11pub struct WorkContextHash([u8; DIGEST_LEN]);
12
13wowlab_engine_macros::define_error! {
14/// Error returned when a [`WorkContextHash`] hex string is malformed.
15#[derive(Debug)]
16pub struct WorkContextHashParseError {
17    #[source]
18    kind: WorkContextHashParseErrorKind,
19}
20
21#[derive(Debug, thiserror::Error)]
22enum WorkContextHashParseErrorKind {
23    #[error("work context hash must be {DIGEST_LEN} bytes, got {0}")]
24    Length(usize),
25    #[error("work context hash is not valid hex: {0}")]
26    Hex(#[source] data_encoding::DecodeError),
27}
28}
29
30impl WorkContextHashParseError {
31    const fn length(actual: usize) -> Self {
32        Self {
33            kind: WorkContextHashParseErrorKind::Length(actual),
34        }
35    }
36
37    fn hex(error: data_encoding::DecodeError) -> Self {
38        Self {
39            kind: WorkContextHashParseErrorKind::Hex(error),
40        }
41    }
42
43    /// Returns the decoded byte length when the digest length was invalid.
44    #[must_use]
45    pub const fn actual_length(&self) -> Option<usize> {
46        match self.kind {
47            WorkContextHashParseErrorKind::Length(actual) => Some(actual),
48            WorkContextHashParseErrorKind::Hex(_) => None,
49        }
50    }
51
52    /// Returns whether the input was not valid hexadecimal text.
53    #[must_use]
54    pub const fn is_invalid_hex(&self) -> bool {
55        matches!(self.kind, WorkContextHashParseErrorKind::Hex(_))
56    }
57}
58
59impl From<data_encoding::DecodeError> for WorkContextHashParseError {
60    fn from(error: data_encoding::DecodeError) -> Self {
61        Self::hex(error)
62    }
63}
64
65impl WorkContextHash {
66    /// Wrap raw digest bytes.
67    #[must_use]
68    pub const fn from_bytes(bytes: [u8; DIGEST_LEN]) -> Self {
69        Self(bytes)
70    }
71
72    /// Parse from a lowercase or uppercase hex string.
73    ///
74    /// # Errors
75    ///
76    /// Returns [`WorkContextHashParseError`] when the input is not hex or has the wrong length.
77    pub fn from_hex(s: &str) -> Result<Self, WorkContextHashParseError> {
78        let decoded = data_encoding::HEXLOWER_PERMISSIVE.decode(s.as_bytes())?;
79
80        if decoded.len() != DIGEST_LEN {
81            return Err(WorkContextHashParseError::length(decoded.len()));
82        }
83
84        let mut bytes = [0u8; DIGEST_LEN];
85
86        bytes.copy_from_slice(&decoded);
87
88        Ok(Self(bytes))
89    }
90
91    /// Compute the digest over the work context, length-prefixing each part to prevent collisions.
92    #[cfg(feature = "crypto")]
93    #[must_use]
94    pub fn compute(
95        base_sim_config: &str,
96        tournament_payload: &[u8],
97        sentinel_config: &str,
98    ) -> Self {
99        use sha2::{Digest, Sha256};
100        let mut hasher = Sha256::new();
101
102        for part in [
103            base_sim_config.as_bytes(),
104            tournament_payload,
105            sentinel_config.as_bytes(),
106        ] {
107            hasher.update((part.len() as u64).to_le_bytes());
108            hasher.update(part);
109        }
110
111        let mut bytes = [0u8; DIGEST_LEN];
112
113        bytes.copy_from_slice(&hasher.finalize());
114
115        Self(bytes)
116    }
117
118    /// Borrow the raw digest bytes.
119    #[must_use]
120    pub const fn as_bytes(&self) -> &[u8; DIGEST_LEN] {
121        &self.0
122    }
123
124    /// Render as a lowercase hex string.
125    #[must_use]
126    pub fn to_hex(&self) -> String {
127        data_encoding::HEXLOWER.encode(&self.0)
128    }
129}
130
131impl std::fmt::Display for WorkContextHash {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        f.write_str(&self.to_hex())
134    }
135}
136
137impl Serialize for WorkContextHash {
138    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
139    where
140        S: Serializer,
141    {
142        serializer.serialize_str(&self.to_hex())
143    }
144}
145
146impl TryFrom<String> for WorkContextHash {
147    type Error = WorkContextHashParseError;
148
149    fn try_from(value: String) -> Result<Self, Self::Error> {
150        Self::from_hex(&value)
151    }
152}
153
154/// Opaque claim token (UUID string) issued by sentinel when it assigns a chunk.
155#[derive(Clone, Deserialize, Eq, Hash, PartialEq, Serialize)]
156#[serde(transparent)]
157pub struct ClaimToken(String);
158
159impl ClaimToken {
160    /// Wrap an issued claim-token string.
161    pub fn new(value: impl Into<String>) -> Self {
162        Self(value.into())
163    }
164
165    /// Borrow the token string.
166    #[must_use]
167    pub fn as_str(&self) -> &str {
168        &self.0
169    }
170
171    /// Consume the token and return its string representation.
172    #[must_use]
173    pub fn into_string(self) -> String {
174        self.0
175    }
176}
177
178impl std::fmt::Debug for ClaimToken {
179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        f.write_str("ClaimToken([REDACTED])")
181    }
182}
183
184impl std::fmt::Display for ClaimToken {
185    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186        f.write_str(&self.0)
187    }
188}
189
190impl From<String> for ClaimToken {
191    fn from(value: String) -> Self {
192        Self::new(value)
193    }
194}
195
196impl From<&str> for ClaimToken {
197    fn from(value: &str) -> Self {
198        Self::new(value)
199    }
200}
201
202/// Monotonic per-job identifier for a runtime chunk (a batch of work items).
203#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
204#[serde(transparent)]
205pub struct RuntimeChunkId(u64);
206
207impl RuntimeChunkId {
208    /// Wrap a monotonic per-job chunk identifier.
209    #[must_use]
210    pub const fn new(value: u64) -> Self {
211        Self(value)
212    }
213
214    /// Return the underlying numeric identifier.
215    #[must_use]
216    pub const fn get(self) -> u64 {
217        self.0
218    }
219}
220
221impl From<u64> for RuntimeChunkId {
222    fn from(value: u64) -> Self {
223        Self::new(value)
224    }
225}
226
227/// Result fidelity requested for a work item.
228#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
229#[serde(rename_all = "camelCase")]
230#[non_exhaustive]
231pub enum RuntimeWorkFidelity {
232    #[default]
233    DpsOnly,
234    Full,
235}
236
237impl From<RuntimeWorkFidelity> for proto::ResultFidelity {
238    fn from(fidelity: RuntimeWorkFidelity) -> Self {
239        match fidelity {
240            RuntimeWorkFidelity::DpsOnly => proto::ResultFidelity::DpsOnly,
241            RuntimeWorkFidelity::Full => proto::ResultFidelity::Full,
242        }
243    }
244}
245
246impl From<proto::ResultFidelity> for RuntimeWorkFidelity {
247    fn from(fidelity: proto::ResultFidelity) -> Self {
248        match fidelity {
249            proto::ResultFidelity::DpsOnly => RuntimeWorkFidelity::DpsOnly,
250            proto::ResultFidelity::Full => RuntimeWorkFidelity::Full,
251        }
252    }
253}
254
255/// What a work item evaluates, carrying the parameters needed to derive a concrete sim config.
256#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
257#[serde(
258    tag = "type",
259    rename_all = "camelCase",
260    rename_all_fields = "camelCase"
261)]
262#[non_exhaustive]
263pub enum RuntimeWorkItemKind {
264    Base,
265    Tournament {
266        perm_idx: u32,
267    },
268    FactorialMain {
269        slot_index: u32,
270        item_idx: u32,
271    },
272    FactorialInteraction {
273        slot_a: u32,
274        item_a: u32,
275        slot_b: u32,
276        item_b: u32,
277    },
278}
279
280/// One abstract unit of work in a runtime chunk, derived from `kind` and routed by `tag`.
281#[derive(Clone, Debug, Deserialize, Serialize)]
282#[serde(rename_all = "camelCase")]
283pub struct RuntimeWorkItem {
284    pub item_id: u64,
285    pub kind: RuntimeWorkItemKind,
286    pub tag: u64,
287    pub iterations: u32,
288    pub seed_offset: u64,
289    pub fidelity: RuntimeWorkFidelity,
290}
291
292/// A batch of work items assigned to one node in one Centrifuge publication.
293#[derive(Clone, Debug, Deserialize, Serialize)]
294#[serde(rename_all = "camelCase")]
295pub struct RuntimeChunkPayload {
296    pub job_id: String,
297    pub chunk_id: RuntimeChunkId,
298    pub work_context_hash: WorkContextHash,
299    pub claim_token: ClaimToken,
300    pub work_items: Vec<RuntimeWorkItem>,
301}
302
303/// Compact result for one executed work item, mirroring the `BatchWorkResult` proto.
304#[derive(Clone, Debug)]
305pub struct RuntimeWorkResult {
306    pub item_id: u64,
307    pub tag: u64,
308    pub iterations: u32,
309    pub mean_dps_x10: u32,
310    pub m2_dps_bits: u64,
311    /// Encoded `ChunkTelemetry`, present only for full-fidelity items.
312    pub telemetry_pb: Option<Vec<u8>>,
313}
314
315impl RuntimeWorkResult {
316    /// Build from the protobuf wire form.
317    #[must_use]
318    pub fn from_proto(result: &proto::BatchWorkResult) -> Self {
319        Self {
320            item_id: result.item_id,
321            tag: result.tag,
322            iterations: result.iterations,
323            mean_dps_x10: result.mean_dps_x10,
324            m2_dps_bits: result.m2_dps_bits,
325            telemetry_pb: result.telemetry_pb.clone(),
326        }
327    }
328
329    /// Convert to the protobuf wire form.
330    #[must_use]
331    pub fn to_proto(&self) -> proto::BatchWorkResult {
332        proto::BatchWorkResult {
333            item_id: self.item_id,
334            tag: self.tag,
335            iterations: self.iterations,
336            mean_dps_x10: self.mean_dps_x10,
337            m2_dps_bits: self.m2_dps_bits,
338            telemetry_pb: self.telemetry_pb.clone(),
339        }
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use std::error::Error as _;
346
347    use googletest::prelude::*;
348
349    use super::*;
350
351    #[gtest]
352    fn work_context_hash_hex_roundtrips() -> Result<()> {
353        let hash = WorkContextHash::from_bytes([7u8; DIGEST_LEN]);
354        let hex = hash.to_hex();
355
356        verify_that!(hex.len(), eq(DIGEST_LEN * 2))?;
357        let parsed = WorkContextHash::from_hex(&hex).or_fail()?;
358
359        verify_that!(parsed, eq(hash))?;
360
361        Ok(())
362    }
363
364    #[gtest]
365    fn work_context_hash_rejects_wrong_length() -> Result<()> {
366        let err = WorkContextHash::from_hex("dead").err().or_fail()?;
367
368        verify_that!(
369            err,
370            predicate(|error: &WorkContextHashParseError| error.actual_length() == Some(2))
371        )?;
372
373        Ok(())
374    }
375
376    #[gtest]
377    fn work_context_hash_preserves_hex_decode_source() -> Result<()> {
378        let error = WorkContextHash::from_hex("not-hex").err().or_fail()?;
379
380        verify_that!(error.is_invalid_hex(), eq(true))?;
381        let kind = error.source().or_fail()?;
382
383        verify_that!(kind.source(), some(anything()))
384    }
385
386    #[gtest]
387    fn work_context_hash_serializes_as_hex_string() -> Result<()> {
388        let hash = WorkContextHash::from_bytes([0xABu8; DIGEST_LEN]);
389        let json = serde_json::to_string(&hash).or_fail()?;
390
391        verify_that!(json, eq(format!("\"{}\"", hash.to_hex()).as_str()))?;
392        let back: WorkContextHash = serde_json::from_str(&json).or_fail()?;
393
394        verify_that!(back, eq(hash))?;
395
396        Ok(())
397    }
398
399    #[cfg(feature = "crypto")]
400    #[gtest]
401    fn work_context_hash_compute_is_deterministic_and_distinct() -> Result<()> {
402        let a = WorkContextHash::compute("base", b"payload", "sentinel");
403        let b = WorkContextHash::compute("base", b"payload", "sentinel");
404
405        verify_that!(a, eq(b))?;
406        let c = WorkContextHash::compute("base", b"payloa", "dsentinel");
407
408        verify_that!(a, not(eq(c)))?;
409
410        Ok(())
411    }
412
413    #[gtest]
414    fn chunk_payload_serializes_camelcase() -> Result<()> {
415        let payload = RuntimeChunkPayload {
416            job_id: "job-1".into(),
417            chunk_id: RuntimeChunkId::new(42),
418            work_context_hash: WorkContextHash::from_bytes([1u8; DIGEST_LEN]),
419            claim_token: ClaimToken::new("tok-1"),
420            work_items: vec![RuntimeWorkItem {
421                item_id: 5,
422                kind: RuntimeWorkItemKind::Tournament { perm_idx: 9 },
423                tag: 9,
424                iterations: 2_000,
425                seed_offset: 18_000,
426                fidelity: RuntimeWorkFidelity::DpsOnly,
427            }],
428        };
429        let json = serde_json::to_string(&payload).or_fail()?;
430
431        verify_that!(json, contains_substring("\"jobId\":\"job-1\""))?;
432        verify_that!(json, contains_substring("\"chunkId\":42"))?;
433        verify_that!(json, contains_substring("\"workItems\""))?;
434        verify_that!(json, contains_substring("\"seedOffset\":18000"))?;
435        verify_that!(json, contains_substring("\"fidelity\":\"dpsOnly\""))?;
436        verify_that!(json, contains_substring("\"type\":\"tournament\""))?;
437        verify_that!(json, contains_substring("\"permIdx\":9"))?;
438
439        let back: RuntimeChunkPayload = serde_json::from_str(&json).or_fail()?;
440
441        verify_that!(back.chunk_id, eq(RuntimeChunkId::new(42)))?;
442        verify_that!(back.work_items.len(), eq(1))?;
443        verify_that!(
444            back.work_items[0].kind,
445            matches_pattern!(RuntimeWorkItemKind::Tournament { perm_idx: eq(9) })
446        )?;
447
448        Ok(())
449    }
450
451    #[gtest]
452    fn factorial_kinds_serialize_with_type_tag() -> Result<()> {
453        let main = serde_json::to_string(&RuntimeWorkItemKind::FactorialMain {
454            slot_index: 2,
455            item_idx: 3,
456        })
457        .or_fail()?;
458
459        verify_that!(main, contains_substring("\"type\":\"factorialMain\""))?;
460        verify_that!(main, contains_substring("\"slotIndex\":2"))?;
461
462        let interaction = serde_json::to_string(&RuntimeWorkItemKind::FactorialInteraction {
463            slot_a: 1,
464            item_a: 2,
465            slot_b: 3,
466            item_b: 4,
467        })
468        .or_fail()?;
469
470        verify_that!(
471            interaction,
472            contains_substring("\"type\":\"factorialInteraction\"")
473        )?;
474
475        Ok(())
476    }
477
478    #[gtest]
479    fn work_result_proto_roundtrips() -> Result<()> {
480        let result = RuntimeWorkResult {
481            item_id: 1,
482            tag: 7,
483            iterations: 2_000,
484            mean_dps_x10: 12_345,
485            m2_dps_bits: 99,
486            telemetry_pb: Some(vec![1, 2, 3]),
487        };
488        let proto = result.to_proto();
489        let back = RuntimeWorkResult::from_proto(&proto);
490
491        verify_that!(
492            back,
493            matches_pattern!(RuntimeWorkResult {
494                item_id: eq(&1),
495                tag: eq(&7),
496                mean_dps_x10: eq(&12_345),
497                telemetry_pb: some(container_eq([1, 2, 3])),
498                ..
499            })
500        )?;
501
502        Ok(())
503    }
504
505    #[gtest]
506    fn fidelity_proto_conversions_roundtrip() -> Result<()> {
507        for fidelity in [RuntimeWorkFidelity::DpsOnly, RuntimeWorkFidelity::Full] {
508            let proto: proto::ResultFidelity = fidelity.into();
509
510            verify_that!(RuntimeWorkFidelity::from(proto), eq(fidelity))?;
511        }
512
513        Ok(())
514    }
515
516    #[gtest]
517    fn claim_token_debug_is_redacted() -> Result<()> {
518        let token = ClaimToken::new("super-secret-token");
519        let debug = format!("{token:?}");
520
521        verify_that!(debug, eq("ClaimToken([REDACTED])"))?;
522
523        verify_that!(debug, not(contains_substring(token.as_str())))
524    }
525}