Skip to main content

printobserver_types/
contract.rs

1//! This crate's own claim about the types it declares.
2//!
3//! Every type this crate declares as its own is listed in [`declared`], with
4//! the JSON Schema it emits and a canonical value of it. This is what the
5//! `printobserver-types:schemas` graph target generates the checked-in schemas
6//! from, and what this crate's contract tests walk, so that a type cannot fall
7//! out of the schema set or out of the round-trip corpus by being forgotten in
8//! a list somebody maintains by hand.
9//!
10//! Each entry carries two values rather than one: `full`, which carries every
11//! field the type declares, and `minimal`, which carries every optional field
12//! absent. The second is what proves that an absent optional serializes as
13//! absent rather than as `null`, a zero or an empty string.
14
15use std::collections::BTreeMap;
16
17use schemars::{JsonSchema, generate::SchemaSettings};
18use serde::Serialize;
19use serde::de::DeserializeOwned;
20use serde_json::Value;
21
22use crate::action::{
23    AcknowledgementDisposition, ActionKind, ActionRecord, ActionRequest, Actor, ActorClass,
24    ExecutionOutcome, PrintAction,
25};
26use crate::adjustable::Adjustable;
27use crate::assessment::{AgentAssessment, Confidence};
28use crate::context::PrintContext;
29use crate::event::{
30    ActionExecutedPayload, ActionRejectedPayload, ActionRequestedPayload, AgentAssessmentPayload,
31    EventKind, EventPayload, EventRecord, EventSource, InterventionExpiredPayload,
32    MalformedExternalEventPayload, ObicoFailureAlertPayload, ObicoNotificationType,
33    ObicoPrinterNotificationPayload, OperatorAcknowledgementPayload, PortFailurePayload,
34    PortFailureSite, StartupOutcome, StartupReconciliationPayload, SupervisionSessionClosedPayload,
35    SupervisionSessionOpenedPayload,
36};
37use crate::file_name::FileName;
38use crate::ids::{ActionId, EventId, ImageId, InterventionId, PrintId};
39use crate::image::{ImageRecord, ImageRef};
40use crate::intervention::{Intervention, InterventionOutcome};
41use crate::manifest::JobManifest;
42use crate::obico::{
43    ObicoEventType, ObicoFailureAlert, ObicoFailureEvent, ObicoFailureEventType,
44    ObicoNotificationEvent, ObicoPrintInfo, ObicoPrinterInfo, ObicoPrinterNotification,
45    ObicoTimestamp,
46};
47use crate::policy::{EffectiveBounds, PolicyDecision, RejectionReason, SafetyEnvelope};
48use crate::print::{ManifestNarrowing, PrintRecord};
49use crate::printer::{HeaterSnapshot, JobSnapshot, PrinterSnapshot, PrinterState};
50use crate::raw::RawBytes;
51use crate::reported::{
52    COMPLETION_RANGE, FAN_PERCENT_RANGE, FEEDRATE_FACTOR_RANGE, FLOWRATE_FACTOR_RANGE,
53    HEATER_ACTUAL_C_RANGE, HEATER_OFFSET_C_RANGE, HEATER_TARGET_C_RANGE, Range, Reported,
54};
55use crate::session::{SessionPhase, SupervisionSession};
56use crate::timestamp::Timestamp;
57
58/// One field this crate declares a plausibility range for.
59#[derive(Debug, Clone, Copy, PartialEq)]
60pub struct RangedField {
61    /// The type the field belongs to.
62    pub type_name: &'static str,
63    /// The field's own name.
64    pub field: &'static str,
65    /// The plausibility range declared for it.
66    pub range: Range,
67}
68
69/// Every field this crate types as [`Reported<f64>`], with its declared range.
70///
71/// These are validity ranges on what a printer can plausibly report. They are
72/// not the operator's [`SafetyEnvelope`], and nothing intersects, compares or
73/// substitutes the two.
74pub const RANGED_FIELDS: [RangedField; 7] = [
75    RangedField {
76        type_name: "PrinterSnapshot",
77        field: "feedrate_factor",
78        range: FEEDRATE_FACTOR_RANGE,
79    },
80    RangedField {
81        type_name: "PrinterSnapshot",
82        field: "flowrate_factor",
83        range: FLOWRATE_FACTOR_RANGE,
84    },
85    RangedField {
86        type_name: "PrinterSnapshot",
87        field: "fan_percent",
88        range: FAN_PERCENT_RANGE,
89    },
90    RangedField {
91        type_name: "JobSnapshot",
92        field: "completion",
93        range: COMPLETION_RANGE,
94    },
95    RangedField {
96        type_name: "HeaterSnapshot",
97        field: "actual_c",
98        range: HEATER_ACTUAL_C_RANGE,
99    },
100    RangedField {
101        type_name: "HeaterSnapshot",
102        field: "target_c",
103        range: HEATER_TARGET_C_RANGE,
104    },
105    RangedField {
106        type_name: "HeaterSnapshot",
107        field: "offset_c",
108        range: HEATER_OFFSET_C_RANGE,
109    },
110];
111
112/// A canonical value of one type, for the schema set and the round-trip corpus.
113pub trait Sample: Sized {
114    /// A value carrying every field the type declares.
115    fn sample_full() -> Self;
116
117    /// A value carrying every optional field absent.
118    #[must_use]
119    fn sample_minimal() -> Self {
120        Self::sample_full()
121    }
122
123    /// One value per further arm, for a type whose wire form is a union.
124    ///
125    /// A union's arms declare fields the other arms do not, so a corpus of one
126    /// value per type would leave those fields undriven. Every arm a type
127    /// declares beyond the one `sample_full` carries belongs here.
128    #[must_use]
129    fn sample_alternates() -> Vec<Self> {
130        Vec::new()
131    }
132}
133
134/// One type this crate declares, with its schema and its canonical values.
135#[derive(Debug, Clone, Copy)]
136pub struct TypeContract {
137    /// The type's own name, which is also its schema's file name.
138    pub name: &'static str,
139    /// The JSON Schema the type emits.
140    schema: fn() -> Value,
141    /// A value carrying every field the type declares, serialized.
142    full: fn() -> Value,
143    /// A value carrying every optional field absent, serialized.
144    minimal: fn() -> Value,
145    /// Parse a value as this type and serialize it back.
146    round_trip: fn(Value) -> Result<Value, String>,
147    /// One serialized value per further arm of a union.
148    alternates: fn() -> Vec<Value>,
149}
150
151impl TypeContract {
152    /// The JSON Schema this type emits.
153    #[must_use]
154    pub fn schema(&self) -> Value {
155        (self.schema)()
156    }
157
158    /// A serialized value carrying every field the type declares.
159    #[must_use]
160    pub fn full(&self) -> Value {
161        (self.full)()
162    }
163
164    /// A serialized value carrying every optional field absent.
165    #[must_use]
166    pub fn minimal(&self) -> Value {
167        (self.minimal)()
168    }
169
170    /// Every canonical value of this type: the full one, the minimal one, and
171    /// one per further arm of a union.
172    #[must_use]
173    pub fn samples(&self) -> Vec<Value> {
174        let mut values = vec![self.full(), self.minimal()];
175        values.extend((self.alternates)());
176        values
177    }
178
179    /// Parse a value as this type and serialize the result back.
180    ///
181    /// # Errors
182    ///
183    /// Returns the parse or emission failure, as the message it carried.
184    pub fn round_trip(&self, value: Value) -> Result<Value, String> {
185        (self.round_trip)(value)
186    }
187}
188
189/// The JSON Schema one type emits, under this crate's own generator settings.
190///
191/// Sub-schemas are referenced rather than inlined, so that a schema names the
192/// types it is built from rather than flattening them into anonymous objects.
193///
194/// # Panics
195///
196/// Panics if the generated schema is not JSON, which it is by construction.
197#[must_use]
198pub fn schema_of<T: JsonSchema>() -> Value {
199    let generator = SchemaSettings::draft2020_12().into_generator();
200    serde_json::to_value(generator.into_root_schema_for::<T>())
201        .expect("a generated schema is JSON by construction")
202}
203
204/// Serialize a value, reporting a refusal as the message it carried.
205fn emit<T: Serialize>(value: &T) -> Result<Value, String> {
206    serde_json::to_value(value).map_err(|error| error.to_string())
207}
208
209/// Parse a value as `T` and serialize it back.
210fn round_trip_as<T: Serialize + DeserializeOwned>(value: Value) -> Result<Value, String> {
211    let parsed: T = serde_json::from_value(value).map_err(|error| error.to_string())?;
212    emit(&parsed)
213}
214
215/// Serialize a canonical value, which this crate's own types never refuse.
216fn canonical<T: Serialize>(value: &T) -> Value {
217    emit(value).expect("a canonical sample serializes")
218}
219
220/// One type's contract, under its own name or under a name given for it.
221macro_rules! contract_of {
222    ($ty:ty) => {
223        contract_of!($ty, stringify!($ty))
224    };
225    ($ty:ty, $name:expr) => {
226        TypeContract {
227            name: $name,
228            schema: schema_of::<$ty>,
229            full: || canonical(&<$ty as Sample>::sample_full()),
230            minimal: || canonical(&<$ty as Sample>::sample_minimal()),
231            round_trip: round_trip_as::<$ty>,
232            alternates: || {
233                <$ty as Sample>::sample_alternates()
234                    .iter()
235                    .map(canonical)
236                    .collect()
237            },
238        }
239    };
240}
241
242/// Declare the contract of each named type.
243macro_rules! declare {
244    ($($ty:ty $(=> $name:literal)?),* $(,)?) => {
245        vec![$( contract_of!($ty $(, $name)?) ),*]
246    };
247}
248
249/// Every type this crate declares as its own.
250#[must_use]
251pub fn declared() -> Vec<TypeContract> {
252    declare![
253        AcknowledgementDisposition,
254        ActionExecutedPayload,
255        ActionId,
256        ActionKind,
257        ActionRecord,
258        ActionRejectedPayload,
259        ActionRequest,
260        ActionRequestedPayload,
261        Actor,
262        ActorClass,
263        Adjustable,
264        AgentAssessment,
265        AgentAssessmentPayload,
266        Confidence,
267        EffectiveBounds,
268        EventId,
269        EventKind,
270        EventPayload,
271        EventRecord,
272        EventSource,
273        ExecutionOutcome,
274        FileName,
275        HeaterSnapshot,
276        ImageId,
277        ImageRecord,
278        ImageRef,
279        Intervention,
280        InterventionExpiredPayload,
281        InterventionId,
282        InterventionOutcome,
283        JobManifest,
284        JobSnapshot,
285        MalformedExternalEventPayload,
286        ManifestNarrowing,
287        ObicoEventType,
288        ObicoFailureAlert,
289        ObicoFailureAlertPayload,
290        ObicoFailureEvent,
291        ObicoFailureEventType,
292        ObicoNotificationEvent,
293        ObicoNotificationType,
294        ObicoPrintInfo,
295        ObicoPrinterInfo,
296        ObicoPrinterNotification,
297        ObicoPrinterNotificationPayload,
298        ObicoTimestamp,
299        OperatorAcknowledgementPayload,
300        PolicyDecision,
301        PortFailurePayload,
302        PortFailureSite,
303        PrintAction,
304        PrintContext,
305        PrintId,
306        PrintRecord,
307        PrinterSnapshot,
308        PrinterState,
309        Range,
310        RawBytes,
311        RejectionReason,
312        Reported<f64> => "Reported",
313        SafetyEnvelope,
314        SessionPhase,
315        StartupOutcome,
316        StartupReconciliationPayload,
317        SupervisionSession,
318        SupervisionSessionClosedPayload,
319        SupervisionSessionOpenedPayload,
320        Timestamp,
321    ]
322}
323
324/// The ten types the port crates own, which this crate declares none of.
325///
326/// Each names a shape one port's methods carry rather than a shape the domain
327/// holds, so it is declared by the crate whose methods carry it. Declaring one
328/// here would either duplicate it or relocate it out of the port that owns it.
329pub const PORT_OWNED_TYPES: [&str; 10] = [
330    "PrinterError",
331    "VisionError",
332    "SupervisorError",
333    "StoreError",
334    "NormalizedAlert",
335    "FetchedImage",
336    "TurnRequest",
337    "TurnOutcome",
338    "EventDraft",
339    "HistoryQuery",
340];
341
342/// A fixed print identifier, so that a sample is the same on every run.
343fn print_id() -> PrintId {
344    "0191f0a0-0000-7000-8000-000000000001"
345        .parse()
346        .expect("a fixed version 7 identifier")
347}
348
349/// A fixed event identifier.
350fn event_id() -> EventId {
351    "0191f0a0-0000-7000-8000-000000000002"
352        .parse()
353        .expect("a fixed version 7 identifier")
354}
355
356/// A fixed image identifier.
357fn image_id() -> ImageId {
358    "0191f0a0-0000-7000-8000-000000000003"
359        .parse()
360        .expect("a fixed version 7 identifier")
361}
362
363/// A fixed action identifier.
364fn action_id() -> ActionId {
365    "0191f0a0-0000-7000-8000-000000000004"
366        .parse()
367        .expect("a fixed version 7 identifier")
368}
369
370/// A fixed intervention identifier.
371fn intervention_id() -> InterventionId {
372    "0191f0a0-0000-7000-8000-000000000005"
373        .parse()
374        .expect("a fixed version 7 identifier")
375}
376
377/// A fixed instant, so that a sample is the same on every run.
378fn instant() -> Timestamp {
379    "2026-03-01T12:00:00Z"
380        .parse()
381        .expect("a fixed RFC 3339 instant")
382}
383
384/// A later fixed instant.
385fn later_instant() -> Timestamp {
386    "2026-03-01T12:30:00Z"
387        .parse()
388        .expect("a fixed RFC 3339 instant")
389}
390
391impl Sample for PrintId {
392    fn sample_full() -> Self {
393        print_id()
394    }
395}
396
397impl Sample for EventId {
398    fn sample_full() -> Self {
399        event_id()
400    }
401}
402
403impl Sample for ImageId {
404    fn sample_full() -> Self {
405        image_id()
406    }
407}
408
409impl Sample for ActionId {
410    fn sample_full() -> Self {
411        action_id()
412    }
413}
414
415impl Sample for InterventionId {
416    fn sample_full() -> Self {
417        intervention_id()
418    }
419}
420
421impl Sample for Timestamp {
422    fn sample_full() -> Self {
423        instant()
424    }
425}
426
427impl Sample for FileName {
428    fn sample_full() -> Self {
429        Self::new("benchy.gcode").expect("a name carrying no forbidden property")
430    }
431}
432
433impl Sample for RawBytes {
434    fn sample_full() -> Self {
435        Self::new(b"{\"event\":{}}".to_vec())
436    }
437}
438
439impl Sample for Range {
440    fn sample_full() -> Self {
441        Self::new(0.5, 1.5)
442    }
443}
444
445impl Sample for Reported<f64> {
446    fn sample_full() -> Self {
447        Self::new(1.0, FEEDRATE_FACTOR_RANGE)
448    }
449}
450
451impl Sample for Adjustable {
452    fn sample_full() -> Self {
453        Self::ToolTarget { tool: 0 }
454    }
455}
456
457impl Sample for PrinterState {
458    fn sample_full() -> Self {
459        Self::Printing
460    }
461}
462
463impl Sample for HeaterSnapshot {
464    fn sample_full() -> Self {
465        Self {
466            actual_c: Some(Reported::new(214.5, HEATER_ACTUAL_C_RANGE)),
467            target_c: Some(Reported::new(215.0, HEATER_TARGET_C_RANGE)),
468            offset_c: Some(Reported::new(0.0, HEATER_OFFSET_C_RANGE)),
469        }
470    }
471
472    fn sample_minimal() -> Self {
473        Self {
474            actual_c: None,
475            target_c: None,
476            offset_c: None,
477        }
478    }
479}
480
481impl Sample for PrinterSnapshot {
482    fn sample_full() -> Self {
483        Self {
484            connection: PrinterState::Printing,
485            tools: vec![HeaterSnapshot::sample_full()],
486            bed: Some(HeaterSnapshot::sample_full()),
487            chamber: Some(HeaterSnapshot::sample_full()),
488            feedrate_factor: Some(Reported::new(1.0, FEEDRATE_FACTOR_RANGE)),
489            flowrate_factor: Some(Reported::new(1.0, FLOWRATE_FACTOR_RANGE)),
490            fan_percent: Some(Reported::new(100.0, FAN_PERCENT_RANGE)),
491            observed_at: instant(),
492        }
493    }
494
495    fn sample_minimal() -> Self {
496        Self {
497            connection: PrinterState::Printing,
498            tools: vec![],
499            bed: None,
500            chamber: None,
501            feedrate_factor: None,
502            flowrate_factor: None,
503            fan_percent: None,
504            observed_at: instant(),
505        }
506    }
507}
508
509impl Sample for JobSnapshot {
510    fn sample_full() -> Self {
511        Self {
512            file_name: Some("benchy.gcode".to_owned()),
513            file_origin: Some("local".to_owned()),
514            size_bytes: Some(4_194_304),
515            estimated_print_time_s: Some(7200),
516            completion: Some(Reported::new(0.42, COMPLETION_RANGE)),
517            print_time_s: Some(3024),
518            print_time_left_s: Some(4176),
519            state: PrinterState::Printing,
520            error: Some("none".to_owned()),
521        }
522    }
523
524    fn sample_minimal() -> Self {
525        Self {
526            file_name: None,
527            file_origin: None,
528            size_bytes: None,
529            estimated_print_time_s: None,
530            completion: None,
531            print_time_s: None,
532            print_time_left_s: None,
533            state: PrinterState::Printing,
534            error: None,
535        }
536    }
537}
538
539impl Sample for ManifestNarrowing {
540    fn sample_full() -> Self {
541        Self {
542            adjustable: Adjustable::Feedrate,
543            requested: Range::new(0.2, 4.0),
544            applied: Range::new(0.5, 1.5),
545        }
546    }
547}
548
549impl Sample for PrintRecord {
550    fn sample_full() -> Self {
551        Self {
552            id: print_id(),
553            obico_print_id: Some(4211),
554            file_name: Some("benchy.gcode".to_owned()),
555            state: PrinterState::Printing,
556            opened_at: instant(),
557            ended_at: Some(later_instant()),
558            end_reason: Some("finished".to_owned()),
559            narrowings: vec![ManifestNarrowing::sample_full()],
560        }
561    }
562
563    fn sample_minimal() -> Self {
564        Self {
565            id: print_id(),
566            obico_print_id: None,
567            file_name: None,
568            state: PrinterState::Printing,
569            opened_at: instant(),
570            ended_at: None,
571            end_reason: None,
572            narrowings: vec![],
573        }
574    }
575}
576
577impl Sample for ImageRecord {
578    fn sample_full() -> Self {
579        Self {
580            id: image_id(),
581            print_id: print_id(),
582            event_id: event_id(),
583            source_url: Some("https://obico.example/snapshots/1.jpg".to_owned()),
584            fetched_at: instant(),
585            content_type: "image/jpeg".to_owned(),
586            byte_len: 51_200,
587            sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_owned(),
588            relative_path: "images/0191f0a0/1.jpg".to_owned(),
589        }
590    }
591
592    fn sample_minimal() -> Self {
593        Self {
594            source_url: None,
595            ..Self::sample_full()
596        }
597    }
598}
599
600impl Sample for ImageRef {
601    fn sample_full() -> Self {
602        Self {
603            id: image_id(),
604            sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_owned(),
605        }
606    }
607}
608
609impl Sample for Actor {
610    fn sample_full() -> Self {
611        Self::Agent {
612            session_name: "print-0191f0a0".to_owned(),
613        }
614    }
615}
616
617impl Sample for ActorClass {
618    fn sample_full() -> Self {
619        Self::Agent
620    }
621}
622
623impl Sample for ActionKind {
624    fn sample_full() -> Self {
625        Self::SetFeedrateFactor
626    }
627}
628
629impl Sample for AcknowledgementDisposition {
630    fn sample_full() -> Self {
631        Self::Watch
632    }
633}
634
635impl Sample for ExecutionOutcome {
636    fn sample_full() -> Self {
637        Self::Failed {
638            reason: "the printer refused the command".to_owned(),
639        }
640    }
641}
642
643impl Sample for PrintAction {
644    fn sample_full() -> Self {
645        Self::SetFeedrateFactor {
646            factor: 0.8,
647            duration_s: Some(600),
648            reason: "the first layer is going down fast".to_owned(),
649            actor: Actor::sample_full(),
650        }
651    }
652
653    fn sample_minimal() -> Self {
654        Self::SetFeedrateFactor {
655            factor: 0.8,
656            duration_s: None,
657            reason: "the first layer is going down fast".to_owned(),
658            actor: Actor::sample_full(),
659        }
660    }
661
662    fn sample_alternates() -> Vec<Self> {
663        let reason = || "the print needs it".to_owned();
664        vec![
665            Self::Pause {
666                reason: reason(),
667                actor: Actor::sample_full(),
668            },
669            Self::Resume {
670                reason: reason(),
671                actor: Actor::sample_full(),
672            },
673            Self::Cancel {
674                reason: reason(),
675                actor: Actor::sample_full(),
676            },
677            Self::StartPrint {
678                file_name: FileName::sample_full(),
679                manifest: JobManifest::sample_full(),
680                reason: reason(),
681                actor: Actor::sample_full(),
682            },
683            Self::SetFlowrateFactor {
684                factor: 1.05,
685                duration_s: Some(300),
686                reason: reason(),
687                actor: Actor::sample_full(),
688            },
689            Self::SetToolTargetC {
690                tool: 0,
691                target_c: 215.0,
692                duration_s: Some(300),
693                reason: reason(),
694                actor: Actor::sample_full(),
695            },
696            Self::SetBedTargetC {
697                target_c: 60.0,
698                duration_s: Some(300),
699                reason: reason(),
700                actor: Actor::sample_full(),
701            },
702            Self::SetFanPercent {
703                percent: 80.0,
704                duration_s: Some(300),
705                reason: reason(),
706                actor: Actor::sample_full(),
707            },
708            Self::AcknowledgeFailure {
709                event_id: event_id(),
710                disposition: AcknowledgementDisposition::Watch,
711                reason: reason(),
712                actor: Actor::Operator,
713            },
714        ]
715    }
716}
717
718impl Sample for ActionRequest {
719    fn sample_full() -> Self {
720        Self {
721            action: PrintAction::sample_full(),
722            actor: Actor::sample_full(),
723            requested_at: instant(),
724        }
725    }
726
727    fn sample_minimal() -> Self {
728        Self {
729            action: PrintAction::sample_minimal(),
730            ..Self::sample_full()
731        }
732    }
733}
734
735impl Sample for ActionRecord {
736    fn sample_full() -> Self {
737        Self {
738            id: action_id(),
739            print_id: print_id(),
740            request: ActionRequest::sample_full(),
741            decision: PolicyDecision::Accepted,
742            executed_at: Some(later_instant()),
743            outcome: Some(ExecutionOutcome::Succeeded),
744        }
745    }
746
747    fn sample_minimal() -> Self {
748        Self {
749            request: ActionRequest::sample_minimal(),
750            executed_at: None,
751            outcome: None,
752            ..Self::sample_full()
753        }
754    }
755}
756
757/// The allowed map both the manifest and the envelope carry.
758fn allowed_ranges() -> BTreeMap<Adjustable, Range> {
759    BTreeMap::from([
760        (Adjustable::Feedrate, Range::new(0.5, 1.5)),
761        (Adjustable::ToolTarget { tool: 0 }, Range::new(190.0, 230.0)),
762    ])
763}
764
765impl Sample for JobManifest {
766    fn sample_full() -> Self {
767        Self {
768            file_name: "benchy.gcode".to_owned(),
769            material: "PLA".to_owned(),
770            nozzle_diameter_mm: 0.4,
771            slicer_profile: "0.2mm standard".to_owned(),
772            allowed: allowed_ranges(),
773            metadata: BTreeMap::from([("layer_height_mm".to_owned(), "0.2".to_owned())]),
774        }
775    }
776}
777
778impl Sample for SafetyEnvelope {
779    fn sample_full() -> Self {
780        Self {
781            allowed: allowed_ranges(),
782            actions: BTreeMap::from([(
783                ActorClass::Agent,
784                vec![ActionKind::Pause, ActionKind::SetFeedrateFactor],
785            )]),
786            agent_min_interval_s: 60,
787        }
788    }
789}
790
791impl Sample for EffectiveBounds {
792    fn sample_full() -> Self {
793        Self {
794            allowed: allowed_ranges(),
795        }
796    }
797}
798
799impl Sample for RejectionReason {
800    fn sample_full() -> Self {
801        Self::OutOfBounds {
802            adjustable: Adjustable::Feedrate,
803            requested: 4.0,
804            allowed: Range::new(0.5, 1.5),
805        }
806    }
807}
808
809impl Sample for PolicyDecision {
810    fn sample_full() -> Self {
811        Self::Rejected(RejectionReason::sample_full())
812    }
813}
814
815impl Sample for InterventionOutcome {
816    fn sample_full() -> Self {
817        Self::Superseded {
818            by: intervention_id(),
819        }
820    }
821}
822
823impl Sample for Intervention {
824    fn sample_full() -> Self {
825        Self {
826            id: intervention_id(),
827            print_id: print_id(),
828            action_id: action_id(),
829            adjustable: Adjustable::Feedrate,
830            prior_value: Some(1.0),
831            applied_value: 0.8,
832            applied_at: instant(),
833            expires_at: later_instant(),
834            restored_at: Some(later_instant()),
835            outcome: InterventionOutcome::Restored,
836        }
837    }
838
839    fn sample_minimal() -> Self {
840        Self {
841            prior_value: None,
842            restored_at: None,
843            outcome: InterventionOutcome::StillActive,
844            ..Self::sample_full()
845        }
846    }
847}
848
849impl Sample for SessionPhase {
850    fn sample_full() -> Self {
851        Self::Created
852    }
853}
854
855impl Sample for SupervisionSession {
856    fn sample_full() -> Self {
857        Self {
858            print_id: print_id(),
859            session_name: "print-0191f0a0".to_owned(),
860            harness_identity: "printobserver-supervisor".to_owned(),
861            created_at: instant(),
862            last_turn_at: later_instant(),
863            closed_at: Some(later_instant()),
864            close_reason: Some("the print ended".to_owned()),
865        }
866    }
867
868    fn sample_minimal() -> Self {
869        Self {
870            closed_at: None,
871            close_reason: None,
872            ..Self::sample_full()
873        }
874    }
875}
876
877impl Sample for Confidence {
878    fn sample_full() -> Self {
879        Self::Medium
880    }
881}
882
883impl Sample for AgentAssessment {
884    fn sample_full() -> Self {
885        Self {
886            summary: "the first layer is down and adhesion looks even".to_owned(),
887            confidence: Confidence::Medium,
888            should_continue: true,
889            did: "slowed the feedrate to 80% for ten minutes".to_owned(),
890            why: "the extrusion width was widening on the long edges".to_owned(),
891            escalating: false,
892        }
893    }
894}
895
896impl Sample for EventSource {
897    fn sample_full() -> Self {
898        Self::Obico
899    }
900}
901
902impl Sample for ObicoNotificationType {
903    fn sample_full() -> Self {
904        Self::Started
905    }
906}
907
908impl Sample for EventKind {
909    fn sample_full() -> Self {
910        Self::ObicoFailureAlert
911    }
912}
913
914impl Sample for ObicoFailureAlertPayload {
915    fn sample_full() -> Self {
916        Self {
917            is_warning: true,
918            print_paused: false,
919            obico_print_id: Some(4211),
920            file_name: Some("benchy.gcode".to_owned()),
921            started_at: Some(instant()),
922            ended_at: Some(later_instant()),
923        }
924    }
925
926    fn sample_minimal() -> Self {
927        Self {
928            obico_print_id: None,
929            file_name: None,
930            started_at: None,
931            ended_at: None,
932            ..Self::sample_full()
933        }
934    }
935}
936
937impl Sample for ObicoPrinterNotificationPayload {
938    fn sample_full() -> Self {
939        Self {
940            notification_type: ObicoNotificationType::Started,
941            obico_print_id: Some(4211),
942            file_name: Some("benchy.gcode".to_owned()),
943            started_at: Some(instant()),
944            ended_at: Some(later_instant()),
945        }
946    }
947
948    fn sample_minimal() -> Self {
949        Self {
950            obico_print_id: None,
951            file_name: None,
952            started_at: None,
953            ended_at: None,
954            ..Self::sample_full()
955        }
956    }
957}
958
959impl Sample for PortFailureSite {
960    fn sample_full() -> Self {
961        Self::PrinterSnapshot
962    }
963}
964
965impl Sample for PortFailurePayload {
966    fn sample_full() -> Self {
967        Self {
968            event_id: event_id(),
969            site: PortFailureSite::sample_full(),
970            detail: "the printer is unreachable: connection refused".to_owned(),
971        }
972    }
973}
974
975impl Sample for StartupOutcome {
976    fn sample_full() -> Self {
977        Self::InterventionExpired {
978            intervention_id: intervention_id(),
979            adjustable: Adjustable::Fan,
980            outcome: InterventionOutcome::Restored,
981        }
982    }
983
984    fn sample_alternates() -> Vec<Self> {
985        vec![
986            Self::PrintAdopted,
987            Self::SessionResumed {
988                session_name: "watch-4211".to_owned(),
989            },
990        ]
991    }
992}
993
994impl Sample for StartupReconciliationPayload {
995    fn sample_full() -> Self {
996        Self {
997            print_id: print_id(),
998            outcome: StartupOutcome::sample_full(),
999        }
1000    }
1001}
1002
1003impl Sample for MalformedExternalEventPayload {
1004    fn sample_full() -> Self {
1005        Self {
1006            detail: "the body is not JSON: expected value at line 1 column 1".to_owned(),
1007        }
1008    }
1009}
1010
1011impl Sample for ActionRequestedPayload {
1012    fn sample_full() -> Self {
1013        Self {
1014            action_id: action_id(),
1015            action: PrintAction::sample_full(),
1016            actor: Actor::sample_full(),
1017        }
1018    }
1019
1020    fn sample_minimal() -> Self {
1021        Self {
1022            action: PrintAction::sample_minimal(),
1023            ..Self::sample_full()
1024        }
1025    }
1026}
1027
1028impl Sample for ActionExecutedPayload {
1029    fn sample_full() -> Self {
1030        Self {
1031            action_id: action_id(),
1032            intervention_id: Some(intervention_id()),
1033        }
1034    }
1035
1036    fn sample_minimal() -> Self {
1037        Self {
1038            action_id: action_id(),
1039            intervention_id: None,
1040        }
1041    }
1042}
1043
1044impl Sample for ActionRejectedPayload {
1045    fn sample_full() -> Self {
1046        Self {
1047            action_id: action_id(),
1048            decision: PolicyDecision::sample_full(),
1049        }
1050    }
1051}
1052
1053impl Sample for InterventionExpiredPayload {
1054    fn sample_full() -> Self {
1055        Self {
1056            intervention_id: intervention_id(),
1057            adjustable: Adjustable::Feedrate,
1058            outcome: InterventionOutcome::Restored,
1059        }
1060    }
1061}
1062
1063impl Sample for SupervisionSessionOpenedPayload {
1064    fn sample_full() -> Self {
1065        Self {
1066            session_name: "print-0191f0a0".to_owned(),
1067            harness_identity: "printobserver-supervisor".to_owned(),
1068        }
1069    }
1070}
1071
1072impl Sample for SupervisionSessionClosedPayload {
1073    fn sample_full() -> Self {
1074        Self {
1075            session_name: "print-0191f0a0".to_owned(),
1076            close_reason: "the print ended".to_owned(),
1077        }
1078    }
1079}
1080
1081impl Sample for AgentAssessmentPayload {
1082    fn sample_full() -> Self {
1083        Self {
1084            session_name: "print-0191f0a0".to_owned(),
1085            assessment: AgentAssessment::sample_full(),
1086        }
1087    }
1088}
1089
1090impl Sample for OperatorAcknowledgementPayload {
1091    fn sample_full() -> Self {
1092        Self {
1093            acknowledged_event_id: event_id(),
1094            disposition: AcknowledgementDisposition::Watch,
1095        }
1096    }
1097}
1098
1099impl Sample for EventPayload {
1100    fn sample_full() -> Self {
1101        Self::ObicoFailureAlert(ObicoFailureAlertPayload::sample_full())
1102    }
1103
1104    fn sample_minimal() -> Self {
1105        Self::ObicoFailureAlert(ObicoFailureAlertPayload::sample_minimal())
1106    }
1107
1108    fn sample_alternates() -> Vec<Self> {
1109        vec![
1110            Self::ObicoPrinterNotification(ObicoPrinterNotificationPayload::sample_full()),
1111            Self::MalformedExternalEvent(MalformedExternalEventPayload::sample_full()),
1112            Self::ActionRequested(ActionRequestedPayload::sample_full()),
1113            Self::ActionExecuted(ActionExecutedPayload::sample_full()),
1114            Self::ActionRejected(ActionRejectedPayload::sample_full()),
1115            Self::InterventionExpired(InterventionExpiredPayload::sample_full()),
1116            Self::SupervisionSessionOpened(SupervisionSessionOpenedPayload::sample_full()),
1117            Self::SupervisionSessionClosed(SupervisionSessionClosedPayload::sample_full()),
1118            Self::AgentAssessment(AgentAssessmentPayload::sample_full()),
1119            Self::OperatorAcknowledgement(OperatorAcknowledgementPayload::sample_full()),
1120            Self::PortFailure(PortFailurePayload::sample_full()),
1121            Self::StartupReconciliation(StartupReconciliationPayload::sample_full()),
1122        ]
1123    }
1124}
1125
1126impl Sample for EventRecord {
1127    fn sample_full() -> Self {
1128        Self {
1129            id: event_id(),
1130            print_id: Some(print_id()),
1131            source: EventSource::Obico,
1132            received_at: instant(),
1133            image: Some(ImageRef::sample_full()),
1134            payload: EventPayload::sample_full(),
1135            raw: Some(RawBytes::sample_full()),
1136        }
1137    }
1138
1139    fn sample_minimal() -> Self {
1140        Self {
1141            print_id: None,
1142            image: None,
1143            raw: None,
1144            payload: EventPayload::sample_minimal(),
1145            ..Self::sample_full()
1146        }
1147    }
1148
1149    fn sample_alternates() -> Vec<Self> {
1150        EventPayload::sample_alternates()
1151            .into_iter()
1152            .map(|payload| Self {
1153                payload,
1154                ..Self::sample_full()
1155            })
1156            .collect()
1157    }
1158}
1159
1160impl Sample for PrintContext {
1161    fn sample_full() -> Self {
1162        Self {
1163            print: PrintRecord::sample_full(),
1164            printer: Some(PrinterSnapshot::sample_full()),
1165            job: Some(JobSnapshot::sample_full()),
1166            manifest: Some(JobManifest::sample_full()),
1167            bounds: EffectiveBounds::sample_full(),
1168            interventions: vec![Intervention::sample_full()],
1169            recent_events: vec![EventRecord::sample_full()],
1170            latest_image: Some(ImageRef::sample_full()),
1171        }
1172    }
1173
1174    fn sample_minimal() -> Self {
1175        Self {
1176            print: PrintRecord::sample_minimal(),
1177            printer: None,
1178            job: None,
1179            manifest: None,
1180            bounds: EffectiveBounds::sample_full(),
1181            interventions: vec![],
1182            recent_events: vec![],
1183            latest_image: None,
1184        }
1185    }
1186}
1187
1188impl Sample for ObicoFailureEventType {
1189    fn sample_full() -> Self {
1190        Self::PrintFailure
1191    }
1192}
1193
1194impl Sample for ObicoEventType {
1195    fn sample_full() -> Self {
1196        Self::PrintStarted
1197    }
1198}
1199
1200impl Sample for ObicoTimestamp {
1201    fn sample_full() -> Self {
1202        Self::Seconds(1_772_366_400.5)
1203    }
1204}
1205
1206impl Sample for ObicoPrinterInfo {
1207    fn sample_full() -> Self {
1208        Self {
1209            id: 17,
1210            name: "Prusa MK4".to_owned(),
1211        }
1212    }
1213}
1214
1215impl Sample for ObicoPrintInfo {
1216    fn sample_full() -> Self {
1217        Self {
1218            id: 4211,
1219            filename: "benchy.gcode".to_owned(),
1220            started_at: Some(ObicoTimestamp::Seconds(1_772_366_400.5)),
1221            ended_at: Some(ObicoTimestamp::NotReported),
1222        }
1223    }
1224
1225    fn sample_minimal() -> Self {
1226        Self {
1227            started_at: None,
1228            ended_at: None,
1229            ..Self::sample_full()
1230        }
1231    }
1232}
1233
1234impl Sample for ObicoFailureEvent {
1235    fn sample_full() -> Self {
1236        Self {
1237            event_type: ObicoFailureEventType::PrintFailure,
1238            is_warning: true,
1239            print_paused: false,
1240        }
1241    }
1242}
1243
1244impl Sample for ObicoNotificationEvent {
1245    fn sample_full() -> Self {
1246        Self {
1247            event_type: ObicoEventType::PrintStarted,
1248            is_warning: false,
1249            print_paused: false,
1250        }
1251    }
1252}
1253
1254impl Sample for ObicoFailureAlert {
1255    fn sample_full() -> Self {
1256        Self {
1257            event: ObicoFailureEvent::sample_full(),
1258            printer: ObicoPrinterInfo::sample_full(),
1259            print: ObicoPrintInfo::sample_full(),
1260            img_url: "https://obico.example/snapshots/4211.jpg".to_owned(),
1261        }
1262    }
1263}
1264
1265impl Sample for ObicoPrinterNotification {
1266    fn sample_full() -> Self {
1267        Self {
1268            event: ObicoNotificationEvent::sample_full(),
1269            printer: ObicoPrinterInfo::sample_full(),
1270            print: Some(ObicoPrintInfo::sample_full()),
1271            img_url: Some("https://obico.example/snapshots/4211.jpg".to_owned()),
1272        }
1273    }
1274
1275    fn sample_minimal() -> Self {
1276        Self {
1277            print: None,
1278            img_url: None,
1279            ..Self::sample_full()
1280        }
1281    }
1282}
1283
1284/// One field as a generated schema declares it.
1285///
1286/// This is how the contract tests read a type's fields: off the schema the type
1287/// generates rather than off a table maintained beside it.
1288#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1289pub struct WireField {
1290    /// The field's name on the wire.
1291    pub name: String,
1292    /// What the field is: a referenced type's name, or a primitive's.
1293    pub descriptor: String,
1294    /// Whether an instance must carry it.
1295    pub required: bool,
1296}
1297
1298/// The name a reference names.
1299fn referenced(reference: &str) -> String {
1300    reference.rsplit('/').next().unwrap_or(reference).to_owned()
1301}
1302
1303/// The one non-null arm of a union, when there is exactly one.
1304fn sole_arm(arms: &[Value]) -> Option<&Value> {
1305    let mut kept = arms
1306        .iter()
1307        .filter(|arm| arm.get("type") != Some(&Value::from("null")));
1308    let first = kept.next()?;
1309    kept.next().is_none().then_some(first)
1310}
1311
1312/// What one property schema declares the field to be.
1313fn descriptor(schema: &Value) -> String {
1314    if let Some(Value::String(reference)) = schema.get("$ref") {
1315        return referenced(reference);
1316    }
1317    for key in ["anyOf", "oneOf"] {
1318        if let Some(Value::Array(arms)) = schema.get(key) {
1319            return match sole_arm(arms) {
1320                Some(arm) => descriptor(arm),
1321                None => "enum".to_owned(),
1322            };
1323        }
1324    }
1325    let kind = match schema.get("type") {
1326        Some(Value::String(name)) => name.clone(),
1327        Some(Value::Array(names)) => names
1328            .iter()
1329            .filter_map(Value::as_str)
1330            .find(|name| *name != "null")
1331            .unwrap_or("unknown")
1332            .to_owned(),
1333        _ => "unknown".to_owned(),
1334    };
1335    match kind.as_str() {
1336        "array" => format!(
1337            "array:{}",
1338            schema
1339                .get("items")
1340                .map_or_else(|| "unknown".to_owned(), descriptor)
1341        ),
1342        "object" => match (
1343            schema.get("additionalProperties"),
1344            schema.get("patternProperties"),
1345        ) {
1346            (Some(value), _) if value.is_object() => format!("map:{}", descriptor(value)),
1347            (_, Some(Value::Object(patterns))) if patterns.len() == 1 => {
1348                let value = patterns.values().next().expect("one pattern");
1349                format!("map:{}", descriptor(value))
1350            }
1351            _ => "object".to_owned(),
1352        },
1353        other => other.to_owned(),
1354    }
1355}
1356
1357/// The names one schema object marks required.
1358fn required_names(schema: &Value) -> Vec<&str> {
1359    schema
1360        .get("required")
1361        .and_then(Value::as_array)
1362        .map(|names| names.iter().filter_map(Value::as_str).collect())
1363        .unwrap_or_default()
1364}
1365
1366/// Read the fields off one object schema into a map.
1367fn collect(schema: &Value, into: &mut BTreeMap<String, WireField>) {
1368    let required = required_names(schema);
1369    let Some(Value::Object(properties)) = schema.get("properties") else {
1370        return;
1371    };
1372    for (name, property) in properties {
1373        into.insert(
1374            name.clone(),
1375            WireField {
1376                name: name.clone(),
1377                descriptor: descriptor(property),
1378                required: required.contains(&name.as_str()),
1379            },
1380        );
1381    }
1382}
1383
1384/// Every field one generated schema declares, in name order.
1385///
1386/// A schema whose type flattens a tagged enum declares some of its fields
1387/// inside the union's arms rather than beside them, so this reads both: a field
1388/// every arm requires is required, a field no arm declares is absent, and a
1389/// field the arms declare at differing types is `union`.
1390#[must_use]
1391pub fn wire_fields(schema: &Value) -> Vec<WireField> {
1392    let mut fields = BTreeMap::new();
1393    collect(schema, &mut fields);
1394    for key in ["anyOf", "oneOf", "allOf"] {
1395        let Some(Value::Array(arms)) = schema.get(key) else {
1396            continue;
1397        };
1398        let mut from_arms: BTreeMap<String, WireField> = BTreeMap::new();
1399        for arm in arms {
1400            let mut one = BTreeMap::new();
1401            collect(arm, &mut one);
1402            for (name, field) in one {
1403                from_arms
1404                    .entry(name)
1405                    .and_modify(|held| {
1406                        held.required &= field.required;
1407                        if held.descriptor != field.descriptor {
1408                            "union".clone_into(&mut held.descriptor);
1409                        }
1410                    })
1411                    .or_insert(field);
1412            }
1413        }
1414        fields.extend(from_arms);
1415    }
1416    fields.into_values().collect()
1417}