Skip to content

Commit 9656c12

Browse files
dmealingclaude
andcommitted
docs(spec): rescope FR-011 to "recover hardening" — drop decouple/unicode/fuzzy/general-mapping
Staff-level review concluded the broader "tolerant ingestion engine + declarative payload mapping" framing was unjustified by current demand (one LLM consumer + one adopter intake need). Cut to the high-value, low-risk core that improves FR-010's recover() in place: - enum coercion pipeline (exact → @normalize none|collapse|strip → @enumAlias → @coerceDefault → MALFORMED; @default fills absent; emit DEFAULTED), ASCII-only normalization (byte-identical cross-port), - uniform nested/embedded-object recovery, - conformance-corpus expansion. Dropped: decouple into a general `ingest` package, the @normalize `unicode` mode (can't guarantee byte-identical cross-port), fuzzy matching (deferred — reserved pipeline slot), and the FR-012 declarative-mapping layer (consumer-custom or commercial-metaforge, not OSS core). Renamed the spec file to match. Added FR-011 to roadmap Planned. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 605a21d commit 9656c12

3 files changed

Lines changed: 175 additions & 241 deletions

File tree

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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

Comments
 (0)