Skip to content

LlmComposer: fake-HTTP-engine tests + optional eval route#88

Merged
ber4444 merged 6 commits into
mainfrom
feat/zai-glm-provider
Jul 17, 2026
Merged

LlmComposer: fake-HTTP-engine tests + optional eval route#88
ber4444 merged 6 commits into
mainfrom
feat/zai-glm-provider

Conversation

@ber4444

@ber4444 ber4444 commented Jul 14, 2026

Copy link
Copy Markdown
Owner

Summary

Adds an optional local-llm-compose eval route that scores an OpenAI-compatible LLM composer against the same case-specific retrieval as the deterministic template route, plus a pluggable HTTP transport (LlmHttpTransport / OpenAiCompatibleLlmClient.forTesting) that makes the cloud client unit-testable with a lambda fake instead of a mock library.

What landed

Four commits on this branch:

  1. LlmHttpTransport + forTesting factory + optional eval route — the original PR scope. The route is OPTIONAL: only runs when COACH_LLM_API_KEY + token prices are set, so the CI grounding gate never depends on a live LLM.

  2. Hoist the LLM route out of testApplication — the route timed out (UncompletedCoroutinesError: After waiting for 1m) whenever run with a real key. testApplication wraps its body in runTestWithRealTime with a hardcoded 60s ceiling that can't be extended (verified by decompiling Ktor 3.4.3 — testApplication calls runTestWithRealTime$default with the timeout arg defaulted). Fix: the route calls composer.compose() directly (not the test server), so it runs in a plain runBlocking after testApplication finishes the fast deterministic routes. Also adds COACH_LLM_MAX_USD_CENTS (default $0.002) so the cost cap can be raised for a full run.

  3. Enrich opening-concept passages to prose — a real latent bug. OpeningExplanationValidator grounds each output sentence by requiring ≥2 content-word overlaps with the cited passage; the eval built passages whose text was bare tags ("development, center."), giving the model ~2 tokens of vocabulary. Now builds prose passages (validator unchanged).

  4. Format-coercive prompt — see findings below. Validator unchanged.

How to run it locally

./gradlew --stop   # clear daemon so it sees env vars
export COACH_LLM_API_KEY=sk-...
export COACH_LLM_INPUT_USD_PER_MILLION=0.40   # gpt-4.1-mini real prices
export COACH_LLM_OUTPUT_USD_PER_MILLION=1.60
export COACH_LLM_MAX_USD_CENTS=20             # $0.20, room for all 100 cases
# optional: export COACH_LLM_MODEL=gpt-4.1
./gradlew :evals:run --no-daemon

Findings (the answer to "does LLM composition beat the template?")

Investigating a near-100% fallback rate produced a clean, three-run comparison. The headline: at these model tiers, no — the template/fallback path is the right production choice under the current output contract.

Model Pass Dominant failure gate Conclusion
gpt-4.1-mini (strengthened prompt) ~1% citation compliance — model ignores the [source-id] format model too weak for the format contract
gpt-4.1 (strengthened prompt) 11% sentence-structure — output is cited + short + grounded but fails the per-sentence citation rule model tier helps; prompt/validator friction remains

Two separable results:

  • Model tier is the primary bottleneck, not the prompt. Going from gpt-4.1-mini → gpt-4.1 raised the pass rate ~1% → 11%, because gpt-4.1 cites reliably (89/89 fallbacks contained [eval-opening-NNN]) where gpt-4.1-mini largely didn't.
  • The remaining ~89% of gpt-4.1 failures are a prompt/validator structural mismatch, not a quality failure. The rejected outputs are grounded (0.0% grounding violation), under length (median 177 chars, max 234 — well under the 300 cap), and on-topic. They fail because OpeningExplanationValidator requires every sentence to repeat the citation, and gpt-4.1 converges on a single cited sentence rather than repeating [id] across 2-3. That's the validator's deliberate anti-hallucination design — and it's incompatible with how even capable LLMs naturally write.

