# JSON Fallback For Heterogeneous Attribute Values

## Status

Accepted

## Context

CityJSON permits attribute values of any JSON type, including lists whose items
are not homogeneous (e.g. `[607, false, 28.47]`). The benchmark corpus case
`stress_attribute_heavy` exercises this: it contains an attribute `illo` with a
three-element list mixing unsigned integer, boolean, and float.

Arrow requires every column to have a single, fixed data type. The projection
layer in `cityjson-arrow` previously failed with an `Err` when
`merge_projected_value_specs` encountered two incompatible types for the same
attribute key or list item position. This caused `write_stream` (and by
extension `cityjson-parquet`'s `write_file`) to panic on the corpus case before
any benchmark samples could be collected.

Two related gaps existed alongside the type-conflict error:

1. **No numeric widening.** Attributes that appear as unsigned integers in some
   city objects and signed integers (or floats) in others also triggered the
   error, even though a lossless target type exists in Arrow.
2. **No fallback for truly incompatible types.** Bool + number, or number +
   string, have no natural common Arrow type. The only lossless representation
   is JSON serialization.

## Decision

Introduce a `ProjectedValueSpec::Json` variant and numeric widening rules in
`merge_projected_value_specs`.

**Widening rules** (lossless within normal value ranges):

- `UInt64 + Int64` → `Int64`
- `UInt64 + Float64` or `Int64 + Float64` → `Float64`
- `Int64 + UInt64` symmetric

**JSON fallback:** when two specs cannot be widened to a common numeric or
homogeneous type, `merge_projected_value_specs` returns `ProjectedValueSpec::Json`
instead of an error. `Json` maps to `LargeUtf8` on the wire.

Write path: any `OwnedAttributeValue` projected under a `Json` spec is
serialized to a JSON string via `serde_json`. Read path: the JSON string is
deserialized back to `OwnedAttributeValue`, preserving the original type.

Once a spec becomes `Json` it stays `Json` during merges with any other spec,
so heterogeneous attribute columns remain consistently encoded.

Widening array encoders (`projected_i64_array`, `projected_f64_array`) now
accept all numeric variants up to their precision level, since a column
projected as `Int64` may contain values originally stored as `UInt64`.

## Consequences

Good:

- `write_stream` and `write_file` no longer panic on CityJSON files with
  heterogeneous list or mixed-type attributes.
- All conformance roundtrip tests (70 cases) continue to pass; none of the
  conformance fixtures contain heterogeneous types.
- Numeric widening is lossless for the value ranges generated by
  `cityjson-fake` and typical real-world city models.
- Roundtrip fidelity is preserved for `Json`-projected attributes: the
  deserialized value matches the original type (bool stays bool, int stays
  int).

Trade-offs:

- `Json`-projected columns are opaque to column pushdown and predicate
  filtering in Parquet/Arrow query engines; they cannot be used as typed
  filter targets.
- Write throughput for attribute-heavy workloads that exercise the `Json`
  fallback is noticeably lower than plain string columns, because
  `serde_json::to_string` is called per value.
- `UInt64` values widened to `Int64` saturate at `i64::MAX` for values above
  `2^63 - 1`. This is not a practical concern for city model attributes but is
  a documented limitation.
- Adding `Json` to the `ProjectedValueSpec` enum extends the package manifest
  schema. Old readers that encounter a `Json`-typed field will fail to
  deserialize the manifest. The schema ID (`cityjson-arrow.package.v3alpha3`)
  already signals instability.
