Metadata-Version: 2.4
Name: srdcheck
Version: 0.9.0
Summary: Deterministic rules verdicts for game-running agents — cited, reproducible, honest about jurisdiction. SRD 5.2.1 is adapter #1.
Author: chaoz23
License-Expression: MIT
Project-URL: Homepage, https://github.com/chaoz23/srdcheck
Keywords: srd,ttrpg,rules-engine,ai-agents,mcp,verdicts
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Topic :: Games/Entertainment :: Role-Playing
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: NOTICE
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Requires-Dist: pypdf; extra == "dev"
Dynamic: license-file

# srdcheck

**A shared rules rail for agents: named, cited checks with explicit boundaries,
so every caller can use the same deterministic result without mistaking a
partial check for global legality.**

Machine verdicts over specific rules of the System Reference Document 5.2.1:
cited when a rule is applied, reproducible, offline, and honest enough to refuse
questions outside the shipped scope. The rules lawyer for agents — including an
agent acting as the authorized DM.

> **Status: v0.9.0 alpha — young but real, building in the open.** The kill tests that shaped the product — including the one that killed half our original idea — are in [eval/RESULTS-phase0.md](eval/RESULTS-phase0.md); the with/without-rails demo is in [demo/mage-hand/](demo/mage-hand/); the truth scorecard below is generated by CI.

Runtime and operating-system guarantees are defined in the [support matrix](docs/support-matrix.md): Python 3.10–3.13, a full Ubuntu suite, and explicit installed-artifact smoke contracts on Windows and macOS.

## Why this exists

A game increasingly runs not as one model but as *several agents sharing one evolving state* — a GM, player agents, a rules referee, tool-callers. The moment more than one mind adjudicates, the binding problem stops being any single agent's knowledge or attention budget and becomes **consistency**: five capable models will each read the SRD and rule on grapple, concentration, or a death save a little differently, and the shared game state quietly desyncs. No amount of individual intelligence guarantees they agree with *each other*, and every re-derivation is another chance to drift. The fix is a deterministic rail they all call — the same [atoms](docs/atom-concordance.md), the same citations, the same verdict every time — so every applied-rule result carries one provenance any agent (or a human) can audit. Table rulings retain separate ruling lineage. That is where srdcheck stops being a convenience and becomes load-bearing.

Even at a table of one, the rail earns its place. A model running a game is a brilliant improviser with a finite attention budget: every mechanical micro-check it handles in-context — *is this legal, is that slot spent, does the reaction refresh this round* — spends tokens and attention that belong to the only work that needs a mind, the story. And the checks a model *can* answer, it cannot **prove**, cannot **reproduce**, and — as our own benchmark showed — will not **refuse** when the question is outside the rules' jurisdiction.

We tested this before building. Frontier models answered our SRD rules questions nearly perfectly — and confidently ruled on house rules, GM discretion, and content that doesn't exist in the SRD, where the only correct answer is "not my call." Small local models got 19–30% wrong with zero refusals. So srdcheck does not compete with what models know. It is a rail: structured facts in, a deterministic scoped result out, with citations whenever it applies a rule — and the *same* rail for every agent at the table.

## What it is

srdcheck answers named, bounded questions such as: *does this proposal pass the
modeled action-economy check?* and *which modeled budget option kinds remain?*
See the generated [capability and claim map](docs/capability-map.md) for the
canonical checked and unchecked scope of every tool.

- **Verdicts, not vibes.** Exit code `0` = passes the checked scope, `1` =
  conflicts with the checked scope, `2` = cannot adjudicate. Every applied rule
  carries its SRD citation; boundary refusals do not invent citations.
- **Scope and provenance travel with the verdict.** Verdict schema 4.0 keeps
  `coverage_level`, `checked_scope`, `unchecked_scope`, and `assumptions` on
  every result and separately reports asserted/consumed/derived/missing facts,
  the advisory `rule_result`, any scoped DM `table_decision`, and explicit
  `state_mutation`. Every legal explanation says it is legal only within that
  checked scope.
