# API Design: `numpy_forms()` Strategy

**Date:** 2026-02-02
**Status:** Implemented
**Author:** Claude (with developer collaboration)

## Overview

This document proposes an API for the `numpy_forms()` strategy, which generates
`ak.forms.NumpyForm` objects for property-based testing of Awkward Array code.

## Background

From the
[NumpyForm research](./../research/2026-02-02-numpy-form-research.md) and
[types API design](./../api/2026-01-21-types-api.md):

- Forms are lightweight descriptors of array structure, parallel to types
- One Form maps to exactly one Type, but one Type can map to multiple Forms
- `NumpyForm` adds `inner_shape` and `form_key` beyond what `NumpyType` carries
- `numpy_forms()` is a building block for a future `forms()` recursive strategy
- Target users: Awkward developers, scikit-HEP tool developers

## Design Goals

1. **Parallel to `numpy_types()`**: Same parameter style and naming conventions
2. **Control over `inner_shape`**: The key feature distinguishing forms from types
3. **Reuse existing strategies**: Use `supported_dtypes()` internally
4. **Future extensibility**: Design for `parameters` and `form_key` to be added
   later
5. **Consistency**: Follow patterns from existing strategies

## Proposed API

> **Note:** See
> [positional-keyword-convention](../notes/2026-02-12-positional-keyword-convention.md)
> for the keyword-only convention adopted after this document was written.
> `numpy_forms()` falls in Group A: `type_` is positional, rest keyword-only.

### `numpy_forms()`

```python
def numpy_forms(
    type_: ak.types.NumpyType | st.SearchStrategy[ak.types.NumpyType] | None = None,
    *,
    dtypes: st.SearchStrategy[np.dtype] | None = None,
    allow_datetime: bool = True,
    inner_shape: tuple[int, ...] | st.SearchStrategy[tuple[int, ...]] | None = None,
    allow_inner_shape: bool = True,
) -> st.SearchStrategy[ak.forms.NumpyForm]:
    '''Strategy for NumpyForm (primitive/leaf forms).

    Parameters
    ----------
    type_
        A NumpyType or strategy for NumpyType. If given, generates a
        NumpyForm that corresponds to the type. When set, `dtypes`,
        `allow_datetime`, `inner_shape`, and `allow_inner_shape` are
        ignored (the form is fully determined by the type).
    dtypes
        Strategy for NumPy dtypes to use as the primitive. If None, uses
        `supported_dtypes()`. Ignored when `type_` is given.
    allow_datetime
        Include datetime64/timedelta64 when `dtypes` is None. Ignored
        when `type_` is given.
    inner_shape
        A fixed inner_shape tuple or a strategy for inner_shape tuples. If
        None, generates inner_shape values controlled by `allow_inner_shape`.
        Ignored when `type_` is given.
    allow_inner_shape
        When `inner_shape` is None, allow generation of non-empty
        inner_shape tuples. If False, inner_shape is always `()`. Only
        effective when `inner_shape` is None. Ignored when `type_` is given.

    Returns
    -------
    st.SearchStrategy[ak.forms.NumpyForm]
        Strategy generating NumpyForm objects with `parameters=None` and
        `form_key=None`.

    Examples
    --------
    >>> import hypothesis_awkward.strategies as st_ak
    >>> f = st_ak.numpy_forms().example()
    >>> isinstance(f, ak.forms.NumpyForm)
    True

    >>> # From a NumpyType
    >>> t = ak.types.NumpyType('float64')
    >>> f = st_ak.numpy_forms(type_=t).example()
    >>> f.primitive
    'float64'
    >>> f.inner_shape
    ()

    >>> # Only float primitives, no inner_shape
    >>> f = st_ak.numpy_forms(
    ...     dtypes=st_ak.supported_dtypes().filter(lambda d: d.kind == 'f'),
    ...     allow_inner_shape=False,
    ... ).example()
    >>> f.inner_shape
    ()

    >>> # Fixed inner_shape
    >>> f = st_ak.numpy_forms(inner_shape=(2, 3)).example()
    >>> f.inner_shape
    (2, 3)

    '''
```

### Parameter Details

#### `type_`

Accepts a `NumpyType`, a strategy for `NumpyType`, or `None`:

