|
| 1 | +# R13 — Output-Format Prompt-Fragment Conformance Harness — Design |
| 2 | + |
| 3 | +_Date: 2026-05-30. Status: approved (design). Backlog item: R13 (see `docs/superpowers/specs/2026-05-29-conformance-hardening-review.md`)._ |
| 4 | + |
| 5 | +## Problem |
| 6 | + |
| 7 | +FR-010 shipped an **output-format prompt fragment** in all five ports: `OutputFormatRenderer.render(spec, overrides)` emits a comment-free instruction fragment teaching an LLM the exact shape to return, in three presentation styles (`guide` | `inline` | `exampleOnly`) across two formats (`json` | `xml`), driven by `@promptStyle` / `@example` / `@instruction` / `@enumDoc` on a `template.output` + its payload value-object. |
| 8 | + |
| 9 | +The whole point of that fragment is **prompt-cache stability**: the rendered prefix must be byte-stable so exact-prefix prompt-cache hits are not silently broken — and it must be byte-**identical** across ports so a service written in one language emits a cache-compatible prefix to one written in another. |
| 10 | + |
| 11 | +Today that guarantee is **not pinned by any shared corpus**. Each port's renderer is trusted to its own unit tests (`OutputFormatRenderer*Test`, `*OutputPromptGenerator*Test`). The `template-output-*` metamodel fixtures pin only the *declaration* (loader + serializer), not the rendered bytes. So cross-port byte-identity of the fragment is unverified — the exact property the feature exists to provide. |
| 12 | + |
| 13 | +This is backlog item **R13** ("prompt-parser / prompt corpus"), and was a bounded deferral noted during FR-010 ("output-prompt verify-conformance corpus wiring"). |
| 14 | + |
| 15 | +## Goal |
| 16 | + |
| 17 | +A strong, shared, cross-port conformance harness that pins the rendered output-format prompt fragment **byte-for-byte across all five ports**, plus a round-trip property tying the request side to the response (`recover`) side. Bundle the adjacent FR-011 loader gap: shared negative fixtures for the enum-coercion attribute validation. |
| 18 | + |
| 19 | +## What this harness pins — and what it does not |
| 20 | + |
| 21 | +**Pins:** given an identical spec descriptor, all five ports' `OutputFormatRenderer` produce **byte-identical** fragments for every (style × format) combination. That is the prompt-cache-stability guarantee. |
| 22 | + |
| 23 | +**Does not pin (by design):** the metadata→spec *extraction* (walking a `template.output` + payload VO to build the `OutputFormatSpec`). That logic lives inside each port's codegen emitter (`OutputFormatSpecEmitter` and peers), which emits `new OutputFormatSpec(...)` as source text — there is no runtime metadata→spec builder, and extracting one across five ports would be a large refactor that risks regressing shipped FR-010 codegen. Extraction stays covered by the existing per-port codegen unit tests + FR-010 compile-run proofs. Because the emitted prompt class simply delegates to the engine, **engine byte-identity (this harness) + the existing "generated class delegates to the engine" compile-run proofs are transitively strong.** |
| 24 | + |
| 25 | +This boundary is stated explicitly so the gate is not oversold — consistent with the lesson that "all corpora green" must mean what it says. |
| 26 | + |
| 27 | +## Architecture |
| 28 | + |
| 29 | +The harness is **descriptor-driven**, mirroring the existing `recover-conformance` corpus (which drives the `recover` engine from a serialized `RecoverSchema`). Each case serializes a spec descriptor; every port deserializes it into its native `OutputFormatSpec`, renders, and asserts byte-equality against checked-in expected output. |
| 30 | + |
| 31 | +### Corpus layout — `fixtures/output-prompt-conformance/<case>/` |
| 32 | + |
| 33 | +``` |
| 34 | +spec.json # cross-port oracle INPUT (the unified field descriptor) |
| 35 | +expected.guide.txt # byte-exact rendered fragment, style = guide |
| 36 | +expected.inline.txt # byte-exact rendered fragment, style = inline |
| 37 | +expected.exampleOnly.txt # byte-exact rendered fragment, style = exampleOnly |
| 38 | +README.md # one paragraph: what this case exercises |
| 39 | +``` |
| 40 | + |
| 41 | +`format` (`json` | `xml`) is a property of the case (fixed in `spec.json`), so each case is single-format. All three styles are rendered per case via the runtime style override, yielding three expected files. |
| 42 | + |
| 43 | +### `spec.json` descriptor (the unified oracle input) |
| 44 | + |
| 45 | +```jsonc |
| 46 | +{ |
| 47 | + "format": "json", // "json" | "xml" |
| 48 | + "rootName": "SupportAnswer", // XML root tag / logical JSON root |
| 49 | + "roundTrip": true, // optional; true only when EVERY field has an @example (gates the round-trip assertion) |
| 50 | + "fields": [ |
| 51 | + { |
| 52 | + "name": "text", |
| 53 | + "kind": "STRING", // STRING | INT | LONG | DOUBLE | BOOLEAN | ENUM | OBJECT |
| 54 | + "required": true, |
| 55 | + "array": false, // optional, default false |
| 56 | + "example": "Your refund will appear in 3-5 days.", // optional |
| 57 | + "instruction": "One or two sentences to the customer.", // optional |
| 58 | + "enumValues": null, // string[] when kind = ENUM |
| 59 | + "enumDoc": null, // { MEMBER: "doc" } when kind = ENUM |
| 60 | + "nested": null // a nested { rootName, fields } object when kind = OBJECT |
| 61 | + } |
| 62 | + ] |
| 63 | +} |
| 64 | +``` |
| 65 | + |
| 66 | +One descriptor builds **both**: |
| 67 | +- an `OutputFormatSpec` (consuming `example` / `instruction` / `enumValues` / `enumDoc` / `nested`) for rendering, and |
| 68 | +- a minimal `RecoverSchema` (consuming `name` / `kind` / `required` / `enumValues` / `array` / `nested`) for the round-trip. |
| 69 | + |
| 70 | +They are sibling descriptors; the superset avoids duplicate per-case files. |
| 71 | + |
| 72 | +### Per-port harness (engine-level, all five ports) |
| 73 | + |
| 74 | +For each case directory: |
| 75 | + |
| 76 | +1. Parse `spec.json` → build the port's `OutputFormatSpec`. |
| 77 | +2. For `style` in `{guide, inline, exampleOnly}`: `render(spec, style)` → assert the result is **byte-equal** to `expected.<style>.txt`. **Zero-drift: no ledger.** Any divergence fails the build. |
| 78 | +3. **Determinism:** render a second time, assert identical to the first. |
| 79 | +4. **Round-trip** (only when `spec.json` has `"roundTrip": true`): build a `RecoverSchema` from the same descriptor → `recover(expected.exampleOnly.txt)` → assert every declared field classifies **RECOVERED** (no `MALFORMED` / `LOST_REQUIRED` / `LOST_OPTIONAL`). This is the example↔recover skew guard: it catches a renderer that emits an example the `recover` parser cannot read back. |
| 80 | + |
| 81 | +**Kotlin** reuses the shared JVM `render` engine (the same Java `OutputFormatRenderer` + `Recover` classes), so its harness drives those classes directly. This still runs the corpus end-to-end in the Kotlin module — closing, for this corpus, the "Kotlin runs only 2 of 6 corpora" gap. Engine output is byte-identical to Java by construction; the value is proving the corpus loads and the assertions execute in the Kotlin build. |
| 82 | + |
| 83 | +### Why zero-drift is achievable here |
| 84 | + |
| 85 | +Unlike `render-conformance` (which runs user templates through a Mustache engine and carries one ledgered standalone-comment divergence from Mustache.java), `OutputFormatRenderer` is **hand-written, comment-free, and builds strings directly**. There is no third-party templating engine in the path. The FR-010 renderers were authored per port specifically to be byte-identical, and `recover-conformance` already demonstrates byte-identical canonical values cross-port. |
| 86 | + |
| 87 | +To keep zero-drift robust against the one known cross-runtime hazard — floating-point formatting (the R6 float-fidelity lesson) — **fixture example values are restricted to strings, integers, booleans, and dyadic decimals only**; no raw floats whose textual form differs across runtimes. This restriction is documented in the corpus README. If a genuine platform divergence ever surfaces, it is a renderer bug to fix, not a ledger entry. |
| 88 | + |
| 89 | +## Case matrix |
| 90 | + |
| 91 | +Approximately ten cases × three styles ≈ thirty expected files. Each case is a `spec.json` + three `expected.*.txt`: |
| 92 | + |
| 93 | +| Case | Format | Exercises | |
| 94 | +|---|---|---| |
| 95 | +| `json-scalars` | json | string/int/bool/dyadic-double scalars; `@example` + `@instruction`; required + optional | |
| 96 | +| `xml-scalars` | xml | same field set, XML rendering | |
| 97 | +| `json-enum` | json | `field.enum` with `@enumValues` + `@enumDoc` + `@example` | |
| 98 | +| `xml-enum` | xml | enum, XML | |
| 99 | +| `json-array` | json | scalar array field | |
| 100 | +| `json-nested` | json | nested `OBJECT` field (nested rootName + fields) | |
| 101 | +| `xml-nested` | xml | nested object, XML | |
| 102 | +| `optional-absent` | json | optional fields with no `@example`/`@instruction` (sparsity / skeleton behavior) | |
| 103 | +| `instruction-fallback` | json | fields lacking `@instruction` (guide-style fallback) | |
| 104 | +| `unicode-example` | json | multibyte `@example` value (byte-identity under UTF-8) | |
| 105 | + |
| 106 | +`roundTrip: true` is set on the cases where every field declares an `@example` (at minimum `json-scalars`, `xml-scalars`, `json-enum`, `xml-enum`, `json-nested`, `xml-nested`, `unicode-example`). |
| 107 | + |
| 108 | +## FR-011 attribute-validation negative fixtures (bundled scope add) |
| 109 | + |
| 110 | +Three shared metamodel fixtures, added under `fixtures/conformance/`, exercising the FR-011 enum-coercion attribute validation that currently lives only in per-port loader unit tests: |
| 111 | + |
| 112 | +| Fixture | Declares | Expected error | |
| 113 | +|---|---|---| |
| 114 | +| `error-enum-coerce-default-non-member` | `field.enum` with `@coerceDefault` value not in `@values` | `ERR_BAD_ATTR_VALUE` | |
| 115 | +| `error-enum-default-non-member` | `field.enum` with `@default` value not in `@values` | `ERR_BAD_ATTR_VALUE` | |
| 116 | +| `error-enum-normalize-bad-mode` | `field.enum` with `@normalize` not in `none` / `collapse` / `strip` | `ERR_BAD_ATTR_VALUE` | |
| 117 | + |
| 118 | +These run on the existing metamodel-conformance loader runners (TS / C# / Java / Python). The Kotlin `metadata-ktx` facade is read-only and does not run the metamodel corpus, so it is out of scope for these fixtures — consistent with where the FR-011 validation actually executes. |
| 119 | + |
| 120 | +Each fixture follows the existing `error-*` metamodel-fixture convention — an `input/meta.<name>.json` plus an `expected-errors.json` of the form: |
| 121 | + |
| 122 | +```jsonc |
| 123 | +{ "errors": [ { "code": "ERR_BAD_ATTR_VALUE", |
| 124 | + "source": { "format": "json", "files": ["meta.enums.json"], |
| 125 | + "jsonPath": "$['metadata.root'].children[0]['object.entity'].children[1]['field.enum']" } } ], |
| 126 | + "warnings": [] } |
| 127 | +``` |
| 128 | + |
| 129 | +The loader runners already parse this envelope generically (the `jsonPath` is per-fixture), so the new directories need **no runner change** — only authoring. (Note: missing `@values` is `ERR_MISSING_REQUIRED_ATTR`, already covered by `error-enum-missing-values`; all three FR-011 fixtures are *bad-value* cases → `ERR_BAD_ATTR_VALUE`.) |
| 130 | + |
| 131 | +## Components / file inventory |
| 132 | + |
| 133 | +- **Corpus (shared):** `fixtures/output-prompt-conformance/<case>/{spec.json, expected.*.txt, README.md}` + a top-level `README.md` documenting the descriptor schema and the no-float rule; three `fixtures/conformance/error-enum-*` fixtures. |
| 134 | +- **TS (pilot):** `server/typescript/packages/render/test/output-prompt-conformance.test.ts` — descriptor parse → `OutputFormatSpec` → render × 3 styles → byte assert → determinism → round-trip via `recover`. |
| 135 | +- **C#:** `csharp/MetaObjects.Render.Tests/OutputPromptConformanceTests.cs`. |
| 136 | +- **Java:** `server/java/render/src/test/java/com/metaobjects/render/prompt/OutputPromptConformanceTest.java`. |
| 137 | +- **Python:** `server/python/tests/render/test_output_prompt_conformance.py`. |
| 138 | +- **Kotlin:** a runner in the Kotlin render/test module driving the shared JVM `OutputFormatRenderer` + `Recover` against the corpus. |
| 139 | +- **Descriptor → spec builders:** a small per-port test helper that maps `spec.json` → the native `OutputFormatSpec` (and → `RecoverSchema` for round-trip). Test-only; not production code. |
| 140 | + |
| 141 | +Each runner asserts a **corpus-count guard** (the number of case directories matches an expected constant) so a port silently skipping cases fails — the same guard `recover-conformance` runners carry. |
| 142 | + |
| 143 | +## Testing strategy |
| 144 | + |
| 145 | +The harness *is* the test. Validation that the harness itself is sound: |
| 146 | +- The TS pilot authors the corpus and is the reference; expected files are generated by the TS renderer and then **independently reproduced** by each port's runner (a port that cannot reproduce them fails — that is the gate working). |
| 147 | +- Determinism assertion (render twice) guards against accidental nondeterminism (map ordering, etc.). |
| 148 | +- The round-trip assertion guards example↔recover skew. |
| 149 | +- Corpus-count guards guard silent skips. |
| 150 | +- Zero-drift (no ledger) guards against quiet divergence acceptance. |
| 151 | + |
| 152 | +## Build order and merge strategy |
| 153 | + |
| 154 | +TS pilot (corpus + reference assertions + round-trip) → C# → Java → Python → Kotlin port the runner against the shared corpus → FR-011 error fixtures (small; loader ports) → close-out (KNOWN_GAPS/roadmap/memory). |
| 155 | + |
| 156 | +All work stays on **one branch with a single final merge** (as with FR-011): the shared corpus would red the other ports if the pilot merged alone. Each unit passes a spec-compliance + code-quality review gate before the final merge. Forward-only on `main`. |
| 157 | + |
| 158 | +## Out of scope (explicit) |
| 159 | + |
| 160 | +- Runtime metadata→`OutputFormatSpec` extraction (stays in codegen; covered by existing compile-run proofs). |
| 161 | +- The codegen-emitted prompt class compile-run tier (already covered per-port by FR-010 proofs; the design pins the engine the class delegates to). |
| 162 | +- Payload-VO codegen corpus (backlog item **R12** — separate future plan). |
| 163 | +- Raw-float example values (excluded to preserve zero-drift). |
| 164 | +- Kotlin metamodel-corpus participation for the FR-011 error fixtures (read-only facade). |
0 commit comments