- **Judge, never simulate.** No dice, no narration, no owned game state. State comes in with the query; a verdict goes out. The kernel is a stateless pure function — embeddable in anyone's DM product, VTT, or agent.
- **State commits fail closed.** `event.apply` emits an idempotent transition
  proposal bound to the exact evaluated state; `transition.commit` rejects a
  stale/out-of-order proposal and returns the same semantic receipt on retry.
  The agent-DM or host still performs the atomic state write. See
  [safe state transitions](docs/state-transitions.md).
- **Agent-DM rulings persist without hidden state.** Supply a portable table-policy
  manifest and request/encounter/session/campaign IDs to existing query tools.
  Matching rulings apply automatically, remain distinct from the SRD result,
  and carry explicit table-authority lineage. See
  [table policies](docs/table-policies.md).
- **Deterministic and fast.** No LLM call anywhere in the verdict path. Runs local and offline.
- **Observable without recording the table.** Opt-in metadata-only lifecycle
  events carry request/verdict IDs, versions, refusal class, and timing on
  stderr or a caller sink. Raw prompts, state, rulings, citations, and error
  messages are excluded by contract; SRDCheck persists nothing.
- **For agents first.** MCP + CLI, `--pipe`, `--schema`, `tool.json` at the repo
  root. Humans get plain-English *why* in the same payload; agents treat that
  prose as explanatory, not contractual.

- **Rulesets are adapters.** The kernel knows no game; all rule content loads from adapter packages, each carrying its own provenance manifest — source document, hash, license, attribution — that every applied-rule result cites through. The SRD 5.2.1 adapter ships in this repo as the reference implementation. Anyone can build an adapter for another ruleset — a community, a private table, or a publisher shipping a first-party adapter for their own IP — and their content never passes through this project. The adapter catalog points; it never hosts.

See [docs/product-truths.md](docs/product-truths.md) for the invariants this
project holds itself to, [docs/anatomy-of-a-turn.md](docs/anatomy-of-a-turn.md)
for executable-today versus target examples, and
[docs/compatibility-policy.md](docs/compatibility-policy.md) for the N/N-1
machine contract and correction policy.

**Show your work.** A big context window can *read* the SRD; srdcheck's value is the layer above — a consistent, cited set of rule atoms and a shared schema that several agents can rely on without each re-deriving the rules into context and drifting apart. [`docs/atom-concordance.md`](docs/atom-concordance.md) is the audit trail for that layer: every rule the engine applies, mapped to the exact SRD text it's grounded in (forward and inverse), generated and CI-checked-fresh, with a verbatim-citation oracle proving each quote really is on its cited page. A machine-readable [`atom-concordance.json`](docs/atom-concordance.json) is the slim index an agent can load to know what the rail covers and where it came from.

## What srdcheck does not check yet

Honesty is the product (truth T8), so the boundaries are stated, not implied. The current SRD adapter covers combat-turn fundamentals; it does **not** yet check:

- **Feature prerequisites.** `turn.plan` judges the turn's *action economy* — that you spent at most one action, one bonus action, one reaction, one spell slot. It does not verify that a feature actually grants a given action. A lone two-weapon-fighting offhand attack is "action-economy legal" even though the 2024 rules require the Attack action first; that prerequisite lives in the character's features, which this version doesn't model. The success message says so explicitly.
- **Per-spell effects.** Damage/HP, death saves, and saving throws are now modeled (the reducer folds `damage`/`heal`/`death-save`; `save.check` and `concentration.check` resolve caller-rolled saves), but *what a given spell does* — Fireball's 8d6, Hold Person's paralysis on a failed save — is the long-tail swamp and stays refused. srdcheck computes the save; it does not roll the dice or apply the spell's bespoke effect.
- **Condition coverage is surface-specific.** All 15 SRD conditions are
  registry-anchored and classified clause by clause, while each query claims
  only the effects it actually consumes: attack modifiers, modeled action
  economy and Speed, selected save/check effects, damage typing, immunity, and
  Reaction blocking through Incapacitated and its definitional embeds. The
  [completeness oracle](tests/test_condition_completeness.py) proves its named
  attack/turn surfaces; it does not imply that every condition clause is checked
  by every tool. Positional geometry, initiative order, and stealth/perception
  contests remain explicit boundaries.
