## Purpose (Non-normative)

This note captures implementation-centered decisions and trade-offs for `crystflux.v1.core`.
It is written to reduce “where is this defined?” churn for humans and LLMs.

If you are changing behavior, prefer updating docstrings/specs in code and tests; treat this as
an orientation + decision log.

---

## Hot Path Map (Start Here)

- Public surface (what exists): `src/crystflux/v1/core/value_api.py`
- Mode semantics (Strict/Latent/Dream): `src/crystflux/v1/core/value_modes.py`
- Shared mechanics (freeze/sort/accessors): `src/crystflux/v1/core/value_core.py`
- VOID behavior (absorbing null object): `src/crystflux/v1/core/void_value.py`
- Array operations implementation: `src/crystflux/v1/core/value_array.py`

Design Background: `docs/01-concepts/README.md`

---

## Vocabulary

- **JSON null**: the JSON literal `null`, represented as Python `None`.
- **Missing**: absence due to key/index not found, or type mismatch for an operation.
- **VOID**: `VOID = VoidValue(None)` (absorbs chains; falsy; carries no reason).
- **Reasoned void**: an opt-in void that exposes a `VoidReason` (e.g., `ReasonedVoid` or Dream’s
  reasoned void).
- **Modes**:
  - **Strict**: fail-fast (raise on mismatches).
  - **Latent**: collapse missing/mismatch to VOID, keep chain alive.
  - **Dream**: tolerate undefined methods and (optionally) observe “desire paths”.

---

## Invariants (Core Constraints)

- Values are treated as `JsonValue` only (dict/list/tuple/str/int/float/bool/None).
  - Type classifier: `src/crystflux/v1/core/json_types.py`
- Deep immutability is enforced on construction/spawn:
  - `dict -> MappingProxyType`, `list -> tuple`: `src/crystflux/v1/core/freeze_value.py`
- Internal type classification is one-hot and cached per instance:
  - `SortedJsonValue` via `sort_json_value(...)`: `src/crystflux/v1/core/json_types.py`

These constraints strongly discourage introducing custom sentinel objects into `_value`, because
the classifier intentionally rejects non-JSON types.

---

## Mode Semantics (At a Glance)

### Missing / mismatch behavior (navigation + array ops)

| Operation | Strict | Latent | Dream |
|---|---|---|---|
| `get(key)` missing key | `KeyError` | `VOID` (optional reason) | `VOID` / Dream void (optional reason) |
| `get(key)` non-object | `TypeError` | `VOID` (optional reason) | `VOID` / Dream void (optional reason) |
| `at(i)` out of range | `IndexError` | `VOID` (optional reason) | `VOID` / Dream void (optional reason) |
| `at(i)` non-array | `TypeError` | `VOID` (optional reason) | `VOID` / Dream void (optional reason) |
| `to_array()` non-array | `TypeError` | `VOID` (optional reason) | `VOID` / Dream void (optional reason) |
| `map/filter` non-array | `TypeError` | `VOID` (optional reason) | `VOID` / Dream void (optional reason) |
| Undefined method call | N/A | N/A | logs via `on_desire`, returns Dream void |

Implementation references:
- `src/crystflux/v1/core/value_modes.py`
- `src/crystflux/v1/core/value_core.py`
- `src/crystflux/v1/core/value_array.py`
- `src/crystflux/v1/core/void_value.py`

### `is_void()` / truthiness (important pitfall)

| Value wrapper | When `is_void()` is `True` | Notes |
|---|---|---|
| `VOID` (`VoidValue`) | always | singleton; absorbs everything |
| `StrictValue` | never | even when wrapping JSON null (`None`) |
| `LatentValue` | never | even when wrapping JSON null (`None`) |
| `DreamValue` | `_value is None` | JSON null behaves “void-like” in Dream |

This is a deliberate trade-off; see the decision record below.

---

## Array Operations: Why `*ArrayValue` Exists

Array operations are exposed on `ValueAPI` for a uniform call site, but implemented through a
mode-aware “promotion” step.

- Promotion point: `BaseValue.to_array()` in `src/crystflux/v1/core/value_core.py`
- Array ops implementation: `ArrayOpsMixin` in `src/crystflux/v1/core/value_array.py`

Key idea:
- Values do not auto-switch to an ArrayValue based on content.
- When you call an array operation (`map/filter/first_rest/rest_last/to_array`), the value is
  promoted to `StrictArrayValue` / `LatentArrayValue` / `DreamArrayValue` as needed.

This centralizes non-array behavior (Strict raises; Latent/Dream collapse to VOID) and keeps the
API surface small and mode-consistent.

---

## Array Length Access: len() Pitfalls

To get array length safely, use `len(value.as_str_array() or ())`. Direct `len()` fails on `None` because `None` lacks `__len__`.