- If a `NumpyType`, wrapped in `st.just()` and mapped to `NumpyForm`
- If a strategy, draws from it and maps to `NumpyForm`
- If `None` (default), the form is generated from `dtypes` and `inner_shape`

When `type_` is given, the form is fully determined: the primitive is copied from
the type's `primitive` attribute and `inner_shape` is forced to `()`. This is
because any non-empty `inner_shape` would make `NumpyForm.type` return
`RegularType(...)` instead of a `NumpyType`. All other parameters are ignored.

This establishes a convention for all `*_forms()` strategies: each accepts an
optional `type_` parameter to generate forms matching a given type. For
`NumpyForm` the mapping is 1:1, but for higher-level forms (e.g.,
`list_forms(type_=ListType(...))`) the same type can produce different form
implementations.

#### `dtypes`

Follows the same pattern as `numpy_types()`:

- If `None`, uses `supported_dtypes()` (filtered by `allow_datetime`)
- If a strategy, draws from it
- The drawn dtype is converted to a primitive string via
  `ak.forms.numpyform.dtype_to_primitive()`

#### `allow_datetime`

Same as `numpy_types()`. Filters out datetime64/timedelta64 when `dtypes` is
`None`.

#### `inner_shape`

Controls the `inner_shape` parameter of `NumpyForm`:

- If a `tuple`, used as-is (wrapped in `st.just()`)
- If a strategy, draws from it
- If `None`, generation is controlled by `allow_inner_shape`

When generating inner_shape (i.e., `inner_shape` is `None` and
`allow_inner_shape` is `True`), the strategy should:

- Generate `()` most of the time (the common case)
- Occasionally generate small tuples: `(2,)`, `(3, 4)`, etc.
- Limit dimensions to ~1-3 (non-empty inner_shape is rare in practice)
- Limit dimension sizes to ~2-10

Suggested internal generation:

```python
def _inner_shapes(
    max_ndim: int = 3,
    max_side: int = 10,
) -> st.SearchStrategy[tuple[int, ...]]:
    '''Strategy for inner_shape tuples.'''
    return st.one_of(
        # Empty inner_shape (common case, weighted higher)
        st.just(()),
        # Non-empty inner_shape (rare case)
        st.lists(
            st.integers(min_value=1, max_value=max_side),
            min_size=1,
            max_size=max_ndim,
        ).map(tuple),
    )
```

#### `allow_inner_shape`

Boolean gate for non-empty inner_shape generation:

- `True` (default): inner_shape can be `()` or a non-empty tuple
- `False`: inner_shape is always `()`
- Only effective when `inner_shape` is `None`

This mirrors how `allow_datetime` gates datetime dtypes.

### Not Included (Future Extensibility)

#### `parameters`

Always `None` for now. When added later, the signature would extend to:

```python
def numpy_forms(
    ...,
    parameters: dict | st.SearchStrategy[dict] | None = None,
) -> st.SearchStrategy[ak.forms.NumpyForm]:
```

#### `form_key`

Always `None` for now. When added later:

```python
def numpy_forms(
    ...,
    form_key: str | st.SearchStrategy[str] | None = None,
) -> st.SearchStrategy[ak.forms.NumpyForm]:
```

## Design Decisions

### 1. `type_` Parameter for Type-to-Form Generation

**Decision:** Accept an optional `type_` parameter that generates a form matching
a given `NumpyType`.

**Rationale:**

- Establishes a convention for all `*_forms()` strategies, supporting the
  ultimate `arrays(type=...)` pipeline
- For `NumpyType` the mapping is 1:1 (primitive + empty inner_shape), but the
  pattern becomes valuable for higher-level forms where one type maps to multiple
  form implementations
- When `type_` is given, all other parameters are ignored — the form is fully
  determined by the type

**Constraints:**

- `type_` and `dtypes` are mutually exclusive in intent; `type_` takes precedence
- `inner_shape` is always `()` when `type_` is given because non-empty
  inner_shape produces `RegularType`, not `NumpyType`

### 2. Parallel Parameter Names to `numpy_types()`

**Decision:** Use `dtypes` and `allow_datetime`, matching `numpy_types()`.

**Rationale:**

- Consistent API across type-level and form-level strategies
- Users already familiar with `numpy_types()` can transfer knowledge
- Reduces cognitive load