- **Content outside the SRD 5.2.1** — anything not in the SRD returns exit 2 from `jurisdiction`; in adapter 0.2.1 the name registries cover spells, creatures, conditions, **classes, subclasses, species, feats, and backgrounds** (census-anchored against the source text in CI). Background coverage is jurisdiction-only: it recognizes the four backgrounds actually present in SRD 5.2.1 (Acolyte, Criminal, Sage, and Soldier) but does not yet expose their mechanics as structured facts.
- **Misspelled or unknown query fields refuse loudly** (adapter 0.2.1): every query validates its params against the declared schema — an unrecognized field returns exit 2 naming the offender, never a silently-defaulted verdict. A drift oracle in CI keeps handlers and schemas from ever diverging again.
- **Facts are requested only while they can change the result.** A granted Mage
  Hand use refuses with `missing-fact` until its relevant distance and, when it
  carries or manipulates an object, weight are supplied. A prohibition or an
  already-exceeded limit is decisive and does not trigger an irrelevant repair
  loop. A `turn.plan` move requires explicit `feet`; omission is never treated
  as a zero-foot move.

Nonsensical inputs (negative Speed, a 99th-level spell, exhaustion past 6) return exit 2 rather than a confident-looking answer. When in doubt, srdcheck refuses — a wrong verdict is the only unforgivable bug.

## Try it now

```console
$ pip install git+https://github.com/chaoz23/srdcheck
$ python -m srdcheck jurisdiction "Fireball"          # exit 0 — known content
$ python -m srdcheck jurisdiction "Hexblade"          # exit 2 — not in the SRD, honestly refused
$ python -m srdcheck query mage-hand.use '{"kind": "attack"}'
{
  "verdict": "illegal",
  "exit_code": 1,
  "why": "The hand can't attack.",
  "citations": [{"section": "SRD 5.2.1 p.145 'Spells > Mage Hand'", "page": 145,
                 "quote": "The hand can't attack"}],
  "rule_ids": ["mage-hand.cant-attack"],
  "adapter": "srd-5.2.1@0.2.1",
  "coverage_level": "rule-surface-complete",
  "checked_scope": ["listed uses and prohibitions", "10-pound limit", "30-foot range for the proposal"],
  "unchecked_scope": ["fine-manipulation ambiguity", "duration or range trigger tracking across events", "narrative consequences"],
  "assumptions": ["the supplied use kind describes the proposal", "supplied weight and distance facts are accurate when relevant to the result"]
}
$ python -m srdcheck query mage-hand.use '{"kind": "attack"}' --table-evaluation
# deterministic table.evaluation/1.0; status=findings, authority=self_attested
$ python -m srdcheck query save.check '{"modifier":2,"dc":10,"d20_result":12,"save_ability":"dex","saver_conditions":[]}' \
    --table-evaluation --table-context '{"session_id":"session-7","correlation_id":"discord-42"}'
$ python -m srdcheck cite "Command"                    # verbatim SRD text + page for any registered name
$ python -m srdcheck --schema                          # I/O contract for agents
$ python -m srdcheck capabilities                      # engine, protocol, adapters, digests, tools
```

Deterministic, offline, and no tokens. CI gates 100-verdict p95 latency below
100 ms; lower local observations are not a compatibility promise. The query
surface is young and growing slice by slice — the architecture (kernel +
[adapters](docs/adapter-spec.md), spec at v0.9 RC) is the point.

`query` and `--pipe` accept `--table-evaluation` for portfolio hosts that need
the shared `table.evaluation/1.0` envelope. Legal/illegal verdicts become
complete checked-clean/finding results only within the named query scope;
missing facts, unsupported content, ambiguity, and DM discretion remain
non-clean. The subject carries a machine-readable `srdcheck-query-scope:*`
reference; optional `--table-context` JSON can add caller-owned `session_id`,
`entity_refs`, and a `correlation_id` (emitted as `correlation:*`). Rule and
citation evidence is self-contained and keyed by every finding evidence ref.
Every result is `self_attested`, including when the calling agent is the DM:
this adapter cannot grant table, encounter-state, action, or protected-host
authority.

