Skip to content

Commit 812091a

Browse files
dmealingclaude
andcommitted
Merge FR-010 Python port — Phase 1: recover engine + dirty-input conformance
Brings FR-010's tolerant recover engine to the Python port (metaobjects/render/recover/), the forgiving tier beyond FR-006's strict Pydantic parser. 8-stage pipeline, JSON+XML, never raises. All 10 shared recover-conformance cases green — classifies dirty LLM output identically to Java/C#. Python idioms: enum.Enum, dataclasses, Optional[int] as_int/as_long, bool excluded from numeric. Pre-merge review+simplifier: ASCII-numeric parity gate + JSON depth cap (never-raise on deep nesting). 108 recover tests + render suite 179 green; mypy --strict + ruff clean. Also lands the 3-phase Python port plan. Phases 2 (renderer) + 3 (attrs + serializer fix + codegen) follow. FR-010 then ships in all 5 ports. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 parents 3a3cb57 + 2fbe11f commit 812091a

22 files changed

Lines changed: 2133 additions & 1 deletion
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# FR-010 Python port — native recover engine + output-format renderer + codegen
2+
3+
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development + cross-language-porting. Java (`server/java/render/.../recover/` + `.../prompt/`) is the reference; C# (`server/csharp/MetaObjects.Render/{Recover,Prompt}/` + `MetaObjects.Codegen/Generators/`) and TS are fresh native ports to mirror. The conformance corpus is the ORACLE (`fixtures/recover-conformance/`, `fixtures/conformance/template-output-{xml,json}-simple/`). Reimplement idiomatically (Tier-2); never change Tier-1 (classification, vocabulary, rendered output, wire format). Never edit a fixture to match the port — escalate.
4+
5+
**Goal:** Bring FR-010 to the Python port — the LAST of the five ports. Python ships FR-006 + render + verify + codegen + conformance, but FR-010's recover engine + output-format renderer don't exist yet. Python natively reimplements both under `metaobjects/render/`, then adds the metamodel attrs + serializer fix + codegen. After this, FR-010 ships in all 5 ports.
6+
7+
**Three phases (each a separate merge):**
8+
1. **Recover engine + dirty-input conformance**`metaobjects/render/recover/` reimplemented from Java/C#; a recover-conformance pytest over `fixtures/recover-conformance/` is the gate. (≈ C# Phase 1.)
9+
2. **Output-format renderer**`metaobjects/render/prompt/` (3 comment-free styles × {json,xml}); byte-identical to Java/Kotlin/C#/TS, reusing the `escapers` registry. (≈ C# Phase 2.)
10+
3. **Metamodel attrs + serializer fix + codegen + clean fixtures** — register `@promptStyle`/`@example`/`@instruction`/`@enumAlias`/`@enumDoc`; fix `serializer_json` to sort `properties`-attr map keys (NOT `filter`); extend the output-parser codegen with a tolerant `recover_<name>()`; add an output-prompt generator. (≈ C# Phase 3.)
11+
12+
**Tech stack:** Python 3.11+, `metaobjects` package at `server/python/`, pytest, mypy --strict, ruff. Pydantic is the FR-006 strict tier; recover is the new forgiving tier (no Pydantic). Reuse the `metaobjects.render.escapers` registry for the renderer. Tests under `server/python/tests/`.
13+
14+
## Tier decisions (cross-language-porting)
15+
- **Tier 1 (INVARIANT — must match the corpus):** per-field `FieldRecovery` classification (`RECOVERED|DEFAULTED|LOST_OPTIONAL|LOST_REQUIRED|MALFORMED`) + `empty` flag + canonical normalized value (numeric ±1e-9); pipeline behavior (strip/locate/forgiving-read/malformed-vs-absent/coerce); the rendered fragment (comment-free, valid, 3 styles); `@promptStyle`/`@enumAlias`/`@enumDoc` vocabulary; MALFORMED/TRUNCATED sentinel effect; canonical serialization (`properties` keys sorted, `filter` order preserved).
16+
- **Tier 2 (idiomatic Python):** module layout under `recover/`; `recover(text, schema, opts=None)` free function; enums via `enum.Enum`/`StrEnum` with SCREAMING_SNAKE values matching the corpus; MALFORMED/TRUNCATED as unique sentinel objects; the generated `recover_<name>()` returns `RecoveryResult[<Name>Recovered]` where `<Name>Recovered` is an emitted dataclass with every field `Optional[T]` (parallel to C#/Kotlin/TS nullable mirror — Pydantic payloads can't hold None for a required field). `as_int`/`as_long` both return `Optional[int]` (Python has one int type).
17+
- **Tier 3 (free):** internal mechanism, the conformance runner plumbing, the import-and-call proof.
18+
- **Bounded deferral:** nested-object recover/prompt mapping; array-of-enum (parity with the other ports — though TS implemented engine-level nested; Python may either way as long as the corpus + codegen defer).
19+
20+
---
21+
22+
## PHASE 1 — recover engine + dirty-input conformance (detailed)
23+
24+
Port the engine to `metaobjects/render/recover/` (Python modules), then prove against the corpus. **The corpus is the oracle: when a case is red, ask "should it match?" before "how do I make it match"; never edit a fixture.**
25+
26+
### P1-1: data model (`recover/types.py`)
27+
Port `Format`, `FieldKind`, `FieldRecovery`, `Tolerance`, `Coercion`, `FieldSpec` (+ factories `scalar`/`enum_field`/`range_`/`object_`), `RecoverSchema`, `RecoverOptions` (+ `defaults()`), `RecoverOutcome`, `RecoveryResult`, `RecoveryReport` (mutable: `set`/`add_coercion`/`mark_empty`/`states`/`coercions`/`lost_required`/`malformed`/`has_lost_required`/`is_empty`). Enums with SCREAMING_SNAKE values matching the corpus. **Test:** mirror Java `ModelTest`/`ReportTest`.
28+
29+
### P1-2: `strip` (stage 1) — fence removal (regex, DOTALL). Mirror `StripTest`.
30+
### P1-3: `locate` (stages 2–3) — `locate_json` balanced-brace (string-aware) + `locate_xml`; first-closed-else-first-open; no-throw-on-`</x>`. Mirror `LocateTest`.
31+
### P1-4: `json_forgiving_reader` — tolerant JSON → dict; trailing commas, single/unquoted, **TRUNCATED sentinel**, no-hang on `{"xs":[}`. Port the FIXED version. Mirror `JsonForgivingReaderTest`.
32+
### P1-5: `xml_forgiving_reader` — tolerant XML → dict; unclosed-tag recovery, attr-quote variants, case-folding, no-throw on close-tag-start. Mirror `XmlForgivingReaderTest`.
33+
### P1-6: `coerce` (stage 7) — enum alias/vocab (runtime-alias-wins), numeric range clamp, **non-finite → MALFORMED**, boolean forms, on_field hook, normalizers. **Python numeric note:** use `float(...)`/`int(...)` with `math.isfinite`; reject `0x`/`0b`/`0o` radix prefixes that `int(s,0)` would accept but Java/C# reject (cross-port parity — both C# and TS added this guard); empty-string guard. `MALFORMED` = a unique sentinel object. Mirror `CoerceTest`.
34+
### P1-7: `recover` entry + `recover_map` + classify — assemble strip→locate→read→extract→classify→coerce; array partial-MALFORMED keeps elements; object-given-scalar → MALFORMED; TRUNCATED→MALFORMED; empty-guard. `recover_map` accessors `as_string/as_int/as_long/as_double/as_bool/as_string_list` None-safe. Mirror `RecoverTest`.
35+
### P1-8: dirty-input conformance runner
36+
`tests/conformance/test_recover_conformance.py` (parametrized over `fixtures/recover-conformance/`): parse `schema.json` (map UPPER enum strings), read `input.txt`, run `recover`, assert `expected.json`'s `empty` + `states` (EXHAUSTIVE key-set) + `data` (floats ±1e-9). All 10 cases.
37+
### P1-9: review + simplify + merge. `pytest` + `mypy --strict` + `ruff` green; KNOWN_GAPS note. Merge phase 1.
38+
39+
---
40+
41+
## PHASE 2 — output-format renderer (outline; detailed when phase 1 lands)
42+
Port `metaobjects/render/prompt/{prompt_style,prompt_field,output_format_spec,prompt_overrides,output_format_renderer}.py` — 3 comment-free styles × {json,xml}, reusing `escapers`. Byte-identical fragment (verbatim prose: "Fill in each field as described below:", "Respond exactly like this:", " one of ", " VAL = doc", " e.g. "). Numeric-vs-quoted uses `math.isfinite` + the radix guard (NaN/Infinity/0x.. → quoted). Tests mirror the Java renderer tests (comment-free, valid JSON via `json.loads`, NaN-quoted + finite-unquoted).
43+
44+
## PHASE 3 — metamodel attrs + serializer fix + codegen (outline)
45+
1. **Attrs:** `@promptStyle` (closed enum on template.output via a `_TEMPLATE_OUTPUT_ATTRS` AttrSchema with `allowed_values`, like `@format`); `@example`/`@instruction` (string, common field attrs); `@enumAlias`/`@enumDoc` (`ATTR_SUBTYPE_PROPERTIES`). Add constants.
46+
2. **Serializer fix:** in `serializer_json._normalize` (or the attr-value emit), sort `properties`-attr map keys ordinally while preserving `filter` order — value-shape heuristic (all-scalar values → sort; any dict/list value → preserve), same as C#/TS. Confirm filter desugar runs before serialization. Closes `template-output-{json,xml}-simple` (red in Python today).
47+
3. **Codegen:** extend the output-parser generator to additionally emit a `<Name>Recovered` dataclass (all `Optional`) + `recover_<name>(text, opts=None) -> RecoveryResult[<Name>Recovered]` from a baked RecoverSchema (a recover-schema emitter), for json/xml outputs; a new output-prompt generator emitting `render_<name>_format(overrides=None)` backed by the output-format renderer + an output-format-spec emitter. Reuse `type_map.py`.
48+
4. **Proof:** Python's advantage — `importlib` the emitted module in a test and CALL `recover_<name>()` on dirty input (@enumAlias fold) + `render_<name>_format()` (guide fragment). Plus mypy on emitted source if feasible.
49+
5. Clean `template-output-{json,xml}-simple` fixtures conformance (Python green).
50+
51+
---
52+
53+
## Self-review (phase 1)
54+
- All recover modules ported; corpus 10/10 green; classification + canonical-value asserted; FIXED behaviors carried (no-hang JSON, XML no-throw, non-finite MALFORMED, TRUNCATED→MALFORMED, radix-prefix guard, array partial); never-throws; nested deferred.
55+
- Verify-against-live-code: the Python attr-read API, the pytest parametrize idiom, the `escapers` registry (phase 2), the conformance fixture-walk + canonical-serialize compare (phase 3), the importlib proof.

server/python/src/metaobjects/render/__init__.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
1-
"""Render-tier engine (FR-004): the build-time template drift-check ``verify``."""
1+
"""Render-tier engine: the build-time template drift-check ``verify`` (FR-004) and
2+
the FR-010 tolerant ``recover`` parser."""
23

4+
from metaobjects.render.recover import (
5+
FieldKind,
6+
FieldRecovery,
7+
FieldSpec,
8+
Format,
9+
RecoverOptions,
10+
RecoverOutcome,
11+
RecoverSchema,
12+
RecoveryReport,
13+
RecoveryResult,
14+
Tolerance,
15+
recover,
16+
recover_map,
17+
)
318
from metaobjects.render.verify import (
419
ERR_OUTPUT_TAG_MISSING,
520
ERR_PARTIAL_UNRESOLVED,
@@ -17,9 +32,21 @@
1732
"ERR_PARTIAL_UNRESOLVED",
1833
"ERR_REQUIRED_SLOT_UNUSED",
1934
"ERR_VAR_NOT_ON_PAYLOAD",
35+
"FieldKind",
36+
"FieldRecovery",
37+
"FieldSpec",
38+
"Format",
2039
"InMemoryProvider",
2140
"PayloadField",
2241
"Provider",
42+
"RecoverOptions",
43+
"RecoverOutcome",
44+
"RecoverSchema",
45+
"RecoveryReport",
46+
"RecoveryResult",
47+
"Tolerance",
2348
"VerifyError",
49+
"recover",
50+
"recover_map",
2451
"verify",
2552
]
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# FR-010 Python recover engine — known gaps & intentional cross-port divergences
2+
3+
Scope: the tolerant `recover` pipeline (`metaobjects/render/recover/`). The Java engine
4+
(`server/java/render/.../recover/`) is the cross-port reference; `fixtures/recover-conformance/`
5+
is the oracle. All 10 corpus cases pass.
6+
7+
## Additive capability (TS + C# + Python, beyond Java/Kotlin codegen deferral)
8+
9+
- **Nested-object recover is implemented** — a `FieldSpec` with a nested `RecoverSchema`
10+
(the `object_(...)` factory) is descended into, classifying sub-fields with dotted paths
11+
(`meta.n`). Mirrors the Java/C# `extract` OBJECT recursion. Dormant under the corpus (no
12+
nested fixture) and under FR-010 codegen (which emits a scalar placeholder for parity), so
13+
it changes no shared-corpus result.
14+
15+
## Intentional, documented divergences (NOT bugs)
16+
17+
The cross-port contract pins *classification + canonical value* (numbers ±1e-9), not byte-
18+
identical native parsing.
19+
20+
- **Java-style numeric suffixes / hex-float literals.** Java's `Double.parseDouble` accepts
21+
`"42d"`/`"42f"` and hex-float forms (→ RECOVERED); Python rejects them → **MALFORMED** (same
22+
accepted divergence the C#/TS ports record). The load-bearing behavior — finite-only,
23+
`NaN`/`±Infinity` → MALFORMED — is identical.
24+
25+
- **ASCII-numeric gate (parity guard, not a divergence).** Python's `int()`/`float()` accept
26+
underscore digit grouping (`"1_000"`, PEP 515), Unicode digits (`"123"`, `"٣"`), and radix
27+
prefixes (`"0x10"`) that Java/C# reject. `coerce` gates numeric strings on an ASCII-only
28+
pattern so these all classify **MALFORMED**, matching the strict cross-port behavior (C#).
29+
30+
- **NBSP-trailing numerics.** `"1 "` → Python `str.strip()` removes the NBSP (Unicode
31+
whitespace) leaving `"1"` → RECOVERED, as does C# `Trim()`; Java `trim()` keeps it →
32+
MALFORMED. A pre-existing Java/C# disagreement on whitespace trimming; Python sides with C#.
33+
Not corpus-exercised.
34+
35+
## NIT (no cross-port consensus, no corpus case)
36+
37+
- **Out-of-int64-range integers** (e.g. `"9999999999999999999"`): Python has arbitrary-precision
38+
ints (RECOVERED with the exact value); Java saturates to `Long.MAX_VALUE`, C# wraps on the
39+
cast. The three ports already disagree, so there is no single canonical value to match. No
40+
corpus int approaches 2^63. Left as-is.
41+
42+
## Python-specific defensive bound
43+
44+
- **JSON nesting depth cap (`_MAX_DEPTH = 100`).** Python's recursion limit is far below the
45+
JVM/.NET stack, so a pathologically deep input (hundreds of nested brackets) would raise
46+
`RecursionError` — violating the never-throws contract. Past the cap the container is skipped
47+
(string-aware, non-recursive) and recorded as garbled (→ MALFORMED). Far above any realistic
48+
payload nesting; the other ports rely on their larger native stacks instead.
49+
50+
## Bounded deferral (parity with all ports)
51+
52+
- Array-of-enum is not specialized (a scalar array recovers via `as_string_list`).
53+
- `as_int`/`as_long` are identical (`Optional[int]`; Python has one int type), truncating toward zero.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""FR-010 tolerant ``recover`` engine (Tier 2).
2+
3+
A forgiving parser that takes dirty LLM output (fenced / preamble / prose-wrapped /
4+
truncated / trailing-comma JSON, unclosed-tag XML) and recovers it into a typed
5+
``dict``, classifying each field. It NEVER raises — the forgiving tier beyond
6+
FR-006's strict Pydantic parser.
7+
8+
Public entry point: :func:`recover`.
9+
"""
10+
from __future__ import annotations
11+
12+
from metaobjects.render.recover import recover_map
13+
from metaobjects.render.recover.coerce import MALFORMED
14+
from metaobjects.render.recover.json_forgiving_reader import (
15+
TRUNCATED,
16+
JsonForgivingReader,
17+
)
18+
from metaobjects.render.recover.recover import recover
19+
from metaobjects.render.recover.types import (
20+
Coercion,
21+
FieldKind,
22+
FieldRecovery,
23+
FieldSpec,
24+
Format,
25+
Normalizer,
26+
OnField,
27+
RecoverOptions,
28+
RecoverOutcome,
29+
RecoverSchema,
30+
RecoveryReport,
31+
RecoveryResult,
32+
Tolerance,
33+
)
34+
from metaobjects.render.recover.xml_forgiving_reader import XmlForgivingReader
35+
36+
__all__ = [
37+
"MALFORMED",
38+
"TRUNCATED",
39+
"Coercion",
40+
"FieldKind",
41+
"FieldRecovery",
42+
"FieldSpec",
43+
"Format",
44+
"JsonForgivingReader",
45+
"Normalizer",
46+
"OnField",
47+
"RecoverOptions",
48+
"RecoverOutcome",
49+
"RecoverSchema",
50+
"RecoveryReport",
51+
"RecoveryResult",
52+
"Tolerance",
53+
"XmlForgivingReader",
54+
"recover",
55+
"recover_map",
56+
]

0 commit comments

Comments
 (0)