Skip to main content

runmat_runtime/builtins/control/
feedback.rs

1//! MATLAB-compatible SISO `feedback` interconnection for transfer functions.
2
3use runmat_builtins::{
4    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinExtensionDescriptor,
5    BuiltinExtensionMode, BuiltinIntegerBackendRule, BuiltinIntegerCapabilityDescriptor,
6    BuiltinIntegerComputationDomain, BuiltinIntegerInputAvailability,
7    BuiltinIntegerInputCapability, BuiltinIntegerOutputClassRule, BuiltinIntegerOverflowRule,
8    BuiltinIntegerOverloadKind, BuiltinIntegerScalarDoubleRule, BuiltinOutputMode,
9    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
10};
11use runmat_macros::runtime_builtin;
12use runmat_value::Value;
13
14use crate::builtins::common::spec::{
15    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
16    ReductionNaN, ResidencyPolicy, ShapeRequirements,
17};
18use crate::builtins::control::tf_model::{scalar_f64, two_models_ordered};
19use crate::builtins::control::type_resolvers::feedback_type;
20use crate::{BuiltinResult, RuntimeError};
21
22const BUILTIN_NAME: &str = "feedback";
23
24const SINGLE_SYSTEM_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
25    id: "feedback-single-system",
26    mode: BuiltinExtensionMode::RunMatOnly,
27    description: "feedback(sys1) with an implicit unity feedback path is a RunMat extension",
28    error_identifier: Some("RunMat:compatibility:FeedbackSingleSystemExtension"),
29};
30const SCALAR_FORWARD_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
31    id: "feedback-scalar-forward-system",
32    mode: BuiltinExtensionMode::RunMatOnly,
33    description: "feedback with a scalar forward-path system is a RunMat extension",
34    error_identifier: Some("RunMat:compatibility:FeedbackScalarForwardSystemExtension"),
35};
36const SCALAR_SYSTEMS_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
37    id: "feedback-scalar-scalar-systems",
38    mode: BuiltinExtensionMode::RunMatOnly,
39    description: "feedback with scalar forward and feedback-path systems is a RunMat extension",
40    error_identifier: Some("RunMat:compatibility:FeedbackScalarScalarSystemsExtension"),
41};
42const INTEGER_GAIN_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
43    id: "feedback-integer-scalar-gain",
44    mode: BuiltinExtensionMode::RunMatOnly,
45    description: "feedback with a typed-integer scalar system gain is a RunMat extension",
46    error_identifier: Some("RunMat:compatibility:FeedbackIntegerScalarGainExtension"),
47};
48const INTEGER_SIGN_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
49    id: "feedback-integer-sign",
50    mode: BuiltinExtensionMode::RunMatOnly,
51    description: "feedback with a typed-integer sign is a RunMat extension",
52    error_identifier: Some("RunMat:compatibility:FeedbackIntegerSignExtension"),
53};
54const LOGICAL_NUMERIC_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
55    id: "feedback-logical-numeric-input",
56    mode: BuiltinExtensionMode::RunMatOnly,
57    description: "feedback with a logical numeric system or sign is a RunMat extension",
58    error_identifier: Some("RunMat:compatibility:FeedbackLogicalNumericInputExtension"),
59};
60const RESIDENT_NUMERIC_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
61    id: "feedback-resident-numeric-input",
62    mode: BuiltinExtensionMode::RunMatOnly,
63    description: "feedback with a resident numeric system or sign is a RunMat extension",
64    error_identifier: Some("RunMat:compatibility:FeedbackResidentNumericInputExtension"),
65};
66const SINGLE_NUMERIC_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
67    id: "feedback-single-numeric-input",
68    mode: BuiltinExtensionMode::RunMatOnly,
69    description: "feedback with a native-single numeric system or sign is a RunMat extension",
70    error_identifier: Some("RunMat:compatibility:FeedbackSingleNumericInputExtension"),
71};
72
73pub const EXTENSIONS: [BuiltinExtensionDescriptor; 8] = [
74    SINGLE_SYSTEM_EXTENSION,
75    SCALAR_FORWARD_EXTENSION,
76    SCALAR_SYSTEMS_EXTENSION,
77    INTEGER_GAIN_EXTENSION,
78    INTEGER_SIGN_EXTENSION,
79    LOGICAL_NUMERIC_EXTENSION,
80    RESIDENT_NUMERIC_EXTENSION,
81    SINGLE_NUMERIC_EXTENSION,
82];
83
84const INTEGER_GAIN_INPUTS: [BuiltinIntegerInputCapability; 2] = [
85    BuiltinIntegerInputCapability {
86        name: "sys1",
87        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
88        availability: BuiltinIntegerInputAvailability::RunMatOnly,
89        scalar_double: BuiltinIntegerScalarDoubleRule::Allowed,
90        notes: "A typed-integer forward gain is independently gated, then converted once to the floating/complex tf coefficient domain.",
91    },
92    BuiltinIntegerInputCapability {
93        name: "sys2",
94        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
95        availability: BuiltinIntegerInputAvailability::RunMatOnly,
96        scalar_double: BuiltinIntegerScalarDoubleRule::Allowed,
97        notes: "A typed-integer feedback-path gain is independently gated, then converted once to the floating/complex tf coefficient domain.",
98    },
99];
100const INTEGER_SIGN_INPUTS: [BuiltinIntegerInputCapability; 1] =
101    [BuiltinIntegerInputCapability {
102        name: "sign",
103        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
104        availability: BuiltinIntegerInputAvailability::RunMatOnly,
105        scalar_double: BuiltinIntegerScalarDoubleRule::Allowed,
106        notes: "The typed-integer sign is independently gated and must be exactly -1 or +1; unsigned classes can express only +1.",
107    }];
108
109pub const INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 2] = [
110    BuiltinIntegerCapabilityDescriptor {
111        form: "sys = feedback(integer_gain, sys2) or feedback(sys1, integer_gain)",
112        inputs: &INTEGER_GAIN_INPUTS,
113        computation_domain: BuiltinIntegerComputationDomain::FloatingPoint,
114        output_class: BuiltinIntegerOutputClassRule::NotApplicable,
115        overflow: BuiltinIntegerOverflowRule::NotApplicable,
116        backend: BuiltinIntegerBackendRule::GatherFallback,
117        overload: BuiltinIntegerOverloadKind::ScalarOnly,
118        notes: "All eight typed integer classes are RunMat-only scalar gains. The result is a host tf object; int64 and uint64 values outside binary64's exact integer range can round at the explicit floating boundary.",
119    },
120    BuiltinIntegerCapabilityDescriptor {
121        form: "sys = feedback(sys1, sys2, integer_sign)",
122        inputs: &INTEGER_SIGN_INPUTS,
123        computation_domain: BuiltinIntegerComputationDomain::Structural,
124        output_class: BuiltinIntegerOutputClassRule::NotApplicable,
125        overflow: BuiltinIntegerOverflowRule::NotApplicable,
126        backend: BuiltinIntegerBackendRule::GatherFallback,
127        overload: BuiltinIntegerOverloadKind::StructuralParameter,
128        notes: "All eight typed integer classes are admitted as a RunMat-only sign control, but the exact valid set remains -1 or +1. The result is a host tf object.",
129    },
130];
131
132const FEEDBACK_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
133    name: "sys",
134    ty: BuiltinParamType::Any,
135    arity: BuiltinParamArity::Required,
136    default: None,
137    description: "Closed-loop SISO transfer-function object.",
138}];
139const FEEDBACK_PARAM_SYS1: BuiltinParamDescriptor = BuiltinParamDescriptor {
140    name: "sys1",
141    ty: BuiltinParamType::Any,
142    arity: BuiltinParamArity::Required,
143    default: None,
144    description: "Forward-path SISO transfer-function model.",
145};
146const FEEDBACK_PARAM_SYS2: BuiltinParamDescriptor = BuiltinParamDescriptor {
147    name: "sys2",
148    ty: BuiltinParamType::Any,
149    arity: BuiltinParamArity::Optional,
150    default: Some("1"),
151    description: "Feedback-path SISO transfer function or scalar gain.",
152};
153const FEEDBACK_PARAM_SIGN: BuiltinParamDescriptor = BuiltinParamDescriptor {
154    name: "sign",
155    ty: BuiltinParamType::NumericScalar,
156    arity: BuiltinParamArity::Optional,
157    default: Some("-1"),
158    description: "-1 for negative feedback or +1 for positive feedback.",
159};
160const FEEDBACK_INPUT_SYS: [BuiltinParamDescriptor; 1] = [FEEDBACK_PARAM_SYS1];
161const FEEDBACK_INPUT_SYS_OTHER: [BuiltinParamDescriptor; 2] =
162    [FEEDBACK_PARAM_SYS1, FEEDBACK_PARAM_SYS2];
163const FEEDBACK_INPUT_SYS_OTHER_SIGN: [BuiltinParamDescriptor; 3] = [
164    FEEDBACK_PARAM_SYS1,
165    FEEDBACK_PARAM_SYS2,
166    FEEDBACK_PARAM_SIGN,
167];
168const FEEDBACK_SIGNATURES: [BuiltinSignatureDescriptor; 3] = [
169    BuiltinSignatureDescriptor {
170        label: "sys = feedback(sys1)",
171        inputs: &FEEDBACK_INPUT_SYS,
172        outputs: &FEEDBACK_OUTPUT,
173    },
174    BuiltinSignatureDescriptor {
175        label: "sys = feedback(sys1, sys2)",
176        inputs: &FEEDBACK_INPUT_SYS_OTHER,
177        outputs: &FEEDBACK_OUTPUT,
178    },
179    BuiltinSignatureDescriptor {
180        label: "sys = feedback(sys1, sys2, sign)",
181        inputs: &FEEDBACK_INPUT_SYS_OTHER_SIGN,
182        outputs: &FEEDBACK_OUTPUT,
183    },
184];
185const FEEDBACK_ERRORS: [BuiltinErrorDescriptor; 5] = [
186    BuiltinErrorDescriptor {
187        code: "RM.FEEDBACK.INVALID_ARGUMENT",
188        identifier: Some("RunMat:feedback:InvalidArgument"),
189        when: "Inputs do not match supported feedback invocation forms.",
190        message: "feedback: invalid argument",
191    },
192    BuiltinErrorDescriptor {
193        code: "RM.FEEDBACK.INVALID_MODEL",
194        identifier: Some("RunMat:feedback:InvalidModel"),
195        when: "Input systems are not supported SISO transfer-function models.",
196        message: "feedback: invalid model",
197    },
198    BuiltinErrorDescriptor {
199        code: "RM.FEEDBACK.INVALID_SIGN",
200        identifier: Some("RunMat:feedback:InvalidSign"),
201        when: "Feedback sign is not -1 or +1.",
202        message: "feedback: sign must be -1 or +1",
203    },
204    BuiltinErrorDescriptor {
205        code: "RM.FEEDBACK.UNSUPPORTED_MODEL",
206        identifier: Some("RunMat:feedback:UnsupportedModel"),
207        when: "A supported-looking model uses unsupported delays or incompatible sample times.",
208        message: "feedback: unsupported model",
209    },
210    BuiltinErrorDescriptor {
211        code: "RM.FEEDBACK.INTERNAL",
212        identifier: Some("RunMat:feedback:Internal"),
213        when: "Closed-loop transfer-function assembly failed.",
214        message: "feedback: internal error",
215    },
216];
217pub const FEEDBACK_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
218    signatures: &FEEDBACK_SIGNATURES,
219    output_mode: BuiltinOutputMode::Fixed,
220    completion_policy: BuiltinCompletionPolicy::Public,
221    errors: &FEEDBACK_ERRORS,
222};
223
224#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::control::feedback")]
225pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
226    name: "feedback",
227    op_kind: GpuOpKind::Custom("control-feedback-interconnection"),
228    supported_precisions: &[],
229    broadcast: BroadcastSemantics::None,
230    provider_hooks: &[],
231    constant_strategy: ConstantStrategy::InlineLiteral,
232    residency: ResidencyPolicy::GatherImmediately,
233    nan_mode: ReductionNaN::Include,
234    two_pass_threshold: None,
235    workgroup_size: None,
236    accepts_nan_mode: false,
237    notes: "SISO transfer-function interconnection runs on host-side metadata.",
238};
239
240#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::control::feedback")]
241pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
242    name: "feedback",
243    shape: ShapeRequirements::Any,
244    constant_strategy: ConstantStrategy::InlineLiteral,
245    elementwise: None,
246    reduction: None,
247    emits_nan: false,
248    notes: "feedback creates a transfer-function object and terminates numeric fusion chains.",
249};
250
251fn feedback_error(
252    message: impl Into<String>,
253    error: &'static BuiltinErrorDescriptor,
254) -> RuntimeError {
255    let mut builder = crate::build_runtime_error(message).with_builtin(BUILTIN_NAME);
256    if let Some(identifier) = error.identifier {
257        builder = builder.with_identifier(identifier);
258    }
259    builder.build()
260}
261
262#[runtime_builtin(
263    name = "feedback",
264    category = "control",
265    summary = "Form SISO feedback interconnections for transfer-function models.",
266    keywords = "feedback,control system,closed loop,transfer function,tf",
267    type_resolver(feedback_type),
268    descriptor(crate::builtins::control::feedback::FEEDBACK_DESCRIPTOR),
269    extensions(crate::builtins::control::feedback::EXTENSIONS),
270    integer_capabilities(crate::builtins::control::feedback::INTEGER_CAPABILITIES),
271    builtin_path = "crate::builtins::control::feedback"
272)]
273async fn feedback_builtin(sys1: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
274    if rest.len() > 2 {
275        return Err(feedback_error(
276            "feedback: expected feedback(sys1), feedback(sys1, sys2), or feedback(sys1, sys2, sign)",
277            &FEEDBACK_ERRORS[0],
278        ));
279    }
280    ensure_extensions_enabled(&sys1, &rest)?;
281    let sys2 = rest.first().cloned().unwrap_or(Value::Num(1.0));
282    let sign = match rest.get(1) {
283        Some(value) => {
284            let gathered = crate::dispatcher::gather_if_needed_async(value).await?;
285            scalar_f64(&gathered, "sign", BUILTIN_NAME)?
286        }
287        None => -1.0,
288    };
289    if sign != -1.0 && sign != 1.0 {
290        return Err(feedback_error(
291            "feedback: sign must be -1 or +1",
292            &FEEDBACK_ERRORS[2],
293        ));
294    }
295
296    let (forward, feedback_path) = two_models_ordered(sys1, sys2, BUILTIN_NAME).await?;
297    forward
298        .feedback(&feedback_path, sign)?
299        .to_value(BUILTIN_NAME)
300}
301
302fn ensure_extensions_enabled(sys1: &Value, rest: &[Value]) -> BuiltinResult<()> {
303    if rest.is_empty() {
304        ensure_extension(&SINGLE_SYSTEM_EXTENSION)?;
305    }
306
307    let sys2 = rest.first();
308    let sys1_is_scalar = is_scalar_system_value(sys1);
309    if sys1_is_scalar && sys2.is_some_and(is_scalar_system_value) {
310        ensure_extension(&SCALAR_SYSTEMS_EXTENSION)?;
311    }
312    if sys1_is_scalar {
313        ensure_extension(&SCALAR_FORWARD_EXTENSION)?;
314    }
315
316    if is_typed_integer_value(sys1) || sys2.is_some_and(is_typed_integer_value) {
317        ensure_extension(&INTEGER_GAIN_EXTENSION)?;
318    }
319    if rest.get(1).is_some_and(is_typed_integer_value) {
320        ensure_extension(&INTEGER_SIGN_EXTENSION)?;
321    }
322    if is_logical_value(sys1) || rest.iter().any(is_logical_value) {
323        ensure_extension(&LOGICAL_NUMERIC_EXTENSION)?;
324    }
325    if is_resident_value(sys1) || rest.iter().any(is_resident_value) {
326        ensure_extension(&RESIDENT_NUMERIC_EXTENSION)?;
327    }
328    if is_single_numeric_value(sys1) || rest.iter().any(is_single_numeric_value) {
329        ensure_extension(&SINGLE_NUMERIC_EXTENSION)?;
330    }
331    Ok(())
332}
333
334fn ensure_extension(extension: &'static BuiltinExtensionDescriptor) -> BuiltinResult<()> {
335    crate::compatibility::ensure_builtin_extension_enabled(extension, BUILTIN_NAME)
336}
337
338fn is_scalar_system_value(value: &Value) -> bool {
339    match value {
340        Value::Num(_) | Value::Int(_) | Value::Bool(_) | Value::Complex(_, _) => true,
341        Value::Tensor(tensor) => tensor.len() == 1,
342        Value::ComplexTensor(tensor) => tensor.len() == 1,
343        Value::LogicalArray(logical) => logical.data.len() == 1,
344        Value::GpuTensor(handle) => handle.shape.iter().product::<usize>() == 1,
345        _ => false,
346    }
347}
348
349fn is_typed_integer_value(value: &Value) -> bool {
350    matches!(value, Value::Int(_))
351        || matches!(value, Value::Tensor(tensor) if tensor.integer_storage().is_some())
352        || matches!(value, Value::ComplexTensor(tensor) if tensor.integer_storage().is_some())
353        || matches!(value, Value::GpuTensor(handle) if runmat_accelerate_api::handle_integer_type(handle).is_some())
354}
355
356fn is_single_numeric_value(value: &Value) -> bool {
357    matches!(value, Value::Tensor(tensor) if tensor.numeric_dtype() == runmat_value::NumericDType::F32)
358        || matches!(value, Value::ComplexTensor(tensor) if tensor.numeric_dtype() == runmat_value::NumericDType::F32)
359}
360
361fn is_logical_value(value: &Value) -> bool {
362    matches!(value, Value::Bool(_) | Value::LogicalArray(_))
363        || matches!(value, Value::GpuTensor(handle) if runmat_accelerate_api::handle_is_logical(handle))
364}
365
366fn is_resident_value(value: &Value) -> bool {
367    matches!(value, Value::GpuTensor(_))
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use futures::executor::block_on;
374    use runmat_value::{
375        ComplexTensor, IntValue, IntegerComplexStorage, IntegerStorage, LogicalArray, NumericDType,
376        Tensor,
377    };
378
379    fn tf(num: Vec<f64>, den: Vec<f64>) -> Value {
380        block_on(crate::call_builtin_async(
381            "tf",
382            &[
383                Value::Tensor(Tensor::new(num.clone(), vec![1, num.len()]).unwrap()),
384                Value::Tensor(Tensor::new(den.clone(), vec![1, den.len()]).unwrap()),
385            ],
386        ))
387        .expect("tf")
388    }
389
390    fn coeff(value: &Value, field: &str) -> Vec<f64> {
391        let Value::Object(object) = value else {
392            panic!("expected tf object");
393        };
394        let Value::Tensor(tensor) = object.properties.get(field).expect(field) else {
395            panic!("expected tensor");
396        };
397        tensor.materialize_f64().clone()
398    }
399
400    #[test]
401    fn unity_negative_feedback_forms_closed_loop() {
402        let g = tf(vec![2.0], vec![1.0, 3.0]);
403        let out = block_on(feedback_builtin(g, vec![Value::Num(1.0)])).expect("feedback");
404        assert_eq!(coeff(&out, "Numerator"), vec![2.0]);
405        assert_eq!(coeff(&out, "Denominator"), vec![1.0, 5.0]);
406    }
407
408    #[test]
409    fn positive_feedback_subtracts_loop_gain() {
410        let g = tf(vec![2.0], vec![1.0, 3.0]);
411        let out = block_on(feedback_builtin(g, vec![Value::Num(1.0), Value::Num(1.0)]))
412            .expect("feedback");
413        assert_eq!(coeff(&out, "Numerator"), vec![2.0]);
414        assert_eq!(coeff(&out, "Denominator"), vec![1.0, 1.0]);
415    }
416
417    #[test]
418    fn integer_metadata_covers_gain_and_sign_for_all_classes() {
419        assert_eq!(INTEGER_CAPABILITIES.len(), 2);
420        assert_eq!(INTEGER_CAPABILITIES[0].inputs.len(), 2);
421        assert_eq!(INTEGER_CAPABILITIES[0].inputs[0].classes.len(), 8);
422        assert_eq!(INTEGER_CAPABILITIES[0].inputs[1].classes.len(), 8);
423        assert_eq!(INTEGER_CAPABILITIES[1].inputs[0].classes.len(), 8);
424        assert_eq!(
425            INTEGER_CAPABILITIES[0].computation_domain,
426            BuiltinIntegerComputationDomain::FloatingPoint
427        );
428        assert_eq!(
429            INTEGER_CAPABILITIES[1].computation_domain,
430            BuiltinIntegerComputationDomain::Structural
431        );
432    }
433
434    #[test]
435    fn integer_gain_and_sign_support_all_eight_classes_in_runmat_mode() {
436        let _compat = crate::compatibility::push_runmat_extensions_enabled(true);
437        let gains = [
438            IntValue::I8(1),
439            IntValue::I16(1),
440            IntValue::I32(1),
441            IntValue::I64(1),
442            IntValue::U8(1),
443            IntValue::U16(1),
444            IntValue::U32(1),
445            IntValue::U64(1),
446        ];
447        for gain in gains {
448            let out = block_on(feedback_builtin(
449                tf(vec![2.0], vec![1.0, 3.0]),
450                vec![Value::Int(gain.clone()), Value::Int(gain)],
451            ))
452            .expect("typed-integer gain and sign");
453            assert_eq!(coeff(&out, "Denominator"), vec![1.0, 1.0]);
454        }
455    }
456
457    #[test]
458    fn integer_tensor_gain_uses_explicit_binary64_boundary() {
459        let _compat = crate::compatibility::push_runmat_extensions_enabled(true);
460        let wide = (1_u64 << 53) + 1;
461        let gain = Tensor::new_integer(IntegerStorage::U64(vec![wide]), vec![1, 1])
462            .expect("integer scalar tensor");
463        let out = block_on(feedback_builtin(
464            tf(vec![1.0], vec![1.0]),
465            vec![Value::Tensor(gain)],
466        ))
467        .expect("wide integer gain");
468        assert_eq!(coeff(&out, "Denominator"), vec![(wide as f64) + 1.0]);
469    }
470
471    #[test]
472    fn integer_sign_keeps_the_exact_plus_or_minus_one_contract() {
473        let _compat = crate::compatibility::push_runmat_extensions_enabled(true);
474        let negative = block_on(feedback_builtin(
475            tf(vec![2.0], vec![1.0, 3.0]),
476            vec![Value::Num(1.0), Value::Int(IntValue::I64(-1))],
477        ))
478        .expect("negative integer sign");
479        assert_eq!(coeff(&negative, "Denominator"), vec![1.0, 5.0]);
480
481        let error = block_on(feedback_builtin(
482            tf(vec![2.0], vec![1.0, 3.0]),
483            vec![Value::Num(1.0), Value::Int(IntValue::U64(0))],
484        ))
485        .expect_err("zero is not a feedback sign");
486        assert_eq!(error.identifier(), FEEDBACK_ERRORS[2].identifier);
487    }
488
489    #[test]
490    fn logical_numeric_inputs_are_a_separate_runmat_extension() {
491        let g = tf(vec![2.0], vec![1.0, 3.0]);
492        {
493            let _compat = crate::compatibility::push_runmat_extensions_enabled(false);
494            let error = block_on(feedback_builtin(g.clone(), vec![Value::Bool(true)]))
495                .expect_err("strict mode rejects logical gain");
496            assert_eq!(
497                error.identifier(),
498                LOGICAL_NUMERIC_EXTENSION.error_identifier
499            );
500        }
501        {
502            let _compat = crate::compatibility::push_runmat_extensions_enabled(true);
503            let logical = LogicalArray::new(vec![1], vec![1, 1]).expect("logical scalar");
504            let out = block_on(feedback_builtin(g, vec![Value::LogicalArray(logical)]))
505                .expect("logical gain");
506            assert_eq!(coeff(&out, "Denominator"), vec![1.0, 5.0]);
507        }
508    }
509
510    #[test]
511    fn runmat_only_forms_have_stable_independent_guards() {
512        let _compat = crate::compatibility::push_runmat_extensions_enabled(false);
513
514        let documented = block_on(feedback_builtin(
515            tf(vec![2.0], vec![1.0, 3.0]),
516            vec![Value::Num(1.0), Value::Num(-1.0)],
517        ))
518        .expect("tf plus ordinary double gain and sign remains admitted");
519        assert_eq!(coeff(&documented, "Denominator"), vec![1.0, 5.0]);
520
521        let error = block_on(feedback_builtin(tf(vec![1.0], vec![1.0]), vec![]))
522            .expect_err("one-argument form");
523        assert_eq!(error.identifier(), SINGLE_SYSTEM_EXTENSION.error_identifier);
524
525        let error = block_on(feedback_builtin(
526            Value::Num(1.0),
527            vec![tf(vec![1.0], vec![1.0])],
528        ))
529        .expect_err("scalar forward form");
530        assert_eq!(
531            error.identifier(),
532            SCALAR_FORWARD_EXTENSION.error_identifier
533        );
534
535        let error = block_on(feedback_builtin(Value::Num(1.0), vec![Value::Num(1.0)]))
536            .expect_err("scalar-scalar form");
537        assert_eq!(
538            error.identifier(),
539            SCALAR_SYSTEMS_EXTENSION.error_identifier
540        );
541
542        let error = block_on(feedback_builtin(
543            tf(vec![1.0], vec![1.0]),
544            vec![Value::Int(IntValue::I16(1))],
545        ))
546        .expect_err("integer gain");
547        assert_eq!(error.identifier(), INTEGER_GAIN_EXTENSION.error_identifier);
548
549        let error = block_on(feedback_builtin(
550            tf(vec![1.0], vec![1.0]),
551            vec![Value::Num(1.0), Value::Int(IntValue::I8(-1))],
552        ))
553        .expect_err("integer sign");
554        assert_eq!(error.identifier(), INTEGER_SIGN_EXTENSION.error_identifier);
555
556        let complex_integer = ComplexTensor::new_integer(
557            IntegerComplexStorage::new(IntegerStorage::I16(vec![1]), IntegerStorage::I16(vec![0]))
558                .expect("paired integer storage"),
559            vec![1, 1],
560        )
561        .expect("complex integer scalar");
562        let error = block_on(feedback_builtin(
563            tf(vec![1.0], vec![1.0]),
564            vec![Value::ComplexTensor(complex_integer)],
565        ))
566        .expect_err("complex integer gain");
567        assert_eq!(error.identifier(), INTEGER_GAIN_EXTENSION.error_identifier);
568
569        let single = Tensor::new_with_dtype(vec![1.0], vec![1, 1], NumericDType::F32)
570            .expect("single scalar");
571        let error = block_on(feedback_builtin(
572            tf(vec![1.0], vec![1.0]),
573            vec![Value::Tensor(single)],
574        ))
575        .expect_err("single gain");
576        assert_eq!(
577            error.identifier(),
578            SINGLE_NUMERIC_EXTENSION.error_identifier
579        );
580    }
581
582    #[test]
583    fn resident_gate_runs_before_provider_access() {
584        let _compat = crate::compatibility::push_runmat_extensions_enabled(false);
585        let resident = Value::GpuTensor(runmat_accelerate_api::GpuTensorHandle {
586            shape: vec![1, 1],
587            device_id: 0,
588            buffer_id: 9_404_001,
589            descriptor: Default::default(),
590        });
591        let error = block_on(feedback_builtin(tf(vec![1.0], vec![1.0]), vec![resident]))
592            .expect_err("resident gate");
593        assert_eq!(
594            error.identifier(),
595            RESIDENT_NUMERIC_EXTENSION.error_identifier
596        );
597    }
598}