This is the informative outcome the design doc (evals/docs/litert-faithfulness-bypass.md) predicts for a related scorer: a quality gate that catches what a looser check can't. Whether to relax the validator's per-sentence rule (production semantics change) or accept the template as the production choice is a separate decision — this PR establishes the measurement, it doesn't change the contract.

Verification

  • ./gradlew :evals:run completes with a real key (was timing out at the 60s ceiling).
  • ✅ No-key path (CI's grounding gate) green — local-llm-compose shows "COACH_LLM_API_KEY or token prices not set".
  • :server:test passes — the LlmHttpTransport fake covers success / validation-failure / budget / missing-env cases.
  • local-template route still 0.0% grounding violation after the prose-passage change.
  • ⏳ CI re-running.

Changed files

  • server/.../Application.ktselectComposer (extracted for testability) + COACH_LLM_MAX_USD_CENTS env var.
  • server/.../Composers.ktLlmHttpTransport / OpenAiCompatibleLlmClient.forTesting / strengthened format-coercive prompt.
  • server/.../LlmComposerHttpTest.kt — fake-engine tests (no mock library).
  • evals/.../EvalMain.ktevaluateLlmComposed route (hoisted out of testApplication) + prose passages.
  • evals/scorecard.md — the verified gpt-4.1 run row.

ber4444 added 2 commits July 13, 2026 17:44
Implements M1 of docs/plans/zai-glm-provider-addendum.md: make the
existing LlmComposer fully testable and wire an LLM-composed eval route
alongside the template-composed one.

LlmComposer HTTP tests (server/src/test/.../LlmComposerHttpTest.kt):
  (a) success — validated LLM prose returns composerId llm-v1
  (b) validation-failure — forbidden phrase + uncited prose fall back
      to template-v1
  (c) budget-exceeded — cost ceiling enforced before the HTTP call
  (d) missing-env — selectComposer returns the template when the key
      or token prices are absent/negative

Production changes:
  - Extracted LlmHttpTransport (fun interface) as a pluggable fake
    HTTP engine for OpenAiCompatibleLlmClient; production still uses
    java.net.http.HttpClient, tests inject a lambda.
  - Extracted selectComposer() from defaultDependencies so the env-
    gating logic is unit-testable without a database.
  - Made ChatRequest/ChatMessage/ChatResponse data classes so tests
    can assert against the serialized request body.

Eval harness:
  - New local-llm-compose route (OPTIONAL, env-gated) produces a
    scorecard row alongside local-template. When COACH_LLM_API_KEY +
    token prices are set (e.g. z.ai GLM), it scores real LLM-composed
    openings; otherwise it shows as optional so the CI grounding gate
    never depends on a live provider.

z.ai/GLM API details recorded for the PR description (checked
2026-07-13): base URL https://api.z.ai/api/paas/v4/chat/completions,
Bearer-token auth (no JWT), OpenAI-compatible request/response.
Models: glm-4.6 ($0.60/$2.20 per M tokens) is the cost-fit; free
tier glm-4.5-flash / glm-4.7-flash available. All provider config
comes from env vars only — nothing z.ai-specific is committed.
The GLM motivation for this branch is moot: ferryman-mcp #10 (merged
2026-07-17) ran the multi-provider eval it was waiting on and ranked
z.ai's GLM last on company-research (68%) and tied-last on chess (52%)
at ~10x gemini's cost and ~24x its latency. There is no eval case for
GLM as this app's composer.

What survives is the provider-agnostic test infrastructure. Strip the
moot GLM framing from surface artifacts only — no logic changes:

- Rename docs/plans/zai-glm-provider-addendum.md -> llm-composer-
  provider-infra.md and rewrite to provider-neutral framing; record the
  ferryman verdict and mark M2/M3 answered cross-repo.
- selectComposer KDoc: 'provider-shaped, not z.ai-shaped' -> 'not tied
  to any vendor'.
