# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_core.ipynb.

# %% auto 0
__all__ = ['T', 'S', 'P', 'R_co', 'NumberQ', 'Numbers', 'NumbersQ', 'NumberLike', 'ismap', 'isnone', 'notnone', 'isnumber',
           'istuple', 'supports_variadic', 'haslen', 'hasiter', 'unpackable', 'UnpackError', 'method', 'identity',
           'identitymethod', 'instancemethod', 'extent', 'detuple', 'unpack_then_call', 'detuple_then_call',
           'normalize_then_call', 'norm_op_then_map', 'map_op_zip', 'nextnone', 'fill', 'join', 'num_or_zero',
           'slice2range', 'itrs', 'iidx', 'islc', 'iteritem', 'iaxis', 'iterpair', 'detuple_args', 'unpack_args',
           'normalize_args', 'mapped', 'map_op', 'reduce_op', 'pairwise_op', 'boolop', 'pairwise_bool', 'zipped_bool',
           'booly', 'mapsum', 'mapmax', 'mapmin', 'any_eq', 'any_ge', 'any_gt', 'all_le', 'reduce_eq', 'reduce_ne',
           'reduce_gt', 'reduce_ge', 'reduce_lt', 'reduce_le', 'reduce_pow', 'reduce_add', 'reduce_sub', 'reduce_mul',
           'reduce_abs', 'reduce_is_', 'reduce_is_not', 'reduce_concat', 'reduce_floordiv', 'reduce_min', 'reduce_max',
           'reduce_sum', 'pairwise_eq', 'pairwise_ne', 'pairwise_gt', 'pairwise_ge', 'pairwise_lt', 'pairwise_le',
           'pairwise_pow', 'pairwise_add', 'pairwise_sub', 'pairwise_mul', 'pairwise_abs', 'pairwise_is_',
           'pairwise_is_not', 'all_ge_pw_zip', 'all_pw_eq', 'all_pw_ne', 'all_pw_gt', 'all_pw_ge', 'all_pw_lt',
           'all_pw_le', 'any_pw_eq', 'any_pw_ne', 'any_pw_gt', 'any_pw_ge', 'any_pw_lt', 'any_pw_le', 'all_cb_eq',
           'all_cb_ne', 'all_cb_gt', 'all_cb_ge', 'all_cb_lt', 'all_cb_le', 'any_cb_eq', 'any_cb_ne', 'any_cb_gt',
           'any_cb_ge', 'any_cb_lt', 'any_cb_le', 'alltrue', 'allfalse', 'alltruthy', 'anytrue', 'anyfalse',
           'anytruthy', 'reduce_all', 'reduce_any', 'reduce_all_pw_eq', 'reduce_any_pw_eq', 'reduce_all_pw_ne',
           'reduce_any_pw_ne', 'reduce_all_pw_ge', 'reduce_any_pw_ge', 'map_eq', 'map_ne', 'map_gt', 'map_ge', 'map_lt',
           'map_le', 'map_pow', 'map_add', 'map_sub', 'map_mul', 'map_abs', 'map_is_', 'map_is_not', 'map_concat',
           'map_floordiv', 'map_min', 'map_max', 'map_sum', 'map_round', 'map_all', 'map_any', 'increment', 'stepper',
           'zipr']

# %% ../nbs/00_core.ipynb 6
import sys, math, operator, itertools
from inspect import signature, getfullargspec
from functools import wraps, partial, reduce, singledispatch
from itertools import (
    takewhile, count, accumulate, repeat, islice, zip_longest, pairwise, chain, combinations, permutations
)
from _collections_abc import dict_keys, dict_values, dict_items

# %% ../nbs/00_core.ipynb 8
from types import FunctionType, BuiltinFunctionType, SimpleNamespace
from typing import (
    Any, overload, Literal,
    Callable, Iterable, Iterator, Generic, Concatenate, 
    TypeAlias, TypeGuard, TypeVar, ParamSpec, Generator, 
)
from numbers import Number

# %% ../nbs/00_core.ipynb 10
#| export


# %% ../nbs/00_core.ipynb 12
#| export


# %% ../nbs/00_core.ipynb 14
#| export


# %% ../nbs/00_core.ipynb 16
__DOC__ = '__doc__'
__LEN__ = '__len__'
__ITER__ = '__iter__'
__NAME__ = '__name__'
__MODULE__ = '__module__'
__QUALNAME__ = '__qualname__' 
__ANNOTATIONS__ = '__annotations__' 
__TYPE_PARAMS__ = '__type_params__' 


_WRAP_OP = (__ANNOTATIONS__, __DOC__)
_WRAP_FN = (__MODULE__, __NAME__, __QUALNAME__, __TYPE_PARAMS__)

# %% ../nbs/00_core.ipynb 18
T = TypeVar('T')
'''Type variable `T`: Represents a generic type for input values.''';

S = TypeVar('S')
'''Type variable `S`: Represents a generic type, typically used for output or intermediate values in transformations.''';

P = ParamSpec('P')
'''Parameter specification `P`: Captures the types of input parameters for callable objects.''';

R_co = TypeVar('R_co', covariant=True)
'''Covariant type variable `R_co`: Used to indicate that a return type is covariantly related to the input types, allowing for subtype substitution.''';


# %% ../nbs/00_core.ipynb 20
NumberQ: TypeAlias = Number | None
'''`Number | None`''';

Numbers: TypeAlias = tuple[Number, ...]
'''`tuple[Number, ...]`''';

NumbersQ: TypeAlias = tuple[Number, ...] | None
'''`tuple[Number, ...] | None`''';

NumberLike: TypeAlias = Number | tuple[Number, ...] | None
'''`Number | tuple[Number, ...] | None`''';

# %% ../nbs/00_core.ipynb 22
def ismap(x) -> TypeGuard[map]:
    '''Checks if `x` is `map`.'''
    return isinstance(x, map)

def isnone(x) -> TypeGuard[None]:
    '''Checks if `x` is `None`.'''
    return x is None

def notnone(x) -> TypeGuard[Any]:
    '''Checks if `x` is not `None`.'''
    return not isnone(x)

def isnumber(x) -> TypeGuard[Number]:
    '''Checks if `x` is a `Number`.'''
    return isinstance(x, Number)

def istuple(x) -> TypeGuard[tuple]:
    '''Checks if `x` is a `tuple`.'''
    return isinstance(x, tuple)

# %% ../nbs/00_core.ipynb 23
def supports_variadic(fn: Callable) -> bool:
    return callable(fn) and getfullargspec(fn).varargs is not None

def haslen(obj: object) -> bool: 
    return hasattr(obj, __LEN__)

def hasiter(obj: object) -> bool: 
    return hasattr(obj, __ITER__)

def unpackable(obj: tuple, fn: Callable | None = None):
    if supports_variadic(fn) or (isnone(fn) and (haslen(obj) or hasiter(obj))): 
        return True
    return False

# %% ../nbs/00_core.ipynb 25
class UnpackError(Exception):
    def __init__(self, fn: Callable, unpack: bool = True, *args: object) -> None:
        super().__init__(fn, unpack, *args)
        self.fn = fn
        self.un = bool(unpack)
        
        fnname = getattr(fn, '__name__', str(fn))
        upkstr = 'unpacked' if self.un else 'packed'
        self.msg = f'Can not call fn {fnname} with {upkstr} args {args}.'        
    
    def __str__(self) -> str: 
        return f'{type(self).__name__}({self.msg})'

# %% ../nbs/00_core.ipynb 27
class method(Generic[T, P, R_co]):
    __name__: str
    __qualname__: str
    def __init__(self, __f: Callable[Concatenate[type[T], P], R_co], *args: P.args, **kwargs: P.kwargs) -> None:
        setattr(self, '__f', __f or self.__call__)

    def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R_co:
        return args, kwargs
    