### 2. `inner_shape` Accepts Both Fixed Tuples and Strategies

**Decision:** Accept `tuple[int, ...] | st.SearchStrategy[tuple[int, ...]] | None`.

**Rationale:**

- Fixed tuple is the most common use case (e.g., `inner_shape=(2, 3)`)
- Strategy allows full control for advanced users
- `None` enables automatic generation with sensible defaults
- Matches the `dtype` parameter pattern in `numpy_arrays()`

### 3. Separate `allow_inner_shape` Boolean

**Decision:** Add `allow_inner_shape` rather than requiring users to pass
`inner_shape=st.just(())`.

**Rationale:**

- Non-empty inner_shape is uncommon; users often want to exclude it
- Mirrors `allow_datetime` pattern from `numpy_types()`
- More ergonomic than `inner_shape=st.just(())`

### 4. Weight Empty `inner_shape` Higher

**Decision:** Generate `()` more often than non-empty tuples.

**Rationale:**

- Reflects real-world usage (most NumpyForms have empty inner_shape)
- Non-empty inner_shape exists mainly for NumPy sub-array dtypes
- Avoids overwhelming tests with the rare case

### 5. No `max_inner_ndim` or `max_inner_side` Parameters

**Decision:** Use fixed internal defaults (max 3 dimensions, max size 10).

**Rationale:**

- Keeps the public API simple
- These limits match realistic usage
- Advanced users can pass a custom `inner_shape` strategy
- Can be added later if needed without breaking changes

## Relationship to Existing Strategies

```text
supported_dtypes()  <--  numpy_types()   (generates NumpyType)
                    <--  numpy_forms()   (generates NumpyForm)
                    <--  numpy_arrays()  (generates np.ndarray)
```

`numpy_forms()` reuses `supported_dtypes()` the same way `numpy_types()` does.
The primitive string is derived from the drawn dtype.

## Usage Examples

### Basic Usage

```python
import hypothesis_awkward.strategies as st_ak
from hypothesis import given

@given(f=st_ak.numpy_forms())
def test_numpy_form_has_valid_type(f):
    t = f.type
    assert isinstance(t, ak.types.Type)
```

### Only Simple Forms (No Inner Shape)

```python
@given(f=st_ak.numpy_forms(allow_inner_shape=False))
def test_simple_numpy_form(f):
    assert f.inner_shape == ()
    assert isinstance(f.type, ak.types.NumpyType)
```

### Fixed Inner Shape

```python
@given(f=st_ak.numpy_forms(inner_shape=(3,)))
def test_fixed_inner_shape(f):
    assert f.inner_shape == (3,)
```

### Integer Primitives Only

```python
int_dtypes = st_ak.supported_dtypes().filter(lambda d: d.kind == 'i')

@given(f=st_ak.numpy_forms(dtypes=int_dtypes, allow_inner_shape=False))
def test_integer_forms(f):
    assert f.primitive in ('int8', 'int16', 'int32', 'int64')
```

### From a NumpyType

```python
@given(data=st.data())
def test_form_matches_type(data):
    t = data.draw(st_ak.numpy_types())
    f = data.draw(st_ak.numpy_forms(type_=t))
    assert f.primitive == t.primitive
    assert f.inner_shape == ()
    assert isinstance(f.type, ak.types.NumpyType)
```

### Composing with Future `forms()` Strategy

```python
# Future: numpy_forms() as the leaf strategy in recursive forms()
@given(f=st_ak.forms())
def test_forms_have_type(f):
    t = f.type
    assert isinstance(t, ak.types.Type)
```

## Testing Plan

Following the patterns in
[testing-patterns.md](./../../.claude/rules/testing-patterns.md):

1. **TypedDict for kwargs**: `NumpyFormsKwargs`
2. **Strategy for kwargs**: `numpy_forms_kwargs()`
3. **Main property test**: Verify all options are respected
4. **Edge case reachability tests**:
   - Can generate empty inner_shape
   - Can generate non-empty inner_shape
   - Can generate datetime primitives
   - Can generate non-datetime primitives
   - `allow_inner_shape=False` always produces `()`
   - `allow_datetime=False` never produces datetime/timedelta
   - `type_` produces a form with matching primitive and empty inner_shape
