Skip to main content

ta_benchmarks/
performance_qualification.rs

1//! Native SIMD Performance Qualification for the public TYPPRICE Batch Computation.
2//!
3//! This module owns the fixed measurement policy, semantic parity checks,
4//! statistics, checksums, evidence schema, and performance gates. Platform
5//! adapters supply observed provenance and already-prepared external references;
6//! filesystem I/O and artifact publication remain outside this seam.
7
8use fast_ta::price_transform::TYPPRICE;
9use fast_ta::simd::dispatch::{active_indicator_backend, IndicatorBackend};
10use fast_ta::Float;
11use serde_json::{json, Value};
12use std::fmt;
13use std::hint::black_box;
14use std::time::Instant;
15
16#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))]
17use fast_ta::simd::dispatch::qualification::{backend_available, with_indicator_backend};
18
19const INPUT_LENGTHS: [usize; 3] = [256, 4_096, 65_536];
20const SAMPLE_COUNT: usize = 31;
21const BOOTSTRAP_RESAMPLES: usize = 2_000;
22const PERFORMANCE_THRESHOLD_PERCENT: f64 = 5.0;
23const TALIB_VERSION: &str = "0.6.4";
24const TALIB_REVISION: &str = "43f9d5042ecc4bd367941846494ad907bf20ea50";
25const TALIB_ARCHIVE_SHA256: &str =
26    "aa04066d17d69c73b1baaef0883414d3d56ab3775872d82916d1cdb376a3ae86";
27
28/// Explicit process and workflow facts supplied by the native adapter.
29#[derive(Clone, Debug)]
30pub struct Provenance {
31    pub runtime: String,
32    pub os: String,
33    pub architecture: String,
34    pub cpu: String,
35    pub cpu_features: String,
36    pub rust_profile: String,
37    pub cargo_features: String,
38    pub target_features: String,
39    pub commit: String,
40    pub qualification_command: String,
41    pub source_repository: String,
42    pub workflow_name: String,
43    pub workflow_ref: String,
44    pub workflow_run_id: String,
45    pub workflow_run_url: String,
46    pub workflow_job: String,
47    pub workflow_attempt: String,
48}
49
50/// The real native platform adapters supported by this qualification.
51#[derive(Clone, Debug)]
52pub enum NativePlatform {
53    #[cfg(target_arch = "x86_64")]
54    X86,
55    #[cfg(target_arch = "aarch64")]
56    Aarch64 {
57        /// Required for canonical f64 qualification and absent for f32.
58        talib_library: Option<std::path::PathBuf>,
59    },
60}
61
62/// One complete native TYPPRICE qualification request.
63#[derive(Clone, Debug)]
64pub struct QualificationRequest {
65    pub platform: NativePlatform,
66    pub provenance: Provenance,
67}
68
69/// A selected production backend that failed its platform performance gate.
70#[derive(Clone, Debug, PartialEq)]
71pub struct Regression {
72    pub backend: &'static str,
73    pub input_length: usize,
74    pub delta_percent: f64,
75    pub requirement: &'static str,
76}
77
78/// Complete validated evidence plus a separately enforceable performance verdict.
79#[derive(Clone, Debug)]
80pub struct QualificationOutcome {
81    jsonl: String,
82    regressions: Vec<Regression>,
83}
84
85impl QualificationOutcome {
86    /// Returns the validated JSONL payload. Persist it before enforcing the gate.
87    pub fn jsonl(&self) -> &str {
88        &self.jsonl
89    }
90
91    /// Returns observed production-selection regressions.
92    pub fn regressions(&self) -> &[Regression] {
93        &self.regressions
94    }
95
96    /// Enforces the performance gate after evidence has been persisted.
97    pub fn require_pass(&self) -> Result<(), RegressionGateError> {
98        if self.regressions.is_empty() {
99            Ok(())
100        } else {
101            Err(RegressionGateError(self.regressions.clone()))
102        }
103    }
104}
105
106#[derive(Clone, Debug)]
107pub struct RegressionGateError(Vec<Regression>);
108
109impl fmt::Display for RegressionGateError {
110    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
111        write!(
112            formatter,
113            "native TYPPRICE performance gate failed: {:?}",
114            self.0
115        )
116    }
117}
118
119impl std::error::Error for RegressionGateError {}
120
121#[derive(Debug)]
122pub struct QualificationError(String);
123
124impl QualificationError {
125    fn new(message: impl Into<String>) -> Self {
126        Self(message.into())
127    }
128}
129
130impl fmt::Display for QualificationError {
131    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
132        formatter.write_str(&self.0)
133    }
134}
135
136impl std::error::Error for QualificationError {}
137
138#[derive(Clone, Copy, Debug)]
139struct Measurement {
140    median_ns: f64,
141    lower_ns: f64,
142    upper_ns: f64,
143}
144
145#[derive(Debug, PartialEq, Eq)]
146struct ValidationEvidence {
147    unequal_lengths_error: String,
148    non_finite_error: String,
149    short_output_error: String,
150}
151
152/// Runs the fixed native TYPPRICE Performance Qualification.
153///
154/// Semantic or evidence failures return [`QualificationError`] and no affirmative
155/// evidence. A performance failure returns a complete [`QualificationOutcome`]
156/// whose verdict is enforced separately with [`QualificationOutcome::require_pass`].
157pub fn qualify_typprice(
158    request: QualificationRequest,
159) -> Result<QualificationOutcome, QualificationError> {
160    validate_provenance(&request.provenance)?;
161    match request.platform {
162        #[cfg(target_arch = "x86_64")]
163        NativePlatform::X86 => qualify_x86(request.provenance),
164        #[cfg(target_arch = "aarch64")]
165        NativePlatform::Aarch64 { talib_library } => {
166            qualify_aarch64(request.provenance, talib_library)
167        }
168    }
169}
170
171fn validate_provenance(provenance: &Provenance) -> Result<(), QualificationError> {
172    for (name, value) in [
173        ("runtime", provenance.runtime.as_str()),
174        ("os", provenance.os.as_str()),
175        ("architecture", provenance.architecture.as_str()),
176        ("cpu", provenance.cpu.as_str()),
177        ("rust_profile", provenance.rust_profile.as_str()),
178        ("cargo_features", provenance.cargo_features.as_str()),
179        ("commit", provenance.commit.as_str()),
180        (
181            "qualification_command",
182            provenance.qualification_command.as_str(),
183        ),
184        ("workflow_run_id", provenance.workflow_run_id.as_str()),
185        ("workflow_run_url", provenance.workflow_run_url.as_str()),
186        ("workflow_job", provenance.workflow_job.as_str()),
187    ] {
188        if value.is_empty() {
189            return Err(QualificationError::new(format!(
190                "qualification provenance field {name:?} is empty"
191            )));
192        }
193    }
194    Ok(())
195}
196
197#[cfg(target_arch = "x86_64")]
198fn qualify_x86(provenance: Provenance) -> Result<QualificationOutcome, QualificationError> {
199    if provenance.architecture != "x86_64" {
200        return Err(QualificationError::new(format!(
201            "x86 qualification received architecture {:?}",
202            provenance.architecture
203        )));
204    }
205    if !backend_available(IndicatorBackend::Avx2) {
206        return Err(QualificationError::new(
207            "x86 qualification requires an AVX2-capable host",
208        ));
209    }
210    let active = active_indicator_backend();
211    let expected_active = if cfg!(feature = "f32") {
212        if backend_available(IndicatorBackend::Avx512) {
213            IndicatorBackend::Avx512
214        } else {
215            IndicatorBackend::Avx2
216        }
217    } else {
218        IndicatorBackend::Scalar
219    };
220    if active != expected_active {
221        return Err(QualificationError::new(format!(
222            "x86 production dispatch selected {}, expected {}",
223            active.as_str(),
224            expected_active.as_str()
225        )));
226    }
227
228    let backends: Vec<_> = [
229        IndicatorBackend::Scalar,
230        IndicatorBackend::Avx2,
231        IndicatorBackend::Avx512,
232    ]
233    .into_iter()
234    .filter(|backend| backend_available(*backend))
235    .collect();
236    let scalar_validation = validate_public_boundary(IndicatorBackend::Scalar)?;
237    let mut records = vec![x86_metadata(&provenance, active)];
238    for backend in &backends {
239        let validation = validate_public_boundary(*backend)?;
240        if validation != scalar_validation {
241            return Err(QualificationError::new(format!(
242                "{} public error semantics differ from scalar",
243                backend.as_str()
244            )));
245        }
246        records.push(json!({
247            "record": "validation",
248            "indicator": "TYPPRICE",
249            "backend": backend.as_str(),
250            "public_boundary": true,
251            "unequal_lengths_verified": true,
252            "non_finite_verified": true,
253            "short_output_verified": true,
254            "errors_match_scalar": true,
255            "unequal_lengths_error": validation.unequal_lengths_error,
256            "non_finite_error": validation.non_finite_error,
257            "short_output_error": validation.short_output_error,
258        }));
259    }
260
261    let mut regressions = Vec::new();
262    for size in INPUT_LENGTHS {
263        let (high, low, close) = fixture(size);
264        let scalar = compute(IndicatorBackend::Scalar, &high, &low, &close)?;
265        let checksum = checksum(&scalar);
266        let mut measurements = Vec::with_capacity(backends.len());
267        for backend in &backends {
268            let output = compute(*backend, &high, &low, &close)?;
269            if output != scalar {
270                return Err(QualificationError::new(format!(
271                    "{} differs from scalar at input length {size}",
272                    backend.as_str()
273                )));
274            }
275            let timing = measure_fast_ta(*backend, &high, &low, &close)?;
276            measurements.push((*backend, timing));
277        }
278        let scalar_median = measurements
279            .iter()
280            .find(|(backend, _)| *backend == IndicatorBackend::Scalar)
281            .expect("scalar backend is always present")
282            .1
283            .median_ns;
284        for (backend, timing) in measurements {
285            let ratio = timing.median_ns / scalar_median;
286            let slower_percent = (ratio - 1.0) * 100.0;
287            let exceeds = ratio > 1.05;
288            let selected = backend == active;
289            let disposition = if backend == IndicatorBackend::Scalar {
290                "scalar control"
291            } else if exceeds && selected {
292                regressions.push(Regression {
293                    backend: backend.as_str(),
294                    input_length: size,
295                    delta_percent: slower_percent,
296                    requirement: "selected backend must be no more than 5% slower than scalar",
297                });
298                "invalid selection: accelerated backend exceeds the 5% scalar gate"
299            } else if exceeds {
300                "not selected: accelerated backend exceeds the 5% scalar gate"
301            } else if selected {
302                "selected: accelerated backend is within the 5% scalar gate"
303            } else {
304                "qualified but not selected: a higher-priority backend is active"
305            };
306            records.push(measurement_record(
307                backend.as_str(),
308                "public TYPPRICE",
309                size,
310                &checksum,
311                timing,
312                json!({
313                    "scalar_ratio": ratio,
314                    "slower_than_scalar_pct": slower_percent,
315                    "exceeds_5_percent": exceeds,
316                    "selected": selected,
317                    "disposition": disposition,
318                }),
319            ));
320        }
321    }
322    finish(records, regressions, "x86 native TYPPRICE qualification")
323}
324
325#[cfg(target_arch = "x86_64")]
326fn x86_metadata(provenance: &Provenance, active: IndicatorBackend) -> Value {
327    json!({
328        "record": "metadata",
329        "indicator": "TYPPRICE",
330        "indicator_definition": "TYPPRICE: Typical Price",
331        "parameters": "none",
332        "fixture": fixture_id(),
333        "platform": "x86_64",
334        "os": provenance.os,
335        "architecture": provenance.architecture,
336        "precision": precision(),
337        "runtime": provenance.runtime,
338        "rust_profile": provenance.rust_profile,
339        "profile": provenance.rust_profile,
340        "cargo_features": provenance.cargo_features,
341        "features": provenance.cargo_features,
342        "target_features": provenance.target_features,
343        "cpu": provenance.cpu,
344        "commit": provenance.commit,
345        "qualification_command": provenance.qualification_command,
346        "workflow_run_id": provenance.workflow_run_id,
347        "workflow_run_url": provenance.workflow_run_url,
348        "workflow_job": provenance.workflow_job,
349        "active_backend": active.as_str(),
350        "avx2_available": true,
351        "avx512_available": backend_available(IndicatorBackend::Avx512),
352    })
353}
354
355#[cfg(target_arch = "aarch64")]
356fn qualify_aarch64(
357    provenance: Provenance,
358    talib_library: Option<std::path::PathBuf>,
359) -> Result<QualificationOutcome, QualificationError> {
360    if provenance.architecture != "arm64" && provenance.architecture != "aarch64" {
361        return Err(QualificationError::new(format!(
362            "AArch64 qualification received architecture {:?}",
363            provenance.architecture
364        )));
365    }
366    if !backend_available(IndicatorBackend::Neon) {
367        return Err(QualificationError::new(
368            "AArch64 qualification requires an executable NEON backend",
369        ));
370    }
371    let active = active_indicator_backend();
372    if active != IndicatorBackend::Neon {
373        return Err(QualificationError::new(format!(
374            "AArch64 production dispatch selected {}, expected neon",
375            active.as_str()
376        )));
377    }
378
379    #[cfg(not(feature = "f32"))]
380    let c_library = TalibLibrary::load(talib_library.ok_or_else(|| {
381        QualificationError::new(
382            "canonical AArch64 f64 qualification requires QUALIFICATION_TALIB_LIBRARY",
383        )
384    })?)?;
385    #[cfg(feature = "f32")]
386    if talib_library.is_some() {
387        return Err(QualificationError::new(
388            "AArch64 f32 qualification must not receive an f64 TA-Lib control",
389        ));
390    }
391
392    let scalar_validation = validate_public_boundary(IndicatorBackend::Scalar)?;
393    let neon_validation = validate_public_boundary(IndicatorBackend::Neon)?;
394    if neon_validation != scalar_validation {
395        return Err(QualificationError::new(
396            "NEON public error semantics differ from scalar",
397        ));
398    }
399
400    let mut records = vec![aarch64_metadata(&provenance, active)];
401    for backend in [IndicatorBackend::Scalar, IndicatorBackend::Neon] {
402        records.push(json!({
403            "record": "validation",
404            "indicator": "TYPPRICE",
405            "precision": precision(),
406            "backend": backend.as_str(),
407            "mode": "public TYPPRICE",
408            "public_boundary": true,
409            "exact_scalar_equivalence": true,
410            "error_semantics_verified": true,
411            "mismatched_length_error_equal_to_scalar": true,
412            "non_finite_error_equal_to_scalar": true,
413            "observed_backend": backend.as_str(),
414        }));
415    }
416
417    let mut regressions = Vec::new();
418    for size in INPUT_LENGTHS {
419        let (high, low, close) = fixture(size);
420        let scalar = compute(IndicatorBackend::Scalar, &high, &low, &close)?;
421        let neon = compute(IndicatorBackend::Neon, &high, &low, &close)?;
422        if neon != scalar {
423            return Err(QualificationError::new(format!(
424                "NEON differs from scalar at input length {size}"
425            )));
426        }
427        let checksum = checksum(&scalar);
428        let scalar_timing = measure_fast_ta(IndicatorBackend::Scalar, &high, &low, &close)?;
429        let neon_timing = measure_fast_ta(IndicatorBackend::Neon, &high, &low, &close)?;
430        records.push(measurement_record(
431            "scalar",
432            "public TYPPRICE",
433            size,
434            &checksum,
435            scalar_timing,
436            json!({
437                "performance_delta_percent_vs_scalar": 0.0,
438                "performance_threshold_percent": PERFORMANCE_THRESHOLD_PERCENT,
439                "performance_disposition": "scalar_control",
440            }),
441        ));
442        let neon_delta =
443            (scalar_timing.median_ns - neon_timing.median_ns) * 100.0 / scalar_timing.median_ns;
444        if size >= 4_096 && neon_delta <= PERFORMANCE_THRESHOLD_PERCENT {
445            regressions.push(Regression {
446                backend: "neon",
447                input_length: size,
448                delta_percent: neon_delta,
449                requirement: "selected NEON backend must beat scalar by more than 5%",
450            });
451        }
452        records.push(measurement_record(
453            "neon",
454            "public TYPPRICE",
455            size,
456            &checksum,
457            neon_timing,
458            json!({
459                "performance_delta_percent_vs_scalar": neon_delta,
460                "performance_threshold_percent": PERFORMANCE_THRESHOLD_PERCENT,
461                "performance_disposition": performance_disposition(neon_delta),
462            }),
463        ));
464
465        #[cfg(not(feature = "f32"))]
466        {
467            let c_output = c_library.compute(&high, &low, &close)?;
468            if c_output != scalar {
469                return Err(QualificationError::new(format!(
470                    "pinned TA-Lib C differs from scalar at input length {size}"
471                )));
472            }
473            let c_timing = c_library.measure(&high, &low, &close)?;
474            let c_delta =
475                (scalar_timing.median_ns - c_timing.median_ns) * 100.0 / scalar_timing.median_ns;
476            records.push(measurement_record(
477                "ta-lib-c",
478                "direct C caller-owned",
479                size,
480                &checksum,
481                c_timing,
482                json!({
483                    "error_semantics_verified": false,
484                    "performance_delta_percent_vs_scalar": c_delta,
485                    "performance_threshold_percent": PERFORMANCE_THRESHOLD_PERCENT,
486                    "performance_disposition": performance_disposition(c_delta),
487                }),
488            ));
489        }
490    }
491    finish(
492        records,
493        regressions,
494        "AArch64 native TYPPRICE qualification",
495    )
496}
497
498#[cfg(target_arch = "aarch64")]
499fn aarch64_metadata(provenance: &Provenance, active: IndicatorBackend) -> Value {
500    json!({
501        "record": "metadata",
502        "indicator": "TYPPRICE",
503        "indicator_definition": "TYPPRICE: Typical Price",
504        "parameters": "none",
505        "fixture": fixture_id(),
506        "platform": "aarch64",
507        "precision": precision(),
508        "runtime": provenance.runtime,
509        "os": provenance.os,
510        "cpu": provenance.cpu,
511        "cpu_features": provenance.cpu_features,
512        "profile": provenance.rust_profile,
513        "features": provenance.cargo_features,
514        "command": provenance.qualification_command,
515        "commit": provenance.commit,
516        "source_repository": provenance.source_repository,
517        "source_revision": provenance.commit,
518        "workflow_name": provenance.workflow_name,
519        "workflow_ref": provenance.workflow_ref,
520        "workflow_run_id": provenance.workflow_run_id,
521        "workflow_run_url": provenance.workflow_run_url,
522        "workflow_job": provenance.workflow_job,
523        "workflow_attempt": provenance.workflow_attempt,
524        "active_backend": active.as_str(),
525        "neon_available": true,
526        "c_reference_available": !cfg!(feature = "f32"),
527        "ta_lib_version": TALIB_VERSION,
528        "ta_lib_revision": TALIB_REVISION,
529        "ta_lib_archive_sha256": TALIB_ARCHIVE_SHA256,
530    })
531}
532
533fn finish(
534    records: Vec<Value>,
535    regressions: Vec<Regression>,
536    artifact: &str,
537) -> Result<QualificationOutcome, QualificationError> {
538    validate_records(&records, artifact)?;
539    let mut jsonl = String::new();
540    for record in records {
541        jsonl.push_str(
542            &serde_json::to_string(&record)
543                .map_err(|error| QualificationError::new(format!("encode evidence: {error}")))?,
544        );
545        jsonl.push('\n');
546    }
547    Ok(QualificationOutcome { jsonl, regressions })
548}
549
550fn validate_records(records: &[Value], artifact: &str) -> Result<(), QualificationError> {
551    let metadata_count = records
552        .iter()
553        .filter(|record| record.get("record").and_then(Value::as_str) == Some("metadata"))
554        .count();
555    let validation_count = records
556        .iter()
557        .filter(|record| record.get("record").and_then(Value::as_str) == Some("validation"))
558        .count();
559    let measurements: Vec<_> = records
560        .iter()
561        .filter(|record| record.get("record").and_then(Value::as_str) == Some("measurement"))
562        .collect();
563    if metadata_count != 1 || validation_count == 0 || measurements.is_empty() {
564        return Err(QualificationError::new(format!(
565            "{artifact} evidence must contain one metadata record, validation, and measurements"
566        )));
567    }
568    for measurement in measurements {
569        let input_length = measurement
570            .get("input_length")
571            .and_then(Value::as_u64)
572            .and_then(|value| usize::try_from(value).ok())
573            .filter(|value| INPUT_LENGTHS.contains(value))
574            .ok_or_else(|| QualificationError::new("measurement has invalid input_length"))?;
575        let sample_count = measurement
576            .get("sample_count")
577            .and_then(Value::as_u64)
578            .ok_or_else(|| QualificationError::new("measurement has invalid sample_count"))?;
579        if sample_count != SAMPLE_COUNT as u64 {
580            return Err(QualificationError::new(format!(
581                "measurement at {input_length} has sample_count {sample_count}"
582            )));
583        }
584        let median = measurement
585            .get("median_ns")
586            .and_then(Value::as_f64)
587            .ok_or_else(|| QualificationError::new("measurement has invalid median_ns"))?;
588        let lower = measurement
589            .get("ci95_lower_ns")
590            .and_then(Value::as_f64)
591            .ok_or_else(|| QualificationError::new("measurement has invalid ci95_lower_ns"))?;
592        let upper = measurement
593            .get("ci95_upper_ns")
594            .and_then(Value::as_f64)
595            .ok_or_else(|| QualificationError::new("measurement has invalid ci95_upper_ns"))?;
596        if !median.is_finite() || median <= 0.0 || lower > median || median > upper {
597            return Err(QualificationError::new(format!(
598                "measurement at {input_length} has incoherent timing evidence"
599            )));
600        }
601    }
602    Ok(())
603}
604
605fn measurement_record(
606    backend: &str,
607    mode: &str,
608    input_length: usize,
609    output_checksum: &str,
610    timing: Measurement,
611    extra: Value,
612) -> Value {
613    let mut record = json!({
614        "record": "measurement",
615        "indicator": "TYPPRICE",
616        "indicator_family": "Price Transform",
617        "indicator_definition": "TYPPRICE: Typical Price",
618        "case_id": "TYPPRICE",
619        "mode": mode,
620        "backend": backend,
621        "parameters": "none",
622        "input_length": input_length,
623        "output_kind": "float",
624        "output_arity": 1,
625        "output_begin": 0,
626        "output_count": input_length,
627        "output_checksum": output_checksum,
628        "equivalent_to_scalar": true,
629        "exact_scalar_equivalence": true,
630        "error_semantics_verified": true,
631        "same_public_boundary": true,
632        "semantic_status": "verified",
633        "timing_status": "measured",
634        "sample_count": SAMPLE_COUNT,
635        "median_ns": timing.median_ns,
636        "ci95_lower_ns": timing.lower_ns,
637        "ci95_upper_ns": timing.upper_ns,
638        "throughput_observations_per_second": input_length as f64 * 1_000_000_000.0 / timing.median_ns,
639        "fixture": fixture_id(),
640        "timed_boundary": if backend == "ta-lib-c" { "direct TA-Lib C caller-owned" } else { "public TYPPRICE validation, dispatch, and caller-owned output" },
641    });
642    if let (Some(target), Some(source)) = (record.as_object_mut(), extra.as_object()) {
643        target.extend(source.clone());
644    }
645    record
646}
647
648fn validate_public_boundary(
649    backend: IndicatorBackend,
650) -> Result<ValidationEvidence, QualificationError> {
651    with_indicator_backend(backend, || {
652        let mut mismatch_output = [91.0 as Float; 2];
653        let unequal_lengths = TYPPRICE(
654            &[2.0 as Float, 3.0 as Float],
655            &[1.0 as Float],
656            &[1.5 as Float, 2.5 as Float],
657            &mut mismatch_output,
658        )
659        .map_err(|error| error.to_string())
660        .expect_err("unequal input lengths must fail");
661        if mismatch_output != [91.0 as Float; 2] {
662            return Err(QualificationError::new(
663                "unequal-length failure mutated output",
664            ));
665        }
666
667        let mut non_finite_output = [92.0 as Float; 1];
668        let non_finite = TYPPRICE(
669            &[2.0 as Float],
670            &[Float::NAN],
671            &[1.5 as Float],
672            &mut non_finite_output,
673        )
674        .map_err(|error| error.to_string())
675        .expect_err("non-finite input must fail");
676        if non_finite_output != [92.0 as Float; 1] {
677            return Err(QualificationError::new("non-finite failure mutated output"));
678        }
679
680        let mut short_output = [93.0 as Float; 1];
681        let short = TYPPRICE(
682            &[2.0 as Float, 3.0 as Float],
683            &[1.0 as Float, 2.0 as Float],
684            &[1.5 as Float, 2.5 as Float],
685            &mut short_output,
686        )
687        .map_err(|error| error.to_string())
688        .expect_err("short output must fail");
689        if short_output != [93.0 as Float; 1] {
690            return Err(QualificationError::new(
691                "short-output failure mutated output",
692            ));
693        }
694
695        Ok(ValidationEvidence {
696            unequal_lengths_error: unequal_lengths,
697            non_finite_error: non_finite,
698            short_output_error: short,
699        })
700    })
701}
702
703fn compute(
704    backend: IndicatorBackend,
705    high: &[Float],
706    low: &[Float],
707    close: &[Float],
708) -> Result<Vec<Float>, QualificationError> {
709    with_indicator_backend(backend, || {
710        if active_indicator_backend() != backend {
711            return Err(QualificationError::new(format!(
712                "requested {}, observed {}",
713                backend.as_str(),
714                active_indicator_backend().as_str()
715            )));
716        }
717        let mut output = vec![0.0 as Float; high.len()];
718        let range = TYPPRICE(high, low, close, &mut output)
719            .map_err(|error| QualificationError::new(error.to_string()))?;
720        if (range.beg_idx, range.nb_element) != (0, high.len()) {
721            return Err(QualificationError::new(format!(
722                "{} returned range {:?} for input length {}",
723                backend.as_str(),
724                range,
725                high.len()
726            )));
727        }
728        Ok(output)
729    })
730}
731
732fn measure_fast_ta(
733    backend: IndicatorBackend,
734    high: &[Float],
735    low: &[Float],
736    close: &[Float],
737) -> Result<Measurement, QualificationError> {
738    with_indicator_backend(backend, || {
739        let mut output = vec![0.0 as Float; high.len()];
740        let mut run = || -> Result<(), QualificationError> {
741            let range = TYPPRICE(
742                black_box(high),
743                black_box(low),
744                black_box(close),
745                black_box(&mut output),
746            )
747            .map_err(|error| QualificationError::new(error.to_string()))?;
748            if (range.beg_idx, range.nb_element) != (0, high.len()) {
749                return Err(QualificationError::new(
750                    "timed TYPPRICE returned wrong range",
751                ));
752            }
753            black_box(output[output.len() - 1]);
754            Ok(())
755        };
756        measure(&mut run, high.len())
757    })
758}
759
760fn measure(
761    operation: &mut impl FnMut() -> Result<(), QualificationError>,
762    input_length: usize,
763) -> Result<Measurement, QualificationError> {
764    for _ in 0..10 {
765        operation()?;
766    }
767    let iterations = iterations(input_length);
768    let mut samples = Vec::with_capacity(SAMPLE_COUNT);
769    for _ in 0..SAMPLE_COUNT {
770        let started = Instant::now();
771        for _ in 0..iterations {
772            operation()?;
773        }
774        samples.push(started.elapsed().as_nanos() as f64 / iterations as f64);
775    }
776    if samples
777        .iter()
778        .any(|sample| !sample.is_finite() || *sample <= 0.0)
779    {
780        return Err(QualificationError::new(
781            "qualification produced non-positive or non-finite timing",
782        ));
783    }
784    let (lower_ns, upper_ns) = confidence_interval(&samples);
785    let median_ns = median(&mut samples);
786    if lower_ns > median_ns || median_ns > upper_ns {
787        return Err(QualificationError::new(
788            "qualification confidence interval does not contain median",
789        ));
790    }
791    Ok(Measurement {
792        median_ns,
793        lower_ns,
794        upper_ns,
795    })
796}
797
798fn fixture(size: usize) -> (Vec<Float>, Vec<Float>, Vec<Float>) {
799    let mut high = Vec::with_capacity(size);
800    let mut low = Vec::with_capacity(size);
801    let mut close = Vec::with_capacity(size);
802    for index in 0..size {
803        let base = 100.0 as Float + index as Float * 0.015625 as Float;
804        let wave = ((index % 29) as Float - 14.0 as Float) * 0.03125 as Float;
805        let lo = base + wave;
806        low.push(lo);
807        high.push(lo + 1.0 as Float + (index % 5) as Float * 0.0625 as Float);
808        close.push(lo + 0.375 as Float + (index % 7) as Float * 0.03125 as Float);
809    }
810    (high, low, close)
811}
812
813fn iterations(size: usize) -> usize {
814    match size {
815        0..=256 => 2_000,
816        257..=4_096 => 300,
817        _ => 20,
818    }
819}
820
821fn median(values: &mut [f64]) -> f64 {
822    values.sort_by(f64::total_cmp);
823    values[values.len() / 2]
824}
825
826fn confidence_interval(samples: &[f64]) -> (f64, f64) {
827    let mut state = 0x243f_6a88_85a3_08d3u64;
828    let mut medians = Vec::with_capacity(BOOTSTRAP_RESAMPLES);
829    let mut resample = Vec::with_capacity(samples.len());
830    for _ in 0..BOOTSTRAP_RESAMPLES {
831        resample.clear();
832        for _ in 0..samples.len() {
833            state ^= state << 13;
834            state ^= state >> 7;
835            state ^= state << 17;
836            resample.push(samples[state as usize % samples.len()]);
837        }
838        medians.push(median(&mut resample));
839    }
840    medians.sort_by(f64::total_cmp);
841    let lower = ((BOOTSTRAP_RESAMPLES as f64 * 0.025) as usize).min(BOOTSTRAP_RESAMPLES - 1);
842    let upper = ((BOOTSTRAP_RESAMPLES as f64 * 0.975) as usize).min(BOOTSTRAP_RESAMPLES - 1);
843    (medians[lower], medians[upper])
844}
845
846fn checksum(values: &[Float]) -> String {
847    let mut hash = 0xcbf2_9ce4_8422_2325u64;
848    for value in values {
849        for byte in value.to_le_bytes() {
850            hash ^= u64::from(byte);
851            hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
852        }
853    }
854    format!("fnv1a64:{hash:016x}")
855}
856
857fn precision() -> &'static str {
858    if cfg!(feature = "f32") {
859        "f32"
860    } else {
861        "f64"
862    }
863}
864
865fn fixture_id() -> &'static str {
866    if cfg!(feature = "f32") {
867        "catalogue_fixture_v1:f32le"
868    } else {
869        "catalogue_fixture_v1:f64le"
870    }
871}
872
873#[cfg(target_arch = "aarch64")]
874fn performance_disposition(delta_percent: f64) -> &'static str {
875    if delta_percent > PERFORMANCE_THRESHOLD_PERCENT {
876        "benefit_over_5_percent"
877    } else if delta_percent < -PERFORMANCE_THRESHOLD_PERCENT {
878        "regression_over_5_percent"
879    } else {
880        "within_5_percent"
881    }
882}
883
884#[cfg(all(target_arch = "aarch64", not(feature = "f32")))]
885struct TalibLibrary {
886    handle: *mut std::ffi::c_void,
887    typprice: unsafe extern "C" fn(
888        std::ffi::c_int,
889        std::ffi::c_int,
890        *const f64,
891        *const f64,
892        *const f64,
893        *mut std::ffi::c_int,
894        *mut std::ffi::c_int,
895        *mut f64,
896    ) -> std::ffi::c_int,
897    shutdown: unsafe extern "C" fn() -> std::ffi::c_int,
898}
899
900#[cfg(all(target_arch = "aarch64", not(feature = "f32")))]
901impl TalibLibrary {
902    fn load(path: std::path::PathBuf) -> Result<Self, QualificationError> {
903        use std::ffi::{c_char, c_int, c_void, CStr, CString};
904        const RTLD_NOW: c_int = 0x2;
905        const TA_SUCCESS: c_int = 0;
906        type InitializeFn = unsafe extern "C" fn() -> c_int;
907        type ShutdownFn = unsafe extern "C" fn() -> c_int;
908        type VersionFn = unsafe extern "C" fn() -> *const c_char;
909        type TyppriceFn = unsafe extern "C" fn(
910            c_int,
911            c_int,
912            *const f64,
913            *const f64,
914            *const f64,
915            *mut c_int,
916            *mut c_int,
917            *mut f64,
918        ) -> c_int;
919        unsafe extern "C" {
920            fn dlopen(path: *const c_char, mode: c_int) -> *mut c_void;
921            fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void;
922        }
923        unsafe fn symbol<T: Copy>(handle: *mut c_void, name: &'static [u8]) -> T {
924            let pointer = unsafe { dlsym(handle, name.as_ptr().cast()) };
925            assert!(!pointer.is_null(), "missing TA-Lib symbol");
926            unsafe { std::mem::transmute_copy(&pointer) }
927        }
928
929        let path = CString::new(path.to_string_lossy().as_bytes())
930            .map_err(|_| QualificationError::new("TA-Lib path contains NUL"))?;
931        let handle = unsafe { dlopen(path.as_ptr(), RTLD_NOW) };
932        if handle.is_null() {
933            return Err(QualificationError::new("unable to load pinned TA-Lib"));
934        }
935        let initialize = unsafe { symbol::<InitializeFn>(handle, b"TA_Initialize\0") };
936        let shutdown = unsafe { symbol::<ShutdownFn>(handle, b"TA_Shutdown\0") };
937        let typprice = unsafe { symbol::<TyppriceFn>(handle, b"TA_TYPPRICE\0") };
938        let version = unsafe { symbol::<VersionFn>(handle, b"TA_GetVersionString\0") };
939        let version = unsafe { CStr::from_ptr(version()) }
940            .to_str()
941            .map_err(|_| QualificationError::new("TA-Lib version is not UTF-8"))?;
942        if version.split_whitespace().next() != Some(TALIB_VERSION) {
943            return Err(QualificationError::new(format!(
944                "TA-Lib version mismatch: {version}"
945            )));
946        }
947        if unsafe { initialize() } != TA_SUCCESS {
948            return Err(QualificationError::new("TA_Initialize failed"));
949        }
950        Ok(Self {
951            handle,
952            typprice,
953            shutdown,
954        })
955    }
956
957    fn compute(
958        &self,
959        high: &[Float],
960        low: &[Float],
961        close: &[Float],
962    ) -> Result<Vec<Float>, QualificationError> {
963        let mut output = vec![0.0; high.len()];
964        self.execute(high, low, close, &mut output)?;
965        Ok(output)
966    }
967
968    fn execute(
969        &self,
970        high: &[Float],
971        low: &[Float],
972        close: &[Float],
973        output: &mut [Float],
974    ) -> Result<(), QualificationError> {
975        let mut begin = -1;
976        let mut count = -1;
977        let result = unsafe {
978            (self.typprice)(
979                0,
980                i32::try_from(high.len())
981                    .map_err(|_| QualificationError::new("TA-Lib input too long"))?
982                    - 1,
983                high.as_ptr(),
984                low.as_ptr(),
985                close.as_ptr(),
986                &mut begin,
987                &mut count,
988                output.as_mut_ptr(),
989            )
990        };
991        if result != 0 || (begin, count) != (0, high.len() as i32) {
992            return Err(QualificationError::new("TA_TYPPRICE failed"));
993        }
994        black_box(output[output.len() - 1]);
995        Ok(())
996    }
997
998    fn measure(
999        &self,
1000        high: &[Float],
1001        low: &[Float],
1002        close: &[Float],
1003    ) -> Result<Measurement, QualificationError> {
1004        let mut output = vec![0.0; high.len()];
1005        let mut run = || self.execute(high, low, close, &mut output);
1006        measure(&mut run, high.len())
1007    }
1008}
1009
1010#[cfg(all(target_arch = "aarch64", not(feature = "f32")))]
1011impl Drop for TalibLibrary {
1012    fn drop(&mut self) {
1013        unsafe extern "C" {
1014            fn dlclose(handle: *mut std::ffi::c_void) -> std::ffi::c_int;
1015        }
1016        let _ = unsafe { (self.shutdown)() };
1017        let _ = unsafe { dlclose(self.handle) };
1018    }
1019}
1020
1021#[cfg(test)]
1022mod tests {
1023    use super::*;
1024
1025    #[test]
1026    fn fixed_statistics_and_checksum_are_deterministic() {
1027        let mut values = [4.0, 1.0, 3.0, 2.0, 5.0];
1028        assert_eq!(median(&mut values), 3.0);
1029        assert_eq!(confidence_interval(&[1.0, 2.0, 3.0]), (1.0, 3.0));
1030        let values = [1.0 as Float, 2.0 as Float];
1031        assert_eq!(checksum(&values), checksum(&values));
1032    }
1033
1034    #[test]
1035    fn performance_gate_is_separate_from_evidence() {
1036        let outcome = QualificationOutcome {
1037            jsonl: "evidence\n".to_owned(),
1038            regressions: vec![Regression {
1039                backend: "neon",
1040                input_length: 4_096,
1041                delta_percent: 4.9,
1042                requirement: "must beat scalar",
1043            }],
1044        };
1045        assert_eq!(outcome.jsonl(), "evidence\n");
1046        assert!(outcome.require_pass().is_err());
1047    }
1048}