diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3c65d6..0119f3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -519,9 +519,9 @@ jobs: echo "$(dirname "$clangxx")" >> "$GITHUB_PATH" echo "$HOME/qemu-hexagon-plugins" >> "$GITHUB_PATH" - # Release (-O2), matching how the baselines were recorded; both engines' - # workloads in one tree, tests and examples off. - - name: Build workloads (both engines) + # Release (-O2), matching how the baselines were recorded; every + # engine's workloads in one tree, tests and examples off. + - name: Build workloads (every engine) run: | case "${{ matrix.target }}" in m55) tc=cmake/arm-cortex-m55-mps3.cmake ;; @@ -539,13 +539,19 @@ jobs: --build-dir build --plugin /tmp/libinsncount.so # Runs even if async failed: each engine's numbers are independent - # evidence. The job still fails if either step failed. + # evidence. The job still fails if any step failed. - name: Ratchet bridge if: ${{ !cancelled() }} run: > python3 scripts/icount.py --engine bridge --target ${{ matrix.target }} --build-dir build --plugin /tmp/libinsncount.so + - name: Ratchet rational + if: ${{ !cancelled() }} + run: > + python3 scripts/icount.py --engine rational --target ${{ matrix.target }} + --build-dir build --plugin /tmp/libinsncount.so + # Each engine README's instruction-count table derives 1:1 from its # committed baselines; regenerating it must produce no diff. icount-docs: @@ -555,7 +561,7 @@ jobs: strategy: fail-fast: false matrix: - engine: [async, bridge] + engine: [async, bridge, rational] steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Docs freshness diff --git a/CLAUDE.md b/CLAUDE.md index 004ad88..73b4323 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,10 +8,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co over one substrate. `async/` (`tap::sr::async`) is the asynchronous near-unity converter that *absorbs the clock*; `bridge/` (`tap::sr::bridge`) is the synchronous 44.1 ↔ 48 kHz converter that *converts the number*; `rational/` (`tap::sr::rational`) is the synchronous small-factor L/M -converter within a rate family (L, M ∈ {2^a·3^b}), chains of Nyquist stages, at M5 of its plan -(ratio types, pinned designs, the single stages, the named chains, the 182-row coverage matrix and -the fixed-point profiles measured per stage; the C ABI follows). All build on DspTap (`submodules/dsptap`, -`tap::dsp`), pinned once at the root. Each engine has its own `README.md`, `PLAN.md` and `CLAUDE.md`; +converter within a rate family (L, M ∈ {2^a·3^b}), chains of Nyquist stages, at M6 of its plan +(ratio types, pinned designs, the single stages, the named chains, the 182-row coverage matrix, the +fixed-point profiles measured per stage, the C ABI, the executed matrix notebook and the ratchet). +All build on DspTap (`submodules/dsptap`, `tap::dsp`), pinned once at the root. Each engine has its own `README.md`, `PLAN.md` and `CLAUDE.md`; read the engine's before touching its code. **`PLAN.md` is the family plan**: the charters and the coverage rule (section 2), the settled @@ -42,10 +42,10 @@ made this tree (sections 5–6). Do not re-derive what it settles. - **Substrate discipline.** Shared code (design math, sample traits, kernels, quantization, measurement instruments) lands in DspTap first; this tree bumps the submodule pin. Never fork substrate code into an engine. -- **One version (D13).** `TAP_SR_VERSION_*` is 0.4.0, defined token-identically in each umbrella +- **One version (D13).** `TAP_SR_VERSION_*` is 0.5.0, defined token-identically in each umbrella header (checked by `tests/family/version_macros.cpp`) and returned bit-packed by each C ABI's `tap_sr__version()` (pinned by `CApi.VersionIsBitPacked`). Tags are `vX.Y.Z`; bump all - three headers and the root `project()` together (0.5.0 at `rational`'s M6). + three headers and the root `project()` together (0.5.0 came with `rational`'s M6). - **Clean renames, no aliases (D7).** Retired options fail the configure (`cmake/retired_options.cmake`); retired override macros hit an `#error`. Do not add aliases. @@ -67,12 +67,12 @@ over statistical sampling, and measured numbers stated with their provenance. **Embedded legs.** CI runs every engine's battery on Cortex-M33 and M55 under `qemu-system-arm` (`cmake/arm-cortex-*.cmake`, one-shot `bare_metal_main.cpp` per engine) and on Hexagon under -`qemu-hexagon`, and the instruction-count ratchet gates every workload of async and bridge -(`rational`'s arrive at its M6) two-sided at -±3 % (`scripts/icount.py --engine async|bridge --target m33|m55|hexagon`, baselines in +`qemu-hexagon`, and the instruction-count ratchet gates every workload of every engine two-sided +at ±3 % (`scripts/icount.py --engine async|bridge|rational --target m33|m55|hexagon`, baselines in `/bench/baselines.json`). A change that moves a count beyond tolerance re-records the baseline in the same PR; an improvement beyond tolerance fails too, by design. Guest markers -(`SRT_ICOUNT_DONE`, `RATIO_ICOUNT_DONE`) are part of the counted binaries and never change. +(`SRT_ICOUNT_DONE`, `RATIO_ICOUNT_DONE`, `RATIONAL_ICOUNT_DONE`) are part of the counted binaries +and never change. ## Style @@ -98,5 +98,6 @@ migration's gates and their run record are in `PLAN.md` sections 5–6 and `docs DspTap changes land in DspTap first, then this tree bumps `submodules/dsptap`. The book (`book/`, https://tap.github.io/SampleRateTap/) is published from `main` by `book-pages`; the notebooks are committed executed against the shipping C++ through each engine's C ABI and -binding (`async/notebooks/`, `bridge/notebooks/tap_sr_bridge_py.py`) — re-execute them when the +binding (`async/notebooks/`, `bridge/notebooks/tap_sr_bridge_py.py`, +`rational/notebooks/tap_sr_rational_py.py`) — re-execute them when the behaviour they measure changes. diff --git a/CMakeLists.txt b/CMakeLists.txt index d1e3277..e1356ab 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,7 +6,7 @@ cmake_minimum_required(VERSION 3.24) # composes them over one tap::dsp, declares the family options (D9) and the # umbrella target, and adds the family's own tests (the dependency rule of # PLAN.md 4.2). -project(SampleRateTap VERSION 0.4.0 LANGUAGES CXX) +project(SampleRateTap VERSION 0.5.0 LANGUAGES CXX) enable_testing() include(cmake/retired_options.cmake) @@ -19,8 +19,8 @@ include(cmake/retired_options.cmake) # calls find the cache entry set. option(TAP_SR_BUILD_TESTS "Build the engines' tests and the family's own" ON) option(TAP_SR_BUILD_EXAMPLES "Build the engines' examples" ON) -option(TAP_SR_BUILD_CAPI "Build both engines' C ABI shared libraries" OFF) -option(TAP_SR_BUILD_ICOUNT_BENCH "Build both engines' instruction-count ratchet workloads" OFF) +option(TAP_SR_BUILD_CAPI "Build every engine's C ABI shared library" OFF) +option(TAP_SR_BUILD_ICOUNT_BENCH "Build every engine's instruction-count ratchet workloads" OFF) option(TAP_SR_BUILD_BENCHMARKS "Build the async engine's benchmarks (host-only)" OFF) option(TAP_SR_BUILD_COMPARE_BENCH "Build the async engine's resampler comparison benchmarks (host-only)" OFF) option(TAP_SR_BUILD_COMPARE_SHIM "Build the async engine's r8brain shim for its comparison notebook" OFF) diff --git a/PLAN.md b/PLAN.md index b77a5fa..4633b4d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1024,8 +1024,15 @@ G13 and G14, plus G9 from 3.7. stage: bit-pinned Q15 / Q31 tables, exact unity, saturation, Q31 within 3.4e−9 of double, Q15's floor and attained stopband stated (a Q15 decimator by 6 or 8 attains −65 / −63 dB, not 70) — landed - (`rational/PLAN.md` v0.8). Next: the C ABI, notebook and icount - baselines with the family version 0.5.0 (M6). + (`rational/PLAN.md` v0.8). M6 — the C ABI (the named chains as + constants, never a rate; one stage at a stated design divisor), the + binding and `matrix.ipynb` executed through the C ABIs (728 of 728 + pins reproduced), the icount ratchet on M33 / M55 / Hexagon, and the + family version 0.5.0 — landed (`rational/PLAN.md` v0.9). The plan's + milestones are complete; the codegen levers it defers (sparse rows for + the mixed ratios going down, the symmetry-halved table, the Q15 + decimators' per-branch quantization, an MVE Q15 kernel) wait for a + consumer. --- diff --git a/README.md b/README.md index d3ca503..10d0e08 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ quantization, measurement instruments). |---|---|---|---| | **`async`** | `tap::sr::async` | Asynchronous, near-unity (±`max_deviation_ppm`, default 1000 ppm): two clock domains at nominally the same rate, one thread pushing at the input clock and one pulling at the output clock. **Absorbs the clock.** | [`async/`](async/README.md) | | **`bridge`** | `tap::sr::bridge` | Synchronous 44.1 ↔ 48 kHz (160/147 up, 147/160 down) and the pair at 2× and 4× (88.2 ↔ 96, 176.4 ↔ 192), direction and rate scale fixed at compile time, speed-first with Q15/Q31 profiles for M33/M55-class targets. **Converts the number.** | [`bridge/`](bridge/README.md) | -| **`rational`** | `tap::sr::rational` | Synchronous small-factor L/M *within* a rate family (L, M ∈ {2^a·3^b}: ↑2, ↓3, 2/3, …), as chains of Nyquist (L-th-band) stages, the ratio a compile-time type. **Converts the number.** M1–M5 landed (ratio types, pinned designs, the single stages for every ratio of the vocabulary, the named chains and the 182-row coverage matrix generated from the measured lengths, the fixed-point profiles measured per stage); the C ABI follows its plan. | [`rational/`](rational/README.md) | +| **`rational`** | `tap::sr::rational` | Synchronous small-factor L/M *within* a rate family (L, M ∈ {2^a·3^b}: ↑2, ↓3, 2/3, …), as chains of Nyquist (L-th-band) stages, the ratio a compile-time type. **Converts the number.** M1–M6 landed (ratio types, pinned designs, the single stages for every ratio of the vocabulary, the named chains and the 182-row coverage matrix generated from the measured lengths, the fixed-point profiles measured per stage, the C ABI, the executed matrix notebook and the instruction-count ratchet): its plan is complete. | [`rational/`](rational/README.md) | The engines never route by rate. The caller declares the clock topology by choosing a type: `async` when the clocks are independent, `bridge` when the @@ -49,8 +49,8 @@ tests, by ctest label. The family options are `TAP_SR_*`: |---|---|---| | `TAP_SR_BUILD_TESTS` | ON | the engines' tests and the family's own (`tests/`) | | `TAP_SR_BUILD_EXAMPLES` | ON | the engines' examples | -| `TAP_SR_BUILD_CAPI` | OFF | the engines' C ABI shared libraries (`libtap_sr_async_capi`, `libtap_sr_bridge_capi`; `rational`'s at its M6) | -| `TAP_SR_BUILD_ICOUNT_BENCH` | OFF | the engines' instruction-count ratchet workloads (`rational`'s at its M6) | +| `TAP_SR_BUILD_CAPI` | OFF | the engines' C ABI shared libraries (`libtap_sr_async_capi`, `libtap_sr_bridge_capi`, `libtap_sr_rational_capi`) | +| `TAP_SR_BUILD_ICOUNT_BENCH` | OFF | every engine's instruction-count ratchet workloads | | `TAP_SR_BUILD_BENCHMARKS`, `TAP_SR_BUILD_COMPARE_BENCH`, `TAP_SR_BUILD_COMPARE_SHIM` | OFF | the async engine's host-only benchmarks and comparison tooling | | `TAP_SR_ASYNC_WERROR`, `TAP_SR_BRIDGE_WERROR`, `TAP_SR_RATIONAL_WERROR` | OFF | warnings as errors, per engine | @@ -58,9 +58,11 @@ A retired pre-family option (`SRT_*`, `TAP_RATIO_*`) fails the configure loudly (`cmake/retired_options.cmake`) rather than dropping a gate silently. **Version.** One family version, `TAP_SR_VERSION_{MAJOR,MINOR,PATCH}` -(0.4.0), defined identically in each engine's umbrella header and returned -bit-packed — `(major << 16) | (minor << 8) | patch` — by each C ABI's -`tap_sr_async_version()` / `tap_sr_bridge_version()`. Tags are `vX.Y.Z`. +(0.5.0, the minor bump the third engine's C ABI brought), defined +identically in each engine's umbrella header and returned bit-packed — +`(major << 16) | (minor << 8) | patch` — by each C ABI's +`tap_sr_async_version()` / `tap_sr_bridge_version()` / +`tap_sr_rational_version()`. Tags are `vX.Y.Z`. ## Build and test diff --git a/async/capi/tap_sr_async_capi.h b/async/capi/tap_sr_async_capi.h index 0136925..8679a08 100644 --- a/async/capi/tap_sr_async_capi.h +++ b/async/capi/tap_sr_async_capi.h @@ -34,7 +34,8 @@ typedef struct tap_sr_async_converter tap_sr_async_converter; /* ABI/version probe: the family version, bit-packed as * (TAP_SR_VERSION_MAJOR << 16) | (TAP_SR_VERSION_MINOR << 8) | TAP_SR_VERSION_PATCH - * (0x000400 for 0.4.0); tap_sr_bridge_version returns the same value. */ + * (0x000500 for 0.5.0); tap_sr_bridge_version and tap_sr_rational_version + * return the same value. */ unsigned tap_sr_async_version(void); /* preset: 0 = fast, 1 = balanced, 2 = transparent. diff --git a/async/include/tap/sr/async/async.h b/async/include/tap/sr/async/async.h index dd15c6c..954c526 100644 --- a/async/include/tap/sr/async/async.h +++ b/async/include/tap/sr/async/async.h @@ -14,7 +14,7 @@ #pragma once #define TAP_SR_VERSION_MAJOR 0 -#define TAP_SR_VERSION_MINOR 4 +#define TAP_SR_VERSION_MINOR 5 #define TAP_SR_VERSION_PATCH 0 #include "tap/sr/async/converter.h" diff --git a/async/tests/test_capi.cpp b/async/tests/test_capi.cpp index 91a6028..0a9f553 100644 --- a/async/tests/test_capi.cpp +++ b/async/tests/test_capi.cpp @@ -20,9 +20,9 @@ namespace { static_cast((TAP_SR_VERSION_MAJOR << 16) | (TAP_SR_VERSION_MINOR << 8) | TAP_SR_VERSION_PATCH); const unsigned v = tap_sr_async_version(); EXPECT_EQ(v, k_packed); - EXPECT_EQ(v, 0x000400u); // 0.4.0 + EXPECT_EQ(v, 0x000500u); // 0.5.0 EXPECT_EQ(v >> 16, 0u); - EXPECT_EQ((v >> 8) & 0xFFu, 4u); + EXPECT_EQ((v >> 8) & 0xFFu, 5u); EXPECT_EQ(v & 0xFFu, 0u); } diff --git a/book/src/part4/c-abi.md b/book/src/part4/c-abi.md index daa4a96..aca4db1 100644 --- a/book/src/part4/c-abi.md +++ b/book/src/part4/c-abi.md @@ -159,8 +159,9 @@ of sentence you only think to write after watching Part IV's 32-bit ports in action. **`tap_sr_async_version()` is a probe.** It returns the family version -bit-packed, `(major << 16) | (minor << 8) | patch` — `0x000400` (1024) for -0.4.0, the value `tap_sr_bridge_version()` returns as well. A version +bit-packed, `(major << 16) | (minor << 8) | patch` — `0x000500` (1280) for +0.5.0, the value `tap_sr_bridge_version()` and `tap_sr_rational_version()` +return as well. A version *macro* would vanish into the caller's compile; a version *function* reports what the loaded shared library actually is, which is the question an FFI user is really asking when their symbols don't match their @@ -302,7 +303,7 @@ cmake --build build --target tap_sr_async_capi -j # from this file (the impl() helpers are invisible, as promised): nm -D --defined-only build/async/capi/libtap_sr_async_capi.so | grep tap_sr_async_ -# The one-integer smoke test (0.4.0 -> 1024, i.e. 0x000400): +# The one-integer smoke test (0.5.0 -> 1280, i.e. 0x000500): python3 -c "import ctypes; \ print(ctypes.CDLL('build/async/capi/libtap_sr_async_capi.so').tap_sr_async_version())" diff --git a/bridge/include/tap/sr/bridge/ratio.h b/bridge/include/tap/sr/bridge/ratio.h index 295865c..e8fd278 100644 --- a/bridge/include/tap/sr/bridge/ratio.h +++ b/bridge/include/tap/sr/bridge/ratio.h @@ -41,7 +41,7 @@ #include "tap/sr/bridge/schedule.h" // IWYU pragma: export #define TAP_SR_VERSION_MAJOR 0 -#define TAP_SR_VERSION_MINOR 4 +#define TAP_SR_VERSION_MINOR 5 #define TAP_SR_VERSION_PATCH 0 namespace tap::sr::bridge { diff --git a/bridge/tests/test_capi.cpp b/bridge/tests/test_capi.cpp index 93c1b6b..b9bc5e1 100644 --- a/bridge/tests/test_capi.cpp +++ b/bridge/tests/test_capi.cpp @@ -20,9 +20,9 @@ namespace { static_cast((TAP_SR_VERSION_MAJOR << 16) | (TAP_SR_VERSION_MINOR << 8) | TAP_SR_VERSION_PATCH); const unsigned v = tap_sr_bridge_version(); EXPECT_EQ(v, k_packed); - EXPECT_EQ(v, 0x000400u); // 0.4.0 + EXPECT_EQ(v, 0x000500u); // 0.5.0 EXPECT_EQ(v >> 16, 0u); - EXPECT_EQ((v >> 8) & 0xFFu, 4u); + EXPECT_EQ((v >> 8) & 0xFFu, 5u); EXPECT_EQ(v & 0xFFu, 0u); } diff --git a/rational/CLAUDE.md b/rational/CLAUDE.md index 1cc2635..b7308da 100644 --- a/rational/CLAUDE.md +++ b/rational/CLAUDE.md @@ -19,7 +19,7 @@ anything; do not re-derive what it settles (the compile-time ratio, one Nyquist stage factoring by MACs then by stage count, `bridge` at the lowest k, the profile vocabulary, latency as an exact rational). -Current state: **M5** — the tree, `ratio` with its charter `static_assert`s and +Current state: **M6**, the plan complete — the tree, `ratio` with its charter `static_assert`s and `ratio_traits`, `profile` carrying the pinned taps per branch for every band of the vocabulary (the M2 design spike, `notebooks/design_spike.ipynb`, executed; the pins found on a 16384-point grid and enforced by `tests/test_design.cpp`) and, since M4, the relaxation tables @@ -35,8 +35,13 @@ generated from the pinned lengths by `tools/coverage/matrix.py` (section 3 of th cross-family rows through `tests/support/bridge_stage.h`), and, since M5, the fixed-point profiles measured per stage by `tests/test_fixed_point.cpp` (bit-pinned Q15 / Q31 tables, exact unity, saturation, the Q15 floors and attained stopbands stated in PLAN.md section 6 — a -change that moves a table pin is a numeric change to the fixed-point datapath). M6 (C ABI, -notebook, icount baselines; family version 0.5.0) follows in PLAN.md section 6. +change that moves a table pin is a numeric change to the fixed-point datapath), and, since M6, +the C ABI (`capi/`: the named chains as stable `TAP_SR_RATIONAL_*` constants, one stage at a +stated design divisor, exactly sixteen exported symbols; `CApi.*` pins it bit for bit against +`basic_chain`), the ctypes binding and `notebooks/matrix.ipynb` executed through the C ABIs, and +the icount ratchet (`bench/icount/`, `bench/baselines.json`, marker `RATIONAL_ICOUNT_DONE`; a +change that moves a count beyond ±3 % re-records the baselines in the same PR). The codegen +levers PLAN.md defers after M6 wait for a consumer. ## The charter constraints (load-bearing) @@ -45,7 +50,7 @@ notebook, icount baselines; family version 0.5.0) follows in PLAN.md section 6. writes; absorbing a clock is `async`, by composition. The charter is a `static_assert` on `ratio`: L and M of the form 2^a · 3^b, lowest terms, L ≠ M. Nothing else compiles. - **Never routed to by rate (D12).** `ratio` names the number, a chain names the stages; - there is no `(in_hz, out_hz)` lookup here or in the C ABI (whose enumerators name chains). The + there is no `(in_hz, out_hz)` lookup here or in the C ABI (whose constants name chains). The coverage matrix in PLAN.md documents chains; it dispatches nothing. - **Speed-first, like `bridge`.** The ratio is a compile-time type, so every trip count and schedule is a compile-time fact; the stage factoring is chosen by MACs on the measured lengths diff --git a/rational/CMakeLists.txt b/rational/CMakeLists.txt index 707f933..a7bd3eb 100644 --- a/rational/CMakeLists.txt +++ b/rational/CMakeLists.txt @@ -36,6 +36,14 @@ else() option(TAP_SR_BUILD_TESTS "Build the rational engine's tests" OFF) endif() +# C ABI shared library for FFI consumers (the notebooks drive the shipping +# C++ through it — notebooks/matrix.ipynb). +option(TAP_SR_BUILD_CAPI "Build the C ABI shared library" OFF) + +# Fixed deterministic workloads for the QEMU instruction-count ratchet; +# buildable for any target including bare metal (PLAN.md section 5). +option(TAP_SR_BUILD_ICOUNT_BENCH "Build instruction-count ratchet workloads" OFF) + # Warning flags for this engine's own tests; never exported to consumers of # the INTERFACE library. option(TAP_SR_RATIONAL_WERROR "Treat warnings as errors in the rational engine's own targets" OFF) @@ -66,3 +74,11 @@ if(TAP_SR_BUILD_TESTS) enable_testing() add_subdirectory(tests) endif() + +if(TAP_SR_BUILD_CAPI) + add_subdirectory(capi) +endif() + +if(TAP_SR_BUILD_ICOUNT_BENCH) + add_subdirectory(bench/icount) +endif() diff --git a/rational/PLAN.md b/rational/PLAN.md index df9d9ec..f15dc1d 100644 --- a/rational/PLAN.md +++ b/rational/PLAN.md @@ -18,6 +18,7 @@ matrix, layout, test strategy, milestones, non-goals and risks. | v0.8 | 2026-10-02 | M5 landed: the fixed-point profiles measured and pinned for every ratio at every profile (`tests/test_fixed_point.cpp`): exact-unity rows, bit-pinned Q15 / Q31 tables, the structural zeros out of the dot, Q31 a decade under float's floor, Q15's floor and attained stopband stated per stage, saturation without wrap, full-scale DC exact; two honest limits recorded (a Q15 decimator's stopband loses about 20 log10 M; a mixed ratio going down multiplies its band's zeros), section 6 | | v0.7 | 2026-10-02 | M4 landed: `chain.h` (`basic_chain` over DspTap's `chain<>`, each stage designed at its **design divisor** — its lower rate over the chain's lowest, 3.1 — and the 20 named multi-stage chains), `tools/coverage/matrix.py` committed and re-run on the pinned lengths with the per-stage relaxation pins (`design.h`'s relaxation tables, verified by `test_design.cpp`), section 3 regenerated from measured counts (35 of 182 rows changed chain, the ledger in section 8 S1), `test_matrix.cpp` over all 182 rows (every row measured, none skipped: 2.2 had landed), DspTap's `chain<>::flush` (tap/DspTap#52) | | v0.5 | 2026-10-02 | M2 landed: the design spike (`notebooks/design_spike.ipynb`, executed) pins N for every band ∈ {2, 3, 4, 6, 8} × profile on a 16384-point grid, the mixed ratios' taps per phase, measured worst stopband and ripple; `profile` carries the pins and `test_design.cpp` enforces them; the M2 table below is filled from measurements. Finding: the designer's 1024-point default grid under-pins two rows (tap/DspTap#50 made the search grid a parameter) | +| v0.9 | 2026-10-02 | M6 landed: the C ABI (`capi/`, `libtap_sr_rational_capi`: the 28 named within-family chains as stable `TAP_SR_RATIONAL_*` constants, one stage of the vocabulary at a stated design divisor for chains through `bridge`, exact accounting, latency and MACs as rationals; exactly sixteen exported symbols), the ctypes binding and `matrix.ipynb` executed through the C ABIs (all 728 row × profile pins reproduced), the icount ratchet (twelve workloads, `RATIONAL_ICOUNT_DONE`, baselines on M33 / M55 / Hexagon, gated two-sided at ±3 % in CI), family version 0.5.0 | **Status.** This draft is the reviewed-plan deliverable that the family plan's "Next — `rational` (separate plan)" entry asks for @@ -818,7 +819,7 @@ Plus: | M3 | **Single stages, float golden leg**: `basic_stage` for ↑2 / ↓2 (half-band) and ↑3 / ↓3 (third-band) first, then the rest of the vocabulary; both call shapes; `flush`; scipy vectors committed. **Done** (2026-10-02, v0.6: the whole vocabulary at once, since the two machines are generic — the L-phase schedule machine with trimmed phase rows for interpolators and mixed ratios, the M-branch commutator for decimators, its nonzero branches summed under one finalize through DspTap's `accumulate_row` (tap/DspTap#51, the substrate addition M3 needed); MACs per output are the design's nonzero counts for every interpolator and decimator, the half-band decimator 23 of its 43 taps) | Scipy vectors sample-for-sample ✓ (16 vectors, every ratio at `economy` and the by-2 pair at `transparent`; float within 6e-8 of the float64 reference, the 3e-5 tolerance is bridge's); exhaustive phase sweeps ✓ (the impulse reproduces the table bit for bit from every input phase, every format; accounting exact from every position of every superblock); `PullMatchesProcessBitExact` ✓; chunking invariance ✓ (every format); `decimate.h` second-golden agreement at ↓2 / ↓3 / ↓6 within the documented design difference ✓; latency equals (N − 1)/2 at the higher rate by impulse ✓ (every ratio, the exact rational (N − 1)/(2M) at the output rate); DC gain exactly 1 in every format ✓; flush equals zero padding bit for bit ✓ | no | | M4 | **Chains and the coverage matrix**: `chain.h`'s named chains, `tools/coverage/matrix.py` committed, `test_matrix.cpp` pinning (f_pass, A, δ, MACs/out, latency) for all 182 rows. **Done** (2026-10-02, v0.7: `basic_chain` constructs each stage at its design divisor (3.1) over DspTap's `chain<>`, whose `flush` landed for it (tap/DspTap#52); the relaxation tables in `design.h`; the generator re-run on the pinned lengths, section 3 regenerated — 35 rows changed chain, the ledger in S1 — and the 20 named multi-stage chains follow it) | Every row measured and pinned, the k > 0 rows included (2.2 had landed, so nothing was skipped) ✓; chain vectors against sequenced upfirdn ✓ (seven chains, float within 3e-5 and double within 1e-7 of the float64 reference); a chain equals its stages run in sequence bit for bit in every format ✓; `frames_needed` / `outputs_for` exact from every position of every named chain ✓; flush equals zero padding bit for bit ✓; the tone battery's numbers in section 5 | no | | M5 | **Fixed-point profiles**: Q15 and Q31 datapaths through the traits; per-branch quantization; bit-pinned tables; cross-precision numbers. **Done** (2026-10-02, v0.8: the datapaths were M3's — the stage is format-generic over `tap::dsp::sample_traits` — so M5 is the battery that states each format's contract as numbers, `tests/test_fixed_point.cpp`, every ratio at every profile; the measured table and the two limits below) | Q31 within the format floor of float on the reference noise ✓ (worst \|Q31 − double\| 3.4e−9 against float's 5.1e−8, bridge's 5e−8); Q15 format-limited numbers stated per stage ✓ (the table below: RMS −85.6 … −95.8 dBFS, attained stopband per stage); exact-unity row sums ✓ (every phase row, every decimator's whole filter, both formats); wrap safety ✓ (±full-scale noise lands within 8.8e−4 / 1.6e−8 of the clamped double model; full-scale DC of either sign is exactly full scale from every phase); the Q15 half-band's zero taps absent from the dot ✓ (23 MACs of 43 taps at `economy`; no structural zero inside any interpolator's, decimator's or mixed-up stage's dot, Q15 MACs pinned per stage); bit-pinned tables ✓ (FNV-1a-64 per ratio, profile and format, 112 pins) | no | -| M6 | **C ABI, notebook, icount baselines**: `libtap_sr_rational_capi`, `tap_sr_rational_py.py`, `matrix.ipynb` executed through the C ABIs (bridge's for cross rows), `bench/icount/` with baselines on M33 / M55 / Hexagon, README icount table, `CApi.VersionIsBitPacked` | Notebook measures the shipping C++ and reproduces the pinned matrix numbers; ratchet green two-sided on three targets; `nm -D` symbol set recorded; **family version 0.5.0** (decision 10, R11: all three umbrella headers and the root `project()` bump together, D13) | notebook's k > 0 cells wait | +| M6 | **C ABI, notebook, icount baselines**: `libtap_sr_rational_capi`, `tap_sr_rational_py.py`, `matrix.ipynb` executed through the C ABIs (bridge's for cross rows), `bench/icount/` with baselines on M33 / M55 / Hexagon, README icount table, `CApi.VersionIsBitPacked`. **Done** (2026-10-02, v0.9: `tap_sr_rational_create(chain, profile, channels)` takes one of the 28 named within-family chains of sections 3.3 / 3.4 as a stable `TAP_SR_RATIONAL_*` constant, never a rate (D12); `tap_sr_rational_create_stage(L, M, profile, divisor_num, divisor_den, channels)` is one stage of the vocabulary at the design divisor a matrix row states, the piece a chain through `bridge` composes (R16) — the caller still writes that chain; twelve icount workloads, the measured table below) | Notebook measures the shipping C++ and reproduces the pinned matrix numbers ✓ (all 182 rows × four profiles, 728 of 728 pairs reproduce both the MACs-per-output and the latency pins exactly, the cross rows through `bridge`'s C ABI; the tone battery passes 182 / 182 rows at `economy`, worst stopband −71.34 dB and ripple 0.0081 dB, and 182 / 182 at `transparent`, −121.57 dB and 0.00002 dB); every chain constant equals its named `basic_chain` bit for bit with the same latency, MACs and accounting ✓ (`CApi.EveryChainEnumeratorIsItsNamedChain`); ratchet green two-sided on three targets ✓ (re-run `--exact`: every count equal); `nm -D` symbol set recorded ✓ (exactly the sixteen `tap_sr_rational_*` functions — `create`, `create_stage`, `destroy`, `ratio`, `outputs_for`, `frames_needed`, `process`, `flush`, `flush_output_frames`, `reset`, `latency_output_frames`, `latency_seconds`, `macs_per_output`, `stages`, `stage_taps`, `version` — the library built with hidden visibility so no C++ leaks into the table); **family version 0.5.0** ✓ (all three umbrella headers and the root `project()`; `CApi.VersionIsBitPacked` 0x000500 in every engine) | no | **The M2 table, measured** (N = 2mB − 1 per band × profile, the worst stopband on the 16384-point grid in parentheses; the harris estimates the @@ -898,8 +899,30 @@ Two limits, stated rather than papered over (both pinned by the battery): none. Sparse rows are a codegen lever after M6, as the symmetry-halved table is; the matrix's MAC figures count what runs today. +**The icount baselines, measured** (M6, `bench/icount/`: 2 s of stereo at +48 kHz streamed in 32-frame blocks, counted under QEMU by the family's +plugin, `bench/baselines.json`; the README carries the full table): + +| Workload | Cortex-M33 | Cortex-M55 | Hexagon | +|---|---:|---:|---:| +| `down2_float_eco` | 366,459,700 | 24,577,869 | 72,734,422 | +| `down2_q15_eco` | 47,494,712 | 25,070,113 | 18,554,089 | +| `up2_float_eco` | 732,658,864 | 43,442,565 | 137,930,738 | +| `up2_q15_eco` | 67,354,778 | 47,493,112 | 28,133,275 | +| `up3_float_eco` | 1,407,620,075 | 75,330,937 | 254,903,952 | +| `up3_q15_eco` | 92,695,694 | 74,874,023 | 39,793,932 | +| `construct_q15_eco` | 774,741 | 33,682 | 183,854 | + +One finding, recorded for the codegen levers: on the M55, Q15 is not yet +faster than float (↓2 25.07 M against 24.58 M, ↑2 47.49 M against +43.44 M), where on the M33 it executes 7.7–15× fewer instructions and on Hexagon +3.9–6.4× fewer (the ↓2, ↑2 and ↑3 pairs). +The Q15 dot is the generic scalar kernel; an MVE dual-MAC Q15 kernel is a +lever after M6, with the sparse rows and the symmetry-halved table, each +measured against these baselines. + **Order and gating.** M0 lands in DspTap and the family bumps its pin -(`../CLAUDE.md`, "Substrate discipline"); M1–M5 are done; the 2.2 +(`../CLAUDE.md`, "Substrate discipline"); M1–M6 are done; the 2.2 follow-up landed before M4, so nothing waits for it. Every milestone's PR carries its measurements, as `bridge`'s M7 entries do. diff --git a/rational/README.md b/rational/README.md index c8e7806..cc3c139 100644 --- a/rational/README.md +++ b/rational/README.md @@ -14,7 +14,7 @@ substrate ([DspTap](https://github.com/tap/DspTap): the L-th-band designer and the stage composition landed there first, with the float/Q15/Q31 sample-format traits, the dot kernels and the row-sum quantization). -> **Status: M5 of [PLAN.md](PLAN.md) section 6.** The single stages ship +> **Status: M6 of [PLAN.md](PLAN.md) section 6, the plan complete.** The single stages ship > for every ratio of the vocabulary — ↑2, ↓2, ↑3, ↓3, ↑6, ↓6, ↑8, ↓8 and the > mixed 3/2, 2/3, 4/3, 3/4, 8/3, 3/8 — as `converter>` (float, > the golden model pinned sample-for-sample against committed scipy @@ -34,7 +34,8 @@ sample-format traits, the dot kernels and the row-sum quantization). > bit-pinned tables, saturation without wrap, Q31 within 3.4e−9 of double; > Q15 is format-limited, and its numbers are stated per stage — at Q15 use > `economy`, and note that a Q15 decimator by 6 or 8 attains −65 / −63 dB -> of stopband, not 70. The C ABI and the ratchet baselines (M6) follow. The +> of stopband, not 70. The C ABI, the executed coverage-matrix notebook and +> the instruction-count ratchet landed at M6 (below). The > plan is authoritative: charter, the decisions R1–R16, the generated > matrix, layout, test strategy, non-goals and risks. @@ -92,7 +93,7 @@ message). `pull(out, n, pop_fn)` is the callback-driven shape and relaxation tables of `design.h`); `chain` / `chain_q15` / `chain_q31`; `macs_per_output()` exact; and the 20 named multi-stage chains of the coverage matrix (`up_2_up_2` … `down_3_down_8_down_2`). -- `rational.h` — the umbrella and `TAP_SR_VERSION_*` (0.4.0; 0.5.0 at M6); +- `rational.h` — the umbrella and `TAP_SR_VERSION_*` (0.5.0, from M6); the named ratios of the vocabulary (`up_2` … `ratio_3_8`) are `ratio.h`'s. ## The boundaries are identity, not policy @@ -124,6 +125,63 @@ This engine lives in `rational/` of the SampleRateTap family repository; the root build configures every engine and the `rational` label selects this one's tests, including the family's dependency-rule checks for it and the compile-fail charter test. `cmake -S SampleRateTap/rational` configures it on -its own. The design-spike notebook is committed executed; re-run it with -`jupyter nbconvert --to notebook --execute --inplace notebooks/design_spike.ipynb` -in the root `requirements.lock` environment when the design changes. +its own. The notebooks are committed executed; re-run them with +`jupyter nbconvert --to notebook --execute --inplace notebooks/.ipynb` +in the root `requirements.lock` environment when what they measure changes: +`design_spike.ipynb` (the M2 pins, an independent numpy leg) and +`matrix.ipynb` (the coverage matrix through the C ABIs, below). + +### C ABI + +`capi/tap_sr_rational_capi.h` (`-DTAP_SR_BUILD_CAPI=ON`, or +`cmake -S rational/capi -B build_capi` on its own) exposes the float chains +to FFI consumers: `tap_sr_rational_create(chain, profile, channels)` takes +one of the 28 named within-family chains of the coverage matrix as a +constant (`TAP_SR_RATIONAL_UP_2` … `TAP_SR_RATIONAL_DOWN_3_DOWN_8_DOWN_2`), +never a rate; `tap_sr_rational_create_stage(L, M, profile, divisor_num, +divisor_den, channels)` is one stage of the vocabulary at a stated design +divisor, what a chain through `bridge` composes. Each converter reports its +exact accounting, its latency and its MACs per output as exact rationals, +per-stage design lengths, and the bit-packed family version +(`tap_sr_rational_version()`). `notebooks/tap_sr_rational_py.py` is the +ctypes binding the notebook measures the shipping C++ through. + +### Embedded targets and the instruction-count ratchet + +Every push runs this engine's emulation-sized battery on **Cortex-M33** +(QEMU mps2-an505), **Cortex-M55** (mps3-an547) and **Hexagon** +(qemu-hexagon, static musl), and gates twelve fixed workloads — the by-2 +and by-3 stages both ways in float and Q15 at `economy`, the Q15 by-4 +chain, the by-2 pair in float at `transparent`, and construction alone — +against committed per-target instruction counts (`bench/baselines.json`, +two-sided ±3 %), measured by the family's shared harness from the +repository root: + +```sh +cmake -B build-m55 -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_TOOLCHAIN_FILE=cmake/arm-cortex-m55-mps3.cmake \ + -DTAP_SR_BUILD_TESTS=OFF -DTAP_SR_BUILD_EXAMPLES=OFF \ + -DTAP_SR_BUILD_ICOUNT_BENCH=ON +cmake --build build-m55 -j +python3 scripts/icount.py --engine rational --target m55 --build-dir build-m55 \ + --plugin libinsncount.so +``` + + +Executed instructions per fixed workload (`rational/bench/icount/`), measured under QEMU with a counting plugin — deterministic, and gated in CI at ±3% against `rational/bench/baselines.json`: + +| Workload | Cortex-M33 | Cortex-M55 | Hexagon | +|---|---:|---:|---:| +| `construct_q15_eco` | 774,741 | 33,682 | 183,854 | +| `down2_down2_q15_eco` | 62,508,360 | 54,506,280 | 22,251,668 | +| `down2_float_eco` | 366,459,700 | 24,577,869 | 72,734,422 | +| `down2_float_tr` | 955,278,690 | 47,997,517 | 174,664,319 | +| `down2_q15_eco` | 47,494,712 | 25,070,113 | 18,554,089 | +| `down3_float_eco` | 476,573,580 | 31,908,454 | 90,345,997 | +| `down3_q15_eco` | 51,977,458 | 28,017,447 | 19,350,211 | +| `up2_float_eco` | 732,658,864 | 43,442,565 | 137,930,738 | +| `up2_float_tr` | 1,961,674,119 | 89,901,988 | 340,655,706 | +| `up2_q15_eco` | 67,354,778 | 47,493,112 | 28,133,275 | +| `up3_float_eco` | 1,407,620,075 | 75,330,937 | 254,903,952 | +| `up3_q15_eco` | 92,695,694 | 74,874,023 | 39,793,932 | + diff --git a/rational/bench/baselines.json b/rational/bench/baselines.json new file mode 100644 index 0000000..be3fa4a --- /dev/null +++ b/rational/bench/baselines.json @@ -0,0 +1,44 @@ +{ + "hexagon": { + "construct_q15_eco": 183854, + "down2_down2_q15_eco": 22251668, + "down2_float_eco": 72734422, + "down2_float_tr": 174664319, + "down2_q15_eco": 18554089, + "down3_float_eco": 90345997, + "down3_q15_eco": 19350211, + "up2_float_eco": 137930738, + "up2_float_tr": 340655706, + "up2_q15_eco": 28133275, + "up3_float_eco": 254903952, + "up3_q15_eco": 39793932 + }, + "m33": { + "construct_q15_eco": 774741, + "down2_down2_q15_eco": 62508360, + "down2_float_eco": 366459700, + "down2_float_tr": 955278690, + "down2_q15_eco": 47494712, + "down3_float_eco": 476573580, + "down3_q15_eco": 51977458, + "up2_float_eco": 732658864, + "up2_float_tr": 1961674119, + "up2_q15_eco": 67354778, + "up3_float_eco": 1407620075, + "up3_q15_eco": 92695694 + }, + "m55": { + "construct_q15_eco": 33682, + "down2_down2_q15_eco": 54506280, + "down2_float_eco": 24577869, + "down2_float_tr": 47997517, + "down2_q15_eco": 25070113, + "down3_float_eco": 31908454, + "down3_q15_eco": 28017447, + "up2_float_eco": 43442565, + "up2_float_tr": 89901988, + "up2_q15_eco": 47493112, + "up3_float_eco": 75330937, + "up3_q15_eco": 74874023 + } +} diff --git a/rational/bench/icount/CMakeLists.txt b/rational/bench/icount/CMakeLists.txt new file mode 100644 index 0000000..10c335d --- /dev/null +++ b/rational/bench/icount/CMakeLists.txt @@ -0,0 +1,29 @@ +# One binary per scenario (name:id): bare-metal targets have no argv, and +# per-binary instruction totals are what the ratchet compares +# (scripts/icount.py --engine rational). The workloads are PLAN.md section +# 5's: the by-2 and by-3 stages both ways in float and Q15 at economy, the +# by-4 chain (down 2 . down 2) in Q15, the by-2 pair in float at +# transparent, and construction alone (the Q15 by-4 chain's two designs and +# tables, no streaming) as its own scenario from the first baseline. +set(_rational_icount_scenarios + up2_float_eco:0 + down2_float_eco:1 + up3_float_eco:2 + down3_float_eco:3 + up2_q15_eco:4 + down2_q15_eco:5 + up3_q15_eco:6 + down3_q15_eco:7 + down2_down2_q15_eco:8 + up2_float_tr:9 + down2_float_tr:10 + construct_q15_eco:11) + +foreach(_sc IN LISTS _rational_icount_scenarios) + string(REPLACE ":" ";" _parts "${_sc}") + list(GET _parts 0 _name) + list(GET _parts 1 _id) + add_executable(tap_sr_rational_icount_${_name} icount_main.cpp) + target_compile_definitions(tap_sr_rational_icount_${_name} PRIVATE TAP_SR_RATIONAL_SC=${_id}) + target_link_libraries(tap_sr_rational_icount_${_name} PRIVATE tap::sr::rational tap_sr_rational_warnings) +endforeach() diff --git a/rational/bench/icount/icount_main.cpp b/rational/bench/icount/icount_main.cpp new file mode 100644 index 0000000..981a018 --- /dev/null +++ b/rational/bench/icount/icount_main.cpp @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: MIT +// Copyright 2026 Timothy Place and the SampleRateTap contributors +// Deterministic fixed workloads for the instruction-count ratchet (PLAN.md +// section 5): one scenario per binary, selected at compile time because +// bare-metal targets have no argv. The qemu plugin counts the whole run, +// construction included; the streaming loop is sized to dominate (2 s of +// stereo at the input rate in 32-frame blocks, bridge's shape), and one +// scenario measures construction alone. The checksum both defeats +// dead-code elimination and pins cross-run determinism. +// +// TAP_SR_RATIONAL_SC: 0 up2_float_eco, 1 down2_float_eco, 2 up3_float_eco, +// 3 down3_float_eco, 4 up2_q15_eco, 5 down2_q15_eco, 6 up3_q15_eco, +// 7 down3_q15_eco, 8 down2_down2_q15_eco, 9 up2_float_tr, 10 down2_float_tr, +// 11 construct_q15_eco. +#include +#include +#include +#include +#include +#include +#include +#include + +#include "tap/sr/rational/rational.h" + +namespace { + + namespace rat = tap::sr::rational; + + constexpr std::size_t k_channels = 2; + constexpr std::size_t k_block = 32; + constexpr double k_rate_in = 48000.0; // the input rate the sine is drawn at + + template + S make_sample(double v) { + if constexpr (std::is_floating_point_v) { + return static_cast(v); + } + else { + return tap::dsp::detail::round_sat(v * static_cast(std::numeric_limits::max())); + } + } + + // Interleaved sine block, precomputed so libm's sin() stays out of the + // measured loop (on the soft-double targets it would dominate). + template + std::vector sine_block(std::size_t frames) { + std::vector out(frames * k_channels); + const double w = 2.0 * std::numbers::pi * 997.0 / k_rate_in; + for (std::size_t i = 0; i < frames; ++i) { + const S v = make_sample(0.5 * std::sin(w * static_cast(i))); + for (std::size_t c = 0; c < k_channels; ++c) { + out[i * k_channels + c] = v; + } + } + return out; + } + + /// Streams 2 s of the input rate through a converter (a stage or a + /// chain) in k_block-frame blocks; returns the checksum. + template + double stream(Conv& conv) { + using sample = typename Conv::sample; + // 0.25 s of input, cycled block-aligned (12000 % 32 == 0: the seam + // repeats identically every cycle). + const auto input = sine_block(12000); + constexpr std::size_t out_cap = k_block * Conv::k_up / Conv::k_down + Conv::k_up + 2; + std::vector out(out_cap * k_channels); + double sink = 0.0; + std::size_t off = 0; + const std::size_t blocks = 2 * static_cast(k_rate_in) / k_block; + for (std::size_t b = 0; b < blocks; ++b) { + const std::size_t made = conv.process(input.data() + off, k_block, out.data()); + if (made > out_cap) { + return std::numeric_limits::quiet_NaN(); // poisons the checksum + } + off += k_block * k_channels; + if (off + k_block * k_channels > input.size()) { + off = 0; + } + sink += static_cast(out[0]) + static_cast(made); + } + return sink; + } + + template + double stage_workload(const rat::profile& p) { + rat::basic_stage conv(k_channels, p); + return stream(conv); + } + + double run() { + [[maybe_unused]] const rat::profile eco = rat::profile::economy(); + [[maybe_unused]] const rat::profile tr = rat::profile::transparent(); +#if TAP_SR_RATIONAL_SC == 0 + return stage_workload(eco); +#elif TAP_SR_RATIONAL_SC == 1 + return stage_workload(eco); +#elif TAP_SR_RATIONAL_SC == 2 + return stage_workload(eco); +#elif TAP_SR_RATIONAL_SC == 3 + return stage_workload(eco); +#elif TAP_SR_RATIONAL_SC == 4 + return stage_workload(eco); +#elif TAP_SR_RATIONAL_SC == 5 + return stage_workload(eco); +#elif TAP_SR_RATIONAL_SC == 6 + return stage_workload(eco); +#elif TAP_SR_RATIONAL_SC == 7 + return stage_workload(eco); +#elif TAP_SR_RATIONAL_SC == 8 + rat::down_2_down_2 conv(k_channels, eco); + return stream(conv); +#elif TAP_SR_RATIONAL_SC == 9 + return stage_workload(tr); +#elif TAP_SR_RATIONAL_SC == 10 + return stage_workload(tr); +#else + // Construction alone: the Q15 by-4 chain's two designs (pinned + // lengths, no search) and quantized tables, then one output frame. + rat::down_2_down_2 conv(k_channels, eco); + std::int16_t in[4 * k_channels] = {}; + std::int16_t out[2 * k_channels] = {}; + const std::size_t made = conv.process(in, 4, out); + return static_cast(made) + static_cast(conv.template stage<0>().taps()) + + static_cast(conv.template stage<1>().taps()); +#endif + } + +} // namespace + +int main() { + const double checksum = run(); + const bool ok = checksum == checksum; // NaN check + // The marker line is part of the count. Under static musl (Hexagon) + // printf copies the format string's literal runs with memcpy, whose path + // depends on the string's word alignment: pinning the alignment makes the + // line cost the same wherever the rest of the image lands (the family + // PLAN.md, section 7; bridge's icount_main.cpp). Byte-stable from the + // first baseline (R12). + alignas(64) static constexpr char k_done_fmt[] = "RATIONAL_ICOUNT_DONE ok=%d checksum=%.17g\n"; + std::printf(k_done_fmt, ok ? 1 : 0, checksum); + return ok ? 0 : 1; +} diff --git a/rational/capi/CMakeLists.txt b/rational/capi/CMakeLists.txt new file mode 100644 index 0000000..fa4a4d1 --- /dev/null +++ b/rational/capi/CMakeLists.txt @@ -0,0 +1,20 @@ +# The C ABI shared library (see tap_sr_rational_capi.h). Built standalone by the +# notebooks' ctypes binding: +# cmake -S capi -B build_capi && cmake --build build_capi +# or from the top level with -DTAP_SR_BUILD_CAPI=ON. +cmake_minimum_required(VERSION 3.24) + +if(NOT TARGET tap_sr_rational) + project(tap_sr_rational_capi_standalone LANGUAGES CXX) + add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/.. ${CMAKE_CURRENT_BINARY_DIR}/rational) +endif() + +# Hidden by default, the C entry points exported by the source's visibility +# pragma: the dynamic symbol table is exactly the sixteen tap_sr_rational_* +# functions (no template or inline C++ leaks into it). +add_library(tap_sr_rational_capi SHARED tap_sr_rational_capi.cpp) +target_link_libraries(tap_sr_rational_capi PRIVATE tap::sr::rational tap_sr_rational_warnings) +set_target_properties(tap_sr_rational_capi PROPERTIES + CXX_VISIBILITY_PRESET hidden + VISIBILITY_INLINES_HIDDEN ON + POSITION_INDEPENDENT_CODE ON) diff --git a/rational/capi/tap_sr_rational_capi.cpp b/rational/capi/tap_sr_rational_capi.cpp new file mode 100644 index 0000000..db30582 --- /dev/null +++ b/rational/capi/tap_sr_rational_capi.cpp @@ -0,0 +1,326 @@ +/// @file tap_sr_rational_capi.cpp +/// @brief C ABI implementation: one interface over the named chains and the single stages. +// SPDX-License-Identifier: MIT +// Copyright 2026 Timothy Place and the SampleRateTap contributors + +#include "tap_sr_rational_capi.h" + +#include +#include +#include +#include +#include + +#include "tap/sr/rational/rational.h" + +namespace { + + using tap::dsp::exact_ratio; + namespace rat = tap::sr::rational; + + // The chain or stage is a compile-time type in C++; the C ABI makes the + // choice a runtime tag over the instantiations, behind one interface. + struct engine { + virtual ~engine() = default; + virtual std::size_t process(const float* in, std::size_t n, float* out) = 0; + virtual std::size_t outputs_for(std::size_t n) const = 0; + virtual std::size_t frames_needed(std::size_t k) const = 0; + virtual std::size_t flush(float* out) = 0; + virtual std::size_t flush_output_frames() const = 0; + virtual void reset() = 0; + virtual exact_ratio latency_output_frames() const = 0; + virtual exact_ratio macs_per_output() const = 0; + virtual exact_ratio ratio() const = 0; + virtual std::size_t stages() const = 0; + virtual std::size_t stage_taps(std::size_t i) const = 0; + }; + + /// Over a tap::dsp::chain of basic_stage: a named chain + /// (basic_chain) or a one-stage chain built at a stated divisor. + template + class chain_engine final : public engine { + public: + explicit chain_engine(Chain c) + : m_c(std::move(c)) {} + + std::size_t process(const float* in, std::size_t n, float* out) override { return m_c.process(in, n, out); } + std::size_t outputs_for(std::size_t n) const override { return m_c.outputs_for(n); } + std::size_t frames_needed(std::size_t k) const override { return m_c.frames_needed(k); } + std::size_t flush(float* out) override { return m_c.flush(out); } + std::size_t flush_output_frames() const override { return m_c.flush_output_frames(); } + void reset() override { m_c.reset(); } + exact_ratio latency_output_frames() const override { return m_c.latency_output_frames(); } + exact_ratio ratio() const override { return exact_ratio{Chain::k_up, Chain::k_down}; } + std::size_t stages() const override { return Chain::k_stages; } + + /// Each stage's MACs per superblock over its outputs per superblock, + /// scaled by its output rate over the chain's (basic_chain's rule). + exact_ratio macs_per_output() const override { + exact_ratio total{0, 1}; + exact_ratio after{1, 1}; // the rate ratio from stage I's output to the chain's + stage_fold(total, after); + return total; + } + + std::size_t stage_taps(std::size_t i) const override { + std::size_t taps = 0; + [&](std::index_sequence) { + ((i == I ? (taps = m_c.template stage().taps()) : 0), ...); + }(std::make_index_sequence{}); + return taps; + } + + private: + template + void stage_fold(exact_ratio& total, exact_ratio& after) const { + const auto& s = m_c.template stage(); + using st = std::remove_cvref_t; + total = total + exact_ratio{s.macs_per_superblock(), st::k_up == 1 ? 1 : st::k_up} * after; + after = after * exact_ratio{st::k_down, st::k_up}; + if constexpr (I > 0) { + stage_fold(total, after); + } + } + + Chain m_c; + }; + + rat::profile profile_for(int p) { + return p == 0 ? rat::profile::economy() + : p == 1 ? rat::profile::transparent() + : p == 2 ? rat::profile::balanced() + : rat::profile::super_economy(); + } + + template + std::unique_ptr make_chain(const rat::profile& p, unsigned channels) { + using chain = rat::basic_chain; + return std::make_unique>(chain(channels, p)); + } + + std::unique_ptr make_named(int chain, const rat::profile& p, unsigned ch) { + using namespace tap::sr::rational; // NOLINT(google-build-using-namespace) + switch (chain) { + case TAP_SR_RATIONAL_UP_2: + return make_chain(p, ch); + case TAP_SR_RATIONAL_DOWN_2: + return make_chain(p, ch); + case TAP_SR_RATIONAL_UP_3: + return make_chain(p, ch); + case TAP_SR_RATIONAL_DOWN_3: + return make_chain(p, ch); + case TAP_SR_RATIONAL_RATIO_3_2: + return make_chain(p, ch); + case TAP_SR_RATIONAL_RATIO_2_3: + return make_chain(p, ch); + case TAP_SR_RATIONAL_RATIO_4_3: + return make_chain(p, ch); + case TAP_SR_RATIONAL_RATIO_3_4: + return make_chain(p, ch); + case TAP_SR_RATIONAL_UP_2_UP_2: + return make_chain(p, ch); + case TAP_SR_RATIONAL_UP_2_UP_3: + return make_chain(p, ch); + case TAP_SR_RATIONAL_UP_2_RATIO_4_3_UP_3: + return make_chain(p, ch); + case TAP_SR_RATIONAL_UP_3_RATIO_8_3: + return make_chain(p, ch); + case TAP_SR_RATIONAL_UP_2_UP_6: + return make_chain(p, ch); + case TAP_SR_RATIONAL_UP_2_UP_8: + return make_chain(p, ch); + case TAP_SR_RATIONAL_UP_2_UP_6_UP_2: + return make_chain(p, ch); + case TAP_SR_RATIONAL_UP_2_UP_8_UP_2: + return make_chain(p, ch); + case TAP_SR_RATIONAL_UP_2_UP_8_UP_3: + return make_chain(p, ch); + case TAP_SR_RATIONAL_UP_2_RATIO_4_3: + return make_chain(p, ch); + case TAP_SR_RATIONAL_RATIO_3_4_DOWN_2: + return make_chain(p, ch); + case TAP_SR_RATIONAL_DOWN_2_DOWN_2: + return make_chain(p, ch); + case TAP_SR_RATIONAL_DOWN_3_DOWN_2: + return make_chain(p, ch); + case TAP_SR_RATIONAL_DOWN_3_RATIO_3_4_DOWN_2: + return make_chain(p, ch); + case TAP_SR_RATIONAL_RATIO_3_8_DOWN_3: + return make_chain(p, ch); + case TAP_SR_RATIONAL_DOWN_6_DOWN_2: + return make_chain(p, ch); + case TAP_SR_RATIONAL_DOWN_8_DOWN_2: + return make_chain(p, ch); + case TAP_SR_RATIONAL_DOWN_2_DOWN_6_DOWN_2: + return make_chain(p, ch); + case TAP_SR_RATIONAL_DOWN_2_DOWN_8_DOWN_2: + return make_chain(p, ch); + case TAP_SR_RATIONAL_DOWN_3_DOWN_8_DOWN_2: + return make_chain(p, ch); + default: + return nullptr; + } + } + + template + std::unique_ptr make_stage(const rat::profile& p, exact_ratio divisor, unsigned ch) { + using chain = tap::dsp::chain>; + return std::make_unique>(chain(ch, rat::basic_stage(ch, p.relaxed(divisor)))); + } + + std::unique_ptr make_single(unsigned l, unsigned m, const rat::profile& p, exact_ratio d, unsigned ch) { + using namespace tap::sr::rational; // NOLINT(google-build-using-namespace) + const auto key = (static_cast(l) << 8) | m; + switch (key) { + case (2u << 8) | 1u: + return make_stage(p, d, ch); + case (1u << 8) | 2u: + return make_stage(p, d, ch); + case (3u << 8) | 1u: + return make_stage(p, d, ch); + case (1u << 8) | 3u: + return make_stage(p, d, ch); + case (6u << 8) | 1u: + return make_stage(p, d, ch); + case (1u << 8) | 6u: + return make_stage(p, d, ch); + case (8u << 8) | 1u: + return make_stage(p, d, ch); + case (1u << 8) | 8u: + return make_stage(p, d, ch); + case (3u << 8) | 2u: + return make_stage(p, d, ch); + case (2u << 8) | 3u: + return make_stage(p, d, ch); + case (4u << 8) | 3u: + return make_stage(p, d, ch); + case (3u << 8) | 4u: + return make_stage(p, d, ch); + case (8u << 8) | 3u: + return make_stage(p, d, ch); + case (3u << 8) | 8u: + return make_stage(p, d, ch); + default: + return nullptr; + } + } + + void put(exact_ratio r, uint64_t* num, uint64_t* den) { + if (num != nullptr) { + *num = r.num; + } + if (den != nullptr) { + *den = r.den; + } + } + +} // namespace + +struct tap_sr_rational_converter { + std::unique_ptr e; +}; + +// The library builds with hidden visibility (CMakeLists.txt); only the C entry +// points below are exported. +#if defined(__GNUC__) +#pragma GCC visibility push(default) +#endif + +extern "C" { + +tap_sr_rational_converter* tap_sr_rational_create(int chain, int profile, unsigned channels) { + if (chain < 0 || chain >= TAP_SR_RATIONAL_CHAIN_COUNT || profile < 0 || profile > 3 || channels == 0) { + return nullptr; + } + try { + auto c = std::make_unique(); + c->e = make_named(chain, profile_for(profile), channels); + return c->e ? c.release() : nullptr; + } + catch (...) { + return nullptr; + } +} + +tap_sr_rational_converter* tap_sr_rational_create_stage(unsigned L, unsigned M, int profile, uint32_t divisor_num, + uint32_t divisor_den, unsigned channels) { + if (L > 255 || M > 255 || profile < 0 || profile > 3 || channels == 0 || divisor_num == 0 || divisor_den == 0) { + return nullptr; + } + try { + auto c = std::make_unique(); + c->e = make_single(L, M, profile_for(profile), exact_ratio{divisor_num, divisor_den}, channels); + return c->e ? c.release() : nullptr; + } + catch (...) { + return nullptr; + } +} + +void tap_sr_rational_destroy(tap_sr_rational_converter* c) { + delete c; +} + +void tap_sr_rational_ratio(const tap_sr_rational_converter* c, unsigned* L, unsigned* M) { + const exact_ratio r = c->e->ratio(); + if (L != nullptr) { + *L = static_cast(r.num); + } + if (M != nullptr) { + *M = static_cast(r.den); + } +} + +uint64_t tap_sr_rational_outputs_for(const tap_sr_rational_converter* c, uint64_t in_frames) { + return c->e->outputs_for(static_cast(in_frames)); +} + +uint64_t tap_sr_rational_frames_needed(const tap_sr_rational_converter* c, uint64_t out_frames) { + return c->e->frames_needed(static_cast(out_frames)); +} + +size_t tap_sr_rational_process(tap_sr_rational_converter* c, const float* in, size_t in_frames, float* out) { + return c->e->process(in, in_frames, out); +} + +size_t tap_sr_rational_flush(tap_sr_rational_converter* c, float* out) { + return c->e->flush(out); +} + +uint64_t tap_sr_rational_flush_output_frames(const tap_sr_rational_converter* c) { + return c->e->flush_output_frames(); +} + +void tap_sr_rational_reset(tap_sr_rational_converter* c) { + c->e->reset(); +} + +void tap_sr_rational_latency_output_frames(const tap_sr_rational_converter* c, uint64_t* num, uint64_t* den) { + put(c->e->latency_output_frames(), num, den); +} + +double tap_sr_rational_latency_seconds(const tap_sr_rational_converter* c, double out_rate_hz) { + return c->e->latency_output_frames().value() / out_rate_hz; +} + +void tap_sr_rational_macs_per_output(const tap_sr_rational_converter* c, uint64_t* num, uint64_t* den) { + put(c->e->macs_per_output(), num, den); +} + +size_t tap_sr_rational_stages(const tap_sr_rational_converter* c) { + return c->e->stages(); +} + +size_t tap_sr_rational_stage_taps(const tap_sr_rational_converter* c, size_t i) { + return c->e->stage_taps(i); +} + +unsigned tap_sr_rational_version(void) { + return (TAP_SR_VERSION_MAJOR << 16) | (TAP_SR_VERSION_MINOR << 8) | TAP_SR_VERSION_PATCH; +} + +} // extern "C" + +#if defined(__GNUC__) +#pragma GCC visibility pop +#endif diff --git a/rational/capi/tap_sr_rational_capi.h b/rational/capi/tap_sr_rational_capi.h new file mode 100644 index 0000000..000666e --- /dev/null +++ b/rational/capi/tap_sr_rational_capi.h @@ -0,0 +1,117 @@ +/// @file tap_sr_rational_capi.h +/// @brief Minimal C ABI over the float chains and stages, for FFI consumers. +// SPDX-License-Identifier: MIT +// Copyright 2026 Timothy Place and the SampleRateTap contributors +// +// The verification layer's seam (family convention): the notebooks drive the +// SHIPPING C++ through this ABI via ctypes rather than re-implementing +// anything in Python. Float only — the notebooks measure the golden model; +// the fixed-point contracts are pinned by the C++ test suite (M5). +// +// What a caller constructs is named, never looked up (D12): a chain is one of +// the coverage matrix's within-family chains (PLAN.md 3.3 / 3.4, R16), a +// chain constant below; a single stage for composing a chain through bridge is a +// ratio of the vocabulary with the design divisor the matrix row states +// (PLAN.md 3.1). There is no (in_hz, out_hz) entry point. +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct tap_sr_rational_converter tap_sr_rational_converter; + +/// The named within-family chains (PLAN.md 3.3 / 3.4): the eight one-stage +/// chains, then the twenty multi-stage ones, named by their stages in order. +/// The chain argument of tap_sr_rational_create; values are stable. +#define TAP_SR_RATIONAL_UP_2 0 +#define TAP_SR_RATIONAL_DOWN_2 1 +#define TAP_SR_RATIONAL_UP_3 2 +#define TAP_SR_RATIONAL_DOWN_3 3 +#define TAP_SR_RATIONAL_RATIO_3_2 4 +#define TAP_SR_RATIONAL_RATIO_2_3 5 +#define TAP_SR_RATIONAL_RATIO_4_3 6 +#define TAP_SR_RATIONAL_RATIO_3_4 7 +#define TAP_SR_RATIONAL_UP_2_UP_2 8 +#define TAP_SR_RATIONAL_UP_2_UP_3 9 +#define TAP_SR_RATIONAL_UP_2_RATIO_4_3_UP_3 10 +#define TAP_SR_RATIONAL_UP_3_RATIO_8_3 11 +#define TAP_SR_RATIONAL_UP_2_UP_6 12 +#define TAP_SR_RATIONAL_UP_2_UP_8 13 +#define TAP_SR_RATIONAL_UP_2_UP_6_UP_2 14 +#define TAP_SR_RATIONAL_UP_2_UP_8_UP_2 15 +#define TAP_SR_RATIONAL_UP_2_UP_8_UP_3 16 +#define TAP_SR_RATIONAL_UP_2_RATIO_4_3 17 +#define TAP_SR_RATIONAL_RATIO_3_4_DOWN_2 18 +#define TAP_SR_RATIONAL_DOWN_2_DOWN_2 19 +#define TAP_SR_RATIONAL_DOWN_3_DOWN_2 20 +#define TAP_SR_RATIONAL_DOWN_3_RATIO_3_4_DOWN_2 21 +#define TAP_SR_RATIONAL_RATIO_3_8_DOWN_3 22 +#define TAP_SR_RATIONAL_DOWN_6_DOWN_2 23 +#define TAP_SR_RATIONAL_DOWN_8_DOWN_2 24 +#define TAP_SR_RATIONAL_DOWN_2_DOWN_6_DOWN_2 25 +#define TAP_SR_RATIONAL_DOWN_2_DOWN_8_DOWN_2 26 +#define TAP_SR_RATIONAL_DOWN_3_DOWN_8_DOWN_2 27 +#define TAP_SR_RATIONAL_CHAIN_COUNT 28 + +/// Every function below requires a valid converter from a successful create; +/// passing NULL is undefined behavior. The one exception is +/// tap_sr_rational_destroy, where NULL is a safe no-op (the free() convention). + +/// chain: one of the TAP_SR_RATIONAL_* chain constants above. +/// profile: 0 = economy (default tier), 1 = transparent, 2 = balanced, +/// 3 = super_economy (bridge's C ABI tags). +/// Returns NULL on invalid arguments. +tap_sr_rational_converter* tap_sr_rational_create(int chain, int profile, unsigned channels); + +/// One stage at ratio L/M (one of the vocabulary's fourteen: 2/1, 1/2, 3/1, +/// 1/3, 6/1, 1/6, 8/1, 1/8, 3/2, 2/3, 4/3, 3/4, 8/3, 3/8) designed at the +/// profile relaxed by the design divisor divisor_num / divisor_den (the +/// stage's lower rate over its chain's lowest, PLAN.md 3.1): what a chain +/// through bridge composes, as the coverage-matrix test builds it. Returns +/// NULL on invalid arguments. +tap_sr_rational_converter* tap_sr_rational_create_stage(unsigned L, unsigned M, int profile, uint32_t divisor_num, + uint32_t divisor_den, unsigned channels); +void tap_sr_rational_destroy(tap_sr_rational_converter* c); + +/// The converter's ratio, reduced: output frames per input frame = L / M. +void tap_sr_rational_ratio(const tap_sr_rational_converter* c, unsigned* L, unsigned* M); + +/// Exact accounting from the current position (see tap::dsp::chain). +uint64_t tap_sr_rational_outputs_for(const tap_sr_rational_converter* c, uint64_t in_frames); +uint64_t tap_sr_rational_frames_needed(const tap_sr_rational_converter* c, uint64_t out_frames); + +/// Push-transform over interleaved float frames; returns frames written. +/// out must hold tap_sr_rational_outputs_for(c, in_frames) frames. +size_t tap_sr_rational_process(tap_sr_rational_converter* c, const float* in, size_t in_frames, float* out); + +/// Drains the tail, bit-identical to zero padding (out must hold +/// tap_sr_rational_flush_output_frames(c) frames). +size_t tap_sr_rational_flush(tap_sr_rational_converter* c, float* out); +uint64_t tap_sr_rational_flush_output_frames(const tap_sr_rational_converter* c); + +void tap_sr_rational_reset(tap_sr_rational_converter* c); + +/// Group delay in output frames as an exact reduced rational (R7), and in +/// seconds at a given output rate. +void tap_sr_rational_latency_output_frames(const tap_sr_rational_converter* c, uint64_t* num, uint64_t* den); +double tap_sr_rational_latency_seconds(const tap_sr_rational_converter* c, double out_rate_hz); + +/// Multiply-accumulates per output frame per channel, an exact reduced +/// rational (the trimmed rows the kernels execute, PLAN.md 3.3). +void tap_sr_rational_macs_per_output(const tap_sr_rational_converter* c, uint64_t* num, uint64_t* den); + +/// The number of stages, and stage i's Nyquist design length N (0 when i is +/// out of range). +size_t tap_sr_rational_stages(const tap_sr_rational_converter* c); +size_t tap_sr_rational_stage_taps(const tap_sr_rational_converter* c, size_t i); + +/// Library version, packed (major << 16) | (minor << 8) | patch. +unsigned tap_sr_rational_version(void); + +#ifdef __cplusplus +} // extern "C" +#endif diff --git a/rational/include/tap/sr/rational/rational.h b/rational/include/tap/sr/rational/rational.h index b36f0c7..fb598ae 100644 --- a/rational/include/tap/sr/rational/rational.h +++ b/rational/include/tap/sr/rational/rational.h @@ -36,5 +36,5 @@ #include "tap/sr/rational/stage.h" // IWYU pragma: export #define TAP_SR_VERSION_MAJOR 0 -#define TAP_SR_VERSION_MINOR 4 +#define TAP_SR_VERSION_MINOR 5 #define TAP_SR_VERSION_PATCH 0 diff --git a/rational/notebooks/matrix.ipynb b/rational/notebooks/matrix.ipynb new file mode 100644 index 0000000..e98ee0d --- /dev/null +++ b/rational/notebooks/matrix.ipynb @@ -0,0 +1,491 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "b021b55c", + "metadata": {}, + "source": [ + "# The coverage matrix, measured through the C ABIs\n", + "\n", + "`rational`'s M6 notebook (`PLAN.md` section 6). It rebuilds every one of the 182 rows of the\n", + "coverage matrix (PLAN.md section 3; the rows as `tests/coverage/matrix_rows.h` states them)\n", + "from the **shipping** libraries through their C ABIs: a within-family row is the named chain\n", + "`tap_sr_rational_create` constructs from its enumerator, a cross-family row is composed here\n", + "from `tap_sr_rational_create_stage` stages at the row's design divisors and `bridge`'s\n", + "converter through *its* C ABI (a notebook, like a test, may name a sibling: 4.2). Nothing is\n", + "re-implemented in Python; the analysis (bins, a DFT) is the only numpy.\n", + "\n", + "It then checks the numbers the matrix test pins — MACs per output and latency, exact\n", + "rationals, at all four profiles — and measures every row's promise (2.1) with a tone battery at\n", + "`economy` and `transparent`: every image and alias candidate that lands at or below f_pass is at\n", + "or below −A, the passband flat within the stages' ripple candidates." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "87a53878", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-02T18:24:12.563342Z", + "iopub.status.busy": "2026-10-02T18:24:12.563093Z", + "iopub.status.idle": "2026-10-02T18:24:16.225190Z", + "shell.execute_reply": "2026-10-02T18:24:16.223572Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "rational (0, 5, 0) bridge (0, 5, 0)\n" + ] + } + ], + "source": [ + "import pathlib, re, sys\n", + "from fractions import Fraction\n", + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "\n", + "HERE = pathlib.Path.cwd()\n", + "sys.path.insert(0, str(HERE))\n", + "sys.path.insert(0, str(HERE.parents[1] / \"bridge\" / \"notebooks\"))\n", + "import figure_digest\n", + "figure_digest.install()\n", + "import tap_sr_rational_py as rp\n", + "import tap_sr_bridge_py as bp\n", + "\n", + "print(\"rational\", rp.version(), \"bridge\", bp.version())\n", + "assert rp.version() == bp.version() == (0, 5, 0) # one family version (D13)" + ] + }, + { + "cell_type": "markdown", + "id": "b44fd9bb", + "metadata": {}, + "source": [ + "## The rows\n", + "\n", + "The generated table: per row the source and destination rates, the ⚑ flag, the stages in order\n", + "with each rational stage's design divisor (`0/1` marks the bridge stage), and the pinned MACs\n", + "per output and latency in output frames per profile (super_economy, economy, balanced,\n", + "transparent)." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "88d89a89", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-02T18:24:16.228763Z", + "iopub.status.busy": "2026-10-02T18:24:16.228408Z", + "iopub.status.idle": "2026-10-02T18:24:16.241429Z", + "shell.execute_reply": "2026-10-02T18:24:16.239683Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "182 rows; 38 flagged; 92 within a family\n" + ] + } + ], + "source": [ + "text = (HERE.parent / \"tests\" / \"coverage\" / \"matrix_rows.h\").read_text()\n", + "ROWS = []\n", + "for m in re.finditer(r\"ROW\\((\\d+), (\\d+), (true|false), \\(([^)]*)\\), \\(((?:d\\(\\d+, \\d+\\)(?:, )?)*)\\), ([\\d, ]+)\\)\", text):\n", + " nums = [int(v) for v in m.group(6).split(\",\")]\n", + " ROWS.append(dict(\n", + " src=int(m.group(1)), dst=int(m.group(2)), flagged=m.group(3) == \"true\",\n", + " stages=[s.strip() for s in m.group(4).split(\",\")],\n", + " divisors=[Fraction(int(a), int(b)) if int(a) else None\n", + " for a, b in re.findall(r\"d\\((\\d+), (\\d+)\\)\", m.group(5))],\n", + " macs=[Fraction(nums[4 * i], nums[4 * i + 1]) for i in range(4)],\n", + " latency=[Fraction(nums[4 * i + 2], nums[4 * i + 3]) for i in range(4)],\n", + " ))\n", + "PROFILES = [\"super_economy\", \"economy\", \"balanced\", \"transparent\"]\n", + "ATTEN = {\"super_economy\": 70.0, \"economy\": 70.0, \"balanced\": 70.0, \"transparent\": 120.0}\n", + "PASS_FRAC = {\"super_economy\": Fraction(1, 3), \"economy\": Fraction(3, 8),\n", + " \"balanced\": Fraction(19, 48), \"transparent\": Fraction(5, 12)}\n", + "print(len(ROWS), \"rows;\", sum(r[\"flagged\"] for r in ROWS), \"flagged;\",\n", + " sum(not any(s.startswith(\"bridge\") for s in r[\"stages\"]) for r in ROWS), \"within a family\")" + ] + }, + { + "cell_type": "markdown", + "id": "b3bb3926", + "metadata": {}, + "source": [ + "## Building a row\n", + "\n", + "A stage name is its ratio (`up_2` is 2/1, `ratio_3_4` is 3/4); `bridge_down` and\n", + "`bridge_up` are bridge's converter, whose machine is identical at every rate scale k\n", + "(bridge's 2.2), so k only says which rates its frames carry. Each part of a row is a converter\n", + "with its ratio; the row runs them in sequence, which `test_chain.cpp` shows is bit-identical to\n", + "`tap::dsp::chain`." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "2739f5cc", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-02T18:24:16.244304Z", + "iopub.status.busy": "2026-10-02T18:24:16.244070Z", + "iopub.status.idle": "2026-10-02T18:24:16.254364Z", + "shell.execute_reply": "2026-10-02T18:24:16.253123Z" + } + }, + "outputs": [], + "source": [ + "BRIDGE_LM = {\"down\": (147, 160), \"up\": (160, 147)}\n", + "\n", + "def stage_ratio(name):\n", + " if name.startswith(\"bridge\"):\n", + " return Fraction(*BRIDGE_LM[\"down\" if \"down\" in name else \"up\"])\n", + " if name.startswith(\"up_\"):\n", + " return Fraction(int(name[3:]), 1)\n", + " if name.startswith(\"down_\"):\n", + " return Fraction(1, int(name[5:]))\n", + " _, l, m = name.split(\"_\")\n", + " return Fraction(int(l), int(m))\n", + "\n", + "class Part:\n", + " \"\"\"One converter of a row: a rational chain or stage, or bridge.\"\"\"\n", + " def __init__(self, conv, ratio, macs, latency):\n", + " self.conv, self.ratio, self.macs, self.latency = conv, ratio, macs, latency\n", + "\n", + "def bridge_part(name, profile):\n", + " d = \"down\" if \"down\" in name else \"up\"\n", + " c = bp.RatioConverter(direction=d, profile=profile)\n", + " l, m = BRIDGE_LM[d]\n", + " # bridge's cost per output is its taps per phase T; its group delay is\n", + " # (L T - 1) / 2 at the composite rate, (L T - 1) / (2 M) output frames.\n", + " return Part(c, Fraction(l, m), Fraction(c.taps), Fraction(l * c.taps - 1, 2 * m))\n", + "\n", + "def build(row, profile):\n", + " \"\"\"The row's parts: one named chain within a family, else stages and bridge.\"\"\"\n", + " if not any(s.startswith(\"bridge\") for s in row[\"stages\"]):\n", + " c = rp.Chain(\"_\".join(row[\"stages\"]), profile=profile)\n", + " return [Part(c, c.ratio, c.macs_per_output, c.latency_output_frames)]\n", + " parts = []\n", + " for name, d in zip(row[\"stages\"], row[\"divisors\"]):\n", + " if name.startswith(\"bridge\"):\n", + " parts.append(bridge_part(name, profile))\n", + " else:\n", + " r = stage_ratio(name)\n", + " s = rp.Stage(r.numerator, r.denominator, profile=profile, divisor=(d.numerator, d.denominator))\n", + " parts.append(Part(s, s.ratio, s.macs_per_output, s.latency_output_frames))\n", + " return parts\n", + "\n", + "def numbers(row, parts):\n", + " \"\"\"MACs per output and latency in output frames, exact, at the row's output rate.\"\"\"\n", + " rate, macs, lat = Fraction(row[\"src\"]), Fraction(0), Fraction(0)\n", + " for p in parts:\n", + " rate *= p.ratio\n", + " scale = rate / row[\"dst\"] # this part's output frames per chain output frame\n", + " macs += p.macs * scale\n", + " lat += p.latency / scale\n", + " assert rate == row[\"dst\"]\n", + " return macs, lat\n", + "\n", + "def run(parts, x):\n", + " for p in parts:\n", + " x = p.conv.process(x)\n", + " return x\n", + "\n", + "def frames_needed(parts, k):\n", + " for p in reversed(parts):\n", + " k = p.conv.frames_needed(k)\n", + " return k" + ] + }, + { + "cell_type": "markdown", + "id": "e7b5ed23", + "metadata": {}, + "source": [ + "## The pinned numbers, reproduced\n", + "\n", + "For every row at every profile: the MACs per output and the latency the shipping converters\n", + "report, composed at the row's output rate, against the pins `test_matrix.cpp` holds." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "c4168647", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-02T18:24:16.257752Z", + "iopub.status.busy": "2026-10-02T18:24:16.257548Z", + "iopub.status.idle": "2026-10-02T18:24:16.488586Z", + "shell.execute_reply": "2026-10-02T18:24:16.486815Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "728 (row, profile) pairs; 728 reproduce both pins exactly\n" + ] + } + ], + "source": [ + "mismatches, checked = [], 0\n", + "TABLE = {}\n", + "for row in ROWS:\n", + " for i, prof in enumerate(PROFILES):\n", + " macs, lat = numbers(row, build(row, prof))\n", + " checked += 1\n", + " TABLE[(row[\"src\"], row[\"dst\"], prof)] = (macs, lat)\n", + " if macs != row[\"macs\"][i] or lat != row[\"latency\"][i]:\n", + " mismatches.append((row[\"src\"], row[\"dst\"], prof, macs, row[\"macs\"][i], lat, row[\"latency\"][i]))\n", + "print(f\"{checked} (row, profile) pairs; {checked - len(mismatches)} reproduce both pins exactly\")\n", + "for m in mismatches[:10]:\n", + " print(\"MISMATCH\", m)\n", + "assert not mismatches" + ] + }, + { + "cell_type": "markdown", + "id": "cf6728b0", + "metadata": {}, + "source": [ + "## The promise, measured\n", + "\n", + "A tone at input frequency f, an exact bin of an analysis window n output frames long whose\n", + "length makes every rate of the chain a bin too, so the rectangular-window DFT measures every\n", + "image and alias candidate (|k R_j ± f| folded, over every rate R_j along the chain) without\n", + "leakage beside the tone. Seven passband tones and up to five stopband tones per row; a candidate\n", + "counts when it lands at or below f_pass, the promise's band (2.1). The battery is the C++\n", + "matrix test's, run here on the float golden model through the C ABIs." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "0022a7eb", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-02T18:24:16.493142Z", + "iopub.status.busy": "2026-10-02T18:24:16.492787Z", + "iopub.status.idle": "2026-10-02T18:24:24.112675Z", + "shell.execute_reply": "2026-10-02T18:24:24.110968Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "economy : 182/182 rows meet the promise; worst candidate -71.34 dB (spec -70), worst passband deviation 0.00805 dB\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "transparent : 182/182 rows meet the promise; worst candidate -121.57 dB (spec -120), worst passband deviation 0.00002 dB\n" + ] + } + ], + "source": [ + "from math import gcd, lcm\n", + "\n", + "def analysis_length(rates, target=8192):\n", + " f_out, n0 = rates[-1], 1\n", + " for r in rates:\n", + " n0 = lcm(n0, f_out // gcd(r, f_out))\n", + " return n0 * -(-target // n0)\n", + "\n", + "def measure_row(row, prof):\n", + " parts = build(row, prof)\n", + " rates = [row[\"src\"]]\n", + " for p in parts:\n", + " rates.append(int(rates[-1] * p.ratio))\n", + " f_in, f_out = row[\"src\"], row[\"dst\"]\n", + " r_min = min(f_in, f_out)\n", + " f_pass = float(PASS_FRAC[prof]) * r_min\n", + " f_stop = r_min - f_pass\n", + " n = analysis_length(rates)\n", + " _, lat = numbers(row, parts)\n", + " skip = int(2 * float(lat)) + 64\n", + " bin_hz = f_out / n\n", + " pass_bin = int(f_pass / bin_hz)\n", + " n_in = frames_needed(parts, skip + n)\n", + "\n", + " def tone(b):\n", + " for p in parts:\n", + " p.conv.reset()\n", + " w = 2 * np.pi * b * f_out / n / f_in\n", + " y = run(parts, (0.5 * np.sin(w * np.arange(n_in))).astype(np.float32)).astype(np.float64)\n", + " spec = np.abs(np.fft.rfft(y[skip:skip + n])) * 2 / n\n", + " fold = lambda k: (k % n) if (k % n) <= n // 2 else n - (k % n)\n", + " own, cands = fold(b), set()\n", + " for r in rates:\n", + " ru = r * n // f_out\n", + " k = 0\n", + " while k * ru <= 2 * n + b:\n", + " cands.add(fold(k * ru + b)); cands.add(fold(abs(k * ru - b))); k += 1\n", + " spur = max([spec[c] for c in cands if c != own and 0 < c <= pass_bin] or [1e-30])\n", + " return 20 * np.log10(spur / 0.5), 20 * np.log10(spec[own] / 0.5), own\n", + "\n", + " worst_spur, worst_dev = -400.0, 0.0\n", + " for frac in (0.1, 0.3, 0.5, 0.7, 0.85, 0.95, 0.99):\n", + " b = int(frac * f_pass / bin_hz)\n", + " if b >= 1:\n", + " s, g, _ = tone(b)\n", + " worst_spur, worst_dev = max(worst_spur, s), max(worst_dev, abs(g))\n", + " nyq_in = f_in / 2\n", + " if f_stop < nyq_in:\n", + " for f in sorted({f_stop * 1.001, f_stop * 1.05, f_stop + 0.25 * (nyq_in - f_stop),\n", + " f_stop + 0.5 * (nyq_in - f_stop), nyq_in * 0.98}):\n", + " if f_stop < f < nyq_in:\n", + " s, g, own = tone(int(np.ceil(f / bin_hz)))\n", + " worst_spur = max(worst_spur, s)\n", + " if own <= pass_bin:\n", + " worst_spur = max(worst_spur, g)\n", + " n_stages = sum(1 for s in row[\"stages\"])\n", + " delta = n_stages * (0.0001 if ATTEN[prof] >= 100 else 0.01)\n", + " return worst_spur, worst_dev, delta\n", + "\n", + "RESULTS = {}\n", + "for prof in (\"economy\", \"transparent\"):\n", + " for row in ROWS:\n", + " RESULTS[(row[\"src\"], row[\"dst\"], prof)] = measure_row(row, prof)\n", + " spurs = [RESULTS[(r[\"src\"], r[\"dst\"], prof)][0] for r in ROWS]\n", + " devs = [RESULTS[(r[\"src\"], r[\"dst\"], prof)][1] for r in ROWS]\n", + " ok = sum(RESULTS[(r[\"src\"], r[\"dst\"], prof)][0] <= -ATTEN[prof]\n", + " and RESULTS[(r[\"src\"], r[\"dst\"], prof)][1] <= RESULTS[(r[\"src\"], r[\"dst\"], prof)][2] for r in ROWS)\n", + " print(f\"{prof:12s}: {ok}/182 rows meet the promise; worst candidate {max(spurs):.2f} dB \"\n", + " f\"(spec -{ATTEN[prof]:.0f}), worst passband deviation {max(devs):.5f} dB\")" + ] + }, + { + "cell_type": "markdown", + "id": "43590fb3", + "metadata": {}, + "source": [ + "## The cost and the delay of every row\n", + "\n", + "MACs per output against latency at `economy`, one mark per ordered pair: within a family, across\n", + "families, and the ⚑ rows (bridge running at twice the output rate or more: covered, not\n", + "recommended). Both axes logarithmic; the table below the figure is the same data by class." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "bcf964dc", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-02T18:24:24.115846Z", + "iopub.status.busy": "2026-10-02T18:24:24.115603Z", + "iopub.status.idle": "2026-10-02T18:24:24.795263Z", + "shell.execute_reply": "2026-10-02T18:24:24.792645Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[ figure ] digest 6b26187d1a2e8649 (3 arrays)\n" + ] + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAArYAAAHnCAYAAABNOR4jAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjIsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvgI3uAAAAAAlwSFlzAAAPYQAAD2EBqD+naQAAhnBJREFUeJzt3Xd4U2X7B/BvmnSmpS3dpYVCB0ihbJDVlqk4AEVwgODCV1FRf+rrFrfgq6KogIIKKA6E11cFRGSUvVdZHWy6aSkdadrmJOf3R2kgbdom6Ukz+v1cVy/Ic05O7nOnhDvPec7zyARBI4KIiIiIyMG52DoAIiIiIiIpsLAlIiIiIqfAwpaIiIiInAILWyIiIiJyCixsiYiIiMgpsLAlIiIiIqfAwpaIiIiInAILWyIiIiJyCixszfT88y/g448/sXUYRERERFSHwtYB2Npff/2F77//3qR9v/32W6SkpCAuLtbKUdUoLS3F2rVr8c8/G6BWV+Czzz5DUFCQ0X2zs7Px448/4cyZ0xBFEZ06dcJ9992HiIgIg/20Wi3Wr1+PnTt3IScnB+Hh4RgxYgSSk5Na4pTs1sWLF/Hiiy9ixowZGDJkiK3DISIiIgu0+h7bzp274L777tP/3HnnBKxZsxYlJSUG7ffddx9cXV1bLK7vvluC3r37YNOmTbh8+TLWrFmLiooKo/uuXbsWPXv2wubNmzBo0CAMGTIEW7duRc+evfDnn3/q91OpVIiP74bHHnscCoUCN998EwDg7rvvxv33T4UgCC1ybvaorKwMa9asRXZ2tq1DISIiIgu1+h7bTp06olOnjvrH5eXlAIDw8Ha47bbbbBUWRo0aifvuuxfu7u549dXXsGnTpgb3ffPNtxAYGIjffvsN7u7uAIBJkyahT5++mDXrTdx+++0AAI1Ggw4dOmDp0iUIDQ0FAEycOBHx8V3xr389huXLf8S0aVOtf3JEREREVtDqC1tLVVdXY8mSJdizZy/8/PwwZcoU9OrVs95+ly8X46effsSRI0cgCFr06NEDU6dOhb+/X6PHrzuEoDElJSWIiorSF7UA4ObmhqioKJw8eVLf5u3tjf/97zd4enoaPH/06NEAgD17djdZ2D7//AsICwvDE0/MwLJly7B37z4MHDgQDz/8EABgw4YN+OuvdSgsLERYWCjGjRuHgQMH6p8/ffqjGDx4MB54YJq+7dVXX8PZs2fw7rvv6b9knDt3Dq+88gqeeuop/fNPnjyJX35ZgaysLPj5+WHAgP648847IZfLG4y3srISDz300HV5cUdERATuuOMO9OnTGwBw8OAhzJo1CwAwf/4C/P777wCAO+64AxMnTmw0H4cPH8Z///sbLly4AB8fH4waNRK33347ZDKZwX6pqan47bf/4dy5cwgODsLo0TdhxIjhBvs0lbszZ87itddexTPPPIOQkBAsWrQY2dnZuOGGLnj00Ufh5+dXLz5zjhkQEIBvvvkGly4VIjFxKKZMmQKZTIZTp07hu+++Q05OLvr164vp06frr15cvnwZTz31FMaPH18vVzqdDo8++i8kJCRg5synGs0jERGRFFr9UARLiKKI6dMfxeXLlzFy5AicOnUKo0ePRmpqqsF+hw4dRr9+/fDLLyvQt29fJCUlYfXq1RgyZAjOnz8vWTx33TUBhw8fxrFjx/RtJ0+exIEDBzBhwp36NoVCUa+oBYCMjAwAMFoY1ZWSkoJ9+/Zi2rRpKCwsxKBBg1BUVAgAeOKJJzFp0t1wd3fDzTffBJWqAjffPAbvvfee/vnFxcVYvHix/rFKpcJXX32FNWvWYv36v/Xt//yzAWvWrNUX+Bs2bMDgwUNw6VIBRo0aifj4eGzYsAHTpj3QaLyurq4Gw0lGjRqJwsJLGDlyJFauXAUAiIhoh1tvvQUAMGjQQP2+CQkJjR77o48+xrBhw1FYeAk33TQaUVFRePLJpzB9+qMG+3344YdITEzCuXPnMGxYMqKjo/Hll1/i008/0+9jSu6uXLmCNWvWYsuWrXj66WcQFRWFG28cgK+/XoRx48ZDFEWD1zXnmJs2bcLzz7+AuLjO6Ny5M55//gW8++57OHjwEJ599v8QExOLhIQEvP32O3j++Rf0z2/bti0uX76M99//oN7rr1u3Dr/++isiIyMbzSMREZFkBEEj8ufaz5UrxaJSqRQffPBBo9t79uwpBgQEiJs2bdS3VVSoxKioKHHq1Kn6NrW6Quzatas4ePBgsbJSbdDeq1cvccKEO02O6cUXXxSVSqV4+vQpo9urq6vEuXM/EcPDw8XRo0eJo0ePEsPCwsQPP/xQrKqqbPTYVVWV4siRI0Vvb29x//59TcbSs2dPsW3btuJff63Vt6lU5eKqVStFpVIpLliwwGD/N954Q1QqleKOHdtFQdCIn332qejt7S3m5uaIgqAR16xZLbZp00a84447DHJy992TxJ49exo8HjJkSL14ao9j7s8TTzwhxsTE6B+nph4RlUqluHz5DyY9f/PmTaJSqRQ//vhjg/YdO7aLSqVS/OWXX0RB0Ijr168XlUql+MEH79c7Rl5erigIGpNzt3fvXlGpVIp9+/YVy8vL9PutXv2nqFQqxTVrVuvbzD1mv379RJWqXL/f22+/JQYGBop33nmnwWu9//57oq+vr5idnaVv++WXX0SlUin+/fffBq81btw4MTIyUqyoUEn275M//OEPf/jDn8Z+2GNrgfbt2yMxMVH/2M3NDTfeeKNBj+nWrdtw/vx5PP74Y1Aoro34cHV1xaRJk7Bhw8YGbwYz1969ezF//gLEx3fFpEl345577kHv3r2xcOFC7N69u9HnvvHGG9i1axeeeuop9OzZ06TXCwkJwahRo/SP3d3d8d///gYvLy+DIQYA8MQTMwAAv/32GwBg2LBhEEURmzdvBgBs3rwZvXv3xvjx47B9+w5oNBpotVps27YNw4Yl64/j6emFs2fPYv/+/QbHb2iWiOtdvnwZCxYswIwZMzB58hTcd9992L9/H3Jzc3H58mWTzrmuH374AW5ubnjssX8ZtA8YMABxcXH4448/AAA//vgj3N3dMXPmzHrHCAwMBACTc1drwoQJ8PDw0D9OSqqZ0eLo0Wu/f5Yc8/qhLAMHDoJarUafPr0NXmvgwIEQBAEnT6bp28aOvR0hISH45ptrPfFnz57Dxo0bcc89d8PNza3euRMREVkDx9haoEOHDvXaAgICkJ+fr398+vQpAMAPPyzHmjVrIYqi/lLthQsXIAgC8vLyDW5cs0RlZSWmTXsAISEhWL16tX686f33348xY27BtGkPIDX1CLy8vOo996OPPsYXX3yJiRMn4q233jT5NTt2rB/zhQsXEBkZWW/miICAAPj6+uqHXnTt2hUhISHYtGkTJk2ahM2bUzBu3FgMHz4c5eXl2LNnD9zc3FBSUork5GT9cV5++SUcPXoUI0aMRIcOHTBkyGCMHDkK48aNbXSM7enTpzF69E0ICwvDPffcjfDwdnBzc8U//2zAsWPHUVZWhrZt25p87rVOnToNV1dXPPLIdAC47v0VUVRUpJ9d4cyZM+jQoYNB0Whp7mpFRRn+/nl6esLLywsFBdd+/8w9ZocO7Q0et23rD6DmS5xhe02uLl0q0Le5urpi2rSp+PjjT5CVlYWIiAh8++23EEURU6ZMafC8iYiIpMbC1gJubvWn/ZLJZBBFnf6xq2tNL9WoUaMQHd3J6HGCg5vubWxKRkYG8vLyMHXq/QYFnkwmw80334xZs2bhxIkT6Nu3r8HzvvzyS7zzzju444478NVXC+HiYnrnvbe3sl6bu7s7Cgsv1WsXRRGVlZVwd7/W65ecnITNm1OQk5ODtLQ0fPrpXISFhaFLly7YvDkFbm6uUCgUGDp0qP450dHR2L17Fw4dOozt27dj69atePjhh7FwYT+sWbO6wanY5s37HJWVlVizZjV8fX317QcPHjL5fI1xc3OFj48P7r57Ur1t9957r368soeHO/Ly8ho9ljm5A679bl1PJpNBp7s2xrW5x6y9+a2hdp1OZ9D+4IMP4pNP5mLJkiV44YUXsHz5cvTu3Rvx8fH1YiAiIrIWFrZW0q9fTSHp5uZq1WnDam8GKy0trbetpKQEAAwuJQPA4sXf4JVXXsX48eOxePGiRns8TdWjRwL27t2rX/Sh1pEjR1BVVYUePXro24YNG4ZfflmBL7+cDx8fH/Tr1w8AMHz4MGzatAnu7u7o06cP2rRpY/AaMpkMvXv3Qu/evTBz5lP44osv8Oqrr+HIkSP1Cvda+fl5CA8PNyhqAWDr1q0Gj2sL47oFW0P69u2H3bv3YMCAAY0Oh+jbtx927tyFM2fONtg7b07uTGWNYzYmPDwcY8aMwbJl36N9+/YoKirCa6+9JulrEBERNYVjbK2kW7duGD9+PGbPnoO9e/cabMvJycE333wryevExsaiW7d4/PTTz0hPT9e3nz59GkuXLkXnzp0Nes1+/PFHPP/88xg3bhy++Waxwfjf5nj44YehUCjw4osvobq6GkDNogevvPIK/P39MWXKZP2+w4YNAwAsWrQIQ4cO0ccwbNgwHD58GPv27TMYXwvUFOM5OTkGbVeuXIFMJkPbtgENxtWjRw+cOXMGR48e1bctXLgQV65cMdgvLCwMMpkMFy5cMOl8Z8x4HP7+/vjXvx6rN053+/btSEnZAgB47LF/wc/PD0899SSKior0+1y8eBHr168HYF7uTGWNYzZl+vRHkJ+fj1deeRVeXl64664Jkr8GERFRY9hja0ULFy7A66+/gdtuux3R0dEICwtDVlYWqqur8fTTTzf63CNHjmDOnDkAoL9R5+mnn4GXlydCQkIxd+4n+n2XL1+OZ5/9PwwZMhS9e/eGTCbDwYMHMXDgQMydO1d/+TgnJwdPPlkzn2hlZSWmTjWcs7ZHjx548cUXLTrXuLg4LF/+A556aia6d09AXFwcjh8/Dl9fX6xc+atBr2ZoaChuuOEGnDx5Ul/kAsDgwYOhUChQXV1t0F5rzJhb4OHhgYiICOTk5CA/Px+ffPJJo+OUn3rqKWzbtg0jR45C//79kZeXhz59euPRR6fjhRf+rd/P29sbjzzyCD766GPs2LET3t7KRuexDQsLw5o1q/XnGx8fDzc3N5w+fRoxMTF47713AdT0ZP7xx+947LHH0KNHT3Tr1g2VlZUoKyvTv4fm5M5U1jhmU5KSkhAXF4eMjAzce+899XrciYiIrE0mCBqx6d1aD61Wi7/++gvt2kUYXXAhJWULvL2V9S59Hz16FDk5ObjpppvqPae0tBRHjx6FWq1Ghw4d0KlTpyYv/1+6dAl79uwxuk2p9K7XowkA2dnZOHv2LERRRFRUVL35Q1UqlX42AmOCg4PRv3//RuNq6PxraTQapKamoqioCMHBwUhISDA6fvfgwUPIycnGoEGDDG7eSknZgvLyMtx88831epO1Wi0yMzORlZWFtm0DEB/ftdGbsq53/Phx5ObmITo6Gh07RuH8+fM4evQohg8fbnBjXWZmJs6ePYvq6mrExsaic+fOTR773LlzOHXqNDw9PRATE4OQkBCj+6WlpeHixYsICQnBDTfcUG9ccFO5u3LlCrZv344+ffogLCzM4Lnr1q1DREQEunXrJskxS0pKsG3bNvTu3dtgKENZWRm2bNmCXr16oV27dvXO8dln/w/ffvst1q5dg8GDBzeZOyIiIimxsCUiSYiiiPj4bvDw8MDBgwdsHQ4REbVCHGNLRJJYv349srOz8fjjj9s6FCIiaqXYY0tEzbJhw0Z89dVX2LFjB3r37oX//e9/kt2USEREZA4WtkTULOfPn8exY8cQEhKCPn366G9WJCIiamksbImIiIjIKXCMLRERERE5BRa2REREROQUWv0dHjqdDhpNNVxc5BwbSERERGSHRFGETqeFq6ub0fnxa7X6wlajqcbBA9ttHQYRERERNaF3nyFwd/docHurL2xdXGpWAOvdZwjk8lafDodyqSALQcERtg6DiMgq+BlHdI1WK+Dgge36uq0hrb6Sqx1+IJcrOPemg3FxkfM9IyKnxc84ovqaGjbKm8eIiIiIyCmwsCUiIiIip8DCloiIiIicAgtbIiIiInIKLGyJiIiIyCmwsCUiIiIip8DCloiIiIicAgtbIiIiInIKrbawnT9/Prp3T8DAgQNtHQoRERERSaDVFrYzZszA0aOp2LVrl61DsalvFi/CqlUrG91n/vwvsfrPP5p9HGv544/f8dqrr+CZp5/CxYsXrPpadc9TivNWq9V49ZWXUVRU1NzwDCxbugQbN26Q9JhERET2jGv1tXJFlw2LqUVff4XAoCDccced1/YpvATfNm3MOk5LOXo0Fb+uWIF/v/gi2rZti6CgYKu+Xt3zlOK8/7tqJcLCwhAQEAAA0Gm12LDhH+zduxelpaXo0KEDJk6chOCQEP1zSktKsG7dXzhx4gS0WgHR0TEYN/4O+Pv76/dJSk7GW2/OwoABN8Lb27vZcRIREdm7VttjSzUeeXg6Jtw1Uf+4qKgIJVeuNPs4LeX8+fMIDw9D9+4JaNcuAm5ublZ9PanPU61WY/36vzH6ppv1bUuWfIeVK3/FiJEj8a/HHoOnlxdee+0VlJaW6vd5//334CKXY+KkSbhv8hRcvHgRr736CsrLy/X7dOgQhbCwcGzatFGyeImIiOwZe2ydzLFjR7H8h+/x/gdzIJPJcPnyZbz91iwMTUzChAl3AQDWrfsLpzIz8eRTM/Hbb/+Fn78/Jky4Cz8u/wHHjx9Heno6Dh8+BAB45ZXXAABarRb/XbUSqamp0Ol0GDxkCG66rhi7/jhAzfCF8LAw6HS6Bp9T14oVv2Dnju0AAB8fH8TGxmHCXROhVCqN7r9y5X9x6NBhqNWVeObppxAUFIRXX3vDpOPMn/8lgoODoamuRlpaGgRBwPARIzB40GCsWrUSR48ehZeXF24fOxa9evVu8Dyvt337Nqz/+2+89fY7kMlk+vblP3wPlUqFR//1WL3n7N27B56enoiLiwMACBoN/vnnHzz88MMYOHAQACA6OgZHU1Px19o1uPueewEA7777HhSurvrjPPfc83jwwQdw+PAhDBkyVN8+4MYbsXVLCsaOHddg3omIqD5Bp4XCRa7/kxwDe2xbgKAVG30spfbtO+DMmTO4cOE8AOD48WMoKirC3j279fvs27tXf9m76HIRrhQXAwDG3HIrYmKi0advX7zw7xfxwr9rLu8DwF9/rYVarcaU+6dixMiRWLrkO+zdu0d/zOuPA9QMX/jll58bfU5do0ffpH/de++djIsXL+DDOR80uP+oUSOQmJiE0NAQvPDvF/HI9EdNPk5R4SWs/HUFZDIZpk6bhhsHDsRXCxfg5Zdfgpu7O6Y/+ii6deuGj/7zIQoLCxs8z+slJPTAqVOZOHbsqL6tqqoK69evxw1duxp9zvHjxxETE6t/XF1dDa1WgLePj8F+Pj4+Bse9vqgFAMhkuK6W1ouLjcOFCxdQWlJi9PWJiMg4hYscLx75g0Wtg2Fha2WiKGJHpgpTFl7EgLdPYcrCi9iRqYIoWqe4bdOmDSIiInH82DEANYXTiJGjcOHCRZSXl0MQBGRkZCA+vlu95/r7+8Pd3QPeSiXatYtAu3YR+gKqa9d4TJ5yP2JiYpCUlIzevfvg8KFDjcZi7nP8/Pz0r9s1Ph7PPvsc0tLSkJ2dZXR/X19ftGnTBq6ubmjXLgIhIaFmHadbt+645977EB0dg9tvH4vQ0FCEhoZi4sRJiI6OwZ0T7oK3tzdOHD/W6HnWatOmDfr164/Nmzbp23bv3gWZDBjQf4DR51wqKNB/eQAAL6US0dHR+HvdOqjVagA1vfCZmRkoLGx4PO/Klb/Cw8MDCQk9DNprj11w6ZJJ50BERDW9tRvy0vHzhQPYmJ8BQae1dUhkIg5FsCJBW1PUzlyei9o6NjWrEjOX52Le5DAMjlVCITfSzdZM8fHxOH78OG659TacOH4M/3rscaSnncTJkyfg49MGgiAgrnNns47ZISrK4LG/vz8uX74s6XNKSkrw5x+/IzMzA6WlZRBFHQCgoKAA7dpFmByrqcfp0KGDwfN8ff3qxdymjS9Krhvb2pQRI0ZizpzZUKlUUCqV2LxpEwYPHgI3d3ej+wuCALncsDdg5tPPYP6XX2D6Iw/Bx8cHXl5eGDhwEI4fP270GJs3b8Jfa9fg/557Hm3q3OSnUCj0r0NERKZRuMgxN2MzAOCT9E0YERJn44jIVCxsrUghl2FRSjHqds6KIrBoSzGSuljnTvWu8fH4auFCFBUVoajoMmJj49C1azyOHzuGNm3aICYmGh4eHmYdU+5Sv3NfROO9zuY+5/333oWvry/uuHMC2vq3hVwhx/PPPQeNRmNWrKYex8VIfMbazOld756QAH9/P+zYvg09evbEyZMnMOX+qQ3u7+fnZ3BTGACEhYXjnXffR4VKhYqKCgQEBuLzeZ8hMDCg3vO3bduKr7/6Ck888RT69etfb3vtsf39/Uw+ByKi1kzQaZFScArHSnIBAMdKcrExPwNJQdEcluAAOBTByjILqoy2n8o33i6Frl3jUVGhwpo1qxEXFws3NzfEx3fD8ePHcPz4cXTtGt/gc11cXKw2TKIxxcXFOHv2DB548CH07NkL7Tt0gNxFDq3WvJ5GqY5jKZlMhmHDR2Dz5k3YvGkTIiIiERMT0+D+sbGxOHv2jNFtXkolAoOCoKmuxpEjR9CjZy+D7du3bcOC+V/i8RkzMGToUKPHOHP2DHx9/fTDNIiIqHHX99bW+iR9E4taB8HC1spig41fgo4JMd4uhdpxtv+s/1s/lrbLDTcgKysbaWlpRsfX1vLz90dBQYHVYmuI0ssLrq6uOHGi5nJ7pVqNb7/9xmbHaY5hycNw9uxZrFv3F4aPGNHovv37D0BOTo7BDWp79+zRF7vVVVX4etHXcHVV4JYxt+j32bljB+bP/wKPz3gCiYlJDR4/9cgRDBhgfHwvEREZqumtzURGWQHcXOT6n4yyAmwpyORYWwfAoQhWJGhFTE/2NxhjCwAyGTA9yR+CVrTKGFugZpztxYsX0DW+pnfWy8sLUVEdcO7c+UbH144cMRIffPA+HvvXdHh4eOin+7I2N3d3PPzIdHz37TdYtfJXlJerMHToULPnpZXqOM3RNiAAPXv1QuqRIxg6NLHRfUPDwtCrV29s3rwJEydOAgBEto/EF5/PQ2FhESoqKhATE40333zbYKaEBQvmw8XFBatW/opVK3/Vt9988xjcfLUALi8vx4ED+/HBB3OscJZERM5H4SJHcnAsMm99w9ahkIVkgqBp+evOdkQQBOzbm4J+/ZP1N9pISRRFbE1XYdGWYpzKr0JMiDumJ/kjsbPSYK5TqZWXl6Ok5ArCQsPgcvXmpOLiYlRVVSE09Npl6ctFRXCRy+Hn56dv02m1uFxcjKqqSoQEh+BKSQncXF3RxtdXv8+VK1eg02rR9uq0YXWPU1hY2ORzjNFoNCi+fBlt2rSBh6cncnJy0NbfHx6envX2zc+7CKXSD+pKdb0Vx5o6jrH4Ci9dgpu7u8ENWAUFBfDy9NQXlXXP01j+AOCTTz6CqBPx3PMvNHiutbKzs/DmrDfw6WefG8y1e6W4GApXV6OrhuXk5Ohviruej08bffw/Lv8BZWVl+NdjjzcZAxHZn/y8iwgJjbR1GER2wdR6jYWtlQtbAPV6Zq3ZU9ua2OuHfl5uLp599hm8/sYbjY5nvt6lSwXw8fYxWsBbqiA/H75+fnBvYEYGIrJv9voZR2QLptZrHIrQAuoWsSxqndebs97A6dOnkJiYaHJRC6Bej7MUgkNCJD8mERGRPWu1he38+fOxYMFCeHi4Y95nDa9uRWSOxx6fAXd3d/j7+9s6FCIiolan1c6KMGPGDBw9mopdu3bZOhRyIqGhoSxqiYhamdrZEjhrgu212sKWiIiISAoKFzlePPIH57q1AyxsiYiIiCwk6LTYkJeOny8cwMb8DPba2hgLWyIiIiILXb9SGVcosz0WtkREREQWqO2tPVaSCwA4VpLLXlsbY2FLREREZIHre2trsdfWtljYkt0SRRG7d+/Cj8t/wIoVv1j99ZZ89y1ycnJMfmwNBQUFWPnrCkmPWV1dje+XLUVlZaWkxyUias0EnRYpBZnIKCuAm4tc/5NRVoAtBZnstbWRVjuPLdm/P//8A2vXrMbNN48xWObWWtauXYPeffogPDzcpMfWsGTJt+jcuYv+8eo//0BhYaHBPjExsRgydKj+saDRYP+B/Th/7hyUSiV69e6Ndu0i9Nvd3NxQUlqK/65aifsmT7Fa7ERErYnCRY7k4Fhk3vqGrUOh67DHluxW6pEjSB42HOPvuBPDR4y0+utNe+BBhIe3s3h7c507exZHU1MxatRofdv27duRl5+HoOBg/Y9PGx/99vz8PDz77DPYuWMH5AoFLl68iH+/8Dw2bPjH4Ni33nor/vprLVQqldXiJyIisjX22DqhXbt2Ij0tDQDg7eODuNg4JPToUW+/srIy7Ny5A0WFhWjfvgMGDRoEF3nNuKD1f69DQGAgvL29cfjQIfj6+eHmm8cAAI4dPYpjx45CJpOhR89e6NKli8FxLxcVYeeunSgrLUX7Dh1w440DIb963Ma2XW/Z0iW4cOE8KisrsUStRrdu3dG3Xz/88vNPUKvVkMlkcHNTICl5hEEPqqDR4Icfvsfom27GubNnceHiBfj6+iI5eRhcFQps374NOTk5CA8PR2Jikv58AeBSQQGqq6sbzKux7efOncOBA/shaDTo2KkT+vXrD5ns2pLJpp4vAKz/Zz369OkLLy8vg/Yunbvg1ltvM/ocTw9PvPnW2wgICNC3hYSGYumS7zBs2HD9a3Xs2AkBAQHYtnULbh5zS4PnSERENcMMFC5y/Z/kONhj64R8vH30vXvVVVX44ot5WLrkO4N9Tp06hadnPondu3bBzc0NBw8ewCdzP9Zv37NnN7795ht8+81iKFxd4efrBwD44ftl+M9/5kCj0UBdWYl33n4L//3vKv3zcnNz8OyzT+PM6dPw9PTEwQMHMPuD95rcVldgUBBcXV2hVCoRFBwMpbcSABAQGIig4GC0DQhAbm4OXnrxBaQeOaJ/niAIWLt2Dd5+axb2H9gPhUKBtWvW4O233sRbb72J48ePw83dHStW/IKvv/7K4DXXrl2DwsJLDea17vY1a1bjnbffgqq8HHKFAr/8/BM+nDMboiiafb4AkHrkMLrccEO99rS0NPzww/dYu3YNcrKzDba18fU1KGoBoH379qiqqqrXO3vDDV1x+MjhBl+fiIhqcMEFx8UeWytTLZkFbd65eu3y0CgoH3jLKq/ZrXt3dOveXf84MTEJzz33fxg7brx+udcvv/gcPXv1xsyZT+v3y8vLq3MkEe++9wFcXV0BAFkXL+LPP//Ea6+/ju7dEwAAcbFx+PLLz5E4NBGBQUE4sH8/2rWLwMynn7l23NyaaVAa21bXLbfcih3btyGuc2eD3sqRI0fp/56f1wtRHWOwYsUv9XqkhwwZiin3TwUAdO3aFW/OegN33TURk+6+BwDQqVMnfDhnNh55ZDoUV8/PHLm5Ofjh++/xn/98hIjISH3MM596Agf270fffv3MOt/q6moUFBQgJCTEoF0ud4Gbuxs8PT2RdvIkfvh+GR56+BGDPNSVkrIZ7dpF1BuXHBoWhqNHU80+VyKi1qTmprBT+PnCAYwO7YKkoGgWuA6Eha2VafPOQZuV0eKve+zoUWRkZqCstBSiKEImA7Kzs+Dv74+83FxkZ2fh8cdnGDwnNDTU4HFCQg99UQsAx44fg5+fn76oBYAbBw7EggXzkZaWhiFBQQgNDUN2dha2b9uGvn37wsPTE6FhYVeP3/A2U1VXVWH3nt3IzclBUVEBVKpKXLx4sd5+PXv20v+9dlxsj549Ddp0Oh0uFxcjODjYrBgA4MCBA3Bzc8OmzZuAqz20oijC1dUVp0+fQt9+/cw6X7VaDaBmaMH1nnn2/xAUdC2+P37/H7779lv07dMXfle/pFxvzZrVOLD/AN6YNaveNk8PD6hUFWafKxFRa1J3wYURIXE2jojMwaEITmjhgvn4/PN5KC0thb+/P4KCgyGTuaCioqaoKSsvAwCjhdH1vL29DR5fuXIFvr6+Bm0uLi5o08YHV64UAwD69uuHadMewF9/rcXDDz+I1197Bfv37WtymynKy8vx/PP/h7/X/QUA8Pf3h5+fH9Tq+sWam7u7QYwA4O5Wv02ntWw6ltKSEnh4eCAgIAABgYEICAxEYFAQbrt9rL7wN+d8lcqaoRaqCsPhA9cXtQCQlDwMGk01Tp85Xe8YmzZuwPIfvsfTzzyDG27oWm97RUUFfHy867UTEVENLrjg+Nhj62QqVCps3rwJ7773PmJja75llpeXG4yx9ferKWgvXSowq7cyMCAAly8XXe0BrrlBShAEXLlSgoDAQP1+w0eMxPARI6FWq7Fhwz/46KMP8elnnyM0NLTRbU05cGA/NBoN3n7nPcjlcuTnXcSJk5n455/1Jp+DVPz8/aFWV+Cmm26GQtHwPyNTz1ehUKBdu3bIzs5Gnz59GzyeIAgAAK1g+CG7efMmLF68CE/NfBo33jjQ6HOzsrMRFdXR1FMkImp1Glpwgb22joM9tk5Gq9NBFEVotTp92+rVfxrsExgUhNjYWPzx+//0hRIApKenNXrsHj17Qq1WY9fOnfq2jRs3QKGQI75rPAAgIz1df9OSp6cnhg5NhE6nQ2lpaaPbTDo3QYBOJ0LU1ZybRqPB+r//Num5Uuvfrz+0Wi3+99t/Ddqzs7NQkJ8PoPFcGNOzZy+cOH5c/7iwsLDeghCr//wDbm5uBjNRpKRsxqKvv8JTM5/GwIGDGoz55IkT6NW7t3knSkTUSnDBBefAHlsn4+Pjg8TEJHz0nzno268/CvLzUVRUVK9X8cmnZuK9d9/BC8//H+LiOiMnJweR7dsbLA5QV1BQMCZPnoIvv/wCu/fsgk6rw6FDBzF9+qNoc3WIQvGVYnz++Wdo374DfH19cfRoKnr06Ino6Gjs37+vwW2m6D/gRqxatRKvvvoKOnbqhKOpR+Dp6dn0E60gMCgITz41E/O//BJHjhxGu4hIFOTno6ysFM+/8G8AjefCmNE33YTn/u9ZlJSU1Az5EEV89ulc+Pj4ICgoCOfOnUNBQT6efHKmPt9nzpzBgvnzERkZgfS0NP00bwAwduw4tL06Y0LayZOoqFBh0KDBVs4MEZFj4oILzkEmCBrR1kHYkiAI2Lc3Bf36Jzd6SdlStpgVAahZ3CAvLxd+/v7o2bMXNm/ehB49ehpcAtdoNDhy5DCKi4vRoUMU4uKuXWrZs2c3fH396s1RC9TMCHDixAnIZDJ069a93nAGlUqF48eOobSsFBHtIgymsGpsW107d+xAcHAwYmJj9W1qtRoHDx6ASqVCGx9PxMR2xZ49u/UzJwiCgL//XofBgwbrxxBXV1fjn3/WY+iQofqCsKKiAps3b8Kw5GHwujq+dc2a1Rgw4EYEXh1W0dRjACgvK0Pq0VSoVCqEh4fjhi43GMyNa875AsD8+V/Ct00bTJ5yP4CaMcAn004iNzcXbf3bonOXLvrxuEDNcJK9e/caPdb15/vhnNnocsMNGDt2XKOvT0T2Iz/vIkJCI20dBpFdMLVea7WF7fz587FgwUJ4eLhj3mcfWK2wJetxxg/90pIS7N27ByOvW32suaqrq7Fx4waMGjWav+NEDsQZP+OILMXC1kTW7rEl6+GHPhE5M37GEV1jar3Gm8eIiIjILglasdHHRHWxi5KIiIjsjiiK2JGpwqKUYmQWVCE22B3Tk/2R2Fmpn3LS1gSdFgoXuf5Psj322BIREZFdEbQitqarMHN5LlKzKqGuFpGaVYmZy3OxNV1lNz23Chc5XjzyB4taO8LCloiIiOyKQi7DopTi2hXL9UQRWLSlGAq57Xtsa1cp+/nCAadcnaz2fBztvFjYEhERkd3JLKgy2n4q33h7S7t+lbJP0jc5Xa+to/ZGs7AlIiIiuxMb7G60PSbEeHtLqu2tPVaSCwA4VpLrVL22jtwbzcKWiIiI7IqgFTE92R917xGTyYDpSf5WGWNrzqX363trazW319aeLv07cm80C1tyKKUlJThx/DgOHzqEEydO4vChQ1Cr1bYOyyypqUdQWlJi9depqqpCauoRSY9ZXV2NI0cOQ6w78I2ISEIKuQyJnZWYNzkMCZEe8HKTISHSA/MmhyGxs9IqY2xNvfQu6LRIKchERlkB3Fzk+p+MsgJsKci0uDC1l0v/jt4bzem+yGEcPnwIcz/5GB07doKrqyvUahUyMjLx4YcfIapjR1uHZ7L/fPghnnn2WfTp07fBfdb/vQ6ubm5ITh5m8bQ2q1atRGlJCRISeujbqquqcOHCBbi4uCC8XTt4eHjUe15lZSWys7OgqdYgNCwMfn5++m1ubm5Y8csvKCkpQWJikkVxERGZQiaTYXCsEkldvPVtgla0ylRfNcXqKfx84QBGh3ZBUlB0gwWmwkWO5OBYZN76hk1evzmvYcrUZA31Ro8IiZM0HmthYUsOY/36v5GcPAwPPvQwAOD0qZN4+eXXbByVdWgEAd98sxjffrMYcXGdceeECYiP72by868UF2PtmtX45JNP9W2rVq3E+r/XISgoGGq1GkVFhbh/6jSMGDFSv8/2bdvwzTeLEBAQAE9PL5w5cwbDhw/HQw8/ov/P5I4778Q3ixdj8OAhkMsd5/IUETmeuj2z1poNoe6l95Yu4lri9Wt7hOf0GNvgPoJOi+2FZ/S90bVqe6MHB3ayeY9yU1jYOqGsixdRWFgIAPD28UFEu3bw8PQ0um9+fh6KCosQERGBNr6++vbTp0/B09MLAQEBOHfuLORyBWJiYgDUXOI+e+YMZC4ydOzYCW5ubvVjyMpCWVkpIiIi4ePjY/K2hhw5chi5OTnw8lLi8KFDCA4ONrrfsWNHIWgEyFxkCAgIRHhYGFyMFF+lJSXIyspCYFAQgoODkZp6BFEdogxyYMo+Wq0W586dhSAIiIiIhFKprP9apaXIunhRfxxTiaKIqqoqHD2aiqNHUxEREYHo6Bh4enrioYcfafS5Gzb8g9jYOASHhOjbvL298fnnX8LNvebGi3V/rcXXXy1E37794OvrC0GjwcKF8zF23HhMmnQ3ACA9PQ2vv/Yq+vXvr+/57dWrNzSaahzYvx/9Bwww+XyIiOxRbW9p3UvvUvWaNtVTau3Xv/41muoRtkZvdEtjYeuEjh47ioMHDgAASkpKUFCQj8cem4EbBw7U71NWVoZ5n81FRkYGIiPb49KlSxgzZgzG33EnAODH5T9ApxNx6VIB/P3bonPnzoiJicHevXuwYP6XaNs2ADqdFqWlZXj6mWf0RY9arcb7772DgoIChIWFIzc3F8OHD8fd99zb6LamrF2zBsXFxcjMSEfJlSu4ceBARHVoV2+/jRs2oLy8HDqdDjk5OVAqvfDvf79kUOBt2ZKCr7/6Cu3atUNlpRqRke1x+PBh/N9zz+mHB5iyT0Z6OubO/QSenh5QKpW4cOEi7rn3XowZc4v+tbZv34aFC+YjPLwd1Go12rdvD51OZ+5bCqDmC0FWVhaCgoKaLGz379+P/v37G7TddNPNBo+jY2IgiiLKysrg6+uLao0GGo2AqA5R+n06dIiCTCZDRUWFvk0ul6NrfDz27dvLwpaIHJ61L7031VPaEpf+pe4RtucV11jYtpCW/CUYM+YWg+Jq544d+OqrBejRsyc8r/bcLpj/JUpLy/D551+ija8vdFotduzcYXCcU6dOYfacOWjXLgIAUF5WhgXzv8S4ceP1BfD3y5biyy8+x2fzvoCHhwe2b9+G0tJSfPnlAihcXaHT6bB16xYAaHRbU15+5VW8+spL6N2nLyZMuAtAzVCEup5+5ln933VaLb748gt8//0yPPf8CwBqek8XL1qE+++/HzePuQWiKOKrhQug0VTrn2fKPiqVCnPmfID7p05DcvIwAMC5s2fx+uuvonPnLujUqRPKysqw6OuvMXnK/Rhz9TgL5n9pcBxr0F3tRZ5w1131tuXn5yE3JxdXrhRjzZo1GDlqNCIiat5fLy8v3H333fjpp+WoqFDBw8MTmzdvRM+ePdG3bz+D43ToEIXt27ZZ9TyIiKzN2pfem+opbYlL/9boETZlWIOtsLBtIS39S1BZWYmcnByUlZXC08sTarUaWVkXERsbh5KSEuzfvw8vvvSy/rK6i1yOoUMTDY7Rr18/fVELAPv374Moirjt9mvncNfESVi7dg1SU4+gf/8BkAHQaDQoV6ng5+cHFxcXfeHX2DYpXblyBfn5eVBXqBEWFoZ/1v9tcA5ubq4YPfqmmphkMtw54S5s2rTRrH327NkNnU6Htm3bIvVIzcwDIkQEBQUhNfUIOnXqhP3790GhkOOm645z110TkZJi+M1carU91t7e3vW2ZWZmYkvKZhQWFkEQBPTp08dge0JCD+zZswd//vkHPDw8UVhYiHvvuw8KheFHhY+3N0pLrT+zAxGRqSzpQLL2pfemekpb4tK/1D3CLXGjW3OwsG0BLf1LsHXrFnz37Tfw8/ODv39b/Q0+V65cAQBcKigAAERGRDZ6nLYBAQaPCwoKEBQUbFDkeHp6wt/fHwVXjzk0MQmpqal4YsbjiImJQfeEBIwcMRJ+/v6NbpOCKIpYuGA+duzYjsjI9lAqlVCpyvXnXXvuQUHBBuNug4KCDG6CMmWfvLw8iKKIP//4wyCGgIBA/Tjbgvz8+scJDjb5hqvOnTsjNDQMmZkZyMnJMS0JgH4MbXV1/Z7hIUOGYsiQoQCAzZs34cM5c/Cfjz5G+/btUVJSgrfffhMT7pqIcePGA6gZa/3aq69CqVSiX79rQxuqqqvh5mb7SdKJiGrZWy9iS4ydNSUGqXuEbX2jXVNY2LaAlvwlEDQafLVwIf712GP66Ziqq6tx/5TJ+rlHa6d4Klep0NitTHXvPVV6exuMtaxVUVGh7x10d3fH/z33PMrLynDi5Als2rgR6/5ai08/nQdvH59GtzXXgf37sXv3bsyb94W+KN+1ayfmfvLxtXNQKuvNe1tdXQ2tVmvWPh7u7nB1dcOrr73eYDxKb2+o1Yb5qqqqMjhOY+6f+gDi4mp+VzIzM3DwwAGUlJYiol39scXX8/DwgJ+fHy5dutTofsOGDce33yzG8ePH0L59e6Snp6GyshLDhg3X7xMdHYMOHTrg8KFDBoVt4aVLCAsLM+k8iIisTaoOJFNu9DK1V9geps2SukfYHor1pnCBBitr6YmOS0tLodFUIyY6Rt928OABiOK1G5bC27WDv39b7KwzptZY0Xq92Ng4FBZewoULF/RtJ04ch1qt1r9eeVkZgJrZGPr3H4CZTz+D0tJSXLh4odFtQM0Y1czMDIvPvbCoEG3b+hv0NO/ft89gn5jYOOTl5SEvN1ffdvjwIbP36Z6QgJKSKzh8yLBdq9Xq8xgbG4u8vDyD3tbam/rMFRsbh7vvuRePPvov3HLrbU3uHx/fDRnp6frHarUaujoFdUF+PqqqquB7dTiKn5+/vr2WoNGgqKioXq96eno6unXvbtG5EBFJTaqVsppaJMHWizjYmjVWXJMae2ytrKW/sfm3bYv27dtj0aKvcdPNN6MgPx9//vkHXFyufYdxcXHBw488gk/nfoLKykrEx8cjJycHGenpePmVVxs8dlxcHAbceCM+nPMB7pxwF3RaLVas+AUjRoxERGTNsIZ/NvyDtJMn0Ldff/i28cXu3bvQtm1bRHWIwt/r/25wGwCsXPkrVKpyzHrzbYvOvVu37li6ZAmWLVuKuLg4HDl8GHv37jXYp0uXLkhISMCcOR9g/B13Qq1W4/f//Q8uLi6QXe2jNmWf2Ng4jL7pZsyd+zFuHzsOERERKMjPx5YtW/D4jCcQExODzp27oFev3pgz+wPccWfNcf73228G74W1DB8xAp98/DE0Gg1cXV2Rm5uLRV8vxOAhQxEcHIzCwkL8tXYtYmPj0P9qT2xMTAy6do3HZ599ivF33AFPT09s3rQJgiAY9OLm5+fh/PnzeOHfL1r9PIiImiJVL6IpN3rZchEHW3OUOW5lgqBp1WtjCoKAfXtT0K9/cr0bZJp97Ku/BNP3/VRv2+J+91rtl+BKcTH++ON35OXlwc/fH6NGjcLKX3/FuHHjEde5s36/M2fOYNOmjSi+fBkdoqJw2623wevq+NAfl/+AsPBwg4IGqMnXhg3/4PixY5C5yNCjR08MSx5mMI708OFD2LtnD0rLShHRLgKjRt+EgKu9qI1te/yxf2HK/fdj8OAhRs9ryZLvEBMTox8jeu5sJpYv/xmPTJ+OkJBQAEDayZPYuHEDVBUqRHWIwg1du+KP3383GDJQWVmJ1X/+gTNnzyAoMAjJw4bhpRdfxBuzZukXQTBlH6CmR3jvvr1QlZcjPDwcw4aPQHh4uH57VVUVVq/+E6dPn0JQYBBGjRqNn376EXfccSdiYmMbfA8PHjiAsPAwhIWFN7hPU1579RUMHz4cw68uwJCXm4uNGzcgNzcXPj4+6Bofj0GDBhuM+dVoNNi4cQMyMzKg0WgQ3q4dRo++CW3bttXvs2zZUqjKy/H4jCcsjo2ImpafdxEhoY3fC0E1bt26UF/YAkA33zCsSXzM4uM09PymtpP1mFqvsbC1YmFLprtSXIyffvrRrGLJ0g/9SrXaYMGKw4cOYfbs9/H1om/Qpk0bk/exd2fOnME/6//Gvx57XLJjVldXY968T/HII48aLLVLRNJjYds0qTqQantjH973o77t2/6T9b2yTW0n6zO1XmMlR3bBz9+/xXoAV61aCa1Oh7jYOFwqvIT//fYbRo++yaBgNWUfe9epUydJi1oAcHNzw/PP/1vSYxIRWUqqS/7Ghg3+mX1UP2zQHm4EI9Pw5jFqdSbdfQ8CAwOxe/cuZGdl4eGHH6m3kpcp+xARkeMzdqPXxMie+LT3BJwsyYNGp8UWJ7wRzFlxKAKHIjgsXqYjImdmrc84e14O1V4wR/bH1Hqt1fbYzp8/H927J2DgwIG2DoWIiKjFmDplVWvGHDmuVlvYzpgxA0ePpmLXrl22DoWIiKhF1M6t/vOFA1adU93e1J6nKefbWnPkLFptYUtERNTaNGchA3OKQ3tjTg+sVIs9kG2wsCUiInJgglZs9LG+vZkrYTrq5XlzemBberVQkp7Jd0sVFRWZfNCA65Y0JSIiIusQRRE7MlVYlFKMzIIqxAa7Y3qyPxI7KyGTyQz2bc6UVeasumVv6vbANna+nNbL8Zlc2D7+2KMmH3TFr6ssCoaIiIhMI2hritqZy3MhXu2kTc2qxMzluZg3OQyDY5VQyGuK2+Yuh2pOcWhPzFlu11GWjKXGmVzYzv30M2vGQURERGZQyGVYlFKsL2priSKwaEsxkrp4X9u3GQsZmFMc2htzemClWuyBbMvkwrZduwhrxkFERERmyiyoMtp+Kt94uyUc9fI8e2BbJ4tXJNBoNEhLO4n8/HyMHDkKAHC5qAhtOb6WiIioRcQGuyM1q7Jee0yIuyTHd+TikD2wrZNFhW1Bfj7ef/9dFBcXQ61W6wvbJUu/w5DBQ9F/wABJgyQiIiJDglbE9GR/gzG2ACCTAdOT/CFoRf0YW0vZe3HIFcKoLoum+1qy9DskJPTAd98tNWi//fax+P333yQJjIiIiBqmkMuQ2FmJeZPDkBDpAS83GRIiPTBvchgSOyubXdQ6Akedgoysx6Ie27STJzFv3hdwkRv+IkVGROLcuXNSxEVERERNkMlkGByrNLhRTNCK9ab6ckaOPAWZNbD3uoZFPbaCIEB39brH9f94CouK4OHhIU1kRERE1KS6PbOtoacW4AphdbH3uoZFhW23bt2x7q+1Vx/V/AOqqKjAsqVL0D0hQarYiIiIiOppqRXCHGUZYXNWV3N2FhW2U6dOw8aNG/DSi/8GIGL27Pfx1JMzcOHCeUyePEXiEImIiIiuaWgKMql7Kx2lF5S919dYVNiGhoXh44/nYtCgQbjxxoFwc3XDbbePxX8++gRBQcFSx0hEREQEoHZsbaZ+CrLan9opyKTqrXSUXtCW6r12FBbPY+vt44Ox48ZLGAoRERFR41pqCjJHWUbYURfQsBaLC1tBEFBQUIDy8vJ62+LiWmcyiYiIyPE5yjLCjryAhrVYVNieOHEcn336KYqLLxvdvuLXVc0KioiIiMhWHKUX1N4X0LAFiwrbb79ZjAEDBmDsuPHwViqljomIiIjIJtgL6tgsKmzz8/Px7rvvw8PTU+p4iIiIyEY4yT97QR2dZbMihIbhcnGx1LEQERGRDTnK9FZEDbGosL311luxYP4XOHXqFMrLy6FSqQx+iIiIyLE4yvRWRI2xaCjC/PlfAgBeeflFo9t58xgREZFjcZTprYgaY1Fh+8HsOVLHQURERDbiKNNbETXFosI2OjpG6jiIiIjIRhxleiuipnCBBiIiolaM01uRM+ECDURERE0QtCIUclmDjx2ZPU9v5cx5J+vgAg1ERESNEEUROzJVWJRSjMyCKsQGu2N6sj8SOyshk7HIshbmnSxh0XRf+fn5uO++yQgMDISHp2e9HyIiImcgaEVsTVdh5vJcpGZVQl0tIjWrEjOX52JrugqCVrR1iE6ldooxjU7LvJNFuEADERFRAxRyGRalFEOsU0eJIrBoSzEvi0usdoEIVxc5TmRXMe9kNi7QQERE1IjMgiqj7afyjbc7stoeU1sszlB3gYhbeiohN1KlOGPeSTpcoIGIiKgRscHuSM2qrNceE+Jug2isq7bHdE6PsTZ57esXiFiT+Bhu7u6DNUfKDPZzxryTdLhAAxERUQMErYjpyf6YuTzX4LK4TAZMT/J3uLv0BZ0WChe5/s+621IKTuHnCwcwOrRLiy7OYHSBiLx0PDqsA9YdLYNWV7OfM+adpGXRUITo6JhGfxzB/Pnz0b17AgYOHGjrUIiIyE4p5DIkdlZi3uQwJER6wMtNhoRID8ybHIbEzkqHKq6Aaz2yxoqruj2mLVmAGV0gImMzOgZ64NHktk6dd5KWxQs0OLoZM2ZgxowZEAQB+/am2DocIiKyUzKZDINjlUjq4q1vE7Siw0051ViPrC2X1G1sgYiUgkxMT+6Ex4cH1OzrZHkn6VlU2IqiiJSUzdi9aycKCwuh1RoOMv/0s88lCY6IiMge1O0hdLQeQ6B+j+z1y+XackldcxaIcLa8k/QsGorw++//w88//YS4zl1w8eJFDB8xEu07dEBubi4SevSUOEQiIiJqjtoZB+r2yAo67dUexUx9j2ntT+2Sui01Q4JWp8OxK7l45uAqHC/JhVana5HXtabG8k7WYVGP7aaNG/B/zz2Hzp274Jeff8LYseMAAOvW/YWDBw5IGiARERE1T1M9svawpK7cxQUvpv6OYyW5yCy/hDWJj9k0HinYsie8tbKox7agoEB/k5irqxvUajUAIDExCWlpJ6WLjoiIiJrFXnpkm4rR2Xo2HSHvzsiiHludTgeFouapQUGByMzMQEJCD1y6VAC5vNXej0ZERGR3zBnDaivO2LPpCHl3Rs2uQoePGIlP536C+PhuSEtL4/RZREREZLLGZkXYUpCJwYGdOIsAmcyiwva7Jcv0fx87dhz8/f2RkZ6OCRMmYOSo0ZIFR0RERM6NPZskJYsKW6VSafB46NBEDB2aKElARERERESWsHgogiAIKCgoQHl5eb1tcXGOOyaGiIhICqols6DNO1evXR4aBeUDb7V8QEZYY6lXWywfyyVrqZZFhe2JE8fx2aeforj4stHtK35d1aygiIiIHJ027xy0WRm2DqNRtUu9zukx1q6PaY+vSfbJosL2228WY8CAARg7bjy86wxLICIiIvtnjaVebbF8rL0sWcteY/tg0Ty2+fn5uO++yQgMDISHp2e9HyIiaj0ErdjoY7IOc/Ned3vdpV6lKMascUx7fM2G4njxyB8sam3MosI2NDQMl4uLpY6FiIgcjCiK2JGpwpSFFzHg7VOYsvAidmSqIIosbq1NIZc1+vh6179Pg945hZwr1fhH4gURzF1koba9JV/TWmrj+PnCAYdfWMLRmTwUQaVS6f9+6623YsH8LzDtgYcQGhoKmczwH1PdWROIiMj5CNqaYmnm8lzU1rGpWZWYuTwX8yaHYXCsstFiiyzX0I1prvGD4Hn7vwza6r5Pt/X0QbifG6ZvlXZBBHMXWZBiXKy9LOxQt9fYkReWcHQmF7YPPjC1XtsrL79odF/ePEZE5PwUchkWpRSjbuesKAKLthQjqYu3bQKzE/LQKLPazWHsxjS3fjfXK2oBw/dJ7gI8OswPm/MzJV0QwdxFFqQYF2svCzvUnkvdXmNbjfVt7UwubD+YPceacRARkQPKLKgy2n4q33h7a9KiU3q5yOF6y0MAYPTmpdr3qVcHT0QFeCAK0i6IYO4iC1L0cNrLwg720mtMNUwubKOjY6wZBxEROaDYYHekZlXWa48JcbdBNK2XW59RcAuKbPDSfu37dORCJZ77KRduipohIu0DXPH48IAWjdWZejjtpdeYrrFouq/Kykqkph5B//4DDNr37t2DhIQe8PDwkCQ4IiKyX4JWxPRkf4MxtgAgkwHTk/whaEWOsW0JV3trN+SeNHpp//r3SaMV8c/xmoWVZDJg3uSwFn+fnKmHU8peY04XJg2LZkVY/sP3uHy5/uIMl4uK8OOPy5sdFBER2T+FXIbEzkrMmxyGhEgPeLnJkBDpgXmTw5DYmTeOtZTa3tq5mVsA1J/yyp7ep5re2mvje2t/ans4W/NsApwuTBoW9dju3LkTn342r177oMFD8Nz/PYuHHnq42YEREZH9k8lkGByrNLhRTNCK9WbLIWnpb0CTucD19n9hQ+7JRi/t28v7ZC/jYu2NvSwy4QwsKmy1WgHqigr4+PgYtKvVFaiurpYkMCIicgzmzKdK0qh7Y9rcrf8zeGzs0j7fJ/vF6cKkY9FQhPj4bvjxp+XQaDT6turqaixf/gPiu8VLFhwRERE1jJf2HZ+9LDLhLCzqsZ1y//144/XX8eQTjyM6JgYQgdOnT0GnE/H2O+9IHSMREREZwUv7js+ZbqazBxYVtmFh4fj440+wcdNGnD1zBpABN4+5BSNGjESbNm2kjpGIiIjI6XC6MOlZVNgCQBtfX9xxx51SxkJERETUarDHXXoWjbElIiIi+1M7LpPjM6m1YmFLRETkoOoWspwLlVo7FrZEREQO6vpCtvbu+p8vHOBd9dRqWVTYHj50SOo4iIiIyAzXF7LHS3LrzYXKXltqjSwqbGfPfh/i9QuDExERUYuqLWTlMhf4unpyLlQiWFjYhoSEIDsrS+pYiIiIyATXT+o/tl03RHj5GZ0Llb221NpYVNiOG38Hvvjicxw/fgwlJSVQqVQGP0RERGQ91/fWzoxNwub8DK4+RgQL57FduGA+AOCtN2cZ3b7i11WWR0REREQNun5S/4EBUejkHYhO3oGcC5UIFha2H8yeI3UcREREZAJO6k/UMIsK2+joGKnjICIiIiJqFovnsdVoNDh6NBUbNvyjb7tcVCRJUEREZEjQio0+tieOFGtTHO1cHC3ehjjLeVDLs6jHtiA/H++//y6Ki4uhVqsxcuQoAMCSpd9hyOCh6D9ggKRBEhG1ZqIoYkemCotSipFZUIXYYHdMT/ZHYmclZDKZrcMz4EixNsXW56JQuJq1v63jlYqznAfZhkU9tkuWfoeEhB747rulBu233z4Wv//+mySBERFRTU/V1nQVZi7PRWpWJdTVIlKzKjFzeS62pqvsqifLkWJtij2cS0BgqMn72kO8UnCW8yDbsajHNu3kScyb9wVc5Ibz40VGROLcuXNSxEVERAAUchkWpRSj7po4oggs2lKMpC7etgnMCEeKtSm2OhfVklnQ5p2r1y4PjYLygbcafJ6z5N5ZzoNsx6LCVhAE6K7+1l1/WaCwqAgeHh7SREZERACAzIIqo+2n8o2325IjxdoUW5yLNu8ctFkZFj23JeMVdFooXOT6P6XkTL9D1PIsGorQrVt3rPtr7dVHNYVtRUUFli1dgu4JCVLFRkREAGKD3Y22x4QYb7clR4q1KY52Li0Zr8JFjheP/GGVlc0cLe9kXywqbKdOnYaNGzfgpRf/DUDE7Nnv46knZ+DChfOYPHmKxCESEbVeglbE9GR/1L1nRiYDpif529WYQ0eKtSmOdi4tGW/tcr4/XziAjfkZkq5s5mh5J/sjEwSNRb8l5WVl2LRpI06fPg1RFNGxUyeMHDkKPj4+UsdoVYIgYN/eFPTrnwyFwqKRGWQj+XkXERIaaeswiKxOFGtuqFm0pRin8qsQE+KO6UmN3yVu6VhNW8Rqr2yR99LZ04wORZBHxKHNS0uNPKN58Vrq1q0LcawkF918w7Am8TFJj93Sv0PWHFZB0jG1XrO4kvP28cHYceMtfToREZlIJpNhcKzS4MYZQSs2+p98c8ZqNoclsdorR8o70DK5F3RapBScwrGSXADAsZJcbMzPQFJQtGRFYUv/DtUOq5jTY6xVjk8ty+LCtqKiAlu2pCA7KwsAEBERgcSkZHh5eUkWHBER1VDIZY0+tieOFGtTWvpc5KFRZrXXZe14FS5yzM3YbND2SfomjAiJk/Z1WijvtYX6zxcOYHRoF0kLdLINiwrbjPR0zJ79PhQKBaKiOgIA9u7dg19/XYGXX34VMbGxkgZJRETkCFxCOtRrE6sqTH6+NYeJNJeg02J74RlklBXA7briL6OsAFsKMjE4sJPDFYXXF+rWKNCp5VlU2C5evAhDhgzF1GkP6Mc5CIKAZUuXYNGirzDnw48kDZKIiMgReD/4ttH28q/+bfaxigrzzFqkwdoULnIkB8ci89Y3bB2KJFpiWAW1PIsK2+zsbLz+xiyDwbsKhQITJ92Nxx97VLLgiIiIHEGTN425mT/HuyBoJIiMGtJSwyqoZVlU2IaGhqKoqKjeDAhFhYUIDbWfb5dERK1Vc8dqknmaumlMHhDegtFQU5xxWAXVsKiwHXPLLZj7yUe4b/IUREfHAKKI02dO48flP2DcuPFQqVT6fZVKpWTBEhFZk6AVDW5SqfvYkdjzWE1jnCn3xniOlXZKLGoeZxtWQddYVNh+/dVCAMDHH/2n3raFCxdg4cIF+scrfl1lYWhERC1HFEXsyFRhUUoxMguqEBvsjunJjjn/qqNh7m1DFGum0LLVnMdE1mBRYfvB7DlSx9EsJ08cx5kzmVAqfdC7Tz+0adPG1iERkQMRtDWF1czluRCvLlmTmlWJmctzMW9yGAbHKp2q99CeMPe2IWhFHLmgRp+OXjade5dIahYVttHRMVLHYbH9+/agoCAfnaJjcfHCefy26hdMe3C6rcMiIgeikMuwKKVYX1jVEkVg0ZZig4niSVrMvW0o5DL8dqAUfTpy7nlyLg6/hmy37j3g4VFzt2lMTBwWzv9Mf3mFiMhUmQVVRttP5RtvJ+k4Q+5rb8pzCe0IXd7Zeu326MJlzrpAzsemhW11dRVOnjiOI4cP4vLlItw18V5ERLY32Een02HXzm04fvwoNNXVCG8XgWHDR8HPzx8A9EUtAOzdswt9+w1gUUtEZosNdkdqVmW99pgQdxtE07o4Q+4dcSxq+7autg6BSHIutnzxPbt3oSA/D4OHJkGr1UKsey0KwI7tW3HkyCHccstY3D/1YbjIXLByxU8QBMFgv21bUyAIGgwcNLSlwiciJyFoRUxP9kfd78QyGTA9yR+Ctv5nE0nDmXIv6LQGf9ozQSvijj68H4Wcj00L26GJyRh10y0IDg4xul2j0eDQwX24ceBgRES2RxtfX4y66RaUlpYgI/0kAECr1eKvNX/A1dUVSckjWjJ8InISCrkMiZ2VmDc5DAmRHvBykyEh0gPzJochsTNvXrImZ8q9wkWOF4/84RDznyrkMvSO8gRQM1xCHhFX/8eOh1EQNcTioQgajQZpaSeRn5+PkSNHAQAuFxWhbUCAZMFdKsiHRqNB+8hra297eXkhKDgE2dlZ6BrfHbt3bcfp06fQrrISv61aAQC47fbxcHVzM3pMQRCg1V77Nq3VCkb3I6LWRSaTYXCs0uBmJUHL8fotwRlyX7s8688XDmB0aBeHWJa1Nr+OOIyCqCEWFbYF+fl4//13UVxcDLVarS9slyz9DkMGD0X/AQMkCU6lKgcAeHkZLvLg5eWlXwSiS5euCA0zXNFFrmj4tPbu2YldO7df21fuggE39sWlgiy42PmHEBkSBA3y8y7aOgxyMq5u7pC7yKHVaaGpdpybl5yBo+bew8MLvn4B+uVZa5dlLblShMrKCouP2xKfcXK5Aq5u7nBxcYFOp4OgqTbYLooiO4DILuhMHOJjUWG7ZOl3SEjogQceeBD33DNJ33777WOx5LtvJSts9ep8a6/5llkz7iogMAgBgUEmH6r/gEHo0/dafFqtgCOHdyEoOAKKRgpisj/5eRcREhpp6zCIqJUTdFpsyEvHsZJcAMCxklxszM9AUlA0fP0sv4rJzziiawRBwLlzp5rcz6IxtmknT2LSpLvhIjfs4YyMiMS5c+csOaRRtT21arXhN96Kiop6vbimUigUcHd31/+4uTnOXbdERGR/FC5yfW9trU/SN9n9UAQiZ2RRYSsIAnRXZzC4fgxUYVGRwfRbzRUcHAKFQoGsixf0bZWValwqyEd4eDvJXoeIiMgSNWNrM5FRVgA3F7n+J6OsAFsKMh1ihgQiZ2LRtfdu3bpj3V9rMenuewDUFLYVFRVYtnQJuickSBacq5sbuif0xO7dOxDeLgI+Pm2weeM/8PJSIq7zDZK9DhERkSUULnIkB8ci89Y3bB0KEcHCwnbq1GmYNet1HDx4EICI2bPfR2ZGBlxdXfHOu++ZfJwTx49i/d9r9Y9X/voTZDIZbhw4GDcOHAIASEoeAVEU8fOPy6DRaBAW3g4TJt4DtwZmPSAiIiKi1kkmCBqLZr8uLyvDpk0bcfr0aYiiiI6dOmHkyFHw8fEx+Rg6nQ46na5eu4uLC1xc6o+SsMZSuYIgYN/eFPTrn8ybxxwMb6wgouZQLZkFbd65eu3y0Ci7mAKLn3GWs/f3lsxnar1mcSXn7eODsePGW/p0AA0XsA1xpDkNiYjIvmnzzkGblWHrMMgK+N62XjZdeYyIiIiISCqWLdBQUIClS75DWlqafhGF6/38y6/NDsza5s+fjwULFsLDwx3zPvvA1uEQERERUTNZVNh++cXnUCjkeOjhh6G0cD5ZW5sxYwZmzJihH7NBRERERI7NosL21KlTWPjV12bdKEZEREREZE0WFbYBAW2hrqhgYUvUSvGOY9tg3qUlD40yqZ15dzymvrfkfCwqbO+8cwK++moBpj3wEEJDQoA6sxVwjlki58Y7jm2DeZeWqUUp8+54+IWj9bKosA1vF4EzZ87g+eeeNbp9xa+rmhUUEREREZG5LCpsv1q4AJ27dMGoUaMd9uYxIiIiInIuFhW2eXl5eOvtd+Dt7S11PEREREREFrFogYbQ0BCUl5VJHQsRERERkcUs6rEdPmIkvvzyC0x74EGEhobWW+pWqeTwBCJnxjuObYN5tw3mnchxyARBI5r7pEkTJzS63RFuHqu78li//slQKCyq88lG8vMuIiQ00tZhEBFZBT/jiK6pXVCrqXrNokrug9lzLA7MXnDlMSIiIiLnYlFhGx0dI3UcRERERETNwmvvRERELcARVjBzhBiJGmNRYSuKIlJSNmP3rp0oLCyEVqs12P7pZ59LEhwREZGzcIQVzBwhRqLGWFTY/v77//DX2rUYfdNNOHToJ0y5fypOncrEnt27cdPNY6SOkYiIiKge9jBTXRYVtps2bsD/PfccOnfugl9+/gljx44DAKxb9xcOHjggaYBEREStkVzO0YJNYQ8z1WXRv5qCggL9DWSurm5Qq9Xw9PREYmISflz+g6QBEhERtUa1c8SzV5LIdBYVtjqdTj+HWFBQIDIzM5CQ0AOXLhXwGyYREZGE2Ctpv/ilw/40uwodPmIkPp37CeLjuyEtLQ0DBw6UIi4iIiKn4ggrmDlCjPaEXzrsj0WF7XdLlun/PnbsOPj7+yMjPR0TJkzAyFGjJQuOiIjIWThCD54jxEjUGIsK28yMDPTs1Uv/eOjQRAwdmihZUC2h7pK6RERE5FjYw0x1WVTYzp79Pn76eYV+YLsj4pK6RGQPOEbPNph358D3iuqyqLANCQlBdlYWIiIjpY6HiKhVMWWMnqAVoZDLGnxM5nOkvLe2Xkl7yTs5JosK23Hj78AXX3yO+6dORUREpH6GhFpKpVKS4IiIWjtRFLEjU4VFKcXILKhCbLA7pif7I7Gz0qGvmtk7e8i7KIoAWlevpD3k3Ryt7UuHI7CosF24YD4A4K03ZxndvuLXVZZHREREegfPqTFzeS6u1jhIzarEzOW5mDc5DINjlezJshJ7yLtWK1j9NeyJoK0pam2dd3O0pi8djsKiwvaD2XOkjoOIiIz47UCp/j/5WqIILNpSjKQu3rYJqhVg3lueQi7DopRi5p2axeTC9p2338Trb7wJADh37hxGjBhppZCIiKjWhcsao+2n8qtaOJLWhXm3jcwC4/ll3slUJhe2J06cgFarhVwux1cLF7CwJSKSQFNj9Nq3dcXhC5X1tseEuFsxKufHvNun2GB3pGYx72Q5kwvb8PB2WP7D94iNiwMA7N61q8F9b+TqY0REJmlqjN7YXj7480iZweVZmQyYnuTPu8WbgXm3P4JWxPRkf4MxtgDzTuaRCYJGbHo3IC0tDd8vW4K8vDyUlZXBy8urwX2XLP1esgCtrXYe2379k+vN7kD2LT/vIkJCOeUcOTdRFLE1XYVFW4pxKr8KMSHumJ5kv3eJOwt7yHtr/Iyzh7yTfTK1XjO5sL3epIkTnGbmAxa2jqs1fuhT68R5PW3D1nlvrZ9xts472SdT6zUXSw4+b94XFgdGRETmqfufOv+TbxnMu20w79QcFhW2oWFhUsdBRERERNQsrfba+/z587FgwUJ4eLhj3mcf2DocIgBcv95WmHfbYN6JSGqttrCdMWMGZsyYoR+zQWQPTFm/nqTHvNsG805EUrNoKMKHH862aBsRERERkbVYVNju37fPaLtOp8OB/QeaFRARERERkSXMGopwpbjY6N8BQCeKSE9Pg7+/nySBERERERGZw6zC9tFHHzH691pyuRxTpz3Q7KCIiIiIiMxlVmH70cdzAQDPP/es/u/6A8nlaNu2LTw8PaWLjqiVaWr9erIO5t02mHcikppZhW379u0BAF9/vRh+/v5WCYioNWhoZR1OcWRdzck7Vz+yXHN/35l7y5i7gpcjrfjlSLFSy7Jouq/Kykrk5eY2uJ0LOBA1TBRF7MhUYVFKMTILqhAb7I7pyVwL3drMybsoijh4To3fDpQiLa8KHgoXvkcWMvf3vTb3P+4uwfZMFf99WMiSvDvK55IjxUotTyYIGtHcJ02aOKHR7St+XWVxQC3N1LWHyf444jrqgrbmA3nm8lyI1/3Lk8mAeZPDMDhWyV4HKzAn73yPpGNuLpl7Q5Z+xjlz3h0pVpKWqfWaRZXcF18uMHgsijrk5ubi+2XLMOaWWyw5JFGroJDLsCil2OADGQBEEVi0pRhJXbxtE5iTMyfvfI+kY24umXtpOHPeHSlWsg2LCtvg4OB6bSEhofDz88dXC+dj5MhRzQ6MyFllFlQZbT+Vb7ydpGFO3vkeScfcXDL30nDmvLu4ADeEuesfq6p1uFCksctYqeVJeu09NCQEWVnZUh6SyOnEBrsjNauyXntMiLuRvUkq5uSd75F0zM0lcy8NZ877skfrD8+4be45+HnJbRAN2RuLVh4zRq1W47+//RdBQYFSHZLI6QhaEdOT/VH3/gaZDJie5A9Ba/aQdzKBOXnneyQdc3PJ3Eujbh7bB7jihjB33BDujmdHBzhk3lVLZqF09rR6P6olswAASncXu4mVbMuim8fuuXtivTadTgdvb288/cyz6NGjpxSxtQjePOa4HPHmMaDmjt6t6Sos2lKMU/lViAlxx/Qk3tFrbebkne+RdMzNJXN/TWOfcU1Nd1Wbxz8OleHje5ueqcje8146exq0WRn12uURcWjz0lIcOFuB3lGedhErWYep9ZpFhe3hQ4fqtSm9lYiMiHS4BRpY2DouRy1sAc7BaCvm5J3vkXSceT7VxqiWzII271y9dnlolElz+Db0GacvQpuY7ur6vJkSiz3nvanC1p5iJeuw6qwIPXv1sjgwezF//nwsWLAQHh7umPfZB7YOh1qZuh/A/EBuGebkne+RdMzNpbPkXpt3zmgx1hzGprtKzarEzOW59aa7uj5vpsTiyHl3pFjJuizuoqyoqMCWLSnIzsoCAERERCAxKRleXl6SBWdNM2bMwIwZM/TfAIiIiOwdp7siapxFhW1Gejpmz34fCoUCUVEdAQB79+7Br7+uwMsvv4qY2FhJgyQiIqIajjQ1F1FLs6iwXbx4EYYMGYqp0x7Qj3MQBAHLli7BokVfYc6HH0kaJBEREdWw9tRczR0bbA3y0Ciz2qn1sqiwzc7OxutvzDIYvKtQKDBx0t14/LFHJQuOiIiIrqmdmsvYkrK10101d7ypNcYGN5etCmpyPBYVtqGhoSgqKoKPj49Be1FhIUJDQyUJjIjIEdjzneTOzBHybo1eRoVchsTOSsybHGbW1FzW6vG0x7xT62ZRYTvmllsw95OPcN/kKYiOjgFEEafPnMaPy3/AuHHjoVKp9PsqlUrJgiUisieiWHOHelPTLpG0HCXv1upllMlkGByrNLhRTNCKjZ67lLG8tjIP/5wor5d3exzCQK2PRYXt118tBAB8/NF/6m1buHABFi5coH+84tdVFoZGRGS/zJl2yZ44evHhqHlX/7EQ2qKcaw3VldAV51ucd1tOzZWZXw11tajP+7cPtUOfjl52OYSBWh+LCtsPZs+ROg4iIofiqNMuOXrx4ah515zY5dB5b4goAr8dLEWfjvY/1aejf6kj01hU2K5atRL//vdLRrd9+OHsBrcRETkTTrtkG8y7dZk7HvdikcZ6wUjI0b/UkWksKmz379tntF2n0+HA/gPNCoiIyFFYe9olMo55t66mei9V1TqDx5EBrtYMh8gsZhW2V4qLjf4dAHSiiPT0NPj7+0kSGBGRPWuJaZeoPubdNgStiCMX1HjzfwW4cF0PrUwG3NG7jQ0js28c/tDyzCpsH330EaN/ryWXyzF12gPNDoqIyN5ZOu0SNQ/zbhsKuQy9ozzx/JjAennvHeUJgIsoGMPhDy3PrML2o4/nAgCef+5Z/d/1B5LL0bZtW3h4ekoXHRGRHbNk2iVbc4big3m3jabyzh5IsgdmFbbt27cHAHz99WL4+ftbJSAiIkdiy2mXLOEsxQfzbhuOlvfrOcOXC2qaRTePVVZWIi83t8HtoWFhFgdEREREJDVn+XJBjbOosJ0588lGt3NRBiIiIiJqaRYVtl98ucDgsSjqkJubi++XLcOYW26RJDAiIiIiR8bhDy3PosI2ODi4XltISCj8/Pzx1cL5GDlyVLMDIyIiInJkHP7Q8lykPFhoSAiysrKlPKTVzJ8/H927J2DgwIG2DoWIiIiIJGBRj60xarUav/32XwQFBUp1SKuaMWMGZsyYAUEQsG9viq3DIYlxUmzbYN5tg3knIqphUWF7z90T67XpdDp4e3vj6WeebXZQRM3FSbFtg3m3DeadiKiGRYXtSy+9Uq9N6a1EZEQkF2ggIiIiIpuwqLDt2auX1HEQERERETVLs8bYXr58GTk52YAIhLdrh7Zt20oVFxERERGRWSxbeUytxuJvFmPb1i0QRRFAzRrSQxOT8Mgj0+Hh4SFpkERERERETbGosF22bClOn8rEyy+/iri4OABARkYGli79DsuWLcWjj/5L0iCJzMVJsW2DebcN5p2IqIZFhe2ePbsxa9ZbaN+hg76tZ69e8G/rj7ffepOFLdkcpziyDebdNph3IqIaFi3QUFlZCX8j42n9/duisrKy2UEREREREZnLosI2OjoGq1athE6r1bfptFqsWvkrYmJiJAuOiIiIiMhUFg1FmDrtAbz37tvYu2c3OnWKBgCcOXMaanUlXn3tdUkDJCIiIq4wR2QKiwrbmJgYzPv8S2zc8A8uXrwImUyG0aNvwsiRo+Dt4yN1jERERK0eV5gjaprF89j6+Phg/B13ShkLEREREZHFmrVAAxFRaydoRSjksgYfk3Uw77bhCHnnkI3WjYUtEZGFRFHEjkwVFqUUI7OgCrHB7pie7I/EzkrIZPb1n70zaa15dwnpYLzdP6RFXt9R8s4hG60bC1siIgsI2pr/5Gcuz8XVBRiRmlWJmctzMW9yGAbHKu2uJ8sZtOa8ez/4ttWO3VQvZ2vOOzkWiwrbw4cOoWevXlLHQkTkMBRyGRalFOv/k68lisCiLcVI6uJtm8CcXGvNu7UvrzfVy1mb98i2rlC6Gc4U+s/xcqfNOzkeiwrb2bPfx08/r7CrSw9ERC3NxQW4IcwdAKCq1uFCkQYAcCq/ypZhOb3WmHd7uLxeKeiw+tkom8ZA1BSLCtuQkBBkZ2UhIjJS6niIiBzGskcNPwNvm3sOF4o0iAlxt1FErQPzbhtdQq9+meDNWWTHLCpsx42/A1988TnunzoVERGRUCgMD6NUKiUJjoiM438sttFU3pVuLpDJgOlJ/nZ5t7ijYt5tT9CKuKNPGwD20XvcGHlolFnt5FwsKmwXLpgPAHjrzVlGt6/4dZXlERFRk+z9PxZn1VTeY0PdMGNEW7u7S9zRMe+2p5DL0DvK09ZhmIRf7ls3iwrbD2bPkToOIiKH9+6EUAhakcVVC2Pem8+UXs7m5JdXmailWFTYRkfHSB0HEZFTsLfL4I4wob4U7O2cpM67tS+vW7u45FUmaikWz2Or0WiQlnYS+fn5GDlyFADgclER2gYESBacNc2fPx8LFiyEh4c75n32ga3DISKSnKNMqO9srJF39moSmcaiwrYgPx/vv/8uiouLoVar9YXtkqXfYcjgoeg/YICkQVrDjBkzMGPGDAiCgH17U2wdDhGRpDihvm20hrzz5iyyZxYVtkuWfoeEhB544IEHcc89k/Ttt98+Fku++9YhClsiR8b/WGzDkfLuTAsZMO/2hb3HZM8sKmzTTp7EvHlfwEUuN2iPjIjEuXPnpIiLiBrB/1hsw9HynllgfMECR1vIgHknIlNZVNgKggDd1a+j148XKiwqgoeHhzSRERFRs8QGuyM1q7JeOxcysC7mvT5H6nUnx2ZRYdutW3es+2stJt19D4CawraiogLLli5B94QEKeMjIiILCFoR05P9DcZ6AuBCBlbGvBvnaL3u5LgsKmynTp2GWbNex8GDBwGImD37fWRmZMDV1RXvvPuexCESEZG5FHIZEjsrMW9yGBZtKcap/CrEhLhjehJnRbAm5p3ItiwqbEPDwvDxx3OxadNGhISEQBRF3Hb7WIwcOQo+Pj5Sx0hERBaQyWQYHKs0uGGJCxlYH/NOZDsWz2Pr7eODsePGSxgKERFJre5l79Z4GdwWmHci27C4sK2oqMCWLSnIzsoCAERERCAxKRleXl6SBUdEREQkNS7x67wsKmwz0tMxe/b7UCgUiIrqCADYu3cPfv11BV5++VXExMZKGiQRERGRVLjEr/OyqLBdvHgRhgwZiqnTHoBCUXMIQRCwbOkSLFr0FeZ8+JGkQRIRERE5MvYStwwXS56UnZ2NiZPu1he1AKBQKDBx0t3Izs6WLDgiIiIiZ1DbS1zvx0ixS5azqLANDQ1FUVFRvfaiwkKEhoY2OygiIiIiInNZNBRhzC23YO4nH+G+yVMQHR0DiCJOnzmNH5f/gHHjxkOlUun3VSqVkgVLRERERNQQiwrbr79aCAD4+KP/1Nu2cOECLFy4QP94xa+rLAyNiIiISHpc4td5WVTYfjB7jtRxEBEREbUI3qzlvCwqbKOjY6SOg4iIiMhpsZe4ZVi8QAMRERERmYa9xC3DolkRyDKCVmz0MVmHuXnn+yQN5t02nD3v9hyvObHZ83kY42jxUuvFHtsWIooidmSqsCilGJkFVYgNdsf0ZH8kdlZCJuMa4tZibt75PkmDebcNZ8+7PcdrTmz2fB7GOFq81LrJBEEjydeu0tJSKJVKyOVyKQ7XYgRBwL69KejXP9lgwQlJX0Nb86Ewc3kuxOuyLZMB8yaHYXCsEgo5PxzMlZ93ESGhkQ1uNzfvfJ+kwbwbaqnVhpw975bEa4+5N2ffpj7jWoKj/Z6Q8zK1XrNoKMKF8+exbOkS/eOvv/4Kjzz8IB6d/jBOnz5lySGdmkIuw6KUYoMPBQAQRWDRlmJ+KFiJuXnn+yQN5t1QS6025Ox5tyRee8x9a8g7kS1ZVNguW7YUvfv0AQBkZWVh+7ateP2NWRgxYiSW//CDpAE6i8yCKqPtp/KNt5M0zM073ydpMO+24ex5t+d4zYnNns/DGEeLl1o3iwrbzMwMxMbGAQBSjxxGv3790b17AsbfcSfOnDktaYDOIjbY3Wh7TIjxdpKGuXnn+yQN5t02nD3v9hyvObHZ83kY42jxUutmUWHr5uaOy5eLAAAHDx5Et+7dAQDVVVVwc3OTLjonIWhFTE/2R90x9jIZMD3Jn3eXWom5eef7JA3m3TacPe/2HK85sUl1Hqols1A6e1q9H9WSWc09HQP2nHciYywqbPv164cP58zBZ5/ORWZmJvr06QsASE09goQePaWMzyko5DIkdlZi3uQwJER6wMtNhoRID8ybHIbEzhx4by3m5p3vkzSYd9tw9rzbc7zmxCbVebTk+GF7zTuRMRZNA/DAgw9h9Z9/4NKlS3jl1VfRpk0bAMDp06cxceJESQN0FjKZDINjlUjq4q1vE7Qip0qxMnPzzvdJGsz7NS252pCz593ceO01986edyJbsqiwdXNzw50T7qrX/uBDDzc7IGdmrMeErM/cvPN9kgbzXqOlVxty9rybE689596Z805kS2YVtuVlZdiw4R+Mv+NOo9v/99t/MXLkKHj7+EgSHBEREZElWmoeY7IvZhW2q9eshod7w3dBigDWrFmNu++5t7lxEREREVmsdhwytS5m3Ty2b+9e9O3br8Htffv2xb59e5sdFBERETVMHhoFeURc/R8rjB8mciRm9djm5+chOCSkwe0hwSHIz89vdlBERETUMF5KJzLOrB5bhUIBlUrV4PZylarR9XuJiIiIiKzFrMI2OjoGO7Zva3D7jh3bER0d0+ygiIiIiIjMZVZhe+utt+Gnn37E6tV/QtBo9O2CRoPVq//Ezz/9iFtvu03yIK1h/vz56N49AQMHDrR1KERERCQxjkNunWSCoDFrPbzffvsvfv7pJ8jlLggODoYoApcuFUCr1WHy5MkYO268lUK1DkEQsG9vCvr1T+YwCgeTn3cRIaGRtg6DiMgq+BlHdI2p9ZrZldwdd9yJfv36Y+eO7cjJzYEMMgwePBiDhwxBu3YRzQqaiIiIiMhSFnVRRkREYNLd9xjdVlpaql9il4iIiIiopUhy7V2n0+Ho0VRs3LgB+/ftw48//SLFYYmIiIiITNaswrbw0iVsTtmMlM2bUF6uQs+ePTHjiSelio2IiIiIyGRmF7aCRoP9+/dj48YNOHo0FV263IBLly7hxx9/hsLV1RoxEhERERE1yazCdtnSJdiyZQsAICk5GQ8+9DDCw8MxaeIEFrVEREREZFNmFbarV/+J2NhYPPvscwgMCrJWTEREREREZjNrgYaHH5kOQRDw1FNP4qOPPsThw4eg0+msFRsRERERkcnM6rG96aabcdNNN+Ps2TPYuHEjPvt0Lry8lACAgoICBAcHWyVIIiKi5lAtmQVt3rl67fLQKCgfeKvlAyIiq7BoVoSOHTvhkUc6Yer9U7F7z25s2rgRTz05Ax06RKFf//6YOHGS1HESEZEFBK0IhVzW4OPWQpt3DtqsjBZ7PeadyDaaNd2Xm7s7EhOTkJiYhLzcXGzatBEb/lnPwpaIyA6IoogdmSosSilGZkEVYoPdMT3ZH4mdlZDJWGRZC/NOZDtmjbFtTGhYGO6bPAXzF3wl1SGJiMhCglbE1nQVZi7PRWpWJdTVIlKzKjFzeS62pqsgaEVbh+iUWirvdY/D95Oohlk9titWmLai2KRJd1sUDBERSUMhl2FRSjHEOvWOKAKLthQjqYu3bQJzci2Rd/YIEzXMrMJ25a8r4OvrCw8Pz0b3Y2FLRGR7mQVVRttP5RtvJ2lYM++Ctqaonbk8V1881/YIz5schsGxSo7lpVbNrML2hhtuwJkzZ5CQ0APDR4xA167x/HZIRGSnYoPdkZpVWa89JsTdBtHYljw0yqz25rBm3tkTT9Q4swrbt95+Fzk5Odi0cQM+nTsXHh4eGDZ8OJKTktE2IMBaMRIRkZkErYjpyf4GPXsAIJMB05P8W91d+i01pVdL5J098UQNM/vmsfDwcEy5fyoWLPwK998/FelpaXjyyRmY/cH71oiPiIgsoJDLkNhZiXmTw5AQ6QEvNxkSIj0wb3IYEjvzcrW1tETeY4ON9/y2xp54orosnu5LoVCgd+/e0Oq0uHLlCo4cOSJlXERE1EwymQyDY5UGl6cFrcghZFZmzbyzJ56ocRYVtufPn8PmTZuwbdtWtGnji2HDh+OVpGSJQyMiouaqW+Sw6GkZUuRdoXA1etzaHuFFW4pxKr8KMSHumJ7EWRGIADML2/V/r8PmzZuQnZ2NgQMH4d8vvoTOnbtYKzYiIrvHFaZsozXkPSAw1Gi7LXviW0PeybGZVdguXrwIQUFBGDlqNDw8PHDkyBGjQxA43RcRtQaiKGJ7pgqLr5tP9JFkfySx58yqnD3vqiWzoM07V69dHhqlvwnOFj3xzp53cg5mFbbh4eEAgIMH9je6HwtbInJ2grbmP/mn68wn+vTyXHx2dT5RV/ZkSa415F2bdw7arAxbh2GgNeSdnINZhe2nn31urTiIiByKQi7D4gbmE128pRjJrXg+UWtermbeG2et3DPv5CgsnhWBiKi143yi9bXEcq/Mu3HWzj3zTo7A7HlsiYioBucTNSRoRWxNr1nuNTWrEupqUb/c69Z0FQSt2PRBTMC819cSuWfeyRGwsCUisoCgFfFIsj/qdoTJZMAjSf7QSFTEOZKmlnuV4pI4826ctXPPvJOj4FAEIiILKOQyJHVW4rPJYVh83XyijyS17rvErX25ujXkXR4aZVZ7LWvm3lHzbsoME+RcWNgSEVlIJpNhSKzS4MYZTStf2Ss22B2pWZX12qW8XO3sebe04LJ27h0x7/Y4wwRZF4ciEBE1Q91LvK15yqPa5V6NXa6uXe5VKq0h70WFeSbv21K5bw15J8fGwpbIBur+JyPlf/jUMHPyzvfIfNcv95oQ6QEvNxkSIj0wb3IYEjsr9UVRU7lk7msIgsbkfU3JvTPl3ZFipZbFoQhELawlpkOi+szJO98jyxlb7lVVpcVn64vw054rTeaSubecsdznl2jw0q95yL4sOE3eHSlWankyQdC06q85giBg394U9OufDIWCdb4jyc+7iJDQSFuHYRZBW/OBPPO61XuAmsuF866u3sN116VnTt75HklHqxNxNKsSDy7OglZ3rb2hXDL3hiz9jNNqRaTnVeHjdYU4cE4N3dVcOkPezY21dPY0o2Ns5RFxaPPS0pYImSRiar3GoQhELaglpkOi+szJO98j6chdZPhobaFBUQs0nEvmXhpyuQzv/3kJ+85eK2oB58i7ubHKQ6Mgj4ir/9PEDBPkuNhFSdTCuHqPbZiTd75H0jE3l8y9NJw57+bEyim9Wh/22BK1MK7eYxvm5J3vkXTMzSVzLw1nzrsjxUotj4UtUQtqyemQ6BpT8y5oRb5HEjInl8y9dJw5744UK9kGC1uiFmTqdEgkLVPyXnun9RcbijA0TonP7uN71Fym/r4z99Jy5rzzM5SawlkROCuCw3LEWRFqCVqx3l3J/EC2vobyXvdO6xFdlXh+TBDa+bs2+FwyXWO/78x9w5r7GefMeednaOtjar3GSo7IBozdlUzW11De695pvfGECptPqtA7yhN9OnjiiZEBfI+aobHfd+beepw57/wMpYZwKAIREerfaa0Tgf1n1fh+Z7GNImo9mHvbYN7JGbGwJSIC77S2JebeNph3ckYsbFsQ17a2DXPzzvdJGo6Ud2e609qR8l77eubk3tbxNsac2Gx9Hs6Ud6LrcYxtC+Ha1rZhbt75PknD0fJ+/Z3Wi7YU41R+FWJC3DE9ybHee0fLO2Be7u0h3oaYE5s9nIez5J2oLs6K0AKzIjjSOtyOpKk7hs3NO98naThy3h35TmtHznttPI3l3t7ivZ45sZmzb0vM/OLIeafWxdR6jUMRWoAjrcPtTMzNO98naThy3h35TmtHznttPE09tqd4r2dObPZ2Ho6cdyJjWNi2EEdah9uZOPN66faMebcNZ8+7PcdrTmz2fB7GOFq81LqxsG0hvPvUNpx5vXR7xrxLy9Qbd5w97y0drzk3TJkTG/PeON6oRs3BwrYFONMd147Ekrt++T41H/Murdobd6YsvIgBb5/ClIUXsSNTBVFsXh4dLe8tHa+peTc3Nua9cebkncgY3jzWQkvqiqKIrekqh77j2t6YcmOFuXnn+yQN5l0a5t644+x5b6l4LblhypzYTN3XXpYNt+e8U+thar3GwraFClvAse+4tkemfuibm3e+T9Jg3qUxZeFFpGZVwkUG9I7yRJCPHJfKtNBoRXz/aP3ff2fPe0vFa27ezY3NlH3tpbAF7Dvv1DqYWq85/Dy2Wq0WFRUqAICbmzvc3e1zjBLg2HdcOzJz8873SRrMuzQyC6owoqsSz48JQjt/V317TrHG6P7OnveWitfcvJsbG/NunCV5J7qewxe2l4uK8N9Vv0CjqUZCj95ITBpm65CIiCRz7wA/zBwVgK3pKrz4S55+gvxHkvwR5qewiyEDjtbrawpnzLsjvE+OkHeyb04zFOHA/r1QqVRmF7YtORSBpGVPl+mIrEHQiqgSdNh3Ro2nf7TPcYf68ZdOtCqVveS9sc84c/PuCO+TveSd7JNDLdCQk5ONY8dSoSovN7pdo9Hg7JnTSE87gdKSkhaOjojINhRyGZTucizeYp8T5AvammJp5vJcpGZVQl0tIjWrEjOX52Jrusru7vA3lbPl3VHeJ3vPOzkGm3ZRnj17Gtu2bIYoiigsvIRJd0+G0tvbYJ/Ll4uwcsVPcHd3h7ePD9b9tRqJScPRq3dfG0VNRNSy7HWC/KZWpUrq4m38iQ7CWfLuaO+TveadHINNe2xFnYibx9yOO++6u8F9/ln/FwICAjH1gUcw4a57MHLUGKRs3oDi4sstGCkRke3Y84T+zlyEOFPeHel9sue8k/2zaWHbKToGwSEhDW4vKytD1sUL6Nm7j34M0A1d4+Hu7o6M9DQANeOGyspKUVVVherqKpSVlUKn0zV4TEEQUFVVpf+prra/f9RERLXsfUJ/Zy1CnC3vjvI+2Xveyf7Z9d1SRYWXAACBAUH6NhcXF7RtG4DCq9uqq6vx4w9L9dtPn8rEPffdD19fP6PH3LtnJ3bt3K5/LJe7YMCNfXGpIAsuLnIrnAVZiyBokJ930dZhEFmVu7snEjsHYN7kMKMT5JdcKUJVldomsfkFhGF6sr/RCfWnJ/mjqlqDK5fzbBJbc9lD3hv6jDM37470PtlD3sk+6XRak/azi1kRyspK8fXCLzDp7smIbN9B356efhKr//gNTzz1LDw8PPXt//vtV4g6EXdMmGT2awmCAK32WnK0WgFHDu/irAgOiLMiUGtir1M1OdrqZeayZd5NmhXBSVeZs9ffd7Idp1igoTZwjUZjUNhWV1fD87rH5h7z+oQIAntpicj+2euE/jKZDINjlQY3IAla0S6LJUs4S94d7X2y17yT/bOL6b4a4ufnDwAoqTPFV2lJCXyvbiMiIttiEWIbzr7KHJEl7LqwDQgIhJ+fP9LTTujbcnOyUVJyBZ2iY2wYGRERERHZG5sORSguvozs7CxUVVYCAM6ePYOS0hIEB4XoZ0sYNmIUfv9tJURRRJs2vjh0cD+63NAVEREcW0lERERE19i0sFWpVMi6cB4AEB/fHRWqclSoyuHu7q4vbDt1isHkKQ/i5MnjKC25gqGJw3BD13hbhk1EREREdsimhW1ERKRJPa/BISGNzndLRERERGTXY2yJiIjsQd2FAbhQQMtg3slcrbawnT9/Prp3T8DAgQNtHQoREdkxURSxI1OFKQsvYsDbpzBl4UXsyFRBFO2jyHLW4s/e8072yS4WaLAlUyf8JfvDBRqIyNoEbU1xZWzVrnmTwzA4Vmm1abNM+YzTL7yQUozMgirEBrtjerL9LrxgKlvmneyTqfVaq+2xJSIiaopCLsOilGLU7SQURWDRlmKbFleCtqaonbk8F6lZlVBXi0jNqsTM5bnYmq5y6J5be8472TcWtkRERI3ILKgy2n4q33h7S3H24s9e8072jYUtERFRI2KD3Y22x4QYb29Jzlz82XPeyX6xsCUiImqAoBUxPdkfdYerymTA9CR/m1/ud9biz97zTvaLhS0REVEDFHIZEjsrMW9yGBIiPeDlJkNCpAfmTQ5DYmfb3sDkzMWfPeed7BunASAiImqETCbD4Fglkrp469sErWjzWQeuL/4WbSnGqfwqxIS4Y3qS48+KANhv3sm+sbAlIiJqQt0eQnvpMXT24s9e8072i0MRiIiIHJgzF3/OuvgEWU+rLWy58hgREZH94spjZIlWW9jOmDEDR4+mYteuXbYOhYiIiK7jzItPkHW12sKWiIiI7JOzLz5B1sPCloiIiOyOMy8+QdbDwpaIiIjsjrMuPkHWxcKWiIiI7IozLz5B1sXCloiIiOwKVx4jS3GBBiIiIrI7zr74BFkHe2yJiIjILjnz4hNkHSxsiYiIiMgpsLAlIiIiIqfAwpaIiIiInEKrLWznz5+P7t0TMHDgQFuHQkREREQSaLWF7YwZM3D0aCp27dpl61CIiIiISAKttrAlIiIiIufCwpaIiIiInAILWyIiIiJyCixsiYiIiMgpsLAlIiIiIqfAwpaIiIiInILC1gHYmiiKAACtVrBxJGQunU4LQeD7RkTOiZ9xRNfU1mm1dVtDZIKgaXwPJ1dVVYmDB7bbOgwiIiIiakLvPkPg7u7R4PZWX9jqdDpoNNVwcZFDJpNZdIyBAwdadaEHaxxfymM291iWPL+6ugpfL/wcjz72FNzc3C1+bTKftX/fbcmez82WsfEzjp9xrYk9fw40lz2fW1OxiaIInU4LV1c3uLg0PJK21Q9FcHFxabTyN0VlZRUUCuul0hrHl/KYzT2WJc/XarXQanWQyxVWzT3VZ+3fd1uy53OzZWz8jONnXGtiz58DzWXP52ZabK5NHoc3j0ng8ccfc7jjS3nM5h7L2vkjaTnz+2XP52bL2PgZx8+41sSZ3y97PjepYmv1QxHIMVVVVeGLeR/jyZnPwd2dl+mIyLnwM47IMuyxJYckl8sxcNAQyOVyW4dCRCQ5fsYRWYY9tkRERETkFNhjS0REREROgYUtERERETkF+5zzgUhCFRUqVFVVAQB8ff0anf+OiIiIHBcLW3J6B/bvQ0b6SZSWluChRx6Dr6+frUMiIrLYpYIC/PXXn7hSfBkdO0bjpjG3wc3NzdZhEdkFdl2R0xuamIyHpz8OXz8/W4dCRNRsf69bjV69+uCxGU9D5uKCA/v32jokIrvBHluyOVV5Oc6cPQ1vpTc6doo2uk9eXi4KCy9BqVSiffsogylwyspKIQhCved4enrBw6N5q8oREUlNq9UiO/si3NzcERoaZnQflaocl4uK4KVUIiAgUN9eWalGSUkJunXvAZlMht59+mFLykYMHDSkpcInsmssbMlmqqur8Pe6tcjJzoJMJkNQULDRwvbvdWtwKjMd7TtEoSA/H66urph4933w9PQCAKRs2oCCgvx6z+vbbwB69Oxt9fMgIjKFIAjYvWs7jh8/CkEQEBwUgol331dvvz17dmL3zu0IDAxCcXExQsPCMG7cBLi6uUGtVsPT0xMymQwA4OXlBbW6oqVPhchusbAlm9FqdYiNjcMtt47FX2v/gKZaU2+fzMx0HD+WivunPYygoGBUV1fjh++/xY5tWzBy9BgAwO3j7mzp0ImIzCYIAhQKV0yZ8iC2bd2MsrKyevtkZV3A9q0puGvivegQ1REVFSr88P132LlzG5KSR8DTs6aQFUURMpkMFSoVvK5+yScijrElG/L09ESXG+IbXVknPe0EIiLbIygoGADg5uaG+G4JSE8/CVE0bW2RyspKFBdfhk6rQ2lpCcrL6/9nQkRkbR4eHrhx4GAovb0b3Of4saMICQlFh6iOAAAvLyW6d++B48dS9cfw8/PH4UMHoFZXYP/+PYjqaHwIF1FrxMKW7FpRYaHB+DIACAgIRGVlJVSqcpOOkZ52Av9d+QtkMhnWr1uLbVtTrBApEVHzXSrIR3BIqEFbcHAo1Gq1/kv5TWNuw4kTx/Dt4oWQyxXo07e/LUIlskscikB2raq6Cu7u7gZttTeEVVVVwdvbp8lj9OjZm2NticghVFVV1bvp1cPTE0DN1Sdvbx8EBgZh8pQHbBAdkf1jjy3ZNYVCgerqaoO26qqax66urrYIiYjIalzkLvVmeRE0NfcfNDZsi4hqsLAlu+bv3xYlJSUGbSUlV6BQKEzqrSUiciS+bfxQVlpq0FZWXgaZTAYfnzY2iorIcbCwJbsWHR2LCxfOoUKlAgCIooi0kyfQsVM0l8YlIqcT1bETLlw4h+rqKn1bZkYaIiPbQ6Hg6EGipvBfCdnU8WOpEAQBV65cgVYQcOTwQbi4uKB7Qk8AQHy3BBw/dhQrflmOG7rGIzs7C5cvF+GmMbfaNnAiIgtcvHAeglZAWXkZ1JVqnD17GjLIENWxEwCge0JPHDl8EP9dtQI9evRGTk4Wzp87i0n3TLFx5ESOQSYIGtPmTCKygpTNG6HRGI6hVSgUGDZ8lP6xIAg4cfzo1ZXHvBHfrTuHIRCRQ1q75g+oKwwXVJC5yHDnhLv1j9VqNQ7s34PCwkvw8lKiR8/eCKkzUwIRGcfCloiIiIicAgcpEhEREZFTYGFLRERERE6BhS0REREROQUWtkRERETkFFjYEhEREZFTYGFLRERERE6BhS0REREROQUWtkREJjpz5gzSTp60dRhWd+zYUVy6VGC146elpSE3N8dqxyei1ouFLRGRiTZv2og//vzdrOecPn0KaWlpVopIetnZWfjs07nw9PSy2mtcuVKMuZ98Ap1OZ7XXIKLWiYUtEZEVbdy4EWtW/2nrMEz2808/YfjwEfD29rbaa9x440BUVVVh584dVnsNImqdFLYOgIjIUWVdvIjzF84DALy8vNC+fQcEBATot184fx75eXmorFRjx47tAID4+G7w8/PTb8/Lz0NgQCCioqLgIpfrn5uZmQFRBDp06ICzZ8+islKN2Ng4KJXKenFcvHgBubm5CA8LR0RkJAAgLy8PF86fR/8BAwz2zc3NwcWLF9G//4B6x7lcVIR9+/Zi7qef1YujfWQkzp07h4qKCtzQtSs8PT2hVquRnpYGhUKBuM6d4ebmZnC8S5cKcPHCRXj7+KBTx45QuLrqtw1NTMTf69ZhyJChJuWaiMgULGyJiCyUk5ODfXv3AgDKy8uRnp6GCXdNxPjxdwAAsrKzUFBQAEHQ6PeLjGwPd3d3zP3kY1y8eAFRUR2Rm5sDT09PvPjiy/Dz9wcArP/7b5w/fx4aTTUCA4Nw5coVXLlSjDffehvt2kUAACoqKvDZp58gIyMTsbGxKCoqQqdOnfDEk08BooiPP/4PPvzPR+jQIUof8w/ffw+FQmG0sD10+BD8/f0RFhaub1v/9984e/YM1Go1IiIikJeXj4qKCky5/36s+OUXRES0Q05OLtzd3fD++7Ph5u4OAFi1aiX++P1/6NKlCyorK1FRocZzz7+A0NBQAEC3bt3x64oVKC8rg7ePj8TvDBG1VixsiYgs1H/AAIMe0bNnz+DVV17GwIEDERISikGDBuPYsWMoKy3FM8/+n36/r79aCJkM+PzzL6FwdYVOp8Mnn3yEZd8vw8yZT+v3y8nJxuw5/0FERAREUcQ7b7+FP37/HY/PeAIAsGTJd8jPz8fcTz/T9wLv21dTQIeGhaFr167YvGkTHnjwIQBASUkJDh48iJdeftno+Zw9cwYRERH12vPy8vDhfz5GeHg4BI0GM2c+iW+/WYz//OdjBIeEoKqqCk8+8Ti279iO4cNHQBAErFr5K1566RUk9OgBoKanuLq6Wn/M9u3bQxR1OHPmjH4fIqLm4hhbIqJmqKiowInjx7Fr107k5OTA09MLZ86caXB/rVaLbdu2IiIiEvv378euXTuxe/cuBLQNwPFjxwz2jY+P1xeaMpkMN3TtipycmtkEBI0GO7Zvx9ix4/RFLQD069df//fhI0Zi27ZtEDQaAMDWrVvg5+eH7t0TjMZWWlYKpbL+2Nr4+G4ID6/pxVW4uqJjx07onpCA4JAQAIC7uzvat++A3KuxuchkcHV1xYUL5/U3iIWFhaN9+/b6Y3p5ecHFxQWlZaUN5oqIyFzssSUistDu3buwcMF8hISEIjAwEK6urhAEDUpKShp8TklJCaqqqnDu3DkUFRUZbIuPjzd4rPQ2vETvqlBAc7VILS0thUZTjbDwcDRkwIAb8e0332D//v24ceBApGzehOTkZLi4GO/T8PDwRGlp/diVdW4kc3V1hYeHR7222thc5HI8+dRMLFu6BL/99hu6du2KwYOH4MaBA/X7V1dXQ6fTwdPTs8H4iYjMxcKWiMhC3337De6+516MGXOLvu3hhx4ARLHB53h6ekImk2HEyJEYNGiwxa/t6eUFmUyGsrKyBvdxc3PD0MREbN68EQGBAcjKysKLLxofhgAA4WFhOH0q0+KYrtevX3/069cfubk5OHToEBYunI/Cwku47faxAICCgpp5csMbKcyJiMzFoQhERBbQabUoLS0zKMyOHz9Wr9D08PDQ92QCNYVtXFxnbPjnH4h1CuDLdXpwG1NznDhs3bLFoL20Tm/xyBEjceTIEaxatRLx8d30wweM6dY9AVlZWSgvLzc5DmOqq6r0xwgLC8ctt9yKvv36IyMzQ79PenoaAgMDDW5UIyJqLvbYEhFZwEUuR58+fbDku29x6623obS0FGvXroH71VkBakVHR2PTxg34++918Pb2Rnx8NzwyfTrefustvP3WLNw4cBA01dU4evQoQkJC8NDDj5gcw4MPPYJ33n4Ts2e/j759+qKwqAhHU4/gvfdn6/dp36EDOnWKxsEDB/DUdTemGRMTE4MOHaKwc+cOjB59k1n5uJ66shKvvfoK+vbti/YdOqCoqAh7du/G4zNm6PfZuWMHho8YafFrEBEZw8KWiMhEnaKjDXo8n5r5NNav/xvp6enw8fHBq6+9jm1btxqMex04cBCqq6uRkZ4OtVqNyMj26NAhCp/M/RQpKZtxKjMTPm3aYMwtt6Bnz17658XGxkJXp0c3IiISPXqor8XTqRM+/nguNm3aiPT0dLRr1w6vvPp6vbj79e+PnJxsDDAyxVddE+66Cyt++RkjR46Ci4uL0TjiOneG23Vz0gLADV276m9i8/X1xew5HyIlZTPSTp6E0tsbr73+Ojp37gKgZv7fc+fOGcwUQUQkBZkgaBoeDEZERA5v1huvoX37Dnj4kekm7f/9sqVITEoymP9WSps2boCHhycGDbZ8jDERkTEsbImInFRq6hEcO3YMa9esxsefzEVISKitQyIisirePEZE5KROnDiBkitX8Morr7GoJaJWgT22REREROQU2GNLRERERE6BhS0REREROQUWtkRERETkFFjYEhEREZFTYGFLRERERE6BhS0REREROQUWtkRERETkFFjYEhEREZFTYGFLRERERE7h/wG/vRaUG9FpvgAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "class rows MAC/out min median max latency ms max\n", + "within a family 92 7.8 23.0 373.0 1.72\n", + "across families 52 13.9 46.8 172.3 2.40\n", + "across, flagged ⚑ 38 99.0 253.7 550.6 3.46\n" + ] + } + ], + "source": [ + "CLASSES = [(\"within a family\", \"#2a78d6\", \"o\"),\n", + " (\"across families\", \"#eb6834\", \"s\"),\n", + " (\"across, flagged ⚑\", \"#1baf7a\", \"^\")]\n", + "\n", + "def klass(row):\n", + " if not any(s.startswith(\"bridge\") for s in row[\"stages\"]):\n", + " return 0\n", + " return 2 if row[\"flagged\"] else 1\n", + "\n", + "fig, ax = plt.subplots(figsize=(8, 5.2), facecolor=\"#fcfcfb\")\n", + "ax.set_facecolor(\"#fcfcfb\")\n", + "for k, (label, color, marker) in enumerate(CLASSES):\n", + " pts = [(1e3 * float(TABLE[(r[\"src\"], r[\"dst\"], \"economy\")][1]) / r[\"dst\"],\n", + " float(TABLE[(r[\"src\"], r[\"dst\"], \"economy\")][0])) for r in ROWS if klass(r) == k]\n", + " xs, ys = zip(*pts)\n", + " ax.scatter(xs, ys, s=36, color=color, marker=marker, label=f\"{label} ({len(pts)})\",\n", + " edgecolors=\"#fcfcfb\", linewidths=1.0, zorder=3)\n", + "ax.set_xscale(\"log\"); ax.set_yscale(\"log\")\n", + "ax.set_xlabel(\"latency (ms)\", color=\"#52514e\")\n", + "ax.set_ylabel(\"MACs per output frame per channel\", color=\"#52514e\")\n", + "ax.set_title(\"The 182 rows at economy\", color=\"#0b0b0b\", loc=\"left\")\n", + "ax.grid(True, which=\"major\", color=\"#e1e0d9\", linewidth=0.6, zorder=0)\n", + "for s in ax.spines.values():\n", + " s.set_color(\"#c3c2b7\")\n", + "ax.tick_params(colors=\"#898781\")\n", + "ax.legend(frameon=False, labelcolor=\"#52514e\", loc=\"upper left\")\n", + "plt.show()\n", + "\n", + "print(f\"{'class':20s} {'rows':>4s} {'MAC/out min':>11s} {'median':>8s} {'max':>8s} {'latency ms max':>15s}\")\n", + "for k, (label, _, _) in enumerate(CLASSES):\n", + " rs = [r for r in ROWS if klass(r) == k]\n", + " m = sorted(float(TABLE[(r[\"src\"], r[\"dst\"], \"economy\")][0]) for r in rs)\n", + " l = max(1e3 * float(TABLE[(r[\"src\"], r[\"dst\"], \"economy\")][1]) / r[\"dst\"] for r in rs)\n", + " print(f\"{label:20s} {len(rs):4d} {m[0]:11.1f} {m[len(m) // 2]:8.1f} {m[-1]:8.1f} {l:15.2f}\")" + ] + }, + { + "cell_type": "markdown", + "id": "37df28a4", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "Every row is rebuilt from the shipping libraries; the pinned numbers are reproduced exactly and\n", + "the tone battery's verdicts match the C++ matrix test's (`test_matrix.cpp`, which runs the same\n", + "battery in double). The C ABI's surface, as built, is recorded in `PLAN.md` section 6 (M6) with\n", + "`nm -D`." + ] + } + ], + "metadata": { + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.15" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/rational/notebooks/tap_sr_rational_py.py b/rational/notebooks/tap_sr_rational_py.py new file mode 100644 index 0000000..a03377d --- /dev/null +++ b/rational/notebooks/tap_sr_rational_py.py @@ -0,0 +1,192 @@ +# SPDX-License-Identifier: MIT +# Copyright 2026 Timothy Place and the SampleRateTap contributors +"""ctypes binding to the shipping rational C++ through its C ABI +(capi/tap_sr_rational_capi.h). Family convention: the notebooks measure the +real library, never a Python re-implementation. (Re)builds build_capi/ on +import: the build is incremental, and loading a library left over from an +older checkout would silently measure old code. + + from tap_sr_rational_py import Chain, Stage + c = Chain("down_3_down_8_down_2", profile="economy") + y = c.process(x) # float32 in, float32 out + s = Stage(2, 1, divisor=(147, 160)) # the up 2 after bridge in 48 -> 88.2 + c.latency_output_frames # Fraction + c.macs_per_output # Fraction + +What is constructed is named, never looked up from a rate (D12): a chain is +a chain constant of the C ABI, a stage a ratio of the vocabulary at a design +divisor (PLAN.md 3.1). +""" +import ctypes +import pathlib +import subprocess +import sys +from fractions import Fraction + +ROOT = pathlib.Path(__file__).resolve().parents[1] +BUILD = ROOT / "build_capi" + +CHAINS = [ # the TAP_SR_RATIONAL_* chain constants, in value order + "up_2", "down_2", "up_3", "down_3", "ratio_3_2", "ratio_2_3", "ratio_4_3", "ratio_3_4", + "up_2_up_2", "up_2_up_3", "up_2_ratio_4_3_up_3", "up_3_ratio_8_3", "up_2_up_6", "up_2_up_8", + "up_2_up_6_up_2", "up_2_up_8_up_2", "up_2_up_8_up_3", "up_2_ratio_4_3", "ratio_3_4_down_2", + "down_2_down_2", "down_3_down_2", "down_3_ratio_3_4_down_2", "ratio_3_8_down_3", "down_6_down_2", + "down_8_down_2", "down_2_down_6_down_2", "down_2_down_8_down_2", "down_3_down_8_down_2", +] +PROFILES = {"economy": 0, "transparent": 1, "balanced": 2, "super_economy": 3} + + +def _lib_path(): + names = { + "linux": "libtap_sr_rational_capi.so", + "darwin": "libtap_sr_rational_capi.dylib", + "win32": "tap_sr_rational_capi.dll", + } + for key, name in names.items(): + if sys.platform.startswith(key): + return BUILD / name + return BUILD / "libtap_sr_rational_capi.so" + + +def _run(cmd): + # Quiet on success: the build log would otherwise land in the executed + # notebook's outputs, where it varies with the machine and toolchain. + r = subprocess.run(cmd, capture_output=True, text=True) + if r.returncode != 0: + print(r.stdout) + print(r.stderr, file=sys.stderr) + raise RuntimeError("command failed: " + " ".join(cmd)) + + +def _build(): + _run(["cmake", "-S", str(ROOT / "capi"), "-B", str(BUILD), "-DCMAKE_BUILD_TYPE=Release"]) + _run(["cmake", "--build", str(BUILD), "-j"]) + + +def _load(): + _build() + lib = ctypes.CDLL(str(_lib_path())) + vp, u64, sz = ctypes.c_void_p, ctypes.c_uint64, ctypes.c_size_t + fp = ctypes.POINTER(ctypes.c_float) + pu64 = ctypes.POINTER(ctypes.c_uint64) + pun = ctypes.POINTER(ctypes.c_uint) + sig = { + "create": (vp, [ctypes.c_int, ctypes.c_int, ctypes.c_uint]), + "create_stage": (vp, [ctypes.c_uint, ctypes.c_uint, ctypes.c_int, ctypes.c_uint32, ctypes.c_uint32, + ctypes.c_uint]), + "destroy": (None, [vp]), + "ratio": (None, [vp, pun, pun]), + "outputs_for": (u64, [vp, u64]), + "frames_needed": (u64, [vp, u64]), + "process": (sz, [vp, fp, sz, fp]), + "flush": (sz, [vp, fp]), + "flush_output_frames": (u64, [vp]), + "reset": (None, [vp]), + "latency_output_frames": (None, [vp, pu64, pu64]), + "latency_seconds": (ctypes.c_double, [vp, ctypes.c_double]), + "macs_per_output": (None, [vp, pu64, pu64]), + "stages": (sz, [vp]), + "stage_taps": (sz, [vp, sz]), + "version": (ctypes.c_uint, []), + } + for name, (res, args) in sig.items(): + f = getattr(lib, "tap_sr_rational_" + name) + f.restype = res + f.argtypes = args + return lib + + +_LIB = _load() + + +class _Converter: + """The shared surface of a chain and a stage (float32, interleaved).""" + + def __init__(self, handle, channels): + import numpy as np # local import keeps the binding numpy-optional + + if not handle: + raise ValueError("tap_sr_rational create failed (invalid arguments)") + self._np = np + self._h = handle + self._channels = channels + + def __del__(self): + if getattr(self, "_h", None): + _LIB.tap_sr_rational_destroy(self._h) + self._h = None + + @property + def ratio(self): + l, m = ctypes.c_uint(), ctypes.c_uint() + _LIB.tap_sr_rational_ratio(self._h, ctypes.byref(l), ctypes.byref(m)) + return Fraction(l.value, m.value) + + def _fraction(self, fn): + n, d = ctypes.c_uint64(), ctypes.c_uint64() + fn(self._h, ctypes.byref(n), ctypes.byref(d)) + return Fraction(n.value, d.value) + + @property + def latency_output_frames(self): + return self._fraction(_LIB.tap_sr_rational_latency_output_frames) + + @property + def macs_per_output(self): + return self._fraction(_LIB.tap_sr_rational_macs_per_output) + + def latency_seconds(self, out_rate_hz): + return _LIB.tap_sr_rational_latency_seconds(self._h, out_rate_hz) + + @property + def stage_taps(self): + return [_LIB.tap_sr_rational_stage_taps(self._h, i) for i in range(_LIB.tap_sr_rational_stages(self._h))] + + def outputs_for(self, in_frames): + return _LIB.tap_sr_rational_outputs_for(self._h, in_frames) + + def frames_needed(self, out_frames): + return _LIB.tap_sr_rational_frames_needed(self._h, out_frames) + + def process(self, x): + np = self._np + x = np.ascontiguousarray(x, dtype=np.float32) + frames = len(x) // self._channels + y = np.empty(int(self.outputs_for(frames)) * self._channels, np.float32) + made = _LIB.tap_sr_rational_process( + self._h, + x.ctypes.data_as(ctypes.POINTER(ctypes.c_float)), + frames, + y.ctypes.data_as(ctypes.POINTER(ctypes.c_float)), + ) + return y[: made * self._channels] + + def flush(self): + np = self._np + y = np.empty(int(_LIB.tap_sr_rational_flush_output_frames(self._h)) * self._channels, np.float32) + made = _LIB.tap_sr_rational_flush(self._h, y.ctypes.data_as(ctypes.POINTER(ctypes.c_float))) + return y[: made * self._channels] + + def reset(self): + _LIB.tap_sr_rational_reset(self._h) + + +class Chain(_Converter): + """A named within-family chain (a chain constant of the C ABI).""" + + def __init__(self, name, profile="economy", channels=1): + super().__init__(_LIB.tap_sr_rational_create(CHAINS.index(name), PROFILES[profile], channels), channels) + self.name = name + + +class Stage(_Converter): + """One stage at ratio L/M, designed at the profile relaxed by a divisor.""" + + def __init__(self, L, M, profile="economy", divisor=(1, 1), channels=1): + n, d = divisor + super().__init__(_LIB.tap_sr_rational_create_stage(L, M, PROFILES[profile], n, d, channels), channels) + + +def version(): + v = _LIB.tap_sr_rational_version() + return (v >> 16, (v >> 8) & 0xFF, v & 0xFF) diff --git a/rational/tests/CMakeLists.txt b/rational/tests/CMakeLists.txt index d6d32e8..d36a764 100644 --- a/rational/tests/CMakeLists.txt +++ b/rational/tests/CMakeLists.txt @@ -39,6 +39,14 @@ add_executable(tap_sr_rational_tests if(NOT TAP_SR_BARE_METAL) target_sources(tap_sr_rational_tests PRIVATE test_matrix.cpp) endif() +# The C ABI, pinned against the shipped shared library wherever it is built +# (the version probe, D13, and every enumerator against its named chain); +# the library target is defined after this directory, which +# target_link_libraries allows. +if(TAP_SR_BUILD_CAPI) + target_sources(tap_sr_rational_tests PRIVATE test_capi.cpp) + target_link_libraries(tap_sr_rational_tests PRIVATE tap_sr_rational_capi) +endif() # The tests' own headers (the committed reference vectors, the generated # matrix rows, the bridge adapter) resolve relative to this directory. target_include_directories(tap_sr_rational_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/rational/tests/test_capi.cpp b/rational/tests/test_capi.cpp new file mode 100644 index 0000000..defd35c --- /dev/null +++ b/rational/tests/test_capi.cpp @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: MIT +// Copyright 2026 Timothy Place and the SampleRateTap contributors +// +// The C ABI (capi/tap_sr_rational_capi.h) is the notebooks' seam, so it must +// be the shipping C++ exactly: every chain constant equals its named +// basic_chain bit for bit on the reference noise, with the same exact +// latency, MACs and accounting; the stage constructor equals a stage built +// at the same divisor; invalid arguments return NULL; and the version probe +// pins the family encoding (D13). Links the shipped shared library, so it +// exists wherever TAP_SR_BUILD_CAPI builds one. +#include +#include +#include + +#include + +#include "../capi/tap_sr_rational_capi.h" +#include "reference/reference_vectors.h" +#include "tap/sr/rational/rational.h" + +namespace { + + using namespace tap::sr::rational; // NOLINT(google-build-using-namespace) + using tap::dsp::exact_ratio; + + TEST(CApi, VersionIsBitPacked) { + constexpr unsigned k_packed = + static_cast((TAP_SR_VERSION_MAJOR << 16) | (TAP_SR_VERSION_MINOR << 8) | TAP_SR_VERSION_PATCH); + const unsigned v = tap_sr_rational_version(); + EXPECT_EQ(v, k_packed); + EXPECT_EQ(v, 0x000500u); // 0.5.0 + EXPECT_EQ(v >> 16, 0u); + EXPECT_EQ((v >> 8) & 0xFFu, 5u); + EXPECT_EQ(v & 0xFFu, 0u); + } + + /// The ABI's converter against the C++ chain, on the reference noise + /// (stereo, the second channel negated): outputs bit-identical, the + /// accounting, latency and MACs equal, flush equal. + template + void expect_abi_is_the_chain(tap_sr_rational_converter* c, Chain& ref, const char* name) { + ASSERT_NE(c, nullptr) << name; + const auto& n = rational_ref::k_input; + std::vector x(2 * n.size()); + for (std::size_t i = 0; i < n.size(); ++i) { + x[2 * i] = n[i]; + x[2 * i + 1] = -n[i]; + } + unsigned l = 0, m = 0; + tap_sr_rational_ratio(c, &l, &m); + EXPECT_EQ(l, Chain::k_up) << name; + EXPECT_EQ(m, Chain::k_down) << name; + EXPECT_EQ(tap_sr_rational_stages(c), Chain::k_stages) << name; + EXPECT_EQ(tap_sr_rational_stage_taps(c, 0), ref.template stage<0>().taps()) << name; + EXPECT_EQ(tap_sr_rational_stage_taps(c, Chain::k_stages), 0u) << name; + const std::uint64_t fresh = tap_sr_rational_outputs_for(c, n.size()); + EXPECT_EQ(fresh, ref.outputs_for(n.size())) << name; + EXPECT_EQ(tap_sr_rational_frames_needed(c, 100), ref.frames_needed(100)) << name; + std::uint64_t num = 0, den = 0; + tap_sr_rational_latency_output_frames(c, &num, &den); + EXPECT_EQ((exact_ratio{num, den}), ref.latency_output_frames()) << name; + EXPECT_DOUBLE_EQ(tap_sr_rational_latency_seconds(c, 48000.0), ref.latency_seconds(48000.0)) << name; + + std::vector ya(2 * ref.outputs_for(n.size())); + std::vector yb(ya.size()); + ASSERT_EQ(tap_sr_rational_process(c, x.data(), n.size(), ya.data()), ya.size() / 2) << name; + ASSERT_EQ(ref.process(x.data(), n.size(), yb.data()), yb.size() / 2) << name; + EXPECT_TRUE(ya == yb) << name; + const std::size_t tail = static_cast(tap_sr_rational_flush_output_frames(c)); + std::vector fa(2 * tail); + std::vector fb(2 * ref.flush_output_frames()); + ASSERT_EQ(fa.size(), fb.size()) << name; + EXPECT_EQ(tap_sr_rational_flush(c, fa.data()), tail) << name; + ref.flush(fb.data()); + EXPECT_TRUE(fa == fb) << name; + tap_sr_rational_reset(c); + EXPECT_EQ(tap_sr_rational_outputs_for(c, n.size()), fresh) << name << ": reset returns to the fresh state"; + } + + template + void expect_named(int chain, const char* name) { + tap_sr_rational_converter* c = tap_sr_rational_create(chain, 0, 2); + basic_chain ref(2, profile::economy()); + expect_abi_is_the_chain(c, ref, name); + std::uint64_t num = 0, den = 0; + tap_sr_rational_macs_per_output(c, &num, &den); + EXPECT_EQ((exact_ratio{num, den}), ref.macs_per_output_exact()) << name; + tap_sr_rational_destroy(c); + } + + TEST(CApi, EveryChainEnumeratorIsItsNamedChain) { + expect_named(TAP_SR_RATIONAL_UP_2, "up_2"); + expect_named(TAP_SR_RATIONAL_DOWN_2, "down_2"); + expect_named(TAP_SR_RATIONAL_UP_3, "up_3"); + expect_named(TAP_SR_RATIONAL_DOWN_3, "down_3"); + expect_named(TAP_SR_RATIONAL_RATIO_3_2, "ratio_3_2"); + expect_named(TAP_SR_RATIONAL_RATIO_2_3, "ratio_2_3"); + expect_named(TAP_SR_RATIONAL_RATIO_4_3, "ratio_4_3"); + expect_named(TAP_SR_RATIONAL_RATIO_3_4, "ratio_3_4"); + expect_named(TAP_SR_RATIONAL_UP_2_UP_2, "up_2_up_2"); + expect_named(TAP_SR_RATIONAL_UP_2_UP_3, "up_2_up_3"); + expect_named(TAP_SR_RATIONAL_UP_2_RATIO_4_3_UP_3, "up_2_ratio_4_3_up_3"); + expect_named(TAP_SR_RATIONAL_UP_3_RATIO_8_3, "up_3_ratio_8_3"); + expect_named(TAP_SR_RATIONAL_UP_2_UP_6, "up_2_up_6"); + expect_named(TAP_SR_RATIONAL_UP_2_UP_8, "up_2_up_8"); + expect_named(TAP_SR_RATIONAL_UP_2_UP_6_UP_2, "up_2_up_6_up_2"); + expect_named(TAP_SR_RATIONAL_UP_2_UP_8_UP_2, "up_2_up_8_up_2"); + expect_named(TAP_SR_RATIONAL_UP_2_UP_8_UP_3, "up_2_up_8_up_3"); + expect_named(TAP_SR_RATIONAL_UP_2_RATIO_4_3, "up_2_ratio_4_3"); + expect_named(TAP_SR_RATIONAL_RATIO_3_4_DOWN_2, "ratio_3_4_down_2"); + expect_named(TAP_SR_RATIONAL_DOWN_2_DOWN_2, "down_2_down_2"); + expect_named(TAP_SR_RATIONAL_DOWN_3_DOWN_2, "down_3_down_2"); + expect_named(TAP_SR_RATIONAL_DOWN_3_RATIO_3_4_DOWN_2, "down_3_ratio_3_4_down_2"); + expect_named(TAP_SR_RATIONAL_RATIO_3_8_DOWN_3, "ratio_3_8_down_3"); + expect_named(TAP_SR_RATIONAL_DOWN_6_DOWN_2, "down_6_down_2"); + expect_named(TAP_SR_RATIONAL_DOWN_8_DOWN_2, "down_8_down_2"); + expect_named(TAP_SR_RATIONAL_DOWN_2_DOWN_6_DOWN_2, "down_2_down_6_down_2"); + expect_named(TAP_SR_RATIONAL_DOWN_2_DOWN_8_DOWN_2, "down_2_down_8_down_2"); + expect_named(TAP_SR_RATIONAL_DOWN_3_DOWN_8_DOWN_2, "down_3_down_8_down_2"); + EXPECT_EQ(TAP_SR_RATIONAL_CHAIN_COUNT, 28); + } + + TEST(CApi, ProfilesSelectTheirDesigns) { + const int tags[] = {0, 1, 2, 3}; + const profile ps[] = {profile::economy(), profile::transparent(), profile::balanced(), + profile::super_economy()}; + for (std::size_t i = 0; i < 4; ++i) { + tap_sr_rational_converter* c = tap_sr_rational_create(TAP_SR_RATIONAL_DOWN_2, tags[i], 1); + ASSERT_NE(c, nullptr); + EXPECT_EQ(tap_sr_rational_stage_taps(c, 0), (basic_stage(1, ps[i]).taps())) << i; + tap_sr_rational_destroy(c); + } + } + + /// The stage constructor is a stage at profile.relaxed(divisor). + template + void expect_stage(unsigned l, unsigned m, int tag, const profile& p, exact_ratio d) { + tap_sr_rational_converter* c = tap_sr_rational_create_stage(l, m, tag, static_cast(d.num), + static_cast(d.den), 2); + tap::dsp::chain> ref(2, basic_stage(2, p.relaxed(d))); + expect_abi_is_the_chain(c, ref, "stage"); + std::uint64_t num = 0, den = 0; + tap_sr_rational_macs_per_output(c, &num, &den); + const auto& s = ref.template stage<0>(); + EXPECT_EQ((exact_ratio{num, den}), (exact_ratio{s.macs_per_superblock(), R::k_up == 1 ? 1 : R::k_up})); + tap_sr_rational_destroy(c); + } + + TEST(CApi, StageConstructorIsTheStageAtTheDivisor) { + expect_stage(2, 1, 0, profile::economy(), exact_ratio{147, 160}); // 48 -> 88.2's up 2 after bridge + expect_stage(1, 2, 1, profile::transparent(), exact_ratio{2, 1}); + expect_stage(8, 1, 0, profile::economy(), exact_ratio{2, 1}); + expect_stage(3, 4, 0, profile::economy(), exact_ratio{2, 1}); + expect_stage(2, 3, 2, profile::balanced(), exact_ratio{1, 1}); + expect_stage(1, 6, 3, profile::super_economy(), exact_ratio{9, 1}); // a searched divisor + } + + TEST(CApi, InvalidArgumentsReturnNull) { + EXPECT_EQ(tap_sr_rational_create(-1, 0, 1), nullptr); + EXPECT_EQ(tap_sr_rational_create(TAP_SR_RATIONAL_CHAIN_COUNT, 0, 1), nullptr); + EXPECT_EQ(tap_sr_rational_create(0, 4, 1), nullptr); + EXPECT_EQ(tap_sr_rational_create(0, -1, 1), nullptr); + EXPECT_EQ(tap_sr_rational_create(0, 0, 0), nullptr); + EXPECT_EQ(tap_sr_rational_create_stage(4, 1, 0, 1, 1, 1), nullptr); // no single 4th-band stage (decision 6) + EXPECT_EQ(tap_sr_rational_create_stage(5, 1, 0, 1, 1, 1), nullptr); + EXPECT_EQ(tap_sr_rational_create_stage(2, 2, 0, 1, 1, 1), nullptr); + EXPECT_EQ(tap_sr_rational_create_stage(2, 1, 0, 0, 1, 1), nullptr); + EXPECT_EQ(tap_sr_rational_create_stage(2, 1, 0, 1, 0, 1), nullptr); + EXPECT_EQ(tap_sr_rational_create_stage(2, 1, 9, 1, 1, 1), nullptr); + EXPECT_EQ(tap_sr_rational_create_stage(258, 1, 0, 1, 1, 1), nullptr); + tap_sr_rational_destroy(nullptr); // the free() convention + } + +} // namespace diff --git a/rational/tests/test_ratio.cpp b/rational/tests/test_ratio.cpp index 1e09b83..a2931ba 100644 --- a/rational/tests/test_ratio.cpp +++ b/rational/tests/test_ratio.cpp @@ -22,7 +22,7 @@ namespace { TEST(Ratio, VersionIsTheFamilys) { EXPECT_EQ(TAP_SR_VERSION_MAJOR, 0); - EXPECT_EQ(TAP_SR_VERSION_MINOR, 4); + EXPECT_EQ(TAP_SR_VERSION_MINOR, 5); // 0.5.0 from M6 (R11) EXPECT_EQ(TAP_SR_VERSION_PATCH, 0); } diff --git a/scripts/icount.py b/scripts/icount.py index cf31576..e90e2d8 100644 --- a/scripts/icount.py +++ b/scripts/icount.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: MIT # Copyright 2026 Timothy Place and the SampleRateTap contributors -"""Deterministic instruction-count ratchet for both engines. +"""Deterministic instruction-count ratchet for every engine. Runs every workload binary of one engine in a build directory under QEMU with the instruction-counting plugin (tools/qemu_insn_plugin), then @@ -9,7 +9,7 @@ (async/docs/PERFORMANCE.md, bridge/PLAN.md section 7). icount.py --target {hexagon,m55,m33} --build-dir DIR --plugin LIB - [--engine {async,bridge}] [--baselines FILE] [--tolerance 0.03] + [--engine {async,bridge,rational}] [--baselines FILE] [--tolerance 0.03] [--exact] [--update] [--json-out FILE] [--compare-json FILE] --engine (default async) selects the workload binaries, the guest's @@ -43,11 +43,12 @@ # Per engine: the workload binary prefix and the completion marker the # guest prints. The guest markers are kept byte-identical to the two -# repositories' originals on purpose: they are part of what the counted -# binaries execute. +# repositories' originals on purpose (rational's from its first baseline): +# they are part of what the counted binaries execute. ENGINES = { "async": {"prefix": "tap_sr_async_icount_", "done": "SRT_ICOUNT_DONE"}, "bridge": {"prefix": "tap_sr_bridge_icount_", "done": "RATIO_ICOUNT_DONE"}, + "rational": {"prefix": "tap_sr_rational_icount_", "done": "RATIONAL_ICOUNT_DONE"}, } # Printed by the host-side plugin; never affects the guest's count. COUNT_MARKER = "TAP_SR_INSN_COUNT" diff --git a/scripts/update_icount_docs.py b/scripts/update_icount_docs.py index a3a81a1..a102a3f 100644 --- a/scripts/update_icount_docs.py +++ b/scripts/update_icount_docs.py @@ -3,7 +3,7 @@ # Copyright 2026 Timothy Place and the SampleRateTap contributors """Regenerate an engine README's instruction-count table from its baselines. -Usage: scripts/update_icount_docs.py [--engine async|bridge] +Usage: scripts/update_icount_docs.py [--engine async|bridge|rational] Each engine's table lives between the ICOUNT markers in /README.md and is generated from /bench/baselines.json. Run from the @@ -43,7 +43,7 @@ def table(baselines: dict, engine: str) -> str: def main() -> int: ap = argparse.ArgumentParser() - ap.add_argument("--engine", choices=["async", "bridge"], default="async") + ap.add_argument("--engine", choices=["async", "bridge", "rational"], default="async") args = ap.parse_args() readme = pathlib.Path(args.engine) / "README.md" baselines = json.loads((pathlib.Path(args.engine) / "bench" / "baselines.json").read_text()) diff --git a/tests/family/version_macros.cpp b/tests/family/version_macros.cpp index aa5f06e..9c1639a 100644 --- a/tests/family/version_macros.cpp +++ b/tests/family/version_macros.cpp @@ -31,5 +31,5 @@ static_assert(k_async_major == TAP_SR_VERSION_MAJOR && k_async_minor == TAP_SR_V static_assert(k_async_major == TAP_SR_VERSION_MAJOR && k_async_minor == TAP_SR_VERSION_MINOR && k_async_patch == TAP_SR_VERSION_PATCH, "TAP_SR_VERSION_* must be identical in async.h and rational.h (D13)"); -static_assert(k_async_major == 0 && k_async_minor == 4 && k_async_patch == 0, - "the family version is 0.4.0 (D13); re-pin here when it is bumped"); +static_assert(k_async_major == 0 && k_async_minor == 5 && k_async_patch == 0, + "the family version is 0.5.0 (D13); re-pin here when it is bumped");