def identity(*args: P.args, **kwargs: P.kwargs) -> R_co:
    return args, kwargs

class identitymethod(metaclass=method):
    ...
    
class instancemethod(method):
    '''Method namespace so instancemethods and classmethods work the same.'''
    def __init__(self, __f: Callable[Concatenate[type[T], P], R_co], *args: P.args, **kwargs: P.kwargs) -> None:
        # print(__f, args, kwargs)
        setattr(self, '__f', __f)
        
    def __get__(self, __instance: T | None, __owner: type[T], *args: P.args, **kwargs: P.kwargs) -> Callable[P, R_co]:
        ins = __instance
        cls = __owner
        fn = getattr(self, '__f', identitymethod)
        
        @wraps(fn)
        def wrapper(arg1: T | Any | None = None, /, *args: P.args, **kwargs: P.kwargs) -> S:
            kwds = {k: v for k, v in kwargs.items() if k in signature(cls.__init__).parameters}
            okws = {k: v for k, v in kwargs.items() if k not in kwds}
            if arg1 is None and isinstance(ins, cls):
                inst = ins
            elif arg1 == cls:
                inst = cls(*args, **kwds)
            else: 
                inst = cls(arg1, *args, **kwds)
            return fn(inst, **okws)
        return wrapper

# %% ../nbs/00_core.ipynb 30
def extent(it: Iterable[Number]) -> tuple[Number, Number]:
    '''Get the (min, max) of the `it`.'''
    return min(it), max(it)

# %% ../nbs/00_core.ipynb 33
def detuple(arg: tuple | Any | None = None, /, *args: P.args) -> tuple:
    '''
    Flatten inputs by converting nested tuples, ranges, dict_keys, dict_values,
    dict_items, Generators, and Iterables into a single tuple. This function
    is recursive to handle deeply nested structures.
    
    Parameters
    ----------
    arg : tuple | Any | None, optional
        The first argument which may be a tuple or any other value. If it's a collection
        type, it will be detupled. Default is None.
        
    *args : P.args
        Additional arguments which are processed in the same manner as `arg`.
    
    Returns
    -------
    tuple
        A tuple of the detupled inputs.
    '''
    if isinstance(arg, (tuple, range, dict_keys, dict_values, dict_items, Generator, Iterable)) and not args: 
        return detuple(*tuple(arg))
    return (arg, *args)

# %% ../nbs/00_core.ipynb 35
def unpack_then_call(
    fn: Callable[P, T], /, 
    *args: P.args, 
    __unpack: bool | None = True, 
    __fallback: bool = True, 
    **kwargs: P.kwargs
) -> S | TypeError | UnpackError:
    un = __unpack if __unpack is not None else unpackable(args, fn=fn)
    try: 
        if un: return fn(*args, **kwargs)
        else: return fn(args, **kwargs)
    except TypeError:  ...
    if not __fallback: raise UnpackError(fn, un, *args)
    return unpack_then_call(fn, *args, __unpack=not un, __fallback=False, **kwargs)

def detuple_then_call(fn: Callable[P, T], /, *args: P.args, **kwargs: P.kwargs) -> S:
    return fn(*detuple(*args), **kwargs)

def normalize_then_call(fn: Callable, / , *args, __unpack: bool | None = True, __fallback: bool = True, **kwargs):
    fn = partial(unpack_then_call, fn, __unpack=__unpack, __fallback=__fallback)
    fn = partial(detuple_then_call, fn)
    return fn(*args, **kwargs)

def norm_op_then_map(*its, op: Callable, unpack: bool = True) -> map:
    norm_op = partial(normalize_then_call, op, __unpack=unpack, __doboth=True)
    return map(norm_op, *its)

def map_op_zip(*its, op: Callable, unpack: bool = True) -> map:
    return norm_op_then_map(zip(*its), op=op, unpack=unpack)

# %% ../nbs/00_core.ipynb 38
def nextnone(it: Iterable) -> Generator: 
    '''Filter `it` for first non-`None` element or returns `None`.'''
    return next((x for x in it if notnone(x)), None)

def fill(t: tuple, n: int, pad = None) -> tuple:
    '''Fill the provided tuple `t` to have at least `n` values using `pad` if needed.'''
    return t + ((pad, ) * (n - len(t)))

def join(*tuples: tuple, __longest: bool = True) -> tuple:
    '''Join any number of tuples using the first non-`None` value for each idx of the
    given tuples. The results will either be the length of the shortest or longest tuple in
    tuples depending on `__longest `.
    '''
    return tuple(nextnone(tup) for tup in (zip_longest if __longest else zip)(*tuples))

def num_or_zero(nums: tuple, vals: tuple | None = None) -> tuple:
    '''Convert each element in `nums` to zero if the corresponding element in `vals` is None, 
    otherwise leave it unchanged.

    Parameters
    ----------
    nums : tuple
        The tuple of numbers to process.
    vals : tuple | None, optional
        A tuple of values where None indicates that the corresponding element in `nums` should be set to zero.
    Returns
    -------
    tuple
        A tuple where each element is either the original value from `nums` or zero.
    '''
    if isnone(vals): vals = (None, ) * len(nums)
    if any(map(isnone, vals)): return fill((), len(vals), 0)
    return nums
    # return tuple((d if notnone(vals[i]) else 0) for i, d in enumerate(nums))

# %% ../nbs/00_core.ipynb 40
def slice2range(slc: slice) -> range:
    if slc.step: return range(slc.start, slc.stop, slc.step)
    if slc.start: return range(slc.start, slc.stop)
    return range(slc.stop)

def itrs(it: T) -> S:
    '''Transpose iterable.'''
    return (row for row in zip(*it))

def iidx(it: T, ix: int) -> S | None:
    '''Index iterable.'''
    sliced = islice(it, ix, ix + 1)
    return next(sliced, None)

def islc(it: T, sl: slice) -> islice:
    '''Slice iterable.'''
    return islice(it, sl.start, sl.stop, sl.step)
    
def iteritem(it: T, idx: int | slice) -> S | islice | None:
    '''Get item from iterable.'''
    if isinstance(idx, slice):return islc(it, idx)
    else: return iidx(it, idx)

def iaxis(it: T, idx: int | slice, axis: int = 0):
    '''Axis get-item from iterable.'''
    match axis:
        case 0: return iteritem(it, idx)
        case 1: return iaxis(itrs(it), idx, axis=0)
        case _: return iteritem(it, idx)
        

def iterpair(it: T, pn: BuiltinFunctionType = pairwise):
    if pn not in {pairwise, combinations}: pn = pairwise
    @wraps(pn)
    def make_pairs(el):
        match pn:
            case _ if pn == pairwise: return pn(el)
            case _ if pn == combinations: return pn(el, 2)
            case _: return pairwise(el)
    return make_pairs(it)

# %% ../nbs/00_core.ipynb 42
def detuple_args(fn: Callable, /) -> Callable[P, Any]:
    '''
    Decorator to modify an operation (function) to automatically detuple its arguments
    before applying the operation. This allows the operation to be applied to nested
    tuple structures seamlessly.
    
    Parameters
    ----------
    fn : Callable
        The operation to be modified. It should be a function that accepts any number
        of positional arguments.
        
    unpack : bool, default: True
        Whether or not to unpack the variadic args when calling the operator i.e.
        `op(*args, **kwrag)` vs `op(args, **kwargs)`
    
    Returns
    -------
    Callable[P, Any]
        A wrapped version of the operation that detuples its arguments before applying
        the original operation.
    '''
    @wraps(fn)
    def wrapped(*args: P.args, **kwargs: P.kwargs):
        return detuple_then_call(fn, *args, **kwargs)
    return wrapped
    
