Skip to content

refactor(llm): one LLM module behind every call; old entry points delegate - #78

Merged
napetrov merged 5 commits into
intel:mainfrom
napetrov:refactor/llm-module
Oct 17, 2026
Merged

napetrov merged 5 commits into
intel:mainfrom
napetrov:refactor/llm-module

Conversation

@napetrov

@napetrov napetrov commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Part of #4.

Summary

Step 1 of the single LLM-call module: the module, both adapters and their tests. Every old entry point now delegates to LLM. No caller changes.

llm_call / llm_call_with_usage ──┐
ChatOpenAI / ChatAnthropic ──────┤
chat_completion ─────────────────┤   (returns reply.response, the raw litellm object)
utils.get_llm ───────────────────┘   (keeps its own route/key table → private _GetLLMCompat)
        │
        ▼
LLM(model, provider, api_key=None, max_tokens=None, temperature=None, seed=None)
  complete(prompt, system=, cache_prefix=, max_tokens=, stream=, draw=, cache_if=) -> Reply
  ask(prompt, draw=, cache_if=) -> str          chat(messages, tools=) -> Reply   (never cached)
  metrics() -> UsageRecord                       key → response cache → retry (is_retryable) → meter
        │
        ▼  Adapter = (Request) -> Reply(text, usage, finish_reason, tool_calls; .truncated)
  LiteLLMAdapter (production)  |  FakeAdapter (tests, `fake_llm` fixture)