For a live agent-DM host, use the Discord message or resolved-intent event
snowflake as the privacy-safe request join key:

```bash
python -m srdcheck query mage-hand.use '{"kind":"attack"}' \
  --trace --request-id 123456789012345678
```

The verdict stays on stdout; canonical metadata-only NDJSON goes to stderr.
See [privacy-safe observability](docs/observability.md) for the event schema,
MCP configuration, retention boundary, and residual metadata risk.

## For agents (MCP)

srdcheck is an MCP server with zero dependencies — stdlib only. After `pip install`, the command is `srdcheck-mcp`; from a clone it's:

```json
{
  "mcpServers": {
    "srdcheck": {
      "command": "python3",
      "args": ["-m", "srdcheck.mcp"],
      "cwd": "/path/to/srdcheck"
    }
  }
}
```

The complete tool inventory and each tool's checked/unchecked scope are generated
from runtime metadata in the [capability map](docs/capability-map.md); CI rejects
stale or incomplete inventory. Every call returns the same verdict object as the
CLI as structured content. Applied-rule results carry their source citations;
boundary refusals do not invent provenance. An `illegal` verdict is a result,
not an error; `cannot-adjudicate` is an honest refusal, not a failure. Tool
descriptions and schemas come from loaded adapters, so new adapters extend the
tool list without kernel changes. See also [`tool.json`](tool.json) for the CLI
surface. The opt-in `table_evaluation` MCP tool accepts `{query_type, params,
context?}` and emits the same shared envelope without changing native query-tool
responses.

Every first-party `cannot-adjudicate` result carries stable recovery metadata
under `data`: a reason code, recoverability, exact missing-input paths, and a
suggested next action. That tells an agent whether to repair the request,
provide facts, select an adapter, use another capability, resolve a table
ruling, reconcile stale state, or stop—without parsing `why`. When DM authority is required, an
authorized agent-DM can rule directly; another caller can escalate. See the
[machine-actionable refusal contract](docs/refusal-recovery.md).

## As a Python library

Consume a ruleset's content through a stable, versioned interface — no coupling to internal file paths:

```python
from srdcheck import load_adapter, available_adapters

available_adapters()               # ['srd-5.1', 'srd-5.2.1', 'toy-tictactoe']
a = load_adapter("srd-5.2.1")      # a versioned identifier
a.categories()                     # the content categories this adapter carries
a.names("creature")                # every creature name (326, complete from the stat blocks)
a.record("creature", "Ghast")      # {"name": "Ghast", "cr": "2", "xp": 450, "citation": "SRD 5.2.1 p.287"}
a.query("encounter.xp-budget", {"level": 3, "difficulty": "moderate"})  # a verdict dict
```

**Cross-version edition-trap detection** — catch a name that was valid in an older edition but renamed/removed in the current one (the classic LLM failure, e.g. "Goblin" in 2014 → "Goblin Warrior" in 2024):

```python
from srdcheck import edition_check
edition_check("Goblin", "creature")          # exit 1 — a srd-5.1 name, not in 5.2.1;
                                             #   data.candidates_in_current suggests Goblin Warrior/Minion
edition_check("Aboleth", "creature")         # exit 0 — valid in both
# or from the CLI: srdcheck edition-check "Goblin" --category creature
```

Adapter identifiers carry their version, so `load_adapter("srd-5.2.1")` and `load_adapter("srd-5.1")` coexist without a breaking change; `edition_check` is caller-parameterized (`current` / `priors`), so it generalizes to any versions and any category. The handle is content-neutral: it knows about categories and records, never about a specific ruleset's vocabulary.

## The benchmark

[`bench/`](bench/) is the rules-fidelity referee: versioned question sets with SRD-cited gold verdicts, a harness that scores any model or agent (`gemini:`, `ollama:`, or `cmd:your-agent` on stdin/stdout), and a [generated scorecard](bench/scorecard.md) that reports wrong-rate, refusal-rate, and false-confidence separately, per category, with no aggregate number — ever. A [generated leaderboard](bench/LEADERBOARD.md) ranks subjects by wrong-count alone (the one unforgivable failure), and any third party can [submit a tamper-checked result](bench/README.md#get-on-the-leaderboard) — you can't grade your own homework, because the golds are the set's. Published findings: frontier models ace codified rules and fail by *false confidence* exactly where the rules end — and, on a [per-horizon drift lane](bench/README.md#published-findings-so-far) whose gold verdicts are derived by the engine itself, they don't drift even at 30-round horizons, while a local 8B model's errors are mistakes of rule-knowledge, not memory. Benchmark your own DM product with one command.

## Truth scorecard

Every tagged release publishes a scorecard against the product truths — generated by CI, never hand-edited, no aggregate score.

<!-- truth-scorecard:start -->
Generated by `scripts/truth_scorecard.py` — regenerated and diff-checked in CI, never hand-edited. Statuses are honest: *structural* and *held in review* mean exactly that.

| truth | claim | status | evidence |
|---|---|---|---|
| T1 | wrong verdicts | enforced in CI | 709 tests including gold suites ported from the Phase 0 eval; any wrong verdict fails the build |
| T2 | no citation, no rule | enforced in CI | 90/90 rule atoms carry verbatim source quotes; applied-rule paths cite, while boundary refusals do not invent provenance |
| T3 | advise, never overrule | structural | the API has no blocking or veto interface to wire; verdicts are advisory by construction |
| T4 | one payload, two audiences | enforced in CI | every verdict carries machine fields plus a templated plain-English why; schema-tested |
| T5 | enumeration is the direction | proven for shipped slices | consistency sweeps (50 turn states + toy boards) verify enumerate/validate agreement in both directions on every push; creature-complete enumeration remains a target |
| T6 | judge, never simulate | enforced in CI | determinism test plus a purity lint: no randomness anywhere in the kernel, no network or subprocess in the verdict path |
| T7 | mechanism never knows the game | enforced in CI | kernel lint scans every kernel module for game vocabulary (it caught a real violation during development) |
| T8 | honest boundaries | enforced in CI | refusal goldens: unknown content, unmodeled conditions, and genuinely ambiguous rules text all return exit 2; ambiguity cites its source, while registry and input boundaries may have no citation |
| T9 | never a single number | enforced in CI | bench scorecard freshness test; per-category tables, no aggregate score exists anywhere in this repository — even the leaderboard ranks by wrong-count alone (T1) and shows the other failure modes unblended |
| T10 | stranger-agent bootstrap | enforced in CI | cold-start conformance test reaches a first verdict from tool.json/--schema/MCP alone (24 tools); live probe: a frontier model given only tool.json produced a correct first verdict in 1 attempt(s), 3.9s (2026-07-16) |
| T11 | table speed | enforced in CI | p95 latency budget test: 100 verdicts must stay under 100 ms at p95; lower local observations are not a compatibility promise |
| T12 | never sell what the model has | held in review | a strategy invariant: features pitched on knowledge parity are cut in review — enforced by humans and admitted as such |
| T13 | the benchmark is a product | shipped | bench/ publishes 5 sets across 5 subjects with cited gold verdicts, incl. a per-horizon drift lane whose golds are engine-derived; a generated LEADERBOARD ranks any subject; cmd: driver + validate command let a third party submit a tamper-checked result |
| T14 | every derived state has a lineage | enforced in CI | event.apply stamps every successful derived transition (predecessor hash, causing event, rule ids, rule-vs-ruling kind); tests cover replay verification, tamper detection, the schema minimality ratchet, and reducer/validator agreement; demo replays 15 rounds hash-for-hash |
<!-- truth-scorecard:end -->

## Licensing

- Code: MIT.
- `data/`: includes material derived from the System Reference Document 5.2.1 under CC-BY-4.0 — see `srdcheck/adapters/srd-5.2.1/sources/README.md` for provenance and the required attribution.
- srdcheck is unofficial and is not affiliated with or endorsed by Wizards of the Coast.

## vs alternatives

- **Just asking the LLM** — frontier models know these rules nearly perfectly (we [measured it](eval/RESULTS-phase0.md) and killed half our own idea). What they can't do by construction: *prove* a verdict, *reproduce* it, or *refuse* when the question is outside the rules' jurisdiction — they claimed rules authority in the discretion zone in 5 of 6 [demo runs](demo/mage-hand/). srdcheck sells proof, refusal, determinism, and economy — never knowledge.
- **Lookup APIs and reference MCP servers** (Open5e, 5e-srd-api and their wrappers) — they answer *"what does the book say,"* not *"is this legal given this state."* Complementary, not competing; srdcheck cites the same text they serve.
- **Closed engines inside AI-DM products** — the strongest products in this vertical run deterministic rules layers, each rebuilt privately, uncited, unbenchmarked, and locked to their platform. srdcheck is that layer open, cited, benchmarked, and embeddable — including in theirs.
- **Simulator libraries** (combat engines, character libraries) — they *run* games and own state. srdcheck judges and owns nothing (state travels with the query, stamped with lineage), which is exactly what makes it embeddable anywhere.

## Prior art

srdcheck stands on lessons from Temple of Elemental Evil / Temple+ (dispatcher architecture), PCGen (prerequisite predicates), the FoundryVTT PF2e system (rules as data), Datasworn (official rules-as-JSON precedent), and FIREBALL (structured play state). Patterns were studied; no code was taken from any of them.

<!-- MCP registry ownership marker (do not remove): binds this repo's PyPI package to its registry namespace. -->
mcp-name: io.github.chaoz23/srdcheck


## Formula blanks (v0.3)

If a rule is a formula, the verdict is the formula — a **number** appears
only when the caller supplied every blank. Once the required size/free-hand
prerequisite facts are supplied, `grapple.initiate` without `str_modifier` +
`proficiency_bonus` returns `dc_formula` and no `dc`;
`passive.perception` without `perception_modifier` returns `score_formula`
and no `score`. Origin: a live table where the base-8 rendered as "DC 8"
when the true DC was 13. srdcheck stays lookup-pure: the formula is the rule
text, filling caller-supplied blanks is arithmetic — deriving the blanks
from a sheet is charactercheck's lane, never this one.


## Spell & feature facts (v0.4)

```
srdcheck query spell.facts '{"name": "Protection from Evil and Good", "cast_at": 1000}'
srdcheck query feature.uses '{"feature": "divine-sense", "charisma_modifier": 4}'
```

Casting time, range, components, duration, concentration — the facts a DM
misremembers at 2am — as cited verdicts, census-anchored over all 336 SRD
spells (the build fails if any spell parses incompletely). `cast_at` adds
`expires_at` as pure arithmetic on the caller's own clock: srdcheck holds no
state and no clock. `feature.uses` covers the SRD's use-count *formulas*
under the formula-blanks discipline. Origin: a 10-minute ward narrated as
active for seventy minutes, caught by the human player.

## Writing your own adapter (v0.5)

The kernel knows no game — and now the kit proves it. Three bundled adapters
span the tiers (registry-only `srd-5.1`, full `srd-5.2.1`, tutorial
`toy-tictactoe`):

```
srdcheck new-adapter my-ruleset     # conformant skeleton
srdcheck conformance <adapter-id>   # the bar every adapter must clear
```

Conformance is the entry ticket: provenance manifest with a hash-pinned
source, schema-declared inputs that refuse unknown keys, crash-free honest
refusal, census-anchored registries. Golden-verdict pinning
(`scripts/build_golden.py --check`, in CI) guarantees kit refactors are
byte-invisible to existing consumers. Guide: [docs/ADAPTER-GUIDE.md](docs/ADAPTER-GUIDE.md).