def unpack_args(fn: Callable, /, unpack: bool | None = True) -> Callable[P,  S | TypeError | UnpackError]:
    '''Decorator for whether or not to unpack *args when calling the wrapped function.
    
    Parameters
    ----------
    fn : Callable
        The operation to be modified. It should be a function that accepts any number
        of positional arguments.
        
    unpack : bool, default: True
        Whether or not to unpack *args when calling the decorated function.
    '''
    @wraps(fn)
    def wrapped(*args: P.args, **kwargs: P.kwargs) -> S | TypeError | UnpackError:
        return unpack_then_call(fn, *args, __unpack=unpack, __fallback=True, **kwargs)
    return wrapped

def normalize_args(fn: Callable, /, unpack: bool = True) -> Callable[P, Any]:
    '''
    Decorator to modify an operation (function) to automatically detuple its arguments
    before applying the operation. This allows the operation to be applied to nested
    tuple structures seamlessly.
    
    Parameters
    ----------
    fn : Callable
        The operation to be modified. It should be a function that accepts any number
        of positional arguments.
        
    unpack : bool, default: True
        Whether or not to unpack the variadic args when calling the operator i.e.
        `op(*args, **kwrag)` vs `op(args, **kwargs)`
    
    Returns
    -------
    Callable[P, Any]
        A wrapped version of the operation that detuples its arguments before applying
        the original operation.
    '''
    @wraps(fn)
    def wrapped(*args: P.args, **kwargs: P.kwargs) -> S:
        op: Callable[P, R_co] = unpack_args(unpack=unpack)(fn)
        op: Callable[P, R_co] = detuple_args(op)
        return fn(*args, **kwargs)
        return unpack_args(unpack=unpack)(op)(*detuple(*args), **kwargs)
    return wrapped

# %% ../nbs/00_core.ipynb 45
def mapped(op: BuiltinFunctionType | FunctionType) -> Callable[[FunctionType], Callable[[Iterable], map]]:
    '''
    Decorator factory that applies a provided operation `op` to each element in an iterable
    within a wrapped function.

    Parameters
    ----------
    op : BuiltinFunctionType | FunctionType
        The operation to apply to each element.

    Returns
    -------
    Callable[[FunctionType], Callable[[Iterable], map]]
        A decorator that takes a function and returns a wrapped function that applies `op`.
    '''
    def decorator(fn: FunctionType) -> Callable[[Iterable], map]:
        @wraps(op, assigned=_WRAP_OP)
        @wraps(fn, assigned=_WRAP_FN)
        def wrapper(it: Iterable) -> tuple:
            return tuple(map(op, it))
        doc = f'''Apply the `{op.__name__} operation for each element in the provided iterable `it``.'''
        wrapper.__doc__ = doc + getattr(wrapper, '__doc__', '')
        return wrapper
    return decorator

# %% ../nbs/00_core.ipynb 46
# NOTE: old
def map_op(op: BuiltinFunctionType, unpack: bool = True) -> Callable[[tuple[T, ...]], S]:
    '''
    Decorator factory that takes a binary operation and returns a decorator for
    functions that apply the operation over elements from multiple iterables in
    a map-like fashion.

    Parameters
    ----------
    op : BuiltinFunctionType
        A binary operation suitable for mapping over iterables.
        
    unpack : bool, default: True
        Whether or not to unpack the variadic args when calling the operator i.e.
        `op(*args, **kwrag)` vs `op(args, **kwargs)`
    
    Returns
    -------
    Callable[[tuple[T, ...]], S]
        A decorator that, when applied to a function, allows it to map the specified
        operation over elements from the provided iterables.

    Examples
    --------
    >>> @map_op(reduce_eq)
    ... def map_eq(*args): ...
    
    >>> @map_op(reduce_add)
    ... def map_add(*args): ...
    '''
    def decorator(fn: FunctionType):
        @wraps(op, assigned=_WRAP_OP)
        @wraps(fn, assigned=_WRAP_FN)
        def mapped(*its: T) -> map:
            return map_op_zip(*its, op=op, unpack=unpack)
            return map(normalize_args(op, unpack=unpack), zip(*its))
        return mapped
    return decorator


def map_op(op: BuiltinFunctionType, unpack: bool = True) -> Callable[[tuple[T, ...]], S]:
    '''
    Decorator factory that takes a binary operation and returns a decorator for
    functions that apply the operation over elements from multiple iterables in
    a map-like fashion.

    Parameters
    ----------
    op : BuiltinFunctionType
        A binary operation suitable for mapping over iterables.
        
    unpack : bool, default: True
        Whether or not to unpack the variadic args when calling the operator i.e.
        `op(*args, **kwrag)` vs `op(args, **kwargs)`
    
    Returns
    -------
    Callable[[tuple[T, ...]], S]
        A decorator that, when applied to a function, allows it to map the specified
        operation over elements from the provided iterables.

    Examples
    --------
    >>> @map_op(reduce_eq)
    ... def map_eq(*args): ...
    
    >>> @map_op(reduce_add)
    ... def map_add(*args): ...
    '''
    def decorator(fn: FunctionType):
        @wraps(op, assigned=_WRAP_OP)
        @wraps(fn, assigned=_WRAP_FN)
        def mapped(*its: T) -> map: 
            return map(normalize_args(op, unpack=unpack), zip(*its))
        return mapped
    return decorator

# %% ../nbs/00_core.ipynb 48
def reduce_op(op: BuiltinFunctionType, unpack: bool = True) -> Callable[[tuple[T, ...]], S]:
    '''
    Decorator factory that takes a binary operation and returns a decorator.
    This decorator transforms a function into one that applies a reduction
    (fold) using the given operation over its arguments.
    
    Parameters
    ----------
    op : BuiltinFunctionType
        A binary operation, like those found in the `operator` module, that will be
        used to reduce the arguments.
        
    unpack : bool, default: True
        Whether or not to unpack the variadic args when calling the operator i.e.
        `op(*args, **kwrag)` vs `op(args, **kwargs)`
    
    Returns
    -------
    Callable[[tuple[T, ...]], S]
        A decorator that, when applied to a function, transforms it into a reduction
        over its arguments using the specified operation.
        
    Examples
    --------
    >>> @reduce_op(operator.eq)
    ... def reduce_eq(*args): ...
    
    >>> @reduce_op(operator.add)
    ... def reduce_add(*args): ...
    '''
    def decorator(fn: FunctionType):
        @wraps(op, assigned=_WRAP_OP)
        @wraps(fn, assigned=_WRAP_FN)
        def mapped(*args: T, **kwargs) -> S: 
            call: Callable[P, R_co] = unpack_args(unpack=unpack)(partial(reduce, op))
            return call(*args, **kwargs)
            # return reduce(op, *args, **kwargs)
        return mapped
    return decorator

# %% ../nbs/00_core.ipynb 50
# NOTE: old
def pairwise_op(op: BuiltinFunctionType, unpack: bool = True):
    def decorator(fn: FunctionType):
        @wraps(op, assigned=_WRAP_OP)
        @wraps(fn, assigned=_WRAP_FN)
        def paired(*args: T, **kwargs) -> S:
            args = detuple(*args)
            return map_op_zip(pairwise(args), op=op, unpack=unpack)
        return paired
    return decorator

# %% ../nbs/00_core.ipynb 52
def boolop(
    bn: BuiltinFunctionType | FunctionType,
    op: BuiltinFunctionType | FunctionType,
) -> Callable[[FunctionType], Callable[[Iterable], map]]:
    '''
    Decorator factory that creates a function to apply a boolean operation `bn` 
    to the results of applying another operation `op` to pairs of elements in an iterable.

    Parameters
    ----------
    bn : BuiltinFunctionType | FunctionType
        The boolean operation to apply (e.g., any, all).
    op : BuiltinFunctionType | FunctionType
        The operation to apply to element pairs (e.g., operator.gt).

    Returns
    -------
    Callable[[FunctionType], Callable[[Iterable], map]]
        A decorator that takes a function and returns a wrapped function that applies `bn` to the results of `op`.
    '''
    def decorator(fn: FunctionType) -> Callable[[Iterable], map]:
        @wraps(op, assigned=_WRAP_OP)
        @wraps(fn, assigned=_WRAP_FN)
        def wrapper(it: Iterable) -> bool:
            return bn(op(a, b) for (a, b) in it)
        doc = f'''Equivalent to `{bn.__name__}({op.__name__}(a, b) for (a, b) in it)`.\n'''
        wrapper.__doc__ = doc + getattr(wrapper, '__doc__', '')
        return wrapper
    return decorator

