1use serde::{Deserialize, Serialize, Serializer};
4use wowlab_types::proto;
5
6const DIGEST_LEN: usize = 32;
7
8#[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#[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 #[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 #[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 #[must_use]
68 pub const fn from_bytes(bytes: [u8; DIGEST_LEN]) -> Self {
69 Self(bytes)
70 }
71
72 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 #[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 #[must_use]
120 pub const fn as_bytes(&self) -> &[u8; DIGEST_LEN] {
121 &self.0
122 }
123
124 #[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#[derive(Clone, Deserialize, Eq, Hash, PartialEq, Serialize)]
156#[serde(transparent)]
157pub struct ClaimToken(String);
158
159impl ClaimToken {
160 pub fn new(value: impl Into<String>) -> Self {
162 Self(value.into())
163 }
164
165 #[must_use]
167 pub fn as_str(&self) -> &str {
168 &self.0
169 }
170
171 #[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#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
204#[serde(transparent)]
205pub struct RuntimeChunkId(u64);
206
207impl RuntimeChunkId {
208 #[must_use]
210 pub const fn new(value: u64) -> Self {
211 Self(value)
212 }
213
214 #[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#[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#[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#[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#[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#[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 pub telemetry_pb: Option<Vec<u8>>,
313}
314
315impl RuntimeWorkResult {
316 #[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 #[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}