### Return Types
| Method | Return Type | len() Behavior |
|--------|-------------|----------------|
| `as_str_array()` | `tuple[str, ...] | None` | Fails on `None` |
| `as_int_array()` | `tuple[int, ...] | None` | Fails on `None` |
| `as_float_array()` | `tuple[float, ...] | None` | Fails on `None` |
| `as_scalars()` | `tuple[JsonScalar, ...] | None` | Fails on `None` |
| `expected_str_array()` | `tuple[str, ...]` | Always works |
| `expected_int_array()` | `tuple[int, ...]` | Always works |
| `expected_float_array()` | `tuple[float, ...]` | Always works |

### Recommended Patterns
```python
# Pattern A: Get length (recommended)
len(value.as_str_array() or ())

# Pattern B: Discriminate None
arr = value.as_str_array()
if arr is not None:
    len(arr)

# Pattern C: Type-safe (raises on non-array)
len(value.expected_str_array())
```

### Root Cause
`tuple[...]\|None` does not satisfy `Sized` protocol because `None` lacks `__len__`.

### Notes & Pitfalls

**Non-array vs Empty Array**
- Non-array (Mapping) → returns `None`
- Empty array `[]` → returns `()` (empty tuple)
- `len(())` returns `0`

**Type Checker vs Runtime**
- Static: `len(tuple[...]\|None)` is a type error
- Runtime: `len(None)` raises `TypeError`
- Use `or ()` to satisfy both

**None Discrimination**
- Use `if arr is not None` when you need to distinguish `None` from `()`
- `or ()` pattern treats both as length `0`

---

## with_value / map Semantics (Void + JSON Normalization)

`with_value` is a public replacement for `_spawn` that preserves mode/config while swapping the
wrapped JSON value. It is not a “revive” mechanism.

- `with_value(value)`: return a new Value in the same mode/config, wrapping `value`.
- `VOID.with_value(value)`: returns `VOID` (no revival).
- `ReasonedVoid.with_value(value)`: returns itself and keeps the reason.

`map` collects raw JSON values:

- `map(fn)` calls `fn(ValueAPI)` for each element, then collects `fn(...).as_json_value()`.
- The result is always a JSON tuple; it never contains `ValueAPI` objects.
- If `VOID` (or Dream void) appears in the chain, its `as_json_value()` is `None`, so `None`
  appears in the mapped tuple.

Pitfall to avoid:
- `with_value` applies to non-VOID values too. It always replaces the current value, not just
  missing ones.

Minimal example:

```python
value = Crystallizer.latent([{"a": 1}, {"b": 2}, {"a": 3}])
mapped = value.map(lambda x: x.get("a").with_value(0))
# -> (0, None, 0)
```

---

## Filter Predicates: Pitfalls and None Handling

`filter` expects a predicate returning `bool`. If you pass a projection that returns `ValueAPI`,
Python will treat it as truthy and the filter will appear to “work” while silently keeping most
elements. Use scalar predicates instead.

None handling policy for predicates:
- `as_*` predicates treat `None` as `False` (drop the element).
- `expected_*` predicates raise `TypeError` on `None` (fail fast).

Avoid `== True` in predicates because Ruff E712 flags it; prefer explicit helpers like
`is_true()` / `is_false()` when building predicates from projections.

---

## Void Reasoning (Opt-in Diagnostics)

The canonical `VOID` singleton never carries reasons.

- When `_void_reasoning_enabled` is on, operations that collapse to void may return:
  - `ReasonedVoid(reason=...)` (base path), or
  - a Dream-specific void carrying `VoidReason` (Dream path).

Reasons are defined in `src/crystflux/v1/core/void_reason.py`.

---

## Decision Record: Dream uses `None` as the void sentinel

### Decision

`DreamValue.is_void()` is implemented as `return self._value is None` in
`src/crystflux/v1/core/value_modes.py`.

### Rationale (structural)

Dream aims to keep chains alive *without* falling back to the `VOID` singleton, because Dream also
needs to preserve and extend `chain_steps` and optionally call `on_desire`.

At the same time, core internals constrain `_value` to `JsonValue` and run the JSON classifier on
every instance. Introducing a custom sentinel object would violate these constraints and complicate
freezing/classification. Using `None` is the cheapest in-model representation that stays inside
`JsonValue` and aligns with `VOID.as_json_value() == None`.

### Trade-off

Dream conflates:
- **present JSON null** (`None` in input), and
- **absence/failure** (void produced by missing/mismatch),

because both share the same raw representation (`None`) in Dream.

### When this matters (how to handle null explicitly)

If you must distinguish “present null” vs “missing”:
- Prefer doing that part of the pipeline in **Strict** or **Latent** (where JSON null is a value,
  and VOID is distinguishable via `is_void()`), then optionally switch to Dream afterwards.
- If Dream must also preserve the distinction, this requires a spec change (e.g., an explicit void
  flag on `DreamValue` instead of using `None` as the sentinel). This is a behavior change for
  truthiness and `is_void()` and should be treated as a breaking change.

---

## Open Questions

- Should Dream distinguish JSON null from void (explicit void flag)?
- If yes, what is the compatibility story for `__bool__` and existing Dream pipelines?
