feat(schemas): pre-send provider schema preparation, bounds ratchet, local eval harness
Foundation for the KG schema enrichment arc. Canonical *.schema.json files
may now carry maxItems/maxLength generation bounds; providers that cannot
accept a given keyword receive a deterministic, pre-send reduced copy. No
shipped schema is bounded in this lode — the framework is proven by fixture
and by the guardrail ratchet.
Entry point: solstone/think/schema_prep.py::prepare_provider_schema
Eval harness: make eval-schemas
D1 — per-provider support matrix (deviates from a blanket cloud strip).
Current provider docs say OpenAI and Google ACCEPT minItems/maxItems/
minimum/maximum; only minLength/maxLength are outside their supported
subset. Anthropic rejects numeric/string/array constraints beyond minItems
0/1, so all six bounds are stripped there. pattern is never stripped for any
provider. $schema/$comment are stripped for all three cloud providers. local
and any unregistered provider name receive a canonical deep copy; prep never
raises on an unknown provider. STRICT_UNSUPPORTED_KEYWORDS carries the doc
citations and is the single source of truth — the portability test imports it
rather than restating the list. Because today's 22 discovered generation
schemas carry zero bounds, every outgoing payload is byte-identical to before
this change; a parametrized test asserts that over all schemas x all
providers.
D2 — the transform drops only validation-only keywords, recursively. It is
pure, deep-copying, idempotent, and structure-preserving: required,
additionalProperties, type/nullability, enum, const, pattern, format, items
and descriptions are never touched. No runtime try-then-fallback was added;
c78f9ff5 deleted exactly that, and the reduction here is deterministic and
pre-send. local.py::_normalize_schema_patterns stays where it is as a
llama.cpp transport detail.
D4 — response validation stays CANONICAL. All three models.py call sites
(generate, generate_with_result, agenerate) hand the prepared schema to the
provider and keep validating the response against the canonical schema.
HAZARD for follow-on lodes: for anthropic, bounds are stripped from the
request but still enforced on the response, so once shipped schemas carry
maxItems/maxLength an over-long Anthropic response raises
SchemaValidationError. That is a deliberate fail-loudly choice (CLAUDE.md
§8), and it is covered by test_generate_validates_response_against_canonical_schema.
Also in this commit:
- tests/test_schema_strict_portability.py rewritten. violations() now checks
only the structural rules prep does not and must not fix (root object,
additionalProperties:false, all properties required, no oneOf). The
banned-key assertion moved to the provider-facing schema, per provider, so
maxItems legitimately survives for openai/google. Zero-facet hydration
guard retained (hydrate, then prep). Negative self-check strengthened.
- solstone/think/schema_bounds.py::unbounded_nodes — reusable deny-by-default
helper: arrays without maxItems, and free-text strings (no enum/const/
pattern/format) without maxLength.
- scripts/check_schema_bounds.py — ratchet wired into make install-checks,
following the check_call_http_only.py evaluate()->(new, stale, tracked)
idiom. 22 reasoned allowlist entries; stale entries that no longer violate
fail the gate so the allowlist cannot rot. daily_schedule.schema.json is
already clean and is deliberately absent. Each follow-on lode deletes its
entry.
- tests/eval_schemas.py + make eval-schemas — opt-in local strict-JSON eval
over tests/fixtures/schema_eval/cases.jsonl, including a deliberately
bounded case. Reports per-case schema-validity and content-preservation,
writes results atomically to the gitignored tmp/schema-eval/, and exits
non-zero naming `journal install-provider local` when the local model is
not ready. Not collected by pytest; scoring lives in
solstone/think/schema_eval.py with offline unit tests.
Existing test_local/test_models/test_anthropic/test_openai/test_google/
test_sense_schema/test_schedule_schema pass unmodified. No external network
call in any collected test; no new pytest marker.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>