# %% ../nbs/00_core.ipynb 53
# NOTE: old
def pairwise_bool(bn: BuiltinFunctionType, op: FunctionType):
    def decorator(fn: FunctionType):
        @wraps(bn, assigned=_WRAP_OP)
        @wraps(fn, assigned=_WRAP_FN)
        @wraps(op)
        def paired(*args: P.args, **kwargs: P.kwargs) -> Generator[tuple[S], tuple[S] | None, Any]:
            for a in detuple(*args): yield bn(op(a))
        return paired
    return decorator

# %% ../nbs/00_core.ipynb 54
# NOTE: old
def zipped_bool(bn: BuiltinFunctionType, op: BuiltinFunctionType, pn: BuiltinFunctionType):
    def decorator(fn: FunctionType):
        @wraps(op, assigned=_WRAP_OP)
        @wraps(fn, assigned=_WRAP_FN)
        def mapped(*its: T) -> bool: 
            return bn(
                # operator.eq(a, b)
                op(a, b)
                # for el in zipped
                for el in zip(*its)
                # for (a, b) in pairwise(el)
                for (a, b) in iterpair(el, pn)
            )        
        return mapped
    return decorator

# %% ../nbs/00_core.ipynb 55
def booly(
    bn: BuiltinFunctionType, 
    op: BuiltinFunctionType = operator.eq, 
    to: bool | Any | None = True,
    cast: BuiltinFunctionType | None = None,
) -> Callable[[FunctionType], Callable[P, bool]]:
    '''
    Decorator for creating boolean comparison functions.
    
    Parameters
    ----------
    bn : BuiltinFunctionType
        The bool (b) function (fn) i.e `bn` to call for example `all`, `any`, etc
        
    op : BuiltinFunctionType, default: opterator.eq
        The operator (op) to apply to each element in the decorated function's variadic 
        arguments for example `(op(a, to) for a in args)`.
        
    to : bool | Any, default: True
        The value to compare each element in the decorated function's variadic arguments
        `to` e.g. `(a == to for a in args)`.
        
    cast : BuiltinFunctionType, optional
        A function for type casting each element in the decorated function's variadic 
        arguments before applying the operator. By default it is the identity function.
    
    Examples
    --------
    >>> @booly(all, to=True)
    ... def alltrue(*args) -> bool: ...
    
    >>> @booly(all, to=False)
    ... def allfalse(*args) -> bool: ...
    
    >>> @booly(all, to=True, cast=bool)
    ... def alltruthy(*args) -> bool: ...
    
    >>> b1, b2, b3 = (True, True, False, True), (False, False, False, False), (True, 1, list[1,2,3])
    ... alltrue(b1), allfalse(b2), alltruthy(b3)
    (False, True, True)
    '''
    cn = lambda a: a if cast is None else cast(a)
    def decorator(fn: FunctionType) -> Callable[P, bool]:
        @wraps(bn, assigned=_WRAP_OP)
        @wraps(fn, assigned=_WRAP_FN)
        def boolop(*args: P.args, **kwargs: P.kwargs) -> bool:
            args = detuple(*args)
            return bn(op(cn(a), to) for a in args)
        return boolop
    return decorator

# %% ../nbs/00_core.ipynb 60
@mapped(sum)
def mapsum(it: Iterable) -> tuple: '''Apply the sum operation to each element in the provided iterable `it`.'''
@mapped(max)
def mapmax(it: Iterable) -> tuple: '''Apply the max operation to each element in the provided iterable `it`.'''
@mapped(min)
def mapmin(it: Iterable) -> tuple: '''Apply the min operation to each element in the provided iterable `it`.'''

# %% ../nbs/00_core.ipynb 62
@boolop(any, operator.eq)
def any_eq(it: Iterable) -> bool: f'''Equivalent to any(eq(a, b) for (a, b) in it).'''
@boolop(any, operator.ge)
def any_ge(it: Iterable) -> bool: f'''Equivalent to any(ge(a, b) for (a, b) in it).'''
@boolop(any, operator.gt)
def any_gt(it: Iterable) -> bool: f'''Equivalent to any(gt(a, b) for (a, b) in it).'''
@boolop(all, operator.le)
def all_le(it: Iterable) -> bool: f'''Equivalent to all(le(a, b) for (a, b) in it).'''

# %% ../nbs/00_core.ipynb 64
# NOTE: old
@reduce_op(operator.eq)
def reduce_eq(*args): ...
@reduce_op(operator.ne)
def reduce_ne(*args): ...
@reduce_op(operator.gt)
def reduce_gt(*args): ...
@reduce_op(operator.ge)
def reduce_ge(*args): ...
@reduce_op(operator.lt)
def reduce_lt(*args): ...
@reduce_op(operator.le)
def reduce_le(*args): ...
@reduce_op(operator.pow)
def reduce_pow(*args): ...
@reduce_op(operator.add)
def reduce_add(*args): ...
@reduce_op(operator.sub)
def reduce_sub(*args): ...
@reduce_op(operator.mul)
def reduce_mul(*args): ...
@reduce_op(operator.abs)
def reduce_abs(*args): ...
@reduce_op(operator.is_)
def reduce_is_(*args): ...
@reduce_op(operator.is_not)
def reduce_is_not(*args): ...
@reduce_op(operator.concat)
def reduce_concat(*args): ...
@reduce_op(operator.floordiv)
def reduce_floordiv(*args): ...
@reduce_op(min)
def reduce_min(*args): ...
@reduce_op(max)
def reduce_max(*args): ...
@reduce_op(operator.add)
def reduce_sum(*args): ...

# %% ../nbs/00_core.ipynb 66
# NOTE: old
@pairwise_op(operator.eq)
def pairwise_eq(*args): ...
@pairwise_op(operator.ne)
def pairwise_ne(*args): ...
@pairwise_op(operator.gt)
def pairwise_gt(*args): ...
@pairwise_op(operator.ge)
def pairwise_ge(*args): ...
@pairwise_op(operator.lt)
def pairwise_lt(*args): ...
@pairwise_op(operator.le)
def pairwise_le(*args): ...
@pairwise_op(operator.pow)
def pairwise_pow(*args): ...
@pairwise_op(operator.add)
def pairwise_add(*args): ...
@pairwise_op(operator.sub)
def pairwise_sub(*args): ...
@pairwise_op(operator.mul)
def pairwise_mul(*args): ...
@pairwise_op(operator.abs)
def pairwise_abs(*args): ...
@pairwise_op(operator.is_)
def pairwise_is_(*args): ...
@pairwise_op(operator.is_not)
def pairwise_is_not(*args): ...

# %% ../nbs/00_core.ipynb 68
# NOTE: old
@zipped_bool(all, operator.ge, pairwise)
def all_ge_pw_zip(*args, **kwargs): ...

# %% ../nbs/00_core.ipynb 70
# NOTE: old
@pairwise_bool(all, pairwise_eq)
def all_pw_eq(*args): ...
@pairwise_bool(all, pairwise_ne)
def all_pw_ne(*args): ...
@pairwise_bool(all, pairwise_gt)
def all_pw_gt(*args): ...
@pairwise_bool(all, pairwise_ge)
def all_pw_ge(*args): ...
@pairwise_bool(all, pairwise_lt)
def all_pw_lt(*args): ...
@pairwise_bool(all, pairwise_le)
def all_pw_le(*args): ...

