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}