|
| 1 | +# FR-011 — Recover hardening: enum coercion pipeline + nested-object recovery |
| 2 | + |
| 3 | +_Design doc. 2026-05-30. (Rescoped from an earlier "tolerant ingestion engine" draft — see History.)_ |
| 4 | + |
| 5 | +## Context |
| 6 | + |
| 7 | +FR-010 shipped a tolerant `recover()` parser for fluid/hallucinated **LLM output**, in all 5 ports, |
| 8 | +living in the render/prompt pillar (`render/recover/`). It works, but recovering enum values is |
| 9 | +weak: even when you tell an LLM the valid members, it emits casing/spacing/separator variants and |
| 10 | +outright hallucinations, which today fall straight to MALFORMED. This FR hardens the enum-recovery |
| 11 | +path and closes the nested-object gap — improvements to the **existing LLM-output feature**, in place. |
| 12 | + |
| 13 | +**Deliberately NOT generalizing.** An earlier draft proposed decoupling the engine into a standalone |
| 14 | +general-purpose `ingest` package and adding a declarative payload-mapping layer (ESB/iPaaS style). |
| 15 | +That was over-reach: the engine has exactly one real consumer (LLM output), so decoupling is 5-port |
| 16 | +churn for no second consumer, and a general inbound-JSON/XML mapping engine is far more than the |
| 17 | +current need justifies. See **Out of scope**. |
| 18 | + |
| 19 | +## Goals |
| 20 | + |
| 21 | +1. **Enum coercion pipeline** so variant/hallucinated enum values resolve robustly and safely: |
| 22 | + `exact → normalize → @enumAlias → @coerceDefault → MALFORMED`, with `@default` filling absent |
| 23 | + values and the reserved `DEFAULTED` classification finally emitted. |
| 24 | +2. **Configurable normalization** via a `@normalize` attr (ASCII-only modes; per-field + a |
| 25 | + value-object-level default). |
| 26 | +3. **Nested / embedded-object recovery** — close the FR-010 deferral, uniform across ports. |
| 27 | +4. **Expand the conformance corpus** for the above. |
| 28 | + |
| 29 | +Stays where it is — `render/recover/` per port. No package move; `recover()` API unchanged. |
| 30 | + |
| 31 | +## Out of scope (deliberately) |
| 32 | + |
| 33 | +- **Decoupling into a general `ingest` package** — premature abstraction; one consumer today. |
| 34 | +- **The `unicode` normalization mode** — full Unicode NFKC_Casefold can't be guaranteed byte-identical |
| 35 | + cross-port (ICU-class divergence), and enum members are ASCII by definition, so it's unneeded. |
| 36 | + ASCII modes only. |
| 37 | +- **Fuzzy matching** — deferred (a future nice-to-have). The pipeline reserves a clean, non-breaking |
| 38 | + slot for it (between `@enumAlias` and `@coerceDefault`); if real typo-class misses appear, add a |
| 39 | + **guarded integer Levenshtein** there (≤1 + clear margin + normalized-length ≥5, `tolerance=loose` |
| 40 | + only — never a float/Jaro-Winkler variant, to preserve cross-port determinism). Not built here. |
| 41 | +- **Declarative payload mapping** (source paths, multi-source, runtime-injectable mapping, CDM) — |
| 42 | + this is consumer-specific transformation work, best done **custom in the adopter** (single language, |
| 43 | + full control; lean on a path-extraction lib like JMESPath for the primitive; target the metaobjects |
| 44 | + value-object as the canonical model). A general config-driven mapping/ingestion product, if it ever |
| 45 | + earns its keep, belongs in the **commercial layer (metaforge)**, not the Apache-2.0 core. |
| 46 | +- **General scalar-coercion breadth** (locale numbers, date-format soup, currency/units) and a |
| 47 | + production observability/drift-metrics layer — those only mattered for the abandoned general-ingestion |
| 48 | + ambition. |
| 49 | +- A Turing-complete transform DSL — the existing `onField` hook covers rare bespoke compute. |
| 50 | + |
| 51 | +## Enum coercion pipeline |
| 52 | + |
| 53 | +Resolution order for an enum value during recover, given the field's resolved normalization mode `M`: |
| 54 | + |
| 55 | +1. **exact** match against `@values` → `RECOVERED`. |
| 56 | +2. **normalized** match using mode `M` (both input and members normalized by `M`; skipped when |
| 57 | + `M = none`) → `RECOVERED` (a `normalize` coercion logged). |
| 58 | +3. **`@enumAlias`** (explicit synonyms, properties map) — runtime aliases win over schema; alias keys |
| 59 | + are normalized by `M` before comparison; a hit → `RECOVERED` (alias coercion logged). |
| 60 | +4. *(reserved slot — fuzzy, not built; see Out of scope)* |
| 61 | +5. **`@coerceDefault`** (new attr) — a present-but-uncoercible value falls back to this member → |
| 62 | + `DEFAULTED` (coercion logged). If absent, fall through. |
| 63 | +6. **MALFORMED**. |
| 64 | + |
| 65 | +**Absent** (missing) field: filled by **`@default`** (existing attr) if declared → `DEFAULTED`; else |
| 66 | +`LOST_OPTIONAL` / `LOST_REQUIRED` by `@required`. A `DEFAULTED`-via-`@coerceDefault` value satisfies |
| 67 | +`@required` (it's a valid member). Rationale for two attrs: `@default` = "value when none supplied"; |
| 68 | +`@coerceDefault` = "value when supplied-but-garbage" — the LLM omitting a field vs hallucinating one |
| 69 | +may warrant different fallbacks. A present-uncoercible value does **not** borrow `@default` (only |
| 70 | +`@coerceDefault`), keeping the two independent. |
| 71 | + |
| 72 | +### Normalization modes (`@normalize`) |
| 73 | + |
| 74 | +Closed-enum attr, ASCII-only (enum members are ASCII → every mode is a pure `[A-Za-z0-9]` transform, |
| 75 | +**byte-identical cross-port** with zero locale/Unicode machinery — sidesteps the Turkish-İ/locale and |
| 76 | +Unicode-version traps entirely): |
| 77 | + |
| 78 | +- **`none`** — exact only (skip step 2). |
| 79 | +- **`collapse`** — ASCII case-fold (`a–z`↔`A–Z`) + trim + collapse runs of `[\s_\-]+` to one `_`. |
| 80 | + `"in progress"`/`"In-Progress"` → `IN_PROGRESS`; `"inprogress"` stays distinct. |
| 81 | +- **`strip`** *(default)* — ASCII case-fold + strip everything except `[A-Z0-9]`. Separator/ |
| 82 | + punctuation/whitespace-insensitive: `"in progress"`/`"In-Progress"`/`"inprogress"`/`"in progress!"` |
| 83 | + → `INPROGRESS`. Most forgiving. (Load-time warning if two members collide under `strip`.) |
| 84 | + |
| 85 | +Mode resolution (the codegen schema-emitter bakes the resolved `M` into the `FieldSpec`): |
| 86 | +field-level `@normalize` → owning `object.value`-level `@normalize` (object default) → global default |
| 87 | +**`strip`**. |
| 88 | + |
| 89 | +## Nested / embedded-object recovery |
| 90 | + |
| 91 | +Close the FR-010 deferral uniformly. A `FieldSpec` of kind `Object` carries a nested schema; the |
| 92 | +extract stage descends into it, classifying sub-fields with **dotted child paths** (`meta.score`, |
| 93 | +`items[0].label`). Object-given-where-scalar → MALFORMED; scalar-given-where-object → MALFORMED. |
| 94 | +Arrays of objects recover element-wise (partial-MALFORMED keeps good elements — same rule as scalar |
| 95 | +arrays). Replaces the current per-port divergence (TS/C#/Python partially support, Java/Kotlin defer) |
| 96 | +with one corpus-pinned behavior. |
| 97 | + |
| 98 | +## Metamodel attrs |
| 99 | + |
| 100 | +- **`@coerceDefault`** (NEW) — on `field.enum`. String member symbol; must be one of `@values` |
| 101 | + (loader-validated → `ERR_BAD_ATTR_VALUE`). Drives step 5. Generic name (not enum/LLM-specific). |
| 102 | +- **`@normalize`** (NEW) — closed enum `none | collapse | strip` (loader-validated). On `field.enum` |
| 103 | + (per-field) and `object.value` (default for its enum fields). Resolved field → object → global `strip`. |
| 104 | +- **`@default`** (EXISTING) — its enum value also serves as the *absent*-fill during recover; documented |
| 105 | + dual role (DB/codegen default + recover absent-fill). |
| 106 | +- **`@enumAlias`** (EXISTING, FR-010) — alias keys now normalized by the field's mode `M` (step 3). |
| 107 | +- All registered identically across ports. |
| 108 | + |
| 109 | +## Tiers (cross-language-porting) |
| 110 | + |
| 111 | +- **Tier 1 (INVARIANT — corpus-pinned, byte-identical):** per-field classification + `empty`; the |
| 112 | + resolution order; the `none`/`collapse`/`strip` normalization rules (pure ASCII); the |
| 113 | + `@coerceDefault`/`@default`/`@enumAlias`/`@normalize` vocabulary + the field→object→global mode |
| 114 | + resolution; nested dotted-path classification; existing strip/locate/read/coerce behavior; |
| 115 | + MALFORMED/TRUNCATED sentinels. |
| 116 | +- **Tier 2 (idiomatic):** enums/dataclasses/records per language; internal mechanism. |
| 117 | + |
| 118 | +## Conformance corpus expansion |
| 119 | + |
| 120 | +Add to the shared `fixtures/recover-conformance/` (existing 10 cases stay green): |
| 121 | + |
| 122 | +- **Enum pipeline:** normalized-variant per mode (`strip`: `"in progress"`/`"inprogress"`; `collapse`: |
| 123 | + `"in progress"` but NOT `"inprogress"`; `none`: exact-only); field-level vs object-level `@normalize` |
| 124 | + resolution; `@enumAlias` synonym; `@coerceDefault` fallback (→ DEFAULTED); `@default` absent-fill |
| 125 | + (→ DEFAULTED); required-field-with-`@coerceDefault` satisfies required. |
| 126 | +- **XML breadth:** nested element, repeated→array, attribute-vs-element, more malformations (only 1 |
| 127 | + XML case of 10 today). |
| 128 | +- **Nested objects:** clean nested, partial-nested (some sub-fields MALFORMED), array-of-objects. |
| 129 | +- **Multi-malformation:** one input combining fence + preamble + trailing-comma + off-vocab enum, |
| 130 | + asserting per-field independence. |
| 131 | + |
| 132 | +The corpus is the oracle: never edit a fixture to match a port; escalate a suspected-wrong fixture. |
| 133 | + |
| 134 | +## Testing strategy |
| 135 | + |
| 136 | +1. Shared conformance corpus (above) — the cross-port oracle. |
| 137 | +2. Per-stage unit tests per port (coerce incl. each `@normalize` mode + `@coerceDefault`; nested extract). |
| 138 | +3. **Executable proofs** — TS/Python import-and-run, C#/Java/Kotlin compile-and-run — invoking the |
| 139 | + generated parser on dirty input (e.g. an off-vocab enum → `DEFAULTED` end-to-end). |
| 140 | +4. Loader-attr tests for `@coerceDefault` + `@normalize` registration + bad-value rejection. |
| 141 | +5. **Recommended (cheap, high-value):** a small **differential/property fuzz** check — feed random |
| 142 | + inputs to all ports, assert (a) never-throws and (b) identical classification cross-port. This |
| 143 | + validates the never-throws + byte-identical guarantees better than curated fixtures alone. |
| 144 | + |
| 145 | +## Cross-port rollout |
| 146 | + |
| 147 | +**Pilot: TypeScript** (reference port; freshest engine). Then port C# / Java / Kotlin (JVM-shared) / |
| 148 | +Python, each gated on the shared corpus. Per-unit review + simplify before each merge; forward-only |
| 149 | +merges; push for durability. (Established FR-010 workflow.) |
| 150 | + |
| 151 | +## Resolved design decisions (brainstorming 2026-05-30) |
| 152 | + |
| 153 | +- Pipeline = exact → normalize(`M`) → `@enumAlias` → `@coerceDefault` → MALFORMED; `@default` fills absent. |
| 154 | +- New attrs **`@coerceDefault`** (present-but-garbage fallback) + **`@normalize`** (`none|collapse|strip`, |
| 155 | + default `strip`, with object-level default). `@default` handles absent. |
| 156 | +- **No decouple** — stays in `render/recover/`. **No `unicode` mode.** **Fuzzy deferred** (reserved slot). |
| 157 | +- Nested-object recovery in-scope and uniform. |
| 158 | +- TS pilot. |
| 159 | +- The declarative payload-mapping layer is **not** OSS-core (consumer-custom; commercial-metaforge if scaled). |
| 160 | + |
| 161 | +## History |
| 162 | + |
| 163 | +Rescoped 2026-05-30 from a broader "tolerant ingestion engine" draft (decouple + general framing + |
| 164 | +`unicode` mode + fuzzy + a follow-on FR-012 declarative-mapping layer). A staff-level review concluded |
| 165 | +the general-ingestion ambition was unjustified by current demand (one LLM consumer + one adopter intake |
| 166 | +need), so the scope was cut to the high-value, low-risk core that improves the shipped LLM feature in |
| 167 | +place. The mapping layer is consumer/custom (or commercial) work, not the OSS core. |
0 commit comments