Skip to main content

peat_schema/validation/
effector.rs

1//! Effector validators
2//!
3//! Validates EffectorSpec, EffectorStateUpdate, and EffectorCommand messages for Peat Protocol.
4
5use super::{ValidationError, ValidationResult};
6use crate::effector::v1::{
7    AmmunitionStatus, Authorization, AuthorizationLevel, EffectorCategory, EffectorCommand,
8    EffectorCommandType, EffectorSpec, EffectorStateUpdate, EffectorStatus, EffectorType,
9    FiringSolution, SafetyInterlocks, TargetDesignation,
10};
11
12/// Validate safety interlocks
13///
14/// Validates the safety interlock structure is present and properly formed.
15/// This is a basic validation - actual safety verification requires
16/// hardware-level checks beyond schema validation.
17pub fn validate_safety_interlocks(_interlocks: &SafetyInterlocks) -> ValidationResult<()> {
18    // Safety interlocks are boolean flags - no numeric constraints
19    // The semantic validation (are all required interlocks true for firing?) is
20    // application-level logic, not schema validation
21    Ok(())
22}
23
24/// Validate ammunition status
25///
26/// Validates:
27/// - rounds_ready <= rounds_total
28/// - magazine_capacity > 0 if magazines are used
29/// - reload_time_remaining is non-negative
30pub fn validate_ammunition_status(status: &AmmunitionStatus) -> ValidationResult<()> {
31    if status.rounds_ready > status.rounds_total {
32        return Err(ValidationError::ConstraintViolation(
33            "rounds_ready cannot exceed rounds_total".to_string(),
34        ));
35    }
36
37    if status.magazine_capacity == 0 && status.magazines_available > 0 {
38        return Err(ValidationError::InvalidValue(
39            "magazine_capacity must be > 0 if magazines_available > 0".to_string(),
40        ));
41    }
42
43    if status.reload_time_remaining_s < 0.0 {
44        return Err(ValidationError::InvalidValue(
45            "reload_time_remaining_s must be non-negative".to_string(),
46        ));
47    }
48
49    Ok(())
50}
51
52/// Validate firing solution
53///
54/// Validates:
55/// - quality is in [0.0, 1.0]
56/// - hit_probability is in [0.0, 1.0]
57/// - time_to_impact is non-negative
58pub fn validate_firing_solution(solution: &FiringSolution) -> ValidationResult<()> {
59    if solution.quality < 0.0 || solution.quality > 1.0 {
60        return Err(ValidationError::InvalidValue(format!(
61            "quality {} must be in range [0.0, 1.0]",
62            solution.quality
63        )));
64    }
65
66    if solution.hit_probability < 0.0 || solution.hit_probability > 1.0 {
67        return Err(ValidationError::InvalidValue(format!(
68            "hit_probability {} must be in range [0.0, 1.0]",
69            solution.hit_probability
70        )));
71    }
72
73    if solution.time_to_impact_s < 0.0 {
74        return Err(ValidationError::InvalidValue(
75            "time_to_impact_s must be non-negative".to_string(),
76        ));
77    }
78
79    Ok(())
80}
81
82/// Validate target designation
83///
84/// Validates:
85/// - target_track_id is present
86/// - range is non-negative
87pub fn validate_target_designation(target: &TargetDesignation) -> ValidationResult<()> {
88    if target.target_track_id.is_empty() {
89        return Err(ValidationError::MissingField("target_track_id".to_string()));
90    }
91
92    if target.range_m < 0.0 {
93        return Err(ValidationError::InvalidValue(
94            "range_m must be non-negative".to_string(),
95        ));
96    }
97
98    Ok(())
99}
100
101/// Validate authorization record
102///
103/// Validates:
104/// - authorization_id is present
105/// - authorized_by is present
106/// - level is specified
107/// - authorized_at is present
108pub fn validate_authorization(auth: &Authorization) -> ValidationResult<()> {
109    if auth.authorization_id.is_empty() {
110        return Err(ValidationError::MissingField(
111            "authorization_id".to_string(),
112        ));
113    }
114
115    if auth.authorized_by.is_empty() {
116        return Err(ValidationError::MissingField("authorized_by".to_string()));
117    }
118
119    if auth.level == AuthorizationLevel::Unspecified as i32 {
120        return Err(ValidationError::InvalidValue(
121            "authorization level must be specified".to_string(),
122        ));
123    }
124
125    if auth.authorized_at.is_none() {
126        return Err(ValidationError::MissingField("authorized_at".to_string()));
127    }
128
129    // If expires_at is set, it must be after authorized_at
130    if let (Some(authorized), Some(expires)) = (&auth.authorized_at, &auth.expires_at) {
131        if expires.seconds < authorized.seconds
132            || (expires.seconds == authorized.seconds && expires.nanos < authorized.nanos)
133        {
134            return Err(ValidationError::ConstraintViolation(
135                "expires_at must be after authorized_at".to_string(),
136            ));
137        }
138    }
139
140    Ok(())
141}
142
143/// Validate a complete effector specification
144///
145/// Validates:
146/// - effector_id is present
147/// - name is present
148/// - effector_type is specified
149/// - category is specified
150/// - max_range >= min_range
151/// - Type-specific capacity is valid if present
152/// - Safety interlocks are present
153pub fn validate_effector_spec(spec: &EffectorSpec) -> ValidationResult<()> {
154    // Check required fields
155    if spec.effector_id.is_empty() {
156        return Err(ValidationError::MissingField("effector_id".to_string()));
157    }
158
159    if spec.name.is_empty() {
160        return Err(ValidationError::MissingField("name".to_string()));
161    }
162
163    // Type must be specified
164    if spec.effector_type == EffectorType::Unspecified as i32 {
165        return Err(ValidationError::InvalidValue(
166            "effector_type must be specified".to_string(),
167        ));
168    }
169
170    // Category must be specified
171    if spec.category == EffectorCategory::Unspecified as i32 {
172        return Err(ValidationError::InvalidValue(
173            "category must be specified".to_string(),
174        ));
175    }
176
177    // Range constraints
178    if spec.min_range_m < 0.0 {
179        return Err(ValidationError::InvalidValue(
180            "min_range_m must be non-negative".to_string(),
181        ));
182    }
183
184    if spec.max_range_m < spec.min_range_m {
185        return Err(ValidationError::ConstraintViolation(
186            "max_range_m must be >= min_range_m".to_string(),
187        ));
188    }
189
190    // Validate safety interlocks if present
191    if let Some(ref interlocks) = spec.interlocks {
192        validate_safety_interlocks(interlocks)?;
193    }
194
195    // Validate type-specific capacity
196    if let Some(ref capacity) = spec.capacity {
197        use crate::effector::v1::effector_spec::Capacity;
198        match capacity {
199            Capacity::Ammunition(ammo) => validate_ammunition_status(ammo)?,
200            Capacity::Energy(energy) => {
201                // Energy capacity validation
202                if energy.charge_level < 0.0 || energy.charge_level > 1.0 {
203                    return Err(ValidationError::InvalidValue(format!(
204                        "charge_level {} must be in range [0.0, 1.0]",
205                        energy.charge_level
206                    )));
207                }
208                if energy.thermal_level < 0.0 || energy.thermal_level > 1.0 {
209                    return Err(ValidationError::InvalidValue(format!(
210                        "thermal_level {} must be in range [0.0, 1.0]",
211                        energy.thermal_level
212                    )));
213                }
214            }
215            Capacity::Dispenser(dispenser) => {
216                // Dispenser capacity validation
217                if dispenser.units_remaining > dispenser.total_capacity {
218                    return Err(ValidationError::ConstraintViolation(
219                        "units_remaining cannot exceed total_capacity".to_string(),
220                    ));
221                }
222            }
223        }
224    }
225
226    // Validate current target if present
227    if let Some(ref target) = spec.current_target {
228        validate_target_designation(target)?;
229    }
230
231    // Validate firing solution if present
232    if let Some(ref solution) = spec.firing_solution {
233        validate_firing_solution(solution)?;
234    }
235
236    // Validate authorization if present
237    if let Some(ref auth) = spec.current_authorization {
238        validate_authorization(auth)?;
239    }
240
241    Ok(())
242}
243
244/// Validate an effector state update message
245///
246/// Validates:
247/// - platform_id is present
248/// - effector spec is valid
249/// - status is specified
250/// - timestamp is present
251pub fn validate_effector_state_update(update: &EffectorStateUpdate) -> ValidationResult<()> {
252    if update.platform_id.is_empty() {
253        return Err(ValidationError::MissingField("platform_id".to_string()));
254    }
255
256    // Effector spec is required
257    let effector = update
258        .effector
259        .as_ref()
260        .ok_or_else(|| ValidationError::MissingField("effector".to_string()))?;
261    validate_effector_spec(effector)?;
262
263    // Status must be specified
264    if update.status == EffectorStatus::Unspecified as i32 {
265        return Err(ValidationError::InvalidValue(
266            "status must be specified".to_string(),
267        ));
268    }
269
270    // Timestamp is required
271    if update.timestamp.is_none() {
272        return Err(ValidationError::MissingField("timestamp".to_string()));
273    }
274
275    Ok(())
276}
277
278/// Validate an effector command
279///
280/// Validates:
281/// - command_id is present
282/// - platform_id is present
283/// - effector_id is present
284/// - command_type is specified
285/// - issued_at is present
286/// - ARM/ENGAGE commands require authorization
287pub fn validate_effector_command(cmd: &EffectorCommand) -> ValidationResult<()> {
288    if cmd.command_id.is_empty() {
289        return Err(ValidationError::MissingField("command_id".to_string()));
290    }
291
292    if cmd.platform_id.is_empty() {
293        return Err(ValidationError::MissingField("platform_id".to_string()));
294    }
295
296    if cmd.effector_id.is_empty() {
297        return Err(ValidationError::MissingField("effector_id".to_string()));
298    }
299
300    if cmd.command_type == EffectorCommandType::EffectorCommandUnspecified as i32 {
301        return Err(ValidationError::InvalidValue(
302            "command_type must be specified".to_string(),
303        ));
304    }
305
306    if cmd.issued_at.is_none() {
307        return Err(ValidationError::MissingField("issued_at".to_string()));
308    }
309
310    // If expires_at is set, validate it comes after issued_at
311    if let (Some(issued), Some(expires)) = (&cmd.issued_at, &cmd.expires_at) {
312        if expires.seconds < issued.seconds
313            || (expires.seconds == issued.seconds && expires.nanos < issued.nanos)
314        {
315            return Err(ValidationError::ConstraintViolation(
316                "expires_at must be after issued_at".to_string(),
317            ));
318        }
319    }
320
321    // ARM and ENGAGE commands require authorization
322    let requires_auth = cmd.command_type == EffectorCommandType::EffectorCommandArm as i32
323        || cmd.command_type == EffectorCommandType::EffectorCommandEngage as i32;
324
325    if requires_auth && cmd.authorization.is_none() {
326        return Err(ValidationError::MissingField(
327            "authorization (required for ARM/ENGAGE commands)".to_string(),
328        ));
329    }
330
331    // Validate authorization if present
332    if let Some(ref auth) = cmd.authorization {
333        validate_authorization(auth)?;
334    }
335
336    // ENGAGE commands require target designation
337    if cmd.command_type == EffectorCommandType::EffectorCommandEngage as i32 {
338        if cmd.target.is_none() {
339            return Err(ValidationError::MissingField(
340                "target (required for ENGAGE command)".to_string(),
341            ));
342        }
343        if let Some(ref target) = cmd.target {
344            validate_target_designation(target)?;
345        }
346    }
347
348    Ok(())
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use crate::common::v1::Timestamp;
355    use crate::effector::v1::{
356        effector_spec::Capacity, DispenserCapacity, EnergyCapacity, EngagementState, RoeStatus,
357        SafetyState,
358    };
359
360    fn valid_kinetic_effector() -> EffectorSpec {
361        EffectorSpec {
362            effector_id: "m240-coax".to_string(),
363            name: "M240 Coaxial Machine Gun".to_string(),
364            effector_type: EffectorType::Kinetic as i32,
365            category: EffectorCategory::Lethal as i32,
366            effector_class: "7.62x51mm NATO".to_string(),
367            max_range_m: 1800.0,
368            min_range_m: 0.0,
369            rate_of_fire: 650.0,
370            safety_state: SafetyState::Safe as i32,
371            interlocks: Some(SafetyInterlocks {
372                master_arm_enabled: false,
373                firing_circuit_ready: true,
374                muzzle_clear: true,
375                feed_ready: true,
376                thermal_ok: true,
377                authorization_valid: false,
378                roe_compliant: true,
379                engagement_zone_valid: false,
380                friendly_clear: true,
381                human_confirmed: false,
382            }),
383            capacity: Some(Capacity::Ammunition(AmmunitionStatus {
384                rounds_ready: 200,
385                rounds_total: 1000,
386                magazine_capacity: 200,
387                magazines_available: 5,
388                ammunition_type: "7.62 NATO Ball".to_string(),
389                reloading: false,
390                reload_time_remaining_s: 0.0,
391                malfunction: false,
392                malfunction_detail: String::new(),
393            })),
394            engagement_state: EngagementState::Idle as i32,
395            current_target: None,
396            firing_solution: None,
397            required_authorization: AuthorizationLevel::Commander as i32,
398            current_authorization: None,
399            roe_status: Some(RoeStatus {
400                roe_id: "ROE-ALPHA-3".to_string(),
401                roe_description: "Weapons tight".to_string(),
402                weapons_status: "TIGHT".to_string(),
403                engagement_authorized: false,
404                denial_reason: String::new(),
405                updated_at: None,
406            }),
407            mount_actuator_id: "turret-main".to_string(),
408            updated_at: None,
409            metadata_json: String::new(),
410        }
411    }
412
413    fn valid_countermeasure_effector() -> EffectorSpec {
414        EffectorSpec {
415            effector_id: "smoke-l".to_string(),
416            name: "Left Smoke Dispenser".to_string(),
417            effector_type: EffectorType::Obscurant as i32,
418            category: EffectorCategory::Defensive as i32,
419            effector_class: "M18 Smoke".to_string(),
420            max_range_m: 50.0,
421            min_range_m: 5.0,
422            rate_of_fire: 0.0,
423            safety_state: SafetyState::Safe as i32,
424            interlocks: None,
425            capacity: Some(Capacity::Dispenser(DispenserCapacity {
426                units_remaining: 4,
427                total_capacity: 4,
428                unit_type: "M18 Smoke Grenade".to_string(),
429                ready: true,
430            })),
431            engagement_state: EngagementState::Idle as i32,
432            current_target: None,
433            firing_solution: None,
434            required_authorization: AuthorizationLevel::Operator as i32,
435            current_authorization: None,
436            roe_status: None,
437            mount_actuator_id: String::new(),
438            updated_at: None,
439            metadata_json: String::new(),
440        }
441    }
442
443    #[test]
444    fn test_valid_kinetic_effector() {
445        let effector = valid_kinetic_effector();
446        assert!(validate_effector_spec(&effector).is_ok());
447    }
448
449    #[test]
450    fn test_valid_countermeasure_effector() {
451        let effector = valid_countermeasure_effector();
452        assert!(validate_effector_spec(&effector).is_ok());
453    }
454
455    #[test]
456    fn test_missing_effector_id() {
457        let mut effector = valid_kinetic_effector();
458        effector.effector_id = String::new();
459        let err = validate_effector_spec(&effector).unwrap_err();
460        assert!(matches!(err, ValidationError::MissingField(f) if f == "effector_id"));
461    }
462
463    #[test]
464    fn test_missing_name() {
465        let mut effector = valid_kinetic_effector();
466        effector.name = String::new();
467        let err = validate_effector_spec(&effector).unwrap_err();
468        assert!(matches!(err, ValidationError::MissingField(f) if f == "name"));
469    }
470
471    #[test]
472    fn test_unspecified_effector_type() {
473        let mut effector = valid_kinetic_effector();
474        effector.effector_type = EffectorType::Unspecified as i32;
475        let err = validate_effector_spec(&effector).unwrap_err();
476        assert!(matches!(err, ValidationError::InvalidValue(_)));
477    }
478
479    #[test]
480    fn test_unspecified_category() {
481        let mut effector = valid_kinetic_effector();
482        effector.category = EffectorCategory::Unspecified as i32;
483        let err = validate_effector_spec(&effector).unwrap_err();
484        assert!(matches!(err, ValidationError::InvalidValue(_)));
485    }
486
487    #[test]
488    fn test_invalid_range_constraint() {
489        let mut effector = valid_kinetic_effector();
490        effector.min_range_m = 1000.0;
491        effector.max_range_m = 500.0; // max < min
492        let err = validate_effector_spec(&effector).unwrap_err();
493        assert!(matches!(err, ValidationError::ConstraintViolation(_)));
494    }
495
496    #[test]
497    fn test_invalid_ammunition_rounds() {
498        let mut effector = valid_kinetic_effector();
499        effector.capacity = Some(Capacity::Ammunition(AmmunitionStatus {
500            rounds_ready: 500, // > rounds_total
501            rounds_total: 200,
502            magazine_capacity: 100,
503            magazines_available: 2,
504            ammunition_type: "7.62 NATO".to_string(),
505            reloading: false,
506            reload_time_remaining_s: 0.0,
507            malfunction: false,
508            malfunction_detail: String::new(),
509        }));
510        let err = validate_effector_spec(&effector).unwrap_err();
511        assert!(matches!(err, ValidationError::ConstraintViolation(_)));
512    }
513
514    #[test]
515    fn test_invalid_energy_charge_level() {
516        let mut effector = valid_kinetic_effector();
517        effector.capacity = Some(Capacity::Energy(EnergyCapacity {
518            charge_level: 1.5, // > 1.0
519            max_capacity: 1000.0,
520            power_available_kw: 50.0,
521            thermal_level: 0.3,
522            charging: false,
523            charge_time_remaining_s: 0.0,
524            shots_remaining: 10,
525        }));
526        let err = validate_effector_spec(&effector).unwrap_err();
527        assert!(matches!(err, ValidationError::InvalidValue(_)));
528    }
529
530    #[test]
531    fn test_invalid_dispenser_units() {
532        let mut effector = valid_countermeasure_effector();
533        effector.capacity = Some(Capacity::Dispenser(DispenserCapacity {
534            units_remaining: 10, // > total_capacity
535            total_capacity: 4,
536            unit_type: "M18 Smoke".to_string(),
537            ready: true,
538        }));
539        let err = validate_effector_spec(&effector).unwrap_err();
540        assert!(matches!(err, ValidationError::ConstraintViolation(_)));
541    }
542
543    #[test]
544    fn test_valid_effector_state_update() {
545        let update = EffectorStateUpdate {
546            platform_id: "IFV-Alpha-1".to_string(),
547            effector: Some(valid_kinetic_effector()),
548            status: EffectorStatus::Operational as i32,
549            timestamp: Some(Timestamp {
550                seconds: 1702000000,
551                nanos: 0,
552            }),
553        };
554        assert!(validate_effector_state_update(&update).is_ok());
555    }
556
557    #[test]
558    fn test_effector_update_missing_platform_id() {
559        let update = EffectorStateUpdate {
560            platform_id: String::new(),
561            effector: Some(valid_kinetic_effector()),
562            status: EffectorStatus::Operational as i32,
563            timestamp: Some(Timestamp {
564                seconds: 1702000000,
565                nanos: 0,
566            }),
567        };
568        let err = validate_effector_state_update(&update).unwrap_err();
569        assert!(matches!(err, ValidationError::MissingField(f) if f == "platform_id"));
570    }
571
572    #[test]
573    fn test_effector_update_unspecified_status() {
574        let update = EffectorStateUpdate {
575            platform_id: "IFV-Alpha-1".to_string(),
576            effector: Some(valid_kinetic_effector()),
577            status: EffectorStatus::Unspecified as i32,
578            timestamp: Some(Timestamp {
579                seconds: 1702000000,
580                nanos: 0,
581            }),
582        };
583        let err = validate_effector_state_update(&update).unwrap_err();
584        assert!(matches!(err, ValidationError::InvalidValue(_)));
585    }
586
587    #[test]
588    fn test_valid_safe_command() {
589        let cmd = EffectorCommand {
590            command_id: "CMD-001".to_string(),
591            platform_id: "IFV-Alpha-1".to_string(),
592            effector_id: "m240-coax".to_string(),
593            command_type: EffectorCommandType::EffectorCommandSafe as i32,
594            target: None,
595            authorization: None, // Not required for SAFE command
596            rounds_authorized: 0,
597            issued_by: "operator-1".to_string(),
598            priority: 1,
599            issued_at: Some(Timestamp {
600                seconds: 1702000000,
601                nanos: 0,
602            }),
603            expires_at: None,
604        };
605        assert!(validate_effector_command(&cmd).is_ok());
606    }
607
608    #[test]
609    fn test_arm_command_requires_authorization() {
610        let cmd = EffectorCommand {
611            command_id: "CMD-001".to_string(),
612            platform_id: "IFV-Alpha-1".to_string(),
613            effector_id: "m240-coax".to_string(),
614            command_type: EffectorCommandType::EffectorCommandArm as i32,
615            target: None,
616            authorization: None, // Missing - required for ARM
617            rounds_authorized: 0,
618            issued_by: "operator-1".to_string(),
619            priority: 1,
620            issued_at: Some(Timestamp {
621                seconds: 1702000000,
622                nanos: 0,
623            }),
624            expires_at: None,
625        };
626        let err = validate_effector_command(&cmd).unwrap_err();
627        assert!(matches!(err, ValidationError::MissingField(_)));
628    }
629
630    #[test]
631    fn test_engage_command_requires_target() {
632        let cmd = EffectorCommand {
633            command_id: "CMD-001".to_string(),
634            platform_id: "IFV-Alpha-1".to_string(),
635            effector_id: "m240-coax".to_string(),
636            command_type: EffectorCommandType::EffectorCommandEngage as i32,
637            target: None, // Missing - required for ENGAGE
638            authorization: Some(Authorization {
639                authorization_id: "AUTH-001".to_string(),
640                authorized_by: "commander-1".to_string(),
641                level: AuthorizationLevel::Commander as i32,
642                authorized_at: Some(Timestamp {
643                    seconds: 1702000000,
644                    nanos: 0,
645                }),
646                expires_at: None,
647                authorized_target_classes: vec!["hostile_vehicle".to_string()],
648                engagement_zone_id: String::new(),
649                roe_reference: "ROE-ALPHA-3".to_string(),
650                special_instructions: String::new(),
651            }),
652            rounds_authorized: 50,
653            issued_by: "operator-1".to_string(),
654            priority: 1,
655            issued_at: Some(Timestamp {
656                seconds: 1702000000,
657                nanos: 0,
658            }),
659            expires_at: None,
660        };
661        let err = validate_effector_command(&cmd).unwrap_err();
662        assert!(matches!(err, ValidationError::MissingField(_)));
663    }
664
665    #[test]
666    fn test_effector_command_expires_before_issued() {
667        let cmd = EffectorCommand {
668            command_id: "CMD-001".to_string(),
669            platform_id: "IFV-Alpha-1".to_string(),
670            effector_id: "m240-coax".to_string(),
671            command_type: EffectorCommandType::EffectorCommandSafe as i32,
672            target: None,
673            authorization: None,
674            rounds_authorized: 0,
675            issued_by: "operator-1".to_string(),
676            priority: 1,
677            issued_at: Some(Timestamp {
678                seconds: 1702000000,
679                nanos: 0,
680            }),
681            expires_at: Some(Timestamp {
682                seconds: 1701000000, // Before issued_at
683                nanos: 0,
684            }),
685        };
686        let err = validate_effector_command(&cmd).unwrap_err();
687        assert!(matches!(err, ValidationError::ConstraintViolation(_)));
688    }
689}