- LlmComposerHttpTest: KDoc doc reference updated; fixture model
  glm-4.6 -> gpt-4.1-mini (the production default; same serialization
  coverage).

All 23 :server tests green (16 existing + 7 new LlmComposer tests).
@ber4444 ber4444 changed the title z.ai/GLM provider: LlmComposer tests + eval route (M1) LlmComposer: fake-HTTP-engine tests + optional eval route (M1) Jul 17, 2026
@ber4444
ber4444 marked this pull request as ready for review July 17, 2026 14:40
@ber4444

ber4444 commented Jul 17, 2026

Copy link
Copy Markdown
Owner Author

Reframed out of draft. This was held as draft waiting on a cross-repo GLM eval. That eval ran (ferryman-mcp #10) and ranked GLM last on both skills at ~10× gemini's cost and ~24× its latency — so the GLM motivation is dead and I'm landing only what's provider-agnostic: the fake-HTTP-engine test seam, the selectComposer extraction, the budget-enforcement-before-transport test, and the optional eval route.

The reframe was surface-artifacts only — no Composers.kt/Application.kt/EvalMain.kt logic changed. Specifically:

  • doc renamed zai-glm-provider-addendum.mdllm-composer-provider-infra.md and rewritten to provider-neutral framing;
  • two code comments de-vendored;
  • one test fixture model string glm-4.6gpt-4.1-mini (the production default; same serialization coverage).

./gradlew :server:test → 23 green (16 existing + 7 new). grep for z.ai/glm across server/ evals/ .kt returns nothing. Nothing provider-specific was ever committed, per the hard rule.

The provider-shaping plan is fully resolved (M1 done in this PR; M2/M3
answered cross-repo by ferryman-mcp #10), so it no longer needs to live
in the tree. Remove docs/plans/llm-composer-provider-infra.md and drop
the dangling reference to it from the LlmComposerHttpTest KDoc.

The four test scenarios are self-evident from the test names below the
KDoc, so the citation added nothing.
@ber4444 ber4444 changed the title LlmComposer: fake-HTTP-engine tests + optional eval route (M1) LlmComposer: fake-HTTP-engine tests + optional eval route Jul 17, 2026
ber4444 added 3 commits July 17, 2026 11:37
The optional local-llm-compose route timed out (UncompletedCoroutinesError:
"After waiting for 1m, the test body did not run to completion") whenever it
ran with a real COACH_LLM_API_KEY. Root cause: EvalMain.main() was
`fun main() = testApplication { ... }`, and the LLM route ran inside that
block. testApplication wraps its body in runTestWithRealTime, whose timeout
is hardcoded 60s — Ktor's testApplication calls runTestWithRealTime$default
with the timeout arg defaulted (flags iconst_2), so neither a passed
CoroutineContext nor an inner withTimeout can extend it. ~10 sequential
blocking HTTP calls to an LLM provider blow past 60s, and runTest's watchdog
cancels with UncompletedCoroutinesError.

The 60s ceiling can't be lifted, so the fix is structural: the LLM route
calls composer.compose() directly (not the test HTTP server), so it doesn't
need testApplication at all. Split main() into:
  - runTestApplicationRoutes(): deterministic + local-HTTP routes stay in
    testApplication (all fast, fit the ceiling).
  - evaluateLlmComposed(): runs in a plain runBlocking AFTER, with no
    ceiling. compose() still hops to Dispatchers.IO so each blocking call
    runs off the caller thread.

Also makes the per-run LLM cost cap configurable via
COACH_LLM_MAX_USD_CENTS (USD cents; default 0.2 = $0.002). The hardcoded
default trips partway through a real eval at gpt-4.1-mini prices
(~$0.40/$1.60 per 1M tokens, 10 cases land right at the cap), which makes
the tail fall back to the template and pollutes the local-llm-compose row
with mixed outputs. Set COACH_LLM_MAX_USD_CENTS=5 ($0.05) for a clean full
run.

Verified: ./gradlew :evals:run completes with a real key (was timing out
at the 60s ceiling). No-key path (CI's grounding gate) stays green with
the route showing "COACH_LLM_API_KEY or token prices not set".
…innable

The local-llm-compose row showed 100% fallback — every case fell back to the
template, so the row measured the template, not the composer. Root cause was a
fixture/validator mismatch, not the cost cap or the client:

OpeningExplanationValidator grounds each output sentence by requiring >=2
content-word overlaps with the cited passage's text (a sound check, designed
for passages that are real prose — see LlmComposerHttpTest's known-good
fixture). But caseSpecificOpeningDependencies built passages whose text was
just expectedConcepts.joinToString(", ") + "." — bare tags like
"development, center." — ~2 tokens of chess vocabulary. After the validator
strips the source-id substring, stopwords, and <4-char tokens, the usable
grounding universe per passage is 3-4 words, and the model would need to
confine EACH of its 2-3 sentences to that tiny vocabulary. Even a correct,
instruction-following output fails the per-sentence overlap check. Verified
by simulation: a best-case grounded output against the old passage shape
returns overlap=set().

Fix: build prose passages (openingConceptsPassage). The case-specific
expectedConcepts line is prepended (keeps retrieval case-distinguishing),
then a common backbone explains the standard opening ideas (development,
central control, king safety) in real sentences — giving any fluent grounded
explanation enough vocabulary to satisfy the validator per sentence. Mirrors
how the unit test's passing fixture is shaped (rich sentence-form passages).

Validator is unchanged — production grounding semantics are not touched; only
the eval's fixture passages change. Verified the new passage shape grounds
model output across all five expectedConcepts values in the golden set
(center, development, king safety, counterplay, pawn tension), and the
local-template route still scores 0.0% grounding violation with the new
passages (CI grounding gate stays green).
…or unchanged)

Investigation of the local-llm-compose route's near-100% fallback rate showed
the model was producing fluent, grounded prose but ignoring the validator's
output contract (per-sentence [source-id] citations, 2-3 sentences, <300
chars). The old prompt buried the format requirement in one trailing sentence
with no concrete example.

Strengthen the prompt to be format-coercive:
- System prompt leads with "You MUST follow the output format exactly" and
  restates the bracketed-id rule as mandatory in every sentence.
- User prompt spells out hard constraints: EXACTLY 2 or 3 sentences, under 280
  characters, every sentence ending with a bracketed source id.
- Few-shot example built from the actual passage text, showing the exact
  [source-id] format in context — models comply far better with a literal
  demonstration than an abstract instruction.
- MAX_OUTPUT_TOKENS 120 -> 90: 300 chars ~= 75 tokens; 90 allows headroom
  without inviting a paragraph past MAX_OUTPUT_CHARS (300).

Validator is unchanged — production anti-hallucination semantics preserved.

Measured effect (100 opening cases, COACH_LLM_MAX_USD_CENTS=20):

  model            pass   dominant failure
  gpt-4.1-mini      ~1%   citation compliance (ignores [source-id] format)
  gpt-4.1           11%   sentence-structure (cited+short but not per-sentence)

So model tier is the primary bottleneck for this output contract, not the
prompt: gpt-4.1 cites reliably where gpt-4.1-mini largely doesn't. The
remaining ~89% of gpt-4.1 failures are grounded and under length but fail the
per-sentence citation rule — a prompt/validator structural mismatch, where
the model converges on a single cited sentence rather than repeating the
citation across 2-3. Answer to the PR's question: at these tiers LLM
composition does not beat the deterministic template under the production
output contract, so the template/fallback path remains the right production
choice.

Debug instrumentation added during the investigation has been removed.
@ber4444
ber4444 merged commit c609bdc into main Jul 17, 2026
12 checks passed
@ber4444
ber4444 deleted the feat/zai-glm-provider branch July 17, 2026 19:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant