Build and Validate the C libm Library with PRIK¶
libm is the platform's compiled C mathematics library. This example wraps
60 reviewed ISO C99 functions and validates every one with a named numerical
test. PRIK reads their declarations from the active toolchain's <math.h> and
links the already compiled platform library; it does not vendor or compile the
math functions' implementation sources.
The build regenerates the semantic .pyi from that header for the active C
compiler and target.
It follows the maintained real-library example structure: a reviewed native surface, copyable build scripts, a grouped routine inventory, fail-closed coverage audits, numerical tests, documentation, and CI execution.
What this example shows¶
- Generate a target-specific contract from the platform's own
<math.h>and a reviewed function allowlist. - Link an existing system library without vendoring or compiling its sources.
- Preserve exact native
long,long long, andintidentities while keeping ordinary NumPy types in the public Python signature. - Test every exported function and audit the inventory against the built module.
Read C support and the CLI reference first if the C workflow is new to you. For a maintained C example built around NumPy arrays and an edited semantic contract, see TA-Lib.
Versions used¶
| Component | Version / source |
|---|---|
| PRIK | current repository checkout |
| libm | the target's C standard math library |
| Native language | C |
| PRIK declaration input | the target toolchain's <math.h> through libm_probe.h |
| Link input | the platform's already compiled math library |
| Python | 3.12 in the dedicated CI job |
| NumPy | 2.5.1 in CI |
| C compiler | target-specific; see Tested platforms |
The declarations selected by the example are ISO C99. The generated contract, NumPy dtypes, compiler, and library link remain target-specific.
1. Prepare the repository and toolchain¶
git clone https://github.com/PyNumLab/prik.git
cd prik
python3 -m venv .venv
. .venv/bin/activate
python3 -m pip install --upgrade pip
python3 -m pip install -e ".[qa]" "numpy==2.5.1"
Install a C compiler and the Python development headers. On Ubuntu:
sudo apt-get update
sudo apt-get install --yes build-essential python3-dev
All remaining commands run from the repository root. The runnable project is
under examples/c/libm/.
2. Review the selected API¶
libm_probe.h contains only
#include <math.h>, so the active toolchain supplies every declaration.
iso_c99_routines.txt is the
reviewed 60-function public surface. The export allowlist excludes the rest of
the platform header and fails if a requested ISO C99 function is missing.
Generate the contract for the active target with:
mkdir -p build
python3 -m prik generate --pyi --language c examples/c/libm/libm_probe.h \
--compiler "$(command -v cc)" \
--std c99 \
--include-exposure roots-only \
--export-symbols examples/c/libm/iso_c99_routines.txt \
--out build/libm_api.pyi
The compiler probe maps the C types to target-sized public contract dtypes. The
generated @native_call expressions retain an exact C scalar type wherever
normalization would otherwise erase a distinction needed by the declaration.
Macros are not part of this surface. If an API must expose a macro, provide an ordinary native function that evaluates it and wrap that function.
frexp, modf, and remquo are excluded because their output pointers need
an authored direction/projection contract. nan needs authored string
semantics, and non-ISO Bessel extensions are outside the reviewed ISO C99
selection.
3. Build the wrapper¶
The maintained script parses the C declarations in <math.h>, generates the
target contract, compiles PRIK's binding code, and links that binding with the
existing compiled libm. It does not compile libm's implementation:
export EXAMPLE_WORKSPACE="$PWD"
export LIBM_BUILD_ROOT="$(mktemp -d)"
LIBM_COMPILER="${PRIK_LIBM_CC:-cc}"
if ! LIBM_COMPILER_PATH="$(command -v "$LIBM_COMPILER")"; then
echo "libm example: C compiler not found: $LIBM_COMPILER" >&2
return 1 2>/dev/null || exit 1
fi
export LIBM_COMPILER_PATH
mkdir -p "$LIBM_BUILD_ROOT/prik/contract" "$LIBM_BUILD_ROOT/prik/generated"
cd "$LIBM_BUILD_ROOT/prik"
if ! python3 -m prik generate --pyi --language c \
"$EXAMPLE_WORKSPACE/examples/c/libm/libm_probe.h" \
--compiler "$LIBM_COMPILER_PATH" \
--std c99 \
--include-exposure roots-only \
--export-symbols "$EXAMPLE_WORKSPACE/examples/c/libm/iso_c99_routines.txt" \
--out "$LIBM_BUILD_ROOT/prik/contract/libm_api.pyi"; then
return 1 2>/dev/null || exit 1
fi
if ! python3 -m prik --language c "$LIBM_BUILD_ROOT/prik/contract/libm_api.pyi" \
--out prik_reference_libm \
--out-dir "$LIBM_BUILD_ROOT/prik/generated" \
--compiler "$LIBM_COMPILER_PATH" \
--native-library m \
--positional-only \
--collision-adapter-all; then
return 1 2>/dev/null || exit 1
fi
For normal use, source the convenience entrypoint:
source examples/c/libm/build_all.sh
It also exports the built extension directory on PYTHONPATH for the current
shell.
4. Understand exact native scalar types¶
On an LP64 target, C long and long long may both map to public Int64, but
they remain distinct C types. A target-generated contract keeps the native
result declaration explicitly when needed:
@native_call([Arg(0)], result=CLongLong(Return(0)))
def llrint(x: Float64) -> Int64: ...
The expression's position determines its direction. Inside the native argument
list, a cast describes a native parameter. In result=..., it declares the
native function result, which the binding converts into Python result slot 0.
The binding therefore declares llrint as returning long long, receives that
value, and converts it to the public Int64 storage. lrint similarly retains
C long, whose public result may be Int32 or Int64 on different targets.
When the target's canonical fixed-width typedef is already a typedef of
long, no CLong expression is needed; otherwise generation emits one even
when the two C types have the same width. These sparse casts preserve ABI type
identity. The separate --collision-adapter-all mechanism prevents selected
math.h declarations from colliding with identifiers in Python's headers.
LTO is optional and is deliberately not required by this example.
5. Run the complete test suite¶
python3 -m pytest -q examples/c/libm/tests
The inventory contains exactly 60 routines:
| Family | Routines |
|---|---|
| Trigonometric | 7 |
| Hyperbolic | 6 |
| Exponential and logarithmic | 7 |
| Power and roots | 4 |
| Rounding, truncation, and remainder | 12 |
| Floating-point manipulation | 13 |
| Error and gamma functions | 4 |
| Single and extended precision | 7 |
| Total | 60 |
6. See how results are validated¶
Tests compare Python's math module where it has the same operation and use
independent identities elsewhere. The complete elementary group demonstrates
the NumPy scalar boundary, tolerance-based transcendental comparisons, exact
results where the operation permits them, and the precision benefit of
specialized operations such as expm1:
def test_elementary(libm):
assert np.isclose(libm.sin(np.float64(1.0)), math.sin(1.0), rtol=DOUBLE_TOLERANCE, atol=DOUBLE_TOLERANCE)
assert np.isclose(libm.cos(np.float64(1.0)), math.cos(1.0), rtol=DOUBLE_TOLERANCE, atol=DOUBLE_TOLERANCE)
assert np.isclose(libm.tan(np.float64(0.5)), math.tan(0.5), rtol=DOUBLE_TOLERANCE, atol=DOUBLE_TOLERANCE)
assert np.isclose(libm.asin(np.float64(0.5)), math.asin(0.5), rtol=DOUBLE_TOLERANCE, atol=DOUBLE_TOLERANCE)
assert np.isclose(libm.acos(np.float64(0.5)), math.acos(0.5), rtol=DOUBLE_TOLERANCE, atol=DOUBLE_TOLERANCE)
assert np.isclose(libm.atan(np.float64(0.5)), math.atan(0.5), rtol=DOUBLE_TOLERANCE, atol=DOUBLE_TOLERANCE)
assert np.isclose(
libm.atan2(np.float64(1.0), np.float64(2.0)),
math.atan2(1.0, 2.0),
rtol=DOUBLE_TOLERANCE,
atol=DOUBLE_TOLERANCE,
)
assert np.isclose(libm.sinh(np.float64(0.75)), math.sinh(0.75), rtol=DOUBLE_TOLERANCE, atol=DOUBLE_TOLERANCE)
assert np.isclose(libm.cosh(np.float64(0.75)), math.cosh(0.75), rtol=DOUBLE_TOLERANCE, atol=DOUBLE_TOLERANCE)
assert np.isclose(libm.tanh(np.float64(0.75)), math.tanh(0.75), rtol=DOUBLE_TOLERANCE, atol=DOUBLE_TOLERANCE)
assert np.isclose(libm.asinh(np.float64(0.75)), math.asinh(0.75), rtol=DOUBLE_TOLERANCE, atol=DOUBLE_TOLERANCE)
assert np.isclose(libm.acosh(np.float64(1.75)), math.acosh(1.75), rtol=DOUBLE_TOLERANCE, atol=DOUBLE_TOLERANCE)
assert np.isclose(libm.atanh(np.float64(0.75)), math.atanh(0.75), rtol=DOUBLE_TOLERANCE, atol=DOUBLE_TOLERANCE)
assert np.isclose(libm.exp(np.float64(1.0)), math.e, rtol=DOUBLE_TOLERANCE, atol=DOUBLE_TOLERANCE)
# exp2 is exact on a whole exponent, so no tolerance is needed.
assert libm.exp2(np.float64(10.0)) == 1024.0
# expm1 keeps the precision that exp(x) - 1 loses for small x.
assert np.isclose(
libm.expm1(np.float64(1e-9)),
math.expm1(1e-9),
rtol=DOUBLE_TOLERANCE,
atol=DOUBLE_TOLERANCE,
)
assert libm.expm1(np.float64(1e-9)) != math.exp(1e-9) - 1.0
assert np.isclose(libm.log(np.float64(math.e)), 1.0, rtol=DOUBLE_TOLERANCE, atol=DOUBLE_TOLERANCE)
assert libm.log2(np.float64(1024.0)) == 10.0
assert np.isclose(libm.log10(np.float64(1000.0)), 3.0, rtol=DOUBLE_TOLERANCE, atol=DOUBLE_TOLERANCE)
assert np.isclose(libm.log1p(np.float64(1e-9)), math.log1p(1e-9), rtol=DOUBLE_TOLERANCE, atol=DOUBLE_TOLERANCE)
assert libm.pow(np.float64(2.0), np.float64(10.0)) == 1024.0
assert libm.sqrt(np.float64(144.0)) == 12.0
assert np.isclose(libm.cbrt(np.float64(27.0)), 3.0, rtol=DOUBLE_TOLERANCE, atol=DOUBLE_TOLERANCE)
assert libm.hypot(np.float64(3.0), np.float64(4.0)) == 5.0
Precision is asserted rather than assumed. The suite checks float results as
float32, follows the target representation for long double, derives C
int and C long NumPy dtypes from the running target, and checks supported
long long results. Rounding-sensitive functions are compared under the
active floating-point mode, transcendental results use tolerances, and fma
is checked for one fused rounding.
On Apple ARM64, C long double has the same 64-bit storage width as double,
so the generated public contract uses Float64 and the example passes
numpy.float64. A target with wider long double storage instead uses
Float128 and numpy.longdouble; the native declaration remains long double
in either case. The generated .pyi is the authority for that public dtype,
while CLongDouble in @native_call directs the private scalar conversion and
does not add a second accepted Python dtype. The numerical tests use the dtype
named by the generated sinl annotation.
7. Run focused examples¶
python3 -m pytest -q examples/c/libm/tests/test_numerical.py::test_special
python3 -m pytest -q examples/c/libm/tests/test_numerical.py::test_rounding
python3 -m pytest -q examples/c/libm/tests/test_numerical.py::test_precision
- Platform declaration probe →
libm_probe.h - Reviewed function selection →
iso_c99_routines.txt - Public routine list →
routine_inventory.py - Routine coverage checks →
test_routine_coverage.py - Copyable project instructions →
examples/c/libm/README.md
Troubleshooting¶
- Confirm that
ccis onPATHand Python development headers are installed. - Set
PRIK_LIBM_CCto use a compiler other thancc. - Use
source examples/c/libm/build_all.sh; a child shell cannot preserve its exportedPYTHONPATH. - The
--native-library mspelling is platform build configuration. If the target exposes its C math symbols without a separate libm, adjust that link item for the target. - Keep
--collision-adapter-allwhen regenerating this wrapper; it isolates any selectedmath.hidentifier already declared by a binding header.
Tested platforms¶
The Real Libraries Portability workflow builds and runs the complete 60-function suite twice on every hosted target with Python 3.12:
| Operating system | Architectures | C compilers |
|---|---|---|
| Linux | x86-64, ARM64 | GCC 13 and Clang 18 |
| macOS | Intel, ARM64 | Apple Clang and GNU GCC 13 |
Every compiler and target combination exercises the target's own math.h,
libm, scalar probe, generated contract, collision adapter, and numerical tests.
Native Windows/MSVC remains outside PRIK's current POSIX C build support. See
the complete portability
matrix.
Source provenance¶
There are no vendored implementation sources or copied prototypes. The example
parses the target's math.h and links its math library through the reviewed
ISO C99 name selection.