@pairwise_bool(any, pairwise_eq)
def any_pw_eq(*args): ...
@pairwise_bool(any, pairwise_ne)
def any_pw_ne(*args): ...
@pairwise_bool(any, pairwise_gt)
def any_pw_gt(*args): ...
@pairwise_bool(any, pairwise_ge)
def any_pw_ge(*args): ...
@pairwise_bool(any, pairwise_lt)
def any_pw_lt(*args): ...
@pairwise_bool(any, pairwise_le)
def any_pw_le(*args): ...

# %% ../nbs/00_core.ipynb 72
# NOTE: old
def all_cb_eq(*args): return all(operator.eq(a, b) for (a, b) in combinations(args, 2))
def all_cb_ne(*args): return all(operator.ne(a, b) for (a, b) in combinations(args, 2))
def all_cb_gt(*args): return all(operator.gt(a, b) for (a, b) in combinations(args, 2))
def all_cb_ge(*args): return all(operator.ge(a, b) for (a, b) in combinations(args, 2))
def all_cb_lt(*args): return all(operator.lt(a, b) for (a, b) in combinations(args, 2))
def all_cb_le(*args): return all(operator.le(a, b) for (a, b) in combinations(args, 2))

def any_cb_eq(*args): return any(operator.eq(a, b) for (a, b) in combinations(args, 2))
def any_cb_ne(*args): return any(operator.ne(a, b) for (a, b) in combinations(args, 2))
def any_cb_gt(*args): return any(operator.gt(a, b) for (a, b) in combinations(args, 2))
def any_cb_ge(*args): return any(operator.ge(a, b) for (a, b) in combinations(args, 2))
def any_cb_lt(*args): return any(operator.lt(a, b) for (a, b) in combinations(args, 2))
def any_cb_le(*args): return any(operator.le(a, b) for (a, b) in combinations(args, 2))

# %% ../nbs/00_core.ipynb 74
# NOTE: old
@booly(all, to=True)
def alltrue(*args) -> bool: ...

@booly(all, to=False)
def allfalse(*args) -> bool: ...

@booly(all, to=True, cast=bool)
def alltruthy(*args) -> bool: ...

@booly(any, to=True)
def anytrue(*args) -> bool: ...

@booly(any, to=False)
def anyfalse(*args) -> bool: ...

@booly(any, to=True, cast=bool)
def anytruthy(*args) -> bool: ...

# %% ../nbs/00_core.ipynb 75
# NOTE: old
@reduce_op(alltrue, unpack=False)
def reduce_all(*args): ...
@reduce_op(anytrue, unpack=False)
def reduce_any(*args): ...



@reduce_op(all_pw_eq, unpack=False)
def reduce_all_pw_eq(*args): ...
@reduce_op(any_pw_eq, unpack=False)
def reduce_any_pw_eq(*args): ...

@reduce_op(all_pw_ne, unpack=False)
def reduce_all_pw_ne(*args): ...
@reduce_op(any_pw_ne, unpack=False)
def reduce_any_pw_ne(*args): ...

@reduce_op(all_pw_ge, unpack=False)
def reduce_all_pw_ge(*args): ...
@reduce_op(any_pw_ge, unpack=True)
def reduce_any_pw_ge(*args): ...

# %% ../nbs/00_core.ipynb 77
# NOTE: Old
@map_op(reduce_eq)
def map_eq(*args): ...
@map_op(reduce_ne)
def map_ne(*args): ...
@map_op(reduce_gt)
def map_gt(*args): ...
@map_op(reduce_ge)
def map_ge(*args): ...
@map_op(reduce_lt)
def map_lt(*args): ...
@map_op(reduce_le)
def map_le(*args): ...
@map_op(reduce_pow)
def map_pow(*args): ...
@map_op(reduce_add)
def map_add(*args): ...
@map_op(reduce_sub)
def map_sub(*args): ...
@map_op(reduce_mul)
def map_mul(*args): ...
@map_op(reduce_abs)
def map_abs(*args): ...
@map_op(reduce_is_)
def map_is_(*args): ...
@map_op(reduce_is_not)
def map_is_not(*args): ...
@map_op(reduce_concat)
def map_concat(*args): ...
@map_op(reduce_floordiv)
def map_floordiv(*args): ...
@map_op(reduce_min)
def map_min(*args): ...
@map_op(reduce_max)
def map_max(*args): ...
@map_op(reduce_sum)
def map_sum(*args): ...

def map_round(*args, ndigits: int = 3):
    @wraps(round)
    def ndig(num: Number, **kwds): 
        return round(num, ndigits=ndigits)
    @map_op(ndig)
    def digits(*args): ...
    return digits(*args)


@map_op(reduce_all, unpack=True)
def map_all(*args): ...
@map_op(reduce_any, unpack=True)
def map_any(*args): ...


@map_op(reduce_any, unpack=True)
def map_any(*args): ...

