Skip to main content

tenzro_types/
training.rs

1//! Tenzro Train — decentralized, verifiable, multi-modal foundation model
2//! training types.
3//!
4//! These types live in `tenzro-types` so every crate (RPC, storage, network,
5//! token, CLI, agent kit, the Python reference trainer over JSON) can talk
6//! about training tasks, outer gradients, and receipts without circular
7//! dependencies.
8//!
9//! Architecture: the **Rust protocol layer** (this crate plus
10//! `tenzro-training`) owns coordination, aggregation, settlement, and
11//! verification. The **Python reference trainer** (`integrations/trainer/`,
12//! PyTorch FSDP2 + Hivemind + safetensors) owns the inner training loop. See
13//! [`AI.md`] §7.7.1 for the full split.
14//!
15//! # Lifecycle (Phase 1)
16//!
17//! 1. A [`TrainingTask`] is posted on-chain. Sponsor escrows TNZO.
18//! 2. A syncer is elected (VRF-weighted by stake) and posts a TEE attestation.
19//! 3. Trainers enroll, stake, and receive shard assignments.
20//! 4. Each round: trainers run H inner SGD steps on their shard, submit an
21//!    [`OuterGradient`], the syncer aggregates K-of-M, the result is committed
22//!    on-chain.
23//! 5. A [`TrainingReceipt`] is sealed at finalization and may be minted as an
24//!    NFT.
25
26use crate::primitives::{Address, Hash, Signature, Timestamp};
27use serde::{Deserialize, Serialize};
28use std::collections::HashMap;
29use std::fmt;
30
31// ---------------------------------------------------------------------------
32// Trust tier
33// ---------------------------------------------------------------------------
34
35/// Trust tier selected by the sponsor at task posting. Defines what the
36/// trainer hardware must provide and how rewards/penalties scale.
37///
38/// Per `AI.md` §7.3.3: training compute is TEE-optional (Open tier),
39/// key custody and verification are TEE-mandatory in every tier.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
41pub enum TrainingTier {
42    /// Any GPU or CPU, no TEE attestation required for training compute.
43    /// Trust comes from stake bonding, Byzantine-robust aggregation, and
44    /// redundant fragment assignment. Default and cheapest.
45    #[default]
46    Open,
47    /// Trainer posts a TEE attestation per round binding {program hash,
48    /// data shard hash, model hash, DID}. Higher reward weight.
49    Verified,
50    /// TEE-resident training. Data is sealed to the enclave; the host OS
51    /// never sees cleartext. Used for private datasets.
52    Confidential,
53}
54
55impl fmt::Display for TrainingTier {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        match self {
58            Self::Open => write!(f, "open"),
59            Self::Verified => write!(f, "verified"),
60            Self::Confidential => write!(f, "confidential"),
61        }
62    }
63}
64
65impl TrainingTier {
66    /// Parse a tier from a string label (case-insensitive). Falls back to
67    /// `Open` for unknown labels.
68    pub fn from_str_lossy(s: &str) -> Self {
69        match s.to_ascii_lowercase().as_str() {
70            "verified" => Self::Verified,
71            "confidential" => Self::Confidential,
72            _ => Self::Open,
73        }
74    }
75}
76
77// ---------------------------------------------------------------------------
78// Modality
79// ---------------------------------------------------------------------------
80
81/// What the training task is training. Modality drives which Python
82/// reference-trainer adapter handles inner loops; the protocol layer is
83/// modality-agnostic.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
85pub enum TrainingModality {
86    /// Decoder-only or encoder-decoder language models (Llama, Mistral, T5).
87    Language,
88    /// Foundation timeseries forecasting (TimesFM, Chronos, Moirai, TFT,
89    /// N-BEATS, Mamba). Phase 1's lead modality.
90    Timeseries,
91    /// Vision encoders/decoders (ViT, ConvNeXt, diffusion U-Nets).
92    Vision,
93    /// Multimodal towers (CLIP, audio, video).
94    Multimodal,
95}
96
97impl fmt::Display for TrainingModality {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        match self {
100            Self::Language => write!(f, "language"),
101            Self::Timeseries => write!(f, "timeseries"),
102            Self::Vision => write!(f, "vision"),
103            Self::Multimodal => write!(f, "multimodal"),
104        }
105    }
106}
107
108impl TrainingModality {
109    /// Parse from a string label (case-insensitive).
110    pub fn from_str_lossy(s: &str) -> Option<Self> {
111        match s.to_ascii_lowercase().as_str() {
112            "language" | "lm" | "text" => Some(Self::Language),
113            "timeseries" | "ts" | "forecast" | "forecasting" => Some(Self::Timeseries),
114            "vision" | "image" | "vis" => Some(Self::Vision),
115            "multimodal" | "mm" | "clip" => Some(Self::Multimodal),
116            _ => None,
117        }
118    }
119}
120
121// ---------------------------------------------------------------------------
122// Aggregation rule
123// ---------------------------------------------------------------------------
124
125/// Byzantine-robust aggregation rule the syncer applies over K accepted
126/// outer gradients. Phase 1 ships only `Mean`; remaining rules light up
127/// in Phase 2.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
129pub enum AggregationRule {
130    /// Plain mean. Phase 1 default. Not Byzantine-robust.
131    #[default]
132    Mean,
133    /// Trim top/bottom α% per parameter, mean the rest.
134    TrimmedMean { alpha_bps: u16 },
135    /// Coordinate-wise median, robust to up to f < M/2 Byzantine learners.
136    CoordinateMedian,
137    /// Krum / Multi-Krum: pick gradient(s) with lowest sum-of-distances to
138    /// nearest neighbors.
139    Krum { f: u32 },
140}
141
142// ---------------------------------------------------------------------------
143// Architecture spec
144// ---------------------------------------------------------------------------
145
146/// Compact description of the model architecture being trained. The Python
147/// reference trainer uses `family` to pick an inner training loop; the Rust
148/// protocol uses `param_count` and `fragment_count` to size buffers and
149/// route fragment messages.
150#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
151pub struct ArchitectureSpec {
152    /// Family name, e.g. `transformer-decoder`, `timesfm`, `chronos-t5`,
153    /// `vit-b16`, `clip-vit-l14`. The Python reference trainer maps this
154    /// to a concrete model definition.
155    pub family: String,
156    /// Total parameter count (used for bandwidth and reward sizing).
157    pub param_count: u64,
158    /// Modality the architecture targets.
159    pub modality: TrainingModality,
160    /// Number of parameter fragments. The model is partitioned into this
161    /// many fragments for Decoupled DiLoCo–style aggregation.
162    pub fragment_count: u32,
163    /// Optional dtype hint (e.g. `fp16`, `bf16`, `fp32`). Used for
164    /// per-fragment byte sizing.
165    pub dtype: Option<String>,
166    /// Free-form architecture metadata (hidden_size, num_layers, etc.).
167    /// Opaque to the Rust protocol; consumed by the Python trainer.
168    #[serde(default)]
169    pub metadata: HashMap<String, serde_json::Value>,
170}
171
172// ---------------------------------------------------------------------------
173// Training task spec
174// ---------------------------------------------------------------------------
175
176/// The on-chain description of a training run. Posted by the sponsor,
177/// referenced by trainers, and committed verbatim into the final
178/// [`TrainingReceipt`].
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
180pub struct TrainingTaskSpec {
181    /// Unique task ID (deterministic hash of canonicalized task fields).
182    pub task_id: String,
183    /// Sponsor DID (`did:tenzro:human:...` or `did:tenzro:machine:...`).
184    pub sponsor_did: String,
185    /// Sponsor's address for any unspent escrow refund.
186    pub sponsor_address: Address,
187    /// Architecture being trained.
188    pub architecture: ArchitectureSpec,
189    /// Trust tier required for trainer enrollment.
190    pub tier: TrainingTier,
191    /// Aggregation rule the syncer must apply.
192    pub aggregation: AggregationRule,
193    /// Number of trainers (M). Total slots.
194    pub trainer_count: u32,
195    /// Quorum (K). Outer gradient is accepted once K of M have submitted
196    /// for the same fragment.
197    pub quorum: u32,
198    /// Inner SGD steps between outer gradient submissions (H).
199    pub inner_steps: u32,
200    /// Total outer rounds the run will execute.
201    pub max_rounds: u32,
202    /// Adaptive grace window τ in milliseconds. Stragglers are absorbed
203    /// up to this delay; their submissions count if they land within τ.
204    pub grace_window_ms: u64,
205    /// Total reward pool escrowed by sponsor (TNZO, in attoTNZO).
206    pub reward_pool: u128,
207    /// Reference to the dataset. Format depends on tier:
208    /// - Open / Verified (public): `ipfs://...`, `ar://...`, `https://...`
209    /// - Verified (encrypted-at-rest): `enc:...` (key sealed to TEE)
210    /// - Confidential (TEE-resident): `tee://...` (data never leaves owner)
211    pub dataset_ref: String,
212    /// Hash of the dataset's manifest (root of shard hash tree). Bound
213    /// into the receipt for provenance.
214    pub dataset_hash: Hash,
215    /// Optional minimum throughput requirement (samples/sec) for trainer
216    /// enrollment. Syncer enforces at enrollment.
217    pub min_throughput: Option<u64>,
218    /// Posting timestamp.
219    pub created_at: Timestamp,
220    /// Free-form metadata (eval suite, target perplexity, etc.).
221    #[serde(default)]
222    pub metadata: HashMap<String, serde_json::Value>,
223}
224
225// ---------------------------------------------------------------------------
226// Outer gradient
227// ---------------------------------------------------------------------------
228
229/// A trainer's outer gradient submission for one fragment in one round.
230///
231/// The actual tensor payload is referenced by `safetensors_hash` and lives
232/// off-chain (gossip topic + content-addressed object store). The Rust
233/// protocol layer aggregates over `ndarray` views of the decoded payload;
234/// it never holds the raw tensor in memory.
235#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
236pub struct OuterGradient {
237    /// Task this submission is for.
238    pub task_id: String,
239    /// Outer round number (0-indexed).
240    pub round: u32,
241    /// Fragment index within the architecture (0..fragment_count).
242    pub fragment: u32,
243    /// Trainer DID.
244    pub trainer_did: String,
245    /// Address bound to the trainer's TDIP identity.
246    pub trainer_address: Address,
247    /// SHA-256 of the safetensors payload. Aggregator pulls the payload
248    /// by this hash; if it doesn't match, the submission is rejected.
249    pub safetensors_hash: Hash,
250    /// Payload size in bytes (for bandwidth accounting).
251    pub payload_bytes: u64,
252    /// Vector clock entry: number of inner SGD steps the trainer had
253    /// completed when this gradient was emitted. Decoupled DiLoCo's
254    /// asynchronous learner support hinges on this.
255    pub inner_step_count: u64,
256    /// Submission timestamp.
257    pub submitted_at: Timestamp,
258    /// Trainer's signature over the canonical bytes of all fields above.
259    pub signature: Signature,
260    /// Optional TEE attestation (Verified and Confidential tiers).
261    pub attestation: Option<TrainingAttestation>,
262}
263
264/// Per-round TEE attestation a trainer submits in Verified or Confidential
265/// tiers. Binds the program hash, the data shard hash, and the trainer DID
266/// so the syncer can verify the trainer ran the announced code on the
267/// announced data.
268#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
269pub struct TrainingAttestation {
270    /// TEE vendor (`intel-tdx`, `amd-sev-snp`, `aws-nitro`, `nvidia-cc`).
271    pub vendor: String,
272    /// Vendor-specific attestation report bytes (hex-encoded for JSON).
273    pub report_hex: String,
274    /// Hash of the trainer program/binary that produced the gradient.
275    pub program_hash: Hash,
276    /// Hash of the assigned data shard.
277    pub shard_hash: Hash,
278}
279
280// ---------------------------------------------------------------------------
281// Sync round
282// ---------------------------------------------------------------------------
283
284/// Status of an in-flight or completed outer round, as published by the
285/// syncer. `state_root` is committed on-chain; observers can challenge it
286/// with a fraud proof.
287#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
288pub struct SyncRound {
289    pub task_id: String,
290    pub round: u32,
291    /// Per-fragment status: which fragments have reached quorum.
292    pub fragment_quorums: HashMap<u32, FragmentQuorumStatus>,
293    /// Merkle root over (fragment_id, accepted_outer_gradient_hashes,
294    /// post-aggregation parameter hash). Committed on-chain each round.
295    pub state_root: Hash,
296    /// Syncer signature over `state_root || round || task_id`.
297    pub syncer_signature: Signature,
298    /// When this round status was published.
299    pub published_at: Timestamp,
300    /// No-Endorsement-Certificate carry-forward witnesses.
301    ///
302    /// `None` for normal rounds. `Some(sigs)` when the witness committee
303    /// could not assemble a 2f+1 quorum within `grace_window_ms` — each
304    /// committee member publishes a signed "no-quorum" cert and the run
305    /// advances to `round + 1` carrying forward the prior `state_root`
306    /// (which is set to the previous round's state root, or `Hash::zero()`
307    /// for round 0 NEC).
308    pub no_quorum_witnesses: Option<Vec<Signature>>,
309}
310
311#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
312pub struct FragmentQuorumStatus {
313    /// Fragment index.
314    pub fragment: u32,
315    /// Number of accepted outer-gradient submissions.
316    pub accepted: u32,
317    /// Hashes of the accepted submissions (in trainer-DID-sorted order).
318    pub accepted_hashes: Vec<Hash>,
319    /// Whether this fragment reached quorum (`accepted >= K`).
320    pub quorum_met: bool,
321    /// Hash of the fragment's parameters AFTER aggregation + outer
322    /// optimizer step.
323    pub post_step_hash: Hash,
324}
325
326// ---------------------------------------------------------------------------
327// Training receipt
328// ---------------------------------------------------------------------------
329
330/// On-chain finalization artifact. Mintable as an NFT via the standard NFT
331/// factory at precompile 0x1006. Represents proof-of-training.
332#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
333pub struct TrainingReceipt {
334    pub task_id: String,
335    /// The full task spec, captured verbatim for provenance.
336    pub task_spec: TrainingTaskSpec,
337    /// Final model parameter hash (Merkle root over fragments after the
338    /// last accepted outer step).
339    pub final_model_hash: Hash,
340    /// Syncer DID + address.
341    pub syncer_did: String,
342    pub syncer_address: Address,
343    /// Per-trainer contribution count: number of accepted outer gradients
344    /// across all rounds.
345    pub trainer_contributions: HashMap<String, u32>,
346    /// Per-trainer reward in attoTNZO.
347    pub trainer_rewards: HashMap<String, u128>,
348    /// Network commission (treasury cut) in attoTNZO.
349    pub network_commission: u128,
350    /// Per-round state roots. Order matches `0..=last_completed_round`.
351    pub round_state_roots: Vec<Hash>,
352    /// Final Merkle root over the full sequence of `round_state_roots`.
353    /// This single hash anchors the entire training run.
354    pub run_root: Hash,
355    /// Syncer's TEE attestation chain (vendor, report hex, cert chain).
356    pub syncer_attestation: TrainingAttestation,
357    /// When the run finalized.
358    pub finalized_at: Timestamp,
359    /// Syncer signature over the canonical bytes of all fields above.
360    pub syncer_signature: Signature,
361}
362
363// ---------------------------------------------------------------------------
364// Confidential tier — sealed-shard manifest
365// ---------------------------------------------------------------------------
366
367/// One per-trainer sealed-shard record in a [`SealedDatasetManifest`].
368///
369/// The sponsor (data owner) shards the dataset offline, generates a fresh
370/// AES-256-GCM data key per shard, encrypts the shard with that key, and
371/// publishes the encrypted shards to a content-addressed object store. The
372/// data keys are then wrapped one-per-trainer using each enrolled trainer's
373/// **attested enclave public key** (HPKE / ECIES / vendor sealing key — the
374/// concrete construction is encoded in `wrap_alg`). The wrapped key envelope
375/// is what lives on-chain alongside the manifest.
376///
377/// At trainer side, the enclave unwraps the data key with its sealing private
378/// key (which never leaves the TEE) and decrypts the assigned shard inside
379/// the enclave. The host OS never sees the data key or cleartext.
380///
381/// `enclave_pubkey` and `enclave_measurements` are bound into the envelope so
382/// the syncer can verify at enrollment that the trainer's TEE attestation
383/// matches the recipient the sponsor sealed to. A mismatch means the trainer
384/// presented a different enclave than the one the sponsor wrapped to —
385/// enrollment is rejected.
386#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
387pub struct SealedShardEnvelope {
388    /// Trainer DID this envelope is intended for. Acts as the recipient
389    /// selector when the trainer fetches the manifest.
390    pub trainer_did: String,
391    /// Index of the shard this envelope unlocks. Trainers are typically
392    /// assigned one shard per round (or a fixed shard for the run).
393    pub shard_index: u32,
394    /// SHA-256 of the encrypted shard bytes. Trainer pulls the shard by
395    /// this hash and verifies before decrypting.
396    pub shard_ciphertext_hash: Hash,
397    /// Size of the encrypted shard in bytes.
398    pub shard_ciphertext_bytes: u64,
399    /// The encrypted data key (wrapped to `enclave_pubkey`). Opaque bytes
400    /// in the format dictated by `wrap_alg`.
401    pub wrapped_data_key: Vec<u8>,
402    /// Key-wrap algorithm identifier. Phase 4 ships `"hpke-x25519-hkdf-sha256-aes-256-gcm"`
403    /// (RFC 9180 base mode). Other identifiers reserved for vendor sealing
404    /// (e.g. `"intel-tdx-sealing"`, `"sev-snp-sealing"`).
405    pub wrap_alg: String,
406    /// The trainer's attested enclave public key the sponsor wrapped to.
407    /// Format depends on `wrap_alg`; for HPKE-X25519 this is the 32-byte
408    /// X25519 pubkey. Must match the pubkey carried in the trainer's
409    /// per-round [`TrainingAttestation`] for enrollment to succeed.
410    pub enclave_pubkey: Vec<u8>,
411    /// The trainer's attested enclave measurements (program/firmware hashes)
412    /// the sponsor sealed against. Must match the trainer's TEE attestation
413    /// at enrollment. Format is vendor-specific (e.g. TDX MRTD + RTMR0..3,
414    /// SEV-SNP measurement, Nitro PCR set). Stored as a hex string blob so
415    /// the protocol layer stays vendor-agnostic.
416    pub enclave_measurements_hex: String,
417    /// AES-GCM authentication tag is included in `wrapped_data_key` per
418    /// HPKE Base mode; no separate field needed.
419    pub created_at: Timestamp,
420}
421
422/// Per-task sealed-shard manifest. Posted by the Confidential-tier sponsor
423/// alongside the task spec, persisted under `manifest:<task_id>` in
424/// `CF_TRAINING_RUNS`, and referenced from `TrainingTaskSpec::dataset_ref`
425/// as `tee://<sha256(manifest_canonical)>`.
426///
427/// The manifest's `manifest_hash` is the content-addressed handle: trainers
428/// fetch the manifest by hash, the syncer verifies the hash matches what's
429/// referenced in `dataset_ref`. Once enrollment closes, the manifest is
430/// effectively immutable — adding a new trainer requires a new manifest
431/// version and a re-attestation round.
432#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
433pub struct SealedDatasetManifest {
434    /// Task this manifest belongs to.
435    pub task_id: String,
436    /// Sponsor DID that signed the manifest.
437    pub sponsor_did: String,
438    /// SHA-256 over the canonical serialization of all envelopes
439    /// (sorted by `(shard_index, trainer_did)`). Self-referential: not
440    /// signed into the manifest itself; recomputed by readers and matched
441    /// against `dataset_ref`.
442    pub manifest_hash: Hash,
443    /// One envelope per (trainer, shard) tuple. Order is normalized to
444    /// `(shard_index, trainer_did)` ascending.
445    pub envelopes: Vec<SealedShardEnvelope>,
446    /// Sponsor signature over `(task_id || sponsor_did || envelopes)`
447    /// canonical bytes. Verifies sponsor authorship of the manifest.
448    pub sponsor_signature: Signature,
449    pub created_at: Timestamp,
450}
451
452impl SealedDatasetManifest {
453    /// Look up the envelope addressed to a specific trainer. Returns `None`
454    /// if no envelope exists (which means the trainer is not authorized to
455    /// participate at the Confidential tier for this task).
456    pub fn envelope_for(&self, trainer_did: &str) -> Option<&SealedShardEnvelope> {
457        self.envelopes.iter().find(|e| e.trainer_did == trainer_did)
458    }
459}
460
461// ---------------------------------------------------------------------------
462// Run status
463// ---------------------------------------------------------------------------
464
465/// Status of a training run as held in node storage.
466#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
467pub enum TrainingRunStatus {
468    /// Posted on-chain; awaiting syncer election.
469    Pending,
470    /// Syncer elected; awaiting trainer enrollment.
471    Enrolling,
472    /// At least K trainers enrolled; rounds in progress.
473    Training,
474    /// Final round committed; receipt sealed.
475    Completed,
476    /// Sponsor abandoned, syncer slashed, or unrecoverable failure.
477    Failed,
478    /// Cancelled by sponsor before any rounds executed.
479    Cancelled,
480}
481
482impl fmt::Display for TrainingRunStatus {
483    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
484        match self {
485            Self::Pending => write!(f, "pending"),
486            Self::Enrolling => write!(f, "enrolling"),
487            Self::Training => write!(f, "training"),
488            Self::Completed => write!(f, "completed"),
489            Self::Failed => write!(f, "failed"),
490            Self::Cancelled => write!(f, "cancelled"),
491        }
492    }
493}
494
495/// In-flight training run state held off-chain (in CF_TRAINING_RUNS) and
496/// mirrored to chain via per-round state roots. Receipt at completion.
497#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
498pub struct TrainingRun {
499    pub task_id: String,
500    pub task_spec: TrainingTaskSpec,
501    pub status: TrainingRunStatus,
502    pub syncer_did: Option<String>,
503    pub syncer_address: Option<Address>,
504    /// Enrolled trainer DIDs.
505    pub trainers: Vec<String>,
506    /// Most recent completed round (0 = none).
507    pub current_round: u32,
508    /// State roots collected so far (length == current_round).
509    pub round_state_roots: Vec<Hash>,
510    pub created_at: Timestamp,
511    pub last_update: Timestamp,
512}
513
514// ---------------------------------------------------------------------------
515// Tests
516// ---------------------------------------------------------------------------
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521
522    #[test]
523    fn tier_roundtrip() {
524        for s in ["open", "OPEN", "Open", "garbage"] {
525            assert_eq!(TrainingTier::from_str_lossy(s), TrainingTier::Open);
526        }
527        assert_eq!(TrainingTier::from_str_lossy("verified"), TrainingTier::Verified);
528        assert_eq!(
529            TrainingTier::from_str_lossy("CONFIDENTIAL"),
530            TrainingTier::Confidential
531        );
532    }
533
534    #[test]
535    fn modality_roundtrip() {
536        assert_eq!(
537            TrainingModality::from_str_lossy("timeseries"),
538            Some(TrainingModality::Timeseries)
539        );
540        assert_eq!(
541            TrainingModality::from_str_lossy("ts"),
542            Some(TrainingModality::Timeseries)
543        );
544        assert_eq!(
545            TrainingModality::from_str_lossy("language"),
546            Some(TrainingModality::Language)
547        );
548        assert_eq!(TrainingModality::from_str_lossy("nonsense"), None);
549    }
550
551    #[test]
552    fn aggregation_default_is_mean() {
553        assert_eq!(AggregationRule::default(), AggregationRule::Mean);
554    }
555
556    #[test]
557    fn run_status_display() {
558        assert_eq!(TrainingRunStatus::Pending.to_string(), "pending");
559        assert_eq!(TrainingRunStatus::Training.to_string(), "training");
560    }
561}