Metadata-Version: 2.4
Name: grounded-judge-gate
Version: 0.3.0
Summary: A deterministic-authority gate with a grounded LLM-judge rescue path for short-answer grading.
Author: Ernis Badmaev
License: MIT
Project-URL: Homepage, https://github.com/ErnisBadmae/grounded-judge-gate
Project-URL: Repository, https://github.com/ErnisBadmae/grounded-judge-gate
Project-URL: Changelog, https://github.com/ErnisBadmae/grounded-judge-gate/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/ErnisBadmae/grounded-judge-gate/issues
Keywords: llm,llm-as-judge,evaluation,guardrails,quality-gate,grading
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Topic :: Software Development :: Quality Assurance
Classifier: Topic :: Software Development :: Testing
Classifier: Typing :: Typed
Requires-Python: >=3.12
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pyyaml>=6.0
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Dynamic: license-file

# grounded-judge-gate

A small offline example of one rule:

> An LLM judge may suggest a value, but deterministic code decides whether that value is accepted.

The project uses short numeric answers because the full decision path fits in a few files
and can be tested without a live model.

[Русская версия](#русская-версия)

## The problem

Suppose the canonical answer is `3/4`.

| Answer | What happens | Result |
|---|---|---|
| `3/4` | deterministic check matches the canonical value | accept |
| `0.75` | normalization turns it into `3/4` | accept |
| `6/8` | normalization turns it into `3/4` | accept |
| `0.7` | the value is valid but different | reject |
| `three quarters` | the judge extracts `3/4`, then code checks it again | accept |
| `about 0.7` | the judge extracts `0.7`, which fails the second check | manual review |
| `I do not know` | no safe value can be confirmed | manual review |

The important part is not the prompt or the model. It is who has authority to decide.

## The rule

The system has three roles:

1. Deterministic authority handles every answer it can parse.
2. The LLM judge is called only when authority cannot parse the answer.
3. Manual review receives everything that cannot be decided safely.

Authority runs twice:

- before the judge, so standard forms do not need an LLM call;
- after the judge, so an extracted value cannot bypass the original rule.

An unambiguous deterministic reject is final. The judge cannot overturn it.

## Decision flow

```text
answer
  |
  v
normalize and compare
  |
  + matches canonical value: accept
  + parsed but does not match: reject
  + cannot parse: ask the recorded judge
      |
      v
    judge result
      |
      + does not confirm equivalence: manual review
      + extracts a candidate value: compare again
          |
          v
        second deterministic check
          + matches: accept
          + does not match: manual review
```

The judge is intentionally placed between two calls to the same deterministic check.

## Quickstart

Requirements: Python 3.12 and `uv`.

```bash
git clone https://github.com/ErnisBadmae/grounded-judge-gate.git
cd grounded-judge-gate
uv sync
uv run judge-gate run scenarios/short_answer.yaml --report report.md
```

Expected result:

```text
scenario: short_answer_probability   cases: 15
route=authority: 9   route=judge: 2   manual_review: 4
PASS 15/15 (verdict+route+grounding matched contract)
```

The command returns exit code `0` when every case matches its contract and `1` when any
verdict, route, or grounded value differs.

No network call is made during the run. The judge responses come from
`scenarios/fixtures/judge_responses.json`.

## The scenario contract

Each case declares three things:

- the final verdict;
- the route that produced it;
- the value extracted by the judge, when that route was used.

```yaml
- id: verbal-form
  answer: 'three quarters'
  expect: {verdict: accept, route: judge, grounded: '3/4'}

- id: illegal-rescue-trap
  answer: 'about 0.7'
  expect: {verdict: needs_manual_review, route: judge, grounded: '0.7'}
```

Checking the route matters. A correct final verdict reached through an illegal path is
still a defect.

The fixture contains adversarial cases where the recorded judge claims equivalence and
extracts the wrong value. These cases verify that the second deterministic check cannot
be skipped.

### Coverage floors (v0.2)

Per-case expectations cannot express one thing: whether the machinery under test ran at
all. If the judge is unreachable, every case degrades into a legal `needs_manual_review`
row. Nothing in the per-case contract is violated, and the run reports green while
proving nothing.

```yaml
expect_totals:
  route_min:
    authority: 2      # decided without the judge
    judge: 2          # the judge branch actually executed
  reason_max:
    judge_error: 0    # an unreachable judge is a broken run, not a full queue
    timeout: 0
```

`route_min` is a floor on how many cases took each branch; `reason_max` is a ceiling on
failure reasons. The two catch different things: a floor catches a branch that never ran,
a ceiling catches a branch that ran and failed on every case. **A floor alone does not
prove the branch worked** — cases that entered the branch and crashed still satisfy it.

`judge_error` and `timeout` are capped at 0 **by default**, whether or not a scenario
declares `expect_totals`. Making that opt-in was a bug: a scenario that omitted the block
could run with a completely unreachable judge and report green. Raise a ceiling
explicitly (`reason_max: {judge_error: 3}`) when a scenario is deliberately testing
failure handling.

Unknown route or reason names are rejected at load time, because a typo'd floor is a
constraint that can never fire — the same silent pass this block exists to prevent.

The run below satisfies every per-case contract and still exits 1:

```text
scenario: judge_branch_never_ran   cases: 3
route=authority: 3   route=judge: 0   manual_review: 0
PASS cases    3/3 (verdict+route+grounding matched contract)
FAIL coverage 1 aggregate violation(s)
  route 'judge' taken by 0 case(s), contract requires at least 2: that branch was not exercised
```

## Calibration

```bash
uv run python scripts/calibrate.py
```

The script prints two separate results:

1. Agreement and Cohen's kappa for a small synthetic judge-vs-label dataset.
2. Route coverage, manual-review rate, and grounding checks for the three-route gate.

These are different measurements and are not combined into one score. If kappa is not
defined because both sides used only one class, the script prints `N/A` instead of a
misleading perfect score.

## What this project demonstrates

- deterministic work happens before probabilistic work;
- an LLM can help with forms that normal code cannot parse;
- an LLM claim is not enough to accept a result;
- the decision path can be part of the test contract;
- a gate must also assert that the path it tests was taken at all;
- uncertainty can be routed to a person instead of being hidden.

Output validators, retries, and human review are established engineering patterns. This
repository does not claim to invent them. It demonstrates one strict authority hierarchy
in a form that is small, offline, and easy to inspect.

## What this project does not prove

The example has important limits:

- The recorded adapter is not a real LLM. It only replays fixed responses.
- The gold set has 15 synthetic cases from one numeric domain.
- Passing the second check does not prove faithful extraction. A model could fabricate
  `3/4` from an unrelated answer, and the authority would see only a matching value.
  Coverage floors do not touch this: they prove a branch ran, not that it was honest.
- A live judge would need its own calibration and adversarial tests.
- This is not a production library, an agent framework, or an evaluation platform.

The project proves that its routing contract works on the included cases. It does not
prove that an LLM judge is safe in general.

## Project layout

```text
src/judge_gate/                 core authority, judge, runner, contract, report, CLI
scenarios/short_answer.yaml     15-case scenario
scenarios/fixtures/             recorded judge responses
data/calibration_set.json       synthetic calibration data
scripts/calibrate.py            agreement and route metrics
tests/                          deterministic regression tests
```

## Development

```bash
uv sync
uv run pytest -q
```

## Provenance and license

This is a clean-room implementation. No code, data, tests, or internal structure were
copied from a private or employer-owned repository. All examples and calibration data are
synthetic.

MIT license. See `LICENSE`.

---

## Русская версия

Это маленький офлайн-пример одного правила:

> LLM-судья может предложить значение, но решение о принятии остаётся за обычным кодом.

Для демонстрации используются короткие числовые ответы. На таком примере весь маршрут
решения помещается в несколько файлов и проверяется без живой модели.

## В чём проблема

Пусть правильный ответ - `3/4`.

| Ответ | Что происходит | Результат |
|---|---|---|
| `3/4` | детерминированная проверка видит точное совпадение | принять |
| `0.75` | нормализация приводит значение к `3/4` | принять |
| `6/8` | нормализация приводит значение к `3/4` | принять |
| `0.7` | число корректно записано, но не совпадает с каноном | отклонить |
| `три четверти` | модель извлекает `3/4`, затем код проверяет значение ещё раз | принять |
| `примерно 0.7` | модель извлекает `0.7`, повторная проверка не проходит | ручная проверка |
| `не знаю` | безопасно подтвердить значение нельзя | ручная проверка |

Главный вопрос здесь не в модели и не в промпте. Важно заранее решить, кто имеет право
принимать окончательное решение.

## Как распределены полномочия

В системе три роли:

1. Детерминированный арбитр обрабатывает все ответы, которые умеет разобрать.
2. LLM-судья вызывается только для неразбираемой текстовой формы.
3. Человек получает всё, что система не смогла решить безопасно.

Арбитр работает два раза:

- до модели, чтобы не тратить вызов LLM на обычные дроби, проценты и десятичные числа;
- после модели, чтобы извлечённое значение не могло обойти исходное правило.

Если арбитр разобрал ответ и увидел несовпадение, это окончательный отказ. Модель не
может его отменить.

## Схема

```text
ответ
  |
  v
нормализация и сравнение
  |
  + совпало с каноном: принять
  + разобрано, но не совпало: отклонить
  + разобрать не удалось: спросить записанного судью
      |
      v
    ответ судьи
      |
      + эквивалентность не подтверждена: ручная проверка
      + извлечено значение: проверить ещё раз
          |
          v
        повторная детерминированная проверка
          + совпало: принять
          + не совпало: ручная проверка
```

Модель намеренно зажата между двумя вызовами одного и того же арбитра.

## Быстрый запуск

Нужны Python 3.12 и `uv`.

```bash
git clone https://github.com/ErnisBadmae/grounded-judge-gate.git
cd grounded-judge-gate
uv sync
uv run judge-gate run scenarios/short_answer.yaml --report report.md
```

Ожидаемый результат:

```text
scenario: short_answer_probability   cases: 15
route=authority: 9   route=judge: 2   manual_review: 4
PASS 15/15 (verdict+route+grounding matched contract)
```

Код возврата `0` означает полное совпадение с контрактом. Любое расхождение по
вердикту, маршруту или извлечённому значению даёт код `1`.

Во время прогона сеть не используется. Ответы судьи записаны заранее в
`scenarios/fixtures/judge_responses.json`.

## Что проверяет контракт

Для каждого случая зафиксированы:

- итоговый вердикт;
- маршрут решения;
- значение, извлечённое моделью, если использовался маршрут судьи.

```yaml
- id: verbal-form
  answer: 'три четверти'
  expect: {verdict: accept, route: judge, grounded: '3/4'}

- id: illegal-rescue-trap
  answer: 'примерно 0.7'
  expect: {verdict: needs_manual_review, route: judge, grounded: '0.7'}
```

Проверка маршрута нужна потому, что правильный итог можно получить неправильным путём.
Для такого гейта это тоже дефект.

В фикстуре есть враждебные ответы. Записанный судья уверенно заявляет эквивалентность,
но извлекает неправильное значение. Эти случаи проверяют, что повторный вызов арбитра
нельзя незаметно пропустить.

### Нижние границы покрытия (v0.2)

Покейсовые ожидания не умеют выразить одну вещь: отработала ли вообще проверяемая
машинерия. Если судья недоступен, каждый случай честно вырождается в законный
`needs_manual_review`. Ни одно покейсовое ожидание не нарушено, гейт зелёный, доказано
ничего.

```yaml
expect_totals:
  route_min:
    authority: 2      # решено без судьи
    judge: 2          # ветка судьи реально выполнилась
  reason_max:
    judge_error: 0    # недоступный судья - это сломанный прогон, а не полная очередь
    timeout: 0
```

`route_min` - пол по числу случаев, прошедших через ветку; `reason_max` - потолок по
причинам отказа. Ловят они разное: пол ловит ветку, которая не выполнялась вообще,
потолок - ветку, которая выполнилась и упала на всех случаях. Неизвестные имена маршрутов
и причин отвергаются при загрузке: опечатка в нижней границе даёт ограничение, которое
никогда не сработает, то есть ровно тот тихий зелёный, против которого блок и написан.

Прогон ниже удовлетворяет всем покейсовым контрактам и всё равно возвращает 1:

```text
scenario: judge_branch_never_ran   cases: 3
route=authority: 3   route=judge: 0   manual_review: 0
PASS cases    3/3 (verdict+route+grounding matched contract)
FAIL coverage 1 aggregate violation(s)
  route 'judge' taken by 0 case(s), contract requires at least 2: that branch was not exercised
```

## Калибровка

```bash
uv run python scripts/calibrate.py
```

Скрипт выводит два независимых результата:

1. Agreement и каппу Коэна на небольшом синтетическом наборе judge-vs-label.
2. Покрытие маршрутов, долю ручной проверки и структурные нарушения grounding.

Эти измерения нельзя объединять в одну метрику. Если каппа математически не определена,
скрипт выводит `N/A`, а не создаёт видимость идеального согласия.

## Что здесь полезного

- детерминированная работа выполняется раньше вероятностной;
- модель помогает только с формой, которую обычный код не разобрал;
- уверенного заявления модели недостаточно для принятия ответа;
- в тестовый контракт входит не только итог, но и путь решения;
- неопределённость становится обычным маршрутом к человеку.

Валидаторы, повторные запросы и ручная проверка существовали задолго до этого проекта.
Репозиторий не претендует на изобретение нового класса систем. Он показывает одну
жёсткую иерархию полномочий в маленьком, полностью воспроизводимом примере.

## Чего пример не доказывает

- Записанный адаптер не является настоящей LLM. Он только воспроизводит готовые ответы.
- В наборе всего 15 синтетических случаев из одного числового домена.
- Повторная проверка не доказывает честность извлечения. Модель может выдумать `3/4`
  из постороннего текста, а арбитр увидит только совпавшее значение.
- Живой судья потребует отдельной калибровки и враждебных тестов.
- Это не промышленная библиотека, не агентный фреймворк и не платформа оценки.

Проект доказывает работу маршрутизации на включённых сценариях. Он не доказывает
безопасность LLM-судьи вообще.

## Структура проекта

```text
src/judge_gate/                 арбитр, судья, раннер, контракт, отчёт и CLI
scenarios/short_answer.yaml     сценарий из 15 случаев
scenarios/fixtures/             записанные ответы судьи
data/calibration_set.json       синтетические данные калибровки
scripts/calibrate.py            метрики согласия и маршрутов
tests/                          детерминированные регрессионные тесты
```

## Разработка

```bash
uv sync
uv run pytest -q
```

## Происхождение и лицензия

Это clean-room реализация. Код, данные, тесты и внутренняя структура не копировались
из приватных репозиториев или проектов работодателя. Все примеры и данные калибровки
синтетические.

Лицензия MIT. См. `LICENSE`.