# %% ../nbs/00_core.ipynb 96
class increment:
    '''
    A class to generate incremented values with various controls, such as start value, 
    difference between steps, and stop value. It supports both fixed and dynamic increments 
    across multiple dimensions with precision control.
    
    Attributes
    ----------
    init : Numbers
        Initial values for the sequence(s).
    diff : Numbers
        Differences between each value in the sequence(s).
    stop : Numbers
        Stopping values for the sequence(s).
    repeat : int
        Number of times to repeat the generation.
    digits : int
        Number of decimal digits to consider for floats.
    epsilon : float
        A small value to determine the precision of stopping condition.
    yields : NumberQ
        The quantity of values to generate.
    include_end : bool | None
        Whether to include the `stop` value in the generated sequence.
    '''
    init: Numbers
    '''Initial values for the sequence(s).''';
    diff: Numbers
    '''Differences between each value in the sequence(s).''' ;
    stop: Numbers
    '''Stopping values for the sequence(s).''';
    repeat: int
    '''Number of times to repeat the generation.''';
    digits: int
    '''Number of decimal digits to consider for floats.''';
    epsilon: float
    '''A small value to determine the precision of stopping condition.''';
    yields: NumberQ
    '''The quantity of values to generate.''';
    include_end: bool | None
    '''Whether to include the `stop` value in the generated sequence.''';
    
    def __prep__(
        self,
        init: NumberLike = 0,
        diff: NumberLike = 1, 
        stop: NumberLike = 1,
        repeat: Number | bool = 1,
        digits: Number = 3,
        epsilon: Number = 1e-4,
    ) -> tuple[Numbers, Numbers, Numbers, int, int, float]:
        '''
        Prepare and validate the input parameters for the increment generator. This method sets up the initial conditions.

        Parameters
        ----------
        init : NumberLike, optional
            Initial value(s) for the sequence(s), by default 0.
        diff : NumberLike, optional
            Difference(s) between each step in the sequence(s), by default 1.
        stop : NumberLike, optional
            Stop value(s) for the sequence(s), by default 1.
        repeat : Number | bool, optional
            How many times to repeat each value before incrementing, by default 1.
        digits : Number, optional
            The number of decimal places to round to, by default 3.
        epsilon : Number, optional
            A small value to adjust the precision of the floating-point comparison, by default 1e-4.

        Returns
        -------
        tuple[Numbers, Numbers, Numbers, int, int, float]
            A tuple containing prepared values for `init`, `diff`, `stop`, 
            `repeat` count, `ndigits` for rounding, and `epsilon`.
        '''
        # Default values of init, diff, and stop
        if isnone(init): init = 0
        if isnone(diff): diff = 1
        if isnone(stop): stop = 1
                
        # Number of elements to generate in each tuple
        ncol = (int(repeat) if repeat else 1)
        if isnumber(init): 
            init = fill((init, ), ncol, None)
        if isnumber(diff): 
            diff = (diff, )
        if isnumber(stop): 
            stop = (stop, ) * ncol
        
        # The longest length of the input specifications i.e. max length
        mlen = max(map(len, (init, diff, stop)))

        # diff = (di, dj, dk, ...)
        diff = fill(tuple(diff), mlen, 1) # fill diff with 1 as needed
        
        # stagger the increments e.g. (0, 1, 1, 1, ) -> (0, 1, 2, 3, )
        accr = tuple(accumulate((0, *diff)))
        init = join(init, accr)[:mlen]
        
        # fill the final value with stop
        if any_gt(zip(init, (next(itertools.cycle(stop)) for _ in init))):
            mend = max((*stop, *init))  
        else: 
            mend = max(stop)
        stop = fill(stop, mlen, mend)
        
        # Set initial values to instance
        init: Numbers = join(init, [0 * mlen])
        diff: Numbers = join(diff, [1 * mlen])
        stop: Numbers = join(stop, [stop[0] * mlen])
        epsilon = epsilon if epsilon < 1 else 10 ** -digits
        return init, diff, stop, repeat, digits, epsilon
    
    def __init__(
        self,
        init: NumberLike = 0,
        diff: NumberLike = 1, 
        stop: NumberLike = 1,
        repeat: Number | bool = 1,
        digits: Number = 3,
        epsilon: Number = 1e-4,
        yields: NumberQ = None,
        include_end: bool | None = False,
    ):
        '''
        Initialize an increment generator instance with specified parameters for generating sequences of numbers.
        
        Parameters
        ----------
        init : NumberLike, optional
            Initial value(s) for the sequence(s), by default 0.
        diff : NumberLike, optional
            Difference(s) between each step in the sequence(s), by default 1.
        stop : NumberLike, optional
            Stop value(s) for the sequence(s), by default 1.
        repeat : Number | bool, optional
            How many times to repeat each value before incrementing, by default 1.
        digits : Number, optional
            The number of decimal places to round to, by default 3.
        epsilon : Number, optional
            A small value to adjust the precision of the floating-point comparison, by default 1e-4.
        yields : NumberQ, optional
            The number of tuples to yield, by default None which means yield indefinitely.
        include_end : bool | None, optional
            Whether the stopping value is included in the output, by default False.
        '''
        (init, diff, stop, repeat, digits, epsilon) = \
            self.__prep__(init, diff, stop, repeat, digits, epsilon)
        
        # Set initial values to instance
        self.init: Numbers = init
        self.diff: Numbers = diff
        self.stop: Numbers = stop
        # Whether or to stop before the stop value like range, or include it
        self.include_end: bool | None = include_end
        # Number of tuples to return
        self.yields: NumberQ = yields
        # Number of digits to round to
        self.digits: Number = digits
        # Number of times to repeat the `init` value.
        self.repeat: Number = repeat
        # err to include to stop to handle float values
        self.epsilon: Number = epsilon
        
    @property
    def maximum_length(self) -> int:
        '''The maximum length of `init`, `diff`, and `stop`.'''
        try: return self._mlen
        except AttributeError: 
            self._mlen = max(map(len, (self.init, self.diff, self.stop)))
        return self._mlen
    
    def empty(self) -> tuple:
        '''Generate an empty tuple of length equal to the maximum length 
        among `init`, `diff`, and `stop`.'''
        return fill(t=(), n=self.maximum_length, pad=None)
    
    @wraps(round)
    def ndigits(self, num: NumberQ) -> NumberQ:
        '''Round `num` to `digits` or return `None`.'''
        return round(num, self.digits) if isnumber(num) else None
    
    def mapdig(self, it: Iterable) -> tuple:
        '''Rounding each number in the iterable `it`to `ndigits`.'''
        return mapped(self.ndigits)(lambda it: it)(it)
    
    @property
    def zips(self) -> zip:
        '''Property that effectively produces a new zipped instance containing 
        `init`, `diff`, and `stop`.'''
        return zip(self.init, self.diff, self.stop) 
    @property
    def prev(self) -> tuple[NumberQ, ...]:
        '''The previously generated tuple.'''
        try: return self._prev
        except AttributeError: self._prev = self.empty()
        return self._prev
    
    @property
    def last(self) -> Numbers: 
        '''The tuple of `stop` values with `epsilon` tolerance.'''
        try: return self._last
        except AttributeError: 
            self._last = tuple(self.ndigits(s + self.epsilon) for s in self.stop)
        return self._last
    
    @property
    def done(self) -> bool: 
        '''Whether or not the iteration is complete.'''
        try: return self._done
        except AttributeError: self._done = False
        return self._done
    @done.setter
    def done(self, b: bool) -> None: self._done = b
    
    def __make__(self) -> Generator:
        '''
        Make the generator for the increment function, yielding tuples of 
        incremented values according to the instance's parameters.

        Returns
        -------
        Generator
            A generator yielding tuples of numbers according to the specified increment conditions.
        '''
        nret = 0
        while True:
            prev = join(self.prev, self.init, __longest=False)
            incs = num_or_zero(self.diff, self.prev)
            
            curr = mapsum(zip(prev, incs))
            curr = self.mapdig(curr)
            
            pair = tuple(zip(prev, self.last))
            comp = any_gt(pair)
            
            if self.include_end and comp:
                self.done = True
                break
            
            if not self.include_end:
                curr = mapmin(zip(curr, self.stop))
            
            pair = tuple(zip(curr, self.prev))
            comp = any_eq(pair)
            if comp:
                self.done = True
                break
            
            pair = tuple(zip(curr, self.last))
            comp = all_le(pair)
            if comp:
                self._prev = curr
                if isnone(self.yields) or (nret < self.yields):
                    nret += 1
                    yield tuple(curr)
                self.done = False
            
            pair = tuple(zip(self.prev, self.last))
            comp = any_gt(pair)
            if comp:
                self.done = True
                break
    
    def __reset__(self):
        self._prev = self.empty()
        self._done = False
        return self
        
    def __call__(self) -> Iterator: 
        if self.done: self.__reset__()
        return iter(self)
    
    def __iter__(self) -> Iterator: 
        if self.done: self.__reset__()
        return self.__make__()
    
    def __next__(self) -> tuple[Number, Number]: 
        return next(iter(self))
    
    def __repr__(self) -> str: 
        base = f'{self.__class__.__name__}'
        return f'{base}(init={self.init}, diff={self.diff}, stop={self.stop})'
    
    def getaxis(self, idx: int | slice, axis: int = 1) -> NumberLike | NotImplementedError:
        '''
        Get values along a specified axis. This method allows selecting a specific tuple by index or 
        slicing along an axis.

        Parameters
        ----------
        idx : int | slice
            The index or slice to select.
            
        axis : int, default: 1
            The axis along which to select the values, by default `1` 
            i.e. the columns of the increments.

        Returns
        -------
        NumberLike | NotImplementedError
            Selected values along the given axis, or raises NotImplementedError 
            if the operation cannot be performed.
        '''
        self.__reset__()
        return iaxis(self, idx, axis)
        match axis:
            case 1: 
                if abs(idx) < self.maximum_length: 
                    return tuple(e[idx] for e in self)
                raise NotImplementedError
            case 0:
                if abs(idx) < len(self):
                    for i in range(idx): 
                        next(self)
                    return next(self)
                raise NotImplementedError
            case _:
                return self[idx]
    
    def __getitem__(self, idx: int | slice, axis: int = 0):
        '''Get an item from the iterable, defaulting to axis=0.'''
        # return iaxis(self, idx, axis)
        return self.getaxis(idx, axis=axis)
            
    def __length_hint__(self) -> int:
        vals = (self.yields, ) if self.yields else ()
        vals = vals + tuple((round((s - i) // d) + 1) for i, d, s in self.zips)
        return min(*vals, max((*vals, 0)))
    
    def __len__(self) -> int:
        return self.__length_hint__()

# %% ../nbs/00_core.ipynb 108
class stepper:
    '''
    A class that generates a sequence of tuples representing steps from a starting point to a stop point.
    Each step starts at an increment (`step`) from the previous step's start and spans a specified difference (`diff`).
    The sequence continues until the end of a step reaches or exceeds a defined stop point (`stop`),
    adjusted by a small epsilon (`epsl`) to ensure inclusivity of the stop point.

    Parameters
    ----------
    step : Number, default: 1
        The increment by which each step's start is offset from the previous step's start
        
    diff : Number, default: 1
        The length of each step from its start to its end.
        
    stop : Number, default: 1
        The point at which the sequence should stop generating new steps.
        
    epsl : Number, default: 1e-4
        A small value added to `stop` to ensure it is inclusively considered as the end of the last step.
        
    digs : Number, default: 3
        The number of decimal places to round each step's start and end to.

    Returns
    -------
    Iterator[tuple[Number, Number]]
        An iterator over tuples, where each tuple represents the start and end of a step.


    Examples
    --------
    The `stepper` is particularlly useful when wanting to work with uneven increments like timesteps:
    >>> timesteps = list(range(6))
    ... timediffs = [b - a for (a, b) in zip(timesteps, timesteps[1:])]
    ... timespans = range(max(1, min(*timesteps)), max(*timesteps))
    
    For example here we can see that we can generate tuples where the start / stop values have the 
    desired span but increase by a different value:
    >>> for span in timespans:
    ...     timeslice = list(s :=stepper(step := max(*timediffs), diff := span, stop := max(*timesteps)))
    ...     print(s, timeslice)
    stepper(step=1, diff=1, stop=5, len≈5) [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5)]
    stepper(step=1, diff=2, stop=5, len≈4) [(0, 2), (1, 3), (2, 4), (3, 5)]
    stepper(step=1, diff=3, stop=5, len≈3) [(0, 3), (1, 4), (2, 5)]
    stepper(step=1, diff=4, stop=5, len≈2) [(0, 4), (1, 5)]
    
    Likewise we can also just increase by a different number if we wish to skip some values:
    >>> for incr in range(1, len(timesteps)):
    ...     timeslice = list(s :=stepper(step := incr, diff := 1, stop := max(*timesteps)))
    ...     print(s, timeslice)
    stepper(step=1, diff=1, stop=5, len≈5) [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5)]
    stepper(step=2, diff=1, stop=5, len≈3) [(0, 1), (2, 3), (4, 5)]
    stepper(step=3, diff=1, stop=5, len≈2) [(0, 1), (3, 4), (5, 5)]
    stepper(step=4, diff=1, stop=5, len≈2) [(0, 1), (4, 5)]
    stepper(step=5, diff=1, stop=5, len≈2) [(0, 1), (5, 5)]
    
    >>> list(s := stepper(step := 3,    diff := 1,   stop := 12, epsl := 1e-2))
    [(0, 1), (3, 4), (6, 7), (9, 10), (12, 12)]
    
    >>> list(s := stepper(step := 2,    diff := 4,   stop := 12, epsl := 1e-2))
    [(0, 4), (2, 6), (4, 8), (6, 10), (8, 12)]
    
    >>> list(s := stepper(step := 1,    diff := 2,   stop := 10, epsl := 1e-4))
    [(0, 2), (1, 3), (2, 4), (3, 5), (4, 6), (5, 7), (6, 8), (7, 9), (8, 10)]
    
    >>> list(s := stepper(step := 0.15, diff := 2,   stop := 3,  epsl := 1e-2))
    [(0, 2), (0.15, 2.15), (0.3, 2.3), (0.45, 2.45), (0.6, 2.6), (0.75, 2.75), (0.9, 2.9), (1.05, 3)]
    
    >>> list(s := stepper(step := 1,    diff := 0.1, stop := 2,  epsl := 1e-2))
    [(0, 0.1), (1, 1.1), (2, 2)]
    '''
    
    def __init__(
        self,
        step: Number = 1, 
        diff: Number = 1, 
        stop: Number = 1,
        epsl: Number = 1e-4,
        digs: Number = 3,
        upto: bool = False,
    ):
        
        self.step = step
        self.diff = diff
        self.stop = stop
        self.epsl = epsl if epsl < 1 else 10 ** -digs
        self.digs = digs
        self.upto = upto
        self._a: Number = None
        self._b: Number = None
        self._done: bool = False
    
    @wraps(round)
    def ndigits(self, num: Number | None) -> Number: 
        return round(num, self.digs) if isnumber(num) else None
    
    @property
    def a(self) -> Number: return self.ndigits(self._a)
    @a.setter
    def a(self, n: Number | None): self._a = n
    
    @property
    def b(self) -> Number: return self.ndigits(self._b)
    @b.setter
    def b(self, n: Number | None): self._b = n
    
    @property
    def done(self) -> Number: 
        return getattr(self, '_done', ((self.b == self.stop) if (not self.upto) else False))
    @done.setter
    def done(self, b: bool) -> Number: self._done = b
    
    @property
    def prev(self) -> Number: return self.a, self.b
    @property
    def last(self) -> Number: return self.ndigits(self.stop + self.epsl)
    
    def __make__(self) -> Generator:
        '''Make the generator for our stepping function.'''
        while True:
            x = self.a or 0
            y = self.b or 0
            
            a = x + (self.step if self.a != None else 0)
            b = a + self.diff
            
            if self.upto and (b >= self.last or a >= self.last):
                self.done = True
                break
            
            if not self.upto:
                a = min(a, self.stop)
                b = min(a + self.diff, self.stop)
            
            if b == y:
                self.done = True
                break
            
            if (a <= self.last if (not self.upto) else a < self.last):
                self.a, self.b = a, b
                yield self.a, self.b
                self.done = False
            
            if a > self.last: 
                self.done = True
                break
    
    def __reset__(self):
        self._a = None
        self._b = None
        self._done = False
        return self
        
    def __call__(self) -> Iterator: 
        if self.done: self.__reset__()
        return iter(self)
    
    def __iter__(self) -> Iterator: 
        if self.done: self.__reset__()
        return self.__make__()
    
    def __next__(self) -> tuple[Number, Number]: 
        return next(iter(self))
    
    def __length_hint__(self) -> int: 
        return round((self.last - self.diff) / self.step) + 1
    
    def __len__(self) -> int: 
        return self.__length_hint__()
    
    def __repr__(self) -> str: 
        base = f'{self.__class__.__name__}'
        return f'{base}(step={self.step}, diff={self.diff}, stop={self.stop}, len≈{self.__length_hint__()})'
    
    def lte_end(self, num: Number) -> bool:
        '''Whether a number is less than or equal to the last value of the iterator.'''
        if istuple(num): num = num[0]
        if num is None: return False
        num = self.prev[-1]
        return (num <= self.last if not self.upto else num < self.last)
    
    def step_beg(self) -> Number:
        '''The beginning of the current step.'''
        return self.ndigits(min((self.a or 0) + (self.step if self.a != None else 0)), self.stop)
        
    def step_end(self) -> Number: 
        '''The end of the current step.'''
        return self.ndigits(min(self.step_beg() + self.diff, self.stop))
        
    def makestep(self) -> tuple[Number, Number]:
        '''Make the current step.'''
        a, b = (self.step_beg(), self.step_end())
        if b == (self.b or 0): 
            self.done = True
            return
        if (a <= self.last if not self.upto else a < self.last):
            self.a, self.b = a, b
            return a, b
        self.done = True
        return a, b
    
    def gensteps(self) -> Generator: 
        '''Generate the steps using `takewhile`.'''
        return (self.makestep() for _ in self.singletake())
    
    def singletake(self) -> takewhile:
        '''Increase each step's end by `diff` until `stop`.'''
        return takewhile(lambda i: i < self.__length_hint__(), count(0))
    
    def doubletake(self) -> takewhile:
        '''Increase each step's beginning by `step` until `stop`.'''
        return takewhile(self.lte_end, self.gensteps())
    
    def _test_step(self) -> bool:
        '''Confirm that each tuple increments by `step`.'''
        all_a_val = [a for a, *_ in self]
        aval_diff = tuple(self.ndigits(b - a)for a, b in zip(all_a_val, all_a_val[1:]))
        good_begs = all(e == self.step for e in aval_diff)
        return good_begs
    
    def _test_diff(self) -> bool:
        '''Confirm that each tuple has a difference (b - a) less than or equal to `diff`.'''
        step_diff = tuple(self.ndigits(b - a) for a, b in self)
        good_step = all(e <= self.diff for e in step_diff)
        return good_step
    
    def _test_last(self) -> bool:
        '''Confirm that the final tuple ends at `stop`.'''
        good_end = list(self)[-1][-1] == self.stop
        return good_end
    
    def _test_len(self) -> bool:
        '''Confirm that `__length_hint__` is the actual length of the iterator.'''
        good_len = len(list(self)) == self.__length_hint__()
        return good_len
    
    def _test_iter(self) -> bool:
        '''Check that the stepper meets our requirements i.e. each tuple
        - increments by `step`
        - has a difference (b - a) less than or equal to `diff`, 
        - ends at `stop`, and
        - confirms that `__length_hint__` is the actual length of the iterator.
        
        Notes
        -----
        This is solely for internal testing to ensure correct implementation of this class.
        '''
        good_step = self._test_step()
        good_diff = self._test_diff() 
        good_last = self._test_last()
        good_len = self._test_len()
        # each tuple increments by +step, has a (a, b) diff of <= diff and ends at stop
        all_valid = good_step and good_diff and good_last and good_len
        return all_valid

# %% ../nbs/00_core.ipynb 114
class zipr:
    '''
    A utility class for iterating over multiple iterables in parallel (like `zip`)
    and applying operations (both mapped and reduced) over the iterated elements.
    
    Methods in this class can apply operations to pairs of elements from the iterables
    or reduce multiple elements using specified operations. This class supports a variety of 
    operations, including equality checks, arithmetic operations, and logical comparisons, applied across elements from multiple iterables.
    
    Parameters
    ----------
    *its : P.args
        Any number of iterables to be zipped together.
    
    Attributes
    ----------
    zips : zip
        The zipped iterator of the input iterables.
        
    Methods
    -------
    reduce(op)
        Class method that returns a decorator for reducing an iterable using the specified operation.
        
    map(op)
        Class method that returns a decorator for mapping an operation over elements from multiple iterables.
        
    eq, ne, gt, ge, lt, le, pow, add, sub, mul, abs, is_, is_not, concat, floordiv, min, max,
    sum, all, any
        Instance methods that apply specific operations (equality, arithmetic, etc.) over zipped elements.
        
    round(*its, ndigits: int = 3)
        Wrapper that maps the builtin method `round` operation.
        
    Examples
    --------
    >>> a = (1,  2,  3)
    ... b = (1,  3,  4)
    ... c = (1, 13, 14)
    ... list(zipr.eq(a, b, c)), list(zipr.add(a, b, c)), list(zipr.min(a, b, c)), list(zipr.max(a, b, c))
    ([True, False, False], [3, 18, 21], [1, 2, 3], [1, 13, 14])
    
    >>> z = zipr(a, b, c)
    ... list(z.eq())
    [True, False, False]
    
    >>> list(zipr.any((True, False, False), (False, False, False), (False, True, False)))
    [True, True, False]
    
    >>> list(zipr.round((0.0001251, 0.00214122, 0.00753289003), ndigits=5))
    [0.00013, 0.00214, 0.00753]
    '''
    def __init__(self, *its: P.args):
        self._its = its
    
    @property
    def zips(self) -> zip: return zip(*self._its)
    def __iter__(self) -> zip: return self.zips
    def __next__(self): return next(iter(self))
    
    @classmethod
    @wraps(reduce_op)
    def reduce(cls, op, unpack): return reduce_op(op, unpack)
    @classmethod
    @wraps(map_op)
    def map(cls, op, unpack): return map_op(op, unpack)
    
    @wraps(map_eq)
    @instancemethod
    def eq(ins: T, /, *args: P.args, **kwargs: P.kwargs) -> S: return map_eq(ins)
    @wraps(map_ne)
    @instancemethod
    def ne(ins: T, /, *args: P.args, **kwargs: P.kwargs) -> S: return map_ne(ins)
    @wraps(map_gt)
    @instancemethod
    def gt(ins: T, /, *args: P.args, **kwargs: P.kwargs) -> S: return map_gt(ins)
    @wraps(map_ge)
    @instancemethod
    def ge(ins: T, /, *args: P.args, **kwargs: P.kwargs) -> S: return map_ge(ins)
    @wraps(map_lt)
    @instancemethod
    def lt(ins: T, /, *args: P.args, **kwargs: P.kwargs) -> S: return map_lt(ins)
    @wraps(map_le)
    @instancemethod
    def le(ins: T, /, *args: P.args, **kwargs: P.kwargs) -> S: return map_le(ins)
    @wraps(map_pow)
    @instancemethod
    def pow(ins: T, /, *args: P.args, **kwargs: P.kwargs) -> S: return map_pow(ins)
    @wraps(map_add)
    @instancemethod
    def add(ins: T, /, *args: P.args, **kwargs: P.kwargs) -> S: return map_add(ins)
    @wraps(map_sub)
    @instancemethod
    def sub(ins: T, /, *args: P.args, **kwargs: P.kwargs) -> S: return map_sub(ins)
    @wraps(map_mul)
    @instancemethod
    def mul(ins: T, /, *args: P.args, **kwargs: P.kwargs) -> S: return map_mul(ins)
    @wraps(map_abs)
    @instancemethod
    def abs(ins: T, /, *args: P.args, **kwargs: P.kwargs) -> S: return map_abs(ins)
    @wraps(map_is_)
    @instancemethod
    def is_(ins: T, /, *args: P.args, **kwargs: P.kwargs) -> S: return map_is_(ins)
    @wraps(map_is_not)
    @instancemethod
    def is_not(ins: T, /, *args: P.args, **kwargs: P.kwargs) -> S: return map_is_not(ins)
    @wraps(map_concat)
    @instancemethod
    def concat(ins: T, /, *args: P.args, **kwargs: P.kwargs) -> S: return map_concat(ins)
    @wraps(map_floordiv)
    @instancemethod
    def floordiv(ins: T, /, *args: P.args, **kwargs: P.kwargs) -> S: return map_floordiv(ins)
    @wraps(map_min)
    @instancemethod
    def min(ins: T, /, *args: P.args, **kwargs: P.kwargs) -> S: return map_min(ins)
    @wraps(map_max)
    @instancemethod
    def max(ins: T, /, *args: P.args, **kwargs: P.kwargs) -> S: return map_max(ins)
    @wraps(map_sum)
    @instancemethod
    def sum(ins: T, /, *args: P.args, **kwargs: P.kwargs) -> S: return map_sum(ins)
    
    @wraps(map_round)
    @instancemethod
    def round(ins: T, /, *args: P.args, ndigits: int = 3, **kwargs: P.kwargs) -> S: return map_round(ins, ndigits=ndigits)
    
    @wraps(map_all)
    @instancemethod
    def all(ins: T, /, *args: P.args, **kwargs: P.kwargs) -> S: return map_all(ins)
    @wraps(map_any)
    @instancemethod
    def any(ins: T, /, *args: P.args, **kwargs: P.kwargs) -> S: return map_any(ins)
    
