1use 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#[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#[derive(Clone, Debug)]
52pub enum NativePlatform {
53 #[cfg(target_arch = "x86_64")]
54 X86,
55 #[cfg(target_arch = "aarch64")]
56 Aarch64 {
57 talib_library: Option<std::path::PathBuf>,
59 },
60}
61
62#[derive(Clone, Debug)]
64pub struct QualificationRequest {
65 pub platform: NativePlatform,
66 pub provenance: Provenance,
67}
68
69#[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#[derive(Clone, Debug)]
80pub struct QualificationOutcome {
81 jsonl: String,
82 regressions: Vec<Regression>,
83}
84
85impl QualificationOutcome {
86 pub fn jsonl(&self) -> &str {
88 &self.jsonl
89 }
90
91 pub fn regressions(&self) -> &[Regression] {
93 &self.regressions
94 }
95
96 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
152pub 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}