Skip to main content

dsfb_robotics/
syntax.rs

1//! Syntax layer — classify residual sign-tuple sequences into named
2//! structural motifs from the heuristics bank.
3//!
4//! Phase 2 provides a minimal classifier that maps a sign tuple and
5//! grammar state to either an exact [`crate::heuristics::RoboticsMotif`] match or
6//! `RoboticsMotif::Unknown`. Full motif-pattern recognition (periodic
7//! grazing detection, Stribeck-plateau fitting, BPFI harmonic
8//! classification, GRF-desync autocorrelation, CoM-drift integration)
9//! lands incrementally in Phase 3 alongside the dataset adapters that
10//! exercise each motif.
11
12use crate::grammar::{GrammarState, ReasonCode};
13use crate::heuristics::RoboticsMotif;
14use crate::sign::SignTuple;
15
16/// Classify a sign tuple + grammar state into a named motif.
17///
18/// The Phase 2 classifier makes the minimal, obvious assignments:
19///
20/// - `GrammarState::Admissible` → `RoboticsMotif::Unknown` (nothing to
21///   classify).
22/// - `GrammarState::Boundary(RecurrentBoundaryGrazing)` with zero net
23///   drift → `RoboticsMotif::BacklashRing` (characteristic of gear
24///   backlash at velocity reversals).
25/// - `GrammarState::Boundary(SustainedOutwardDrift)` with positive
26///   slew → `RoboticsMotif::BpfiGrowth` (degradation trajectory).
27/// - Everything else → `RoboticsMotif::Unknown`.
28///
29/// The `Unknown` fallback is **not** a failure — it is a first-class
30/// output that tells the operator "DSFB observed structure but does
31/// not have a named motif for it," which is precisely the
32/// augment-not-classify posture of the framework.
33#[must_use]
34pub fn classify(state: GrammarState, sign: &SignTuple) -> RoboticsMotif {
35    debug_assert!(sign.norm.is_finite() || sign.norm.is_nan(), "sign norm must be finite or NaN");
36    debug_assert!(sign.drift.is_finite() || sign.drift.is_nan(), "sign drift must be finite or NaN");
37    debug_assert!(sign.slew.is_finite() || sign.slew.is_nan(), "sign slew must be finite or NaN");
38    match state {
39        GrammarState::Admissible => RoboticsMotif::Unknown,
40        GrammarState::Violation => RoboticsMotif::Unknown,
41        GrammarState::Boundary(reason) => match reason {
42            ReasonCode::RecurrentBoundaryGrazing => {
43                // Grazing with near-zero net drift → backlash ring.
44                if crate::math::abs_f64(sign.drift) < 1e-6 {
45                    RoboticsMotif::BacklashRing
46                } else {
47                    RoboticsMotif::Unknown
48                }
49            }
50            ReasonCode::SustainedOutwardDrift => {
51                // Outward drift with positive slew (degrading direction) → BPFI growth.
52                if sign.slew > 0.0 {
53                    RoboticsMotif::BpfiGrowth
54                } else {
55                    RoboticsMotif::Unknown
56                }
57            }
58            ReasonCode::AbruptSlewViolation | ReasonCode::EnvelopeViolation => {
59                RoboticsMotif::Unknown
60            }
61        },
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68
69    #[test]
70    fn admissible_is_unknown() {
71        let s = SignTuple::zero();
72        assert_eq!(classify(GrammarState::Admissible, &s), RoboticsMotif::Unknown);
73    }
74
75    #[test]
76    fn grazing_with_zero_drift_is_backlash_ring() {
77        let s = SignTuple::new(0.05, 0.0, 0.001);
78        let m = classify(GrammarState::Boundary(ReasonCode::RecurrentBoundaryGrazing), &s);
79        assert_eq!(m, RoboticsMotif::BacklashRing);
80    }
81
82    #[test]
83    fn outward_drift_with_positive_slew_is_bpfi_growth() {
84        let s = SignTuple::new(0.05, 0.01, 0.002);
85        let m = classify(GrammarState::Boundary(ReasonCode::SustainedOutwardDrift), &s);
86        assert_eq!(m, RoboticsMotif::BpfiGrowth);
87    }
88
89    #[test]
90    fn outward_drift_with_flat_slew_is_unknown() {
91        let s = SignTuple::new(0.05, 0.01, 0.0);
92        let m = classify(GrammarState::Boundary(ReasonCode::SustainedOutwardDrift), &s);
93        assert_eq!(m, RoboticsMotif::Unknown);
94    }
95
96    #[test]
97    fn violation_state_is_unknown_motif() {
98        let s = SignTuple::new(0.2, 0.05, 0.01);
99        assert_eq!(classify(GrammarState::Violation, &s), RoboticsMotif::Unknown);
100    }
101}