Next to it: PROVIDERS (canonical name → key variable) plus PROVIDER_ALIASES, resolve_model (bare Bedrock names go through defaults.bedrock_profile, the mapping isn't copied), api_key_env, resolve_api_key, and ask_parsed(ask, prompt, parse, reprompt=). ask_parsed uses a new draw per attempt, caches only replies that parse, and raises Unparseable(ValueError). Cost still goes through _safe_completion_cost, so an unpriced model costs None, not $0. The truncation warning is now a check on Reply.truncated, made once in LLM.complete for every live reply.

Evidence

  • Delegate equivalence. Before: the suite on main. After: the same suite with no test edits, which keeps the old entry points' contracts. That includes the litellm.completion stubs behind test_judge_transport_retry, test_judge_response_cache, test_sampling_forwarding, test_get_llm_bedrock and the truncation-log tests in test_novelty_silent_defaults. tests/test_llm.py also checks each delegate directly against LLM:
    test_llm_call_sends_what_llm_complete_sends    LLM.complete == llm_call_with_usage == llm_call  (same Request)
    test_chat_completion_is_llm_chat_returning_the_raw_response
    test_get_llm_keeps_its_own_routes_keys_and_labels[openrouter: max_tokens=2000, usage model=route/"litellm", ...]
    test_get_llm_on_bedrock_reads_only_the_bearer_token
    
  • Cache-key compatibility. I ran main's llm_call_with_usage and utils.get_llm against a stubbed litellm.completion and recorded the six cache file names they wrote: plain; system + cache_prefix; pinned temperature/seed + max_tokens + draw 2; stream; get_llm on Bedrock with a bare name; get_llm on OpenRouter, pinned, draw 1.
    test_an_entry_main_wrote_is_hit[*]:  write <main's hash>.json → LLM(...).complete / get_llm(...).invoke
                                         → text == "written by main", usage replayed, fake_llm.requests == []
    
    To check that the test can fail, I changed the key's max_tokens: None to 0. Three of the six cases failed.
  • Fake-adapter tests (tests/test_llm.py, 84 tests) cover routes and keys per provider, retry and backoff ([2.0, 4.0]), sampling sent only when set, draws, cache_if on store and replay, chat never cached, the truncation log on a live call but not on a replay, metrics() staying None when unpriced, ask_parsed, and the LiteLLMAdapter request, tool-call and stream shapes.
  • Local: ruff, mypy (210 files, llm.py included, no new exemption), pytest with --randomly-seed=1234 and 98765 (4758 passed each), diff-cover 99% (354 lines; the 1 missing line is unchanged extract_json_array code), make ci-local --offline: everything passes except the changelog fragment (see below).
  • CI: green on 3.11–3.14 tests, mypy, static checks (license headers included), CodeQL, docs build, benchmark, package smoke, DCO; the changelog check passes on the skip-changelog label.

Merge Danger

Door: two-way. A revert restores the two old transports. Nothing persisted changes: cache keys are byte-identical and no schema is touched.

Blast Radius: internal

  • Every LLM call now goes through LLM. Requests sent to litellm are unchanged. Two small differences:
    • google-vertex on the llm_call path reads GEMINI_API_KEY, as get_llm and the google and gemini spellings already did. Before, it read OPENAI_API_KEY. litellm's vertex_ai route doesn't read the key.
    • get_llm's retries and cut replies are now logged the way llm_call's are. chat_completion's retry log line now reads "LLM call failed".
  • chat_completion now builds a Reply with usage for every agent turn. The agent loop still reads the raw response.
  • Changelog: skip-changelog. This is an internal refactor behind delegates, and no request, score, artifact or flag changes.
  • What follows (steps 2–4, one PR each):
    • Judges, personas, validator and question generation move off get_llm onto LLM + ask_parsed. That step decides get_llm's divergences: the $ANTHROPIC_DEFAULT_SONNET_MODEL fallback for bare Bedrock names, OpenRouter's hidden max_tokens=2000, and its stream-less cache keys. Those change behaviour and cache keys, so that PR gets a changelog entry.
    • The answerer, arms and the agent loop move onto LLM.complete / LLM.chat, and chat_completion and the shims go.
    • The key maps in commands/benchmark.py and eval/ragas_eval.py read api_key_env, and the CLI provider lists read PROVIDERS.

Checklist

  • Tests added/updated and pytest passes
  • ruff, mypy, and schema checks pass
  • Docs updated (or N/A) — see the docs-drift checklist in CONTRIBUTING.md
  • No ad-hoc run artifacts committed
  • New files carry the SPDX license header (or are exempt in .github/.licenserc.yaml)

🤖 Generated with Claude Code

…egate

dokimi.llm gets one interface: LLM(model, provider, api_key, max_tokens,
temperature, seed) with complete() -> Reply(text, usage, finish_reason;
.truncated), ask(), chat(messages, tools=) (never response-cached) and
metrics(); ask_parsed() with Unparseable; one PROVIDERS table plus
aliases, resolve_model (Bedrock bare names via defaults.bedrock_profile),
api_key_env, resolve_api_key and is_retryable. Behind it sits one adapter
seam, (Request) -> Reply: LiteLLMAdapter in production, FakeAdapter in
tests through the new fake_llm fixture. Cost keeps the
_safe_completion_cost rule (an unpriced model is None, not $0).

llm_call, llm_call_with_usage, chat_completion, the ChatOpenAI and
ChatAnthropic shims and utils.get_llm now delegate to LLM; no caller
changes. get_llm keeps its own provider table, cache keys and usage
labels through a private LLM subclass until the judge moves.

Response-cache keys are unchanged: tests/test_llm.py replays entries whose
file names main's llm_call_with_usage and get_llm wrote.

Differences from main, none in a request litellm sends:
- google-vertex reads GEMINI_API_KEY on the llm_call path too (it read
  OPENAI_API_KEY); litellm's vertex_ai route does not use the key.
- get_llm's retries and cut replies are logged as llm_call's are;
  chat_completion's retry log reads "LLM call failed".

Part of intel#4.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
@napetrov napetrov added the skip-changelog No user-visible change: bypasses the changelog fragment check label Oct 16, 2026
@codecov-commenter

codecov-commenter commented Oct 16, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 99.43343% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.52%. Comparing base (604f924) to head (7b93ffa).

Files with missing lines Patch % Lines
dokimi/dokimi/llm.py 99.40% 1 Missing and 1 partial ⚠️
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #78      +/-   ##
==========================================
+ Coverage   89.26%   89.52%   +0.26%     
==========================================
  Files         204      204              
  Lines       23626    23758     +132     
  Branches     3796     3800       +4     
==========================================
+ Hits        21089    21270     +181     
+ Misses       1840     1803      -37     
+ Partials      697      685      -12     
Files with missing lines Coverage Δ
dokimi/dokimi/utils.py 97.64% <100.00%> (+9.64%) ⬆️
dokimi/dokimi/llm.py 95.75% <99.40%> (+15.68%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@antonwolfy antonwolfy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review at a367770. The delegation is faithful: I found no behavioural difference from main on any path real litellm responses reach. The problems are in the tests. They do not pin the delegates' argument forwarding, the retry schedule or the stream path, so a refactor could lose those without a failure. Three test gaps are worth closing before merge, and there are four nits.

What I ran, on a Linux review host at a367770:

  • make ci-local: 18 passed, 1 failed, 4 skipped. The failure is the changelog-fragment check, which the PR covers with skip-changelog. 4,759 tests passed. Changed-line coverage is 99%.
  • A differential run of main and the head against a stubbed litellm.completion, with the transport mocked. It covered llm_call, llm_call_with_usage, streaming, chat_completion, get_llm(...).invoke and the shims. It used 11 providers, file: keys, max_retries of 0, 1, 3 and 5, cache hits with cache_if and draw, and 18 response shapes. Sent kwargs, cache file names and contents, returned text and usage, sleep delays and raised exceptions were identical, apart from the differences the PR lists.
  • A mutation run on the new code: 131 single-line mutants. 32 pass the relevant test files, and I re-ran 8 of them on the full suite. All 8 pass (4,758 passed each time). They are listed in the inline comments.

Checked, no issues

  • Cache keys: the keys for plain, system + prefix, pinned + max_tokens + draw, stream and the two get_llm cases match the ones main wrote. Mutating any key field (draw, stream, max_tokens, temperature) fails the suite.
  • Retries: attempt counts and sleep delays match main for all four paths. Permanent errors are raised on the first attempt.
  • Renamed symbols: _build_litellm_model, _is_retryable and _stream_call are kept. Nothing outside llm.py and test_llm.py still uses _resolve_api_key or the old _warn_if_truncated signature.
  • Schemas and persisted output: no schema, SCHEMA_VERSIONS or mkdocs.yml change. The persisted UsageRecord comes from the adapter, not from the metrics() total.
  • Tests: offline and deterministic. time.sleep is patched, the fixture restores the default adapter, and 84 tests pass under several seeds.

Comment thread dokimi/tests/test_llm.py Outdated
Comment on lines +393 to +402
def test_chat_completion_is_llm_chat_returning_the_raw_response(fake_llm):
raw = object()
fake_llm.push(Reply(None, UsageRecord(), response=raw))
tools = [{"type": "function", "function": {"name": "search"}}]
out = chat_completion([{"role": "user", "content": "q"}], model="m", provider="openai",
tools=tools, api_key="k", max_tokens=0, seed=1)
assert out is raw
(req,) = fake_llm.requests
assert (req.tools, req.tool_choice, req.max_tokens, req.seed, req.api_key) == (
tools, None, 0, 1, "k")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests don't pin what the legacy delegates forward, so any of these can be dropped and the suite stays green.

test_chat_completion_is_llm_chat_returning_the_raw_response asserts tool_choice is None, so a forwarded value is never checked. Nothing checks that llm_call_with_usage forwards max_retries or retry_delay, or that the shims forward draw. I applied these mutants to a copy of the head and ran the full suite (4,758 passed, 2 skipped):

  • llm_call_with_usage builds LLM(...) without max_retries=max_retries, retry_delay=retry_delay (llm.py:1010-1011).
  • chat_completion calls llm.chat(messages, tools=tools) without tool_choice=tool_choice (llm.py:1052).
  • _Shim.invoke omits draw=draw (llm.py:949-953).
  • get_llm skips _read_key(api_key) on an explicit key (utils.py:141), so get_llm(..., api_key="file:/path") sends the literal string to the provider.

All four pass. With the first one, llm_call(..., max_retries=0) would retry three times and nothing notices. The PR's evidence says the delegates keep their contracts, but only the parameters that the old stubs happened to exercise are checked.

Suggestion: extend test_llm_call_sends_what_llm_complete_sends to pass non-default max_retries, retry_delay, max_tokens, draw and stream and assert the sleep schedule and the request. In the chat_completion test, pass tool_choice="required" and assert req.tool_choice == "required". Add a draw=1 case through ChatOpenAI.invoke, and a get_llm(..., api_key=f"file:{path}") case.

Comment thread dokimi/tests/test_llm.py
Comment on lines +132 to +136
def test_transient_errors_are_retried_with_doubling_backoff(error, fake_llm, no_cache, sleeps):
fake_llm.push(error, error, "ok")
assert LLM("m.x", "amazon-bedrock").ask("p") == "ok"
assert len(fake_llm.requests) == 3
assert sleeps == [2.0, 4.0]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The backoff test can't tell doubling from adding 2, and never sets retry_delay.

It runs two retries with the default delay and asserts sleeps == [2.0, 4.0]. Doubling and delay += 2 give the same two values. Mutation check, full suite, 4,758 passed: delay *= 2 -> delay += 2 (llm.py:792) passes. A third retry would sleep 6s instead of 8s. No retry test passes a non-default retry_delay, so the value is never observed.

Suggestion: parametrize with LLM(..., retry_delay=0.5, max_retries=3) and three transient errors, and assert sleeps == [0.5, 1.0, 2.0].

Comment thread dokimi/tests/test_llm.py
Comment on lines +493 to +510
def test_the_litellm_adapter_streams_and_measures_ttft(monkeypatch):
import litellm

def chunk(content, finish_reason=None, usage=None):
delta = SimpleNamespace(content=content)
return SimpleNamespace(choices=[SimpleNamespace(delta=delta, finish_reason=finish_reason)],
usage=usage)

sent = []
chunks = [chunk("Hello "), chunk("world", finish_reason="stop"),
chunk(None, usage=SimpleNamespace(prompt_tokens=5, completion_tokens=2,
total_tokens=7))]
monkeypatch.setattr(litellm, "completion", lambda **kw: sent.append(kw) or iter(chunks))
monkeypatch.setattr(llm_mod, "_safe_completion_cost", lambda r, m, **kw: None)
reply = LiteLLMAdapter()(Request("m", "openai", "m", [], max_tokens=0, stream=True))
assert "max_tokens" not in sent[0] and sent[0]["stream_options"] == {"include_usage": True}
assert (reply.text, reply.finish_reason, reply.usage.total_tokens) == ("Hello world", "stop", 7)
assert reply.usage.ttft_sec is not None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The stream test checks only that ttft_sec is set, so the stream request and TTFT can regress unnoticed.

It sends no key, temperature or seed. Mutation check, full suite, 4,758 passed:

  • _stream drops api_key=request.api_key (llm.py:478): a streamed call loses its key.
  • if ttft is None: -> if True: (llm.py:494): TTFT is stamped at the last delta, not the first.
  • _sampling_kwargs uses if seed: instead of if seed is not None: (llm.py:173): seed=0 is dropped from both the request and the cache key. No test passes seed=0 through LLM.

All three pass.

Suggestion: run the stream with api_key="k", temperature=0.0, seed=0 and several content chunks. Assert those kwargs in sent[0] and that ttft_sec is the time of the first chunk, using a patched time.time.

Comment thread dokimi/dokimi/llm.py Outdated
Comment on lines +626 to +630
logger.warning(
f"LLM reply hit the output ceiling ({model}, max_tokens="
f"{max_tokens or 'provider default'}, "
f"{reply.usage.completion_tokens} tokens); "
"pass a higher --max-tokens if this repeats"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the truncation warning advises --max-tokens, which doesn't control the get_llm calls that now reach it.

get_llm("openrouter", "openai/gpt-oss-120b", "k").invoke("q") with a reply cut at the ceiling now logs LLM reply hit the output ceiling (openai/gpt-oss-120b, max_tokens=2000, 2000 tokens); pass a higher --max-tokens if this repeats. Here the ceiling is the hardcoded 2000 of the OpenRouter route, and the judge, persona and validator paths have no such flag. main never warned on this path. The PR description lists the new log, but not that its advice doesn't apply.

Suggestion: drop the flag from the message, or keep it only when the caller passed max_tokens.

Comment thread dokimi/dokimi/llm.py
Comment on lines +740 to +746
def metrics(self) -> UsageRecord:
"""The sum of every reply this object returned, cache replays included.

``n_calls`` counts replies; ``cost_usd`` is ``None`` when none was priced.
"""
with self._lock:
return self._total

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: metrics() says cache replays are included, but the total loses the replayed flag and counts a replay at its stored cost.

With the response cache on, two identical complete("p") calls give one adapter request, the second reply has usage.replayed == True, and metrics() returns n_calls=2, total_tokens=30, replayed=False. The seed record is replayed=False, and UsageRecord.__add__ computes self.replayed and other.replayed, so the total is never marked. A caller that reads metrics().cost_usd as spend, as the spend cap in novelty/client.py does by skipping replayed records, will overcount on a warm cache and can't tell why.

No caller reads it yet. Suggestion: say in the docstring that replays are summed at their stored cost, or exclude them from cost_usd and latency_sec.

Comment thread dokimi/dokimi/llm.py Outdated
Usage:
from dokimi.llm import llm_call
text = llm_call("Your prompt", model="gpt-6.1-sol", provider="openai")
"""Every LLM call in the framework goes through this module.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the module docstring describes the end state of the series, not this step.

  • Line 4, "Every LLM call in the framework goes through this module": the key and route tables in utils.get_llm, commands/benchmark.py (_credential_env_var) and eval/ragas_eval.py (_resolve_key) are still separate. The judge, persona and validator redraw loops don't use ask_parsed. The same applies to "the one model-name map" and "the one key-variable map" at lines 23-24, and to architecture.md's "every LLM call".
  • Lines 46-47: "A live reply cut at the output ceiling ... is logged as a warning" holds for complete/ask, but chat() never calls _warn_if_truncated. A chat() reply with finish_reason="length" logs nothing. That matches main's chat_completion, but the docstring says otherwise.

Suggestion: word it as "the entry point for" and name what hasn't moved yet, and add "complete/ask" to the truncation sentence.

Comment thread dokimi/dokimi/llm.py Outdated
Comment on lines +1017 to +1027
def _stream_call(completion: Any, litellm_model: str, messages: List[Dict[str, Any]],
api_key: Optional[str], model: str, provider: str,
max_tokens: Optional[int] = None, temperature: Optional[float] = None,
seed: Optional[int] = None) -> Tuple[str, UsageRecord]:
"""One streamed completion as ``(text, usage)``; the adapter's streaming path."""
request = Request(model=model, provider=provider, litellm_model=litellm_model,
messages=messages, api_key=api_key, max_tokens=max_tokens,
temperature=temperature, seed=seed, stream=True)
reply = _stream(completion, request)
_warn_if_truncated(reply, model, max_tokens)
return reply.text or "", reply.usage

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: _stream_call is dead in production and its docstring calls it "the adapter's streaming path".

The adapter uses _stream (llm.py:519). Its only caller is tests/test_metrics_usage.py:226. It's the one place the old streaming test still runs, while the new code in _stream is covered by a weaker test (see the stream comment on test_llm.py).

Suggestion: point test_stream_call_measures_ttft at LiteLLMAdapter and delete _stream_call, or mark it as kept only for that test.

claude and others added 2 commits October 16, 2026 23:49
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
Review follow-ups:
- tests: retry_delay doubling ([0.5, 1.0, 2.0]); llm_call/_with_usage
  forward max_retries, retry_delay, max_tokens, stream; draw through the
  shims and llm_call; chat_completion forwards tool_choice; get_llm reads
  an explicit file: key; a streamed LLM call sends key/temperature/seed=0
  and stamps TTFT at the first chunk.
- remove the unused _stream_call; its TTFT test drives LiteLLMAdapter.
- truncation warning no longer points at a --max-tokens flag.
- metrics() says replays are summed at their stored cost.
- module docstring names what has not moved to llm.py yet, and that
  chat() does not log truncation.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RW8mAicb3ibXdzHMV83wDn
@napetrov
napetrov force-pushed the refactor/llm-module branch from 79de0b1 to 106d65f Compare October 16, 2026 23:49
On main chat_completion returned the raw response, so the tool probe
priced a reply with an empty choices list and its spend cap counted it.
The adapter read choices[0] first and the usage was lost. Read the
choice defensively; complete() still raises IndexError and caches nothing.

Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
Empty tool list, chat_completion retry budget, get_llm's four attempts,
a content-less reply stored as null, dict tool calls without arguments,
file:~ keys and the openai-codex route, each as on main.

Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
@xaleryb

xaleryb commented Oct 17, 2026

Copy link
Copy Markdown
Contributor

Ran a differential at the litellm.completion boundary, main vs 106d65f: 2677 scenarios (4033 calls) over 19 provider/model forms and 12 option sets, plus retry (12 error kinds), response-cache and key-leak matrices, 32 threads x 200 calls for the metrics, and about 44 live Bedrock calls (haiku-5-5 and sonnet-5-5, bare names and us.* profiles). Requests, cache keys (old entries hit both ways), retries, usage and cost match main, apart from the vertex key the description lists; no key appears in any log line, exception or cache file.

  1. A billed HTTP 200 with choices: [] through tools preference --max-cost-usd (real litellm 1.105.0 against a local server, cap $5e-05): main attaches the usage to the IndexError and the cap stops after 2 calls; here LiteLLMAdapter.__call__ reads resp.choices[0] (llm.py:540) before the caller can price the reply, so 16 calls run, none has usage, and the cap never trips. Pushed 674ebe3: the adapter reads the choice defensively so chat_completion returns the raw response as on main; complete() still raises and caches nothing.
  2. Pushed 7b93ffa: tests for behaviour the suite left mutable (tools=[] not sending tool_choice, chat_completion's retry budget, get_llm's 4 attempts with 2/4/8 s sleeps, null content, a dict tool call without arguments, file:~ keys, the openai-codex route order); each mutant passed the full suite before.

The description's "same suite with no test edits" and "_stream_call kept" no longer hold after 106d65f.

@xaleryb xaleryb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at 7b93ffa: requests, cache keys, retries, usage and cost match main at the litellm.completion boundary over 2677 scenarios and live Bedrock calls, no key leaks, the billed-reply-without-choices case is priced again by its caller, the delegate behaviours the suite left open are pinned, and all checks pass. Per issue #4, #80 should land right after this.

@napetrov
napetrov merged commit fdb3092 into intel:main Oct 17, 2026
20 checks passed
napetrov added a commit to napetrov/dokimi that referenced this pull request Oct 17, 2026
Resolve conflicts with intel#78/intel#69/intel#43:
- arms: keep main's questions.v2 loader; keep the id-type check before the run.
- cross-judge: keep main's judge_materials gate; stored scores and the
  pairwise/checklist gate read records without a join, and a RecordError
  exits 1 naming the record (tests updated from exit 2).
- arm_runner: digest lookup uses Record.question_id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RW8mAicb3ibXdzHMV83wDn
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-changelog No user-visible change: bypasses the changelog fragment check

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants