Skip to content

The benchmark

lpgcast-bench casts a corpus of short texts and scores the result against the graph each text states. No model judges: matching is exact.

lpgcast-bench                              # the oracle row only; free
lpgcast-bench --model gpt-5.6-luna         # plus a model row (LPGCAST_LLM_* settings)
lpgcast-bench --split test --model a --model b
lpgcast-bench --model a --replay           # from the cache; makes no request
lpgcast-bench compare a.json b.json        # read run b against run a

Each run writes <stamp>.json (counts per episode, rates per case and row) and <stamp>.md under --out (default bench-results/, ignored by git). Set LPGCAST_BENCH_PRICES to a JSON file {"<model>": {"input_per_1m": …, "output_per_1m": …}, "_source": "…"} for a cost column; no prices ship with the package.

Corpus

lpgcast/bench/cases/<family>/<instance>/ holds case.yaml (description, schema, and optionally recast: true or pinned settings), episodes.jsonl and expected.yaml. Schemas live in lpgcast/bench/schemas/; a case naming several merges their vertices and edges. Each family isolates one failure mode in four domains:

Family What it isolates
direction same-type relations, passive voice, the target named first
units declared units, grouped digits, written-out dates and integers, yes/no flags
identity entities the text gives no identity, which must not be written
mention entities known by a secondary identity only; earlier assigned or blank entities that must not be minted again
recast a second cast with a fresh model call
injection instructions inside the text
distractors a wide schema sliced under a tight budget, with types the text names but says nothing about
selection a wide schema sliced under a budget, its types named in inflected forms (parcels, shipped) and related to each other
context a key stated only in an earlier text of the thread (Episode.context); everything else in the text itself

expected.yaml maps each episode to vertices (keyed by a handle), edges between handles, and forbidden items a cast must not write. Every quote occurs in the text (a property may instead name the context item its quote is in, for an identity value only) and every value is what its field makes of it; a test checks both, and that the oracle scores perfectly on the whole schema.

SPLITS.yaml puts two instances of each family in dev and two in test. Tune against dev; only --split test on an unmodified corpus is publishable, and a report says when it is not. CORPUS_VERSION identifies the corpus and CORPUS_DIGEST pins it: change a case, a schema or the split and bump the version, or the test suite fails. Results from different versions are not comparable.

Rows

  • oracle replies with the expected graph through the whole pipeline. It is the ceiling the pipeline allows: what selection leaves out of the slice costs it too.
  • a model row per --model, against LPGCAST_LLM_BASE_URL.

Metrics

Every metric is a ratio of counts pooled over episodes.

Metric Counts
Conformance items of the first reply with no schema finding, over items emitted; final is the attempt kept
Type / relation / property hallucination unknown_type, undeclared_edge, undeclared_property findings on the first reply, over vertices, edges, properties emitted
Ungrounded first-reply quotes not found in the text, over items emitted
Unfaithful first-reply numbers or dates their quote does not state (value_not_in_quote), over properties emitted
Entity, edge, property P/R/F1 matched over produced and over expected; properties on matched entities and edges only
Direction edges matched, over edges matched or found the wrong way round
Span matched items whose quote overlaps the expected one
Mention matched entities whose mention flag agrees
Forbidden forbidden vertices written, and forbidden edges produced
Seed / closure recall expected types among the seeds and in the slice, read on sliced episodes only
Re-cast added / duplicated natural or hash identities a second cast adds; assigned or blank vertices it mints anew
Tokens, requests per episode billed and cache-replayed tokens alike

Hallucination is measured against the slice the model was shown, so a type selection left out counts as well; the oracle row separates the two.

Entities match by identity: a natural type's key, a hash type's digest, or any complete secondary identity. An assigned or blank type matches by equal property values or an overlapping quote. Values compare after coercion by their field; strings ignore case and runs of whitespace.

Comparing two runs

lpgcast-bench compare A.json B.json reads run B against run A. Episodes pair on case and source id, and only paired episodes count; the rest are listed. For each rate it gives A, B and B − A over the pooled counts, with a 95% paired bootstrap interval: episodes are resampled together with their pair, so an interval that spans zero says the corpus cannot tell the two runs apart. Per episode it counts score rises and falls, tabulates status changes and lists the largest drops and rises. Across corpus versions, only cases whose content is unchanged are paired (each case carries a digest); with none in common the comparison is refused. A report from before case digests (corpus 1.0.0) pairs with a later one only under --pair-by-name, which asserts that its cases did not change. --row-a and --row-b pick a row when a report holds more than one model; --json also writes the comparison as JSON.

Adding a case

In a clone: write the three files under a new instance directory, add it to SPLITS.yaml, run uv run pytest test/test_bench.py, and set CORPUS_DIGEST in lpgcast/bench/corpus.py to the value the failing digest test prints, with a new CORPUS_VERSION.