osrforge.monsters
Stage 3: monster resolution — extracted names against osrlib's shipped catalog, plus the stat-block pass.
The resolution population is the union of encounter monster names across every
stages/areas.*.json cache — the names assembly must map — not
survey.monster_names: the survey list is a document-wide superset that
includes wandering-table entries and townsfolk, and resolving those would burn
LLM budget on names no keyed encounter references and emit flags nobody can
act on.
The four resolution tiers, each consulted only when the previous one misses: normalized exact match over derived match forms, the curated alias table, stdlib fuzzy matching, and one LLM pass over the remainder. A fully deterministic resolution makes no model call.
The stat-block pass runs after the tiers, over the names
still unresolved plus the LLM- and fuzzy-resolved ones, gated by the
custom_monsters knob: one transcription request per name over its planned
page set, cached raw in stages/statblocks.json for assembly's deterministic
template mapping. The pass transcribes — every rules judgment lives in
assembly, where it is testable and correctable.
The stat-block veto then runs over the
LLM- and fuzzy-resolved names: a printed block whose Hit Dice count differs
from the picked template's by one or more flips the pick to unresolved —
the vetoed name flows into the existing custom-emission path, and the
discarded pick rides the cache on vetoed_template_id/veto_detail. The
veto changes the cache, not just the draft, so every downstream consumer
agrees with the record. Near-miss judgment (resolution_suspect) is
assembly's job — the comparator caches facts; assembly judges survivors.
MONSTERS_SYSTEM
module-attribute
MONSTERS_SYSTEM = "You match monster names extracted from a tabletop adventure module against a fixed monster catalog. The user message lists the extracted names, each with its candidate templates as `id (Printed Name)` pairs.\n\nFor each name, pick the template a referee would treat as the same creature under another name — edition synonyms, spelling variants, singular versus plural. Answer null when no candidate is that creature: never pick a merely similar monster, a different creature of the same theme, or a \"close enough\" substitute. When in doubt, answer null — an unmatched name keeps the module's own printed stat block downstream, while a wrong pick silently replaces the module's creature with a different one.\n"
MONSTER_ALIASES
module-attribute
The curated alias tier: normalized extracted name → catalog template id.
Entry rule: only names observed in a real module run, each entry carrying a source comment. Growing the table after a resolution fixture was recorded changes that fixture's request fingerprint whenever a new entry covers one of its names — re-recording is the remedy (the re-record rule).
STATBLOCK_PAGE_CAP
module-attribute
The stat-block pass's per-name page budget: encounter pages first, then ascending text hits.
STATBLOCK_SYSTEM
module-attribute
STATBLOCK_SYSTEM = 'You transcribe one creature\'s printed stat block from tabletop adventure module pages. The user message names the creature, then interleaves each page\'s extracted text (each headed by a [page N] marker) with that page\'s image — check the images too: tabular stat blocks often scramble in the extracted text.\n\nRules:\n- Transcribe, never convert. Copy each value as the page prints it, in its printed notation. Do not derive missing values, translate between editions or armour-class systems, or fill gaps from your own knowledge of any game. A value the pages don\'t print is null.\n- "found" is whether the pages print a stat block (or an inline stat line) for this creature. When false, leave every other field null or empty.\n- "ac" is the armour-class value exactly as printed; "ac_notation" classifies the printed system: "descending" (classic, lower is better), "ascending" (modern, higher is better), or "dual" (both printed, like "5 [14]").\n- A Hit Dice line as printed goes in "hit_dice" ("3+1", "1-1", "½", "2d8"); a class-and-level designation ("F 3", "3rd-level cleric", "Thief 2") goes in "class_level". Use whichever the block prints; both null means the block states neither.\n- "thac0" is the printed to-hit line, keeping its notation ("17", "19 [+0]", "+2").\n- "attacks" keeps one entry per printed attack line, with counts and damage as printed ("2 claws (1d4 each)", "1 bite (1d6 + poison)").\n- "movement" is the printed movement line ("120\' (40\')", "Fly 180\' (60\')", "30 ft.").\n- "saves" is the printed saving-throw line, whatever its form ("D12 W13 P14 B15 S16 (2)", "save as F2", "Fort +2, Ref +4, Will +1").\n- "special" keeps one entry per printed special-ability line or note.\n- "number_appearing" is the printed number-appearing value ("1d6 (2d6)", "2-8").\n- "source_pages" refer to the [page N] markers in this request, never to page numbers printed on the pages.\n- "confidence" is your self-assessment in [0, 1] of how faithfully you transcribed the block.\n'
build_monsters_request
build_monsters_request(names_with_candidates: Sequence[tuple[str, Sequence[tuple[str, str]]]]) -> ModelRequest
Build the monsters-stage request over names and their candidate lists.
Public and pure, like build_survey_request
— the runner and fixture tests must build fingerprint-identical requests
without duplicating prompt code. Names are sorted ascending here, so the
prompt, the schema's property order, and its required list always agree
regardless of the caller's order. The request carries no page images, only
text — which is what makes its fixture replay-grade.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
names_with_candidates
|
Sequence[tuple[str, Sequence[tuple[str, str]]]]
|
|
required |
Returns:
| Type | Description |
|---|---|
ModelRequest
|
The request, tagged |
Raises:
| Type | Description |
|---|---|
ValueError
|
If no names are given — the stage never builds an empty request (programmer misuse). |
build_statblock_request
build_statblock_request(name: str, parts: Sequence[TextPart | ImagePart]) -> ModelRequest
Build one name's stat-block transcription request.
Public and pure, like build_monsters_request
— the runner and fixture tests must build fingerprint-identical requests
without duplicating prompt code. Pages ride as text and images: the one
stat-block form the text layer destroys is the tabular one.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
The normalized monster name. |
required |
parts
|
Sequence[TextPart | ImagePart]
|
The planned pages' interleaved parts
( |
required |
Returns:
| Type | Description |
|---|---|
ModelRequest
|
The request, tagged |
Raises:
| Type | Description |
|---|---|
ValueError
|
If no parts are given — a name with no planned pages never builds a request (programmer misuse; the stage writes an absent marker instead). |
deterministic_resolutions
deterministic_resolutions(
names: Sequence[str], catalog: MonsterCatalog, fuzzy_threshold: float
) -> dict[str, MonsterResolution]
Run tiers 1-3 (exact, alias, fuzzy) over normalized names.
Exact matches only when the matched template is unique — the shipped
catalog has no match-form collisions (tested), but an ambiguous hit falls
through to the later tiers rather than picking arbitrarily. Fuzzy
auto-accepts only when the best score clears fuzzy_threshold and the
best template is unique at that score; a tie goes to the LLM tier rather
than a coin flip.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
names
|
Sequence[str]
|
The normalized names to resolve. |
required |
catalog
|
MonsterCatalog
|
The osrlib monster catalog. |
required |
fuzzy_threshold
|
float
|
The fuzzy tier's auto-accept floor. |
required |
Returns:
| Type | Description |
|---|---|
dict[str, MonsterResolution]
|
Resolutions for exactly the names tiers 1-3 resolved. |
encounter_names
encounter_names(levels: Sequence[LevelContent]) -> list[str]
Return the normalized resolution population: every keyed encounter name, deduplicated, sorted.
A name that normalizes to empty is excluded — the frozen stage-cache schema does not forbid an empty monster string, and there is nothing to resolve; assembly skips the same encounters with a flag. Public because it is the population rule: the stage, assembly's stale-cache check, and the extraction runner must all agree on it, or a recorded fixture's request fingerprint could drift from what the stage builds.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
levels
|
Sequence[LevelContent]
|
The content caches to collect names from. |
required |
Returns:
| Type | Description |
|---|---|
list[str]
|
The normalized names, deduplicated and ascending. |
llm_candidates
Return one name's LLM-tier candidates as (template_id, printed_name) pairs.
Ordered by fuzzy score descending, ties broken by template id ascending, the top-k cut taken after that ordering — candidate identity and order are part of the frozen fixture's request fingerprint, so they must be deterministic.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
The normalized extracted name. |
required |
catalog
|
MonsterCatalog
|
The osrlib monster catalog. |
required |
top_k
|
int
|
How many candidates to offer. |
required |
Returns:
| Type | Description |
|---|---|
tuple[tuple[str, str], ...]
|
The top-k candidates. |
monsters
monsters(workdir: Workdir, provider: ModelProvider) -> MonsterResolutions
Run stage 3: resolve every keyed encounter name; write stages/monsters.json and stages/statblocks.json.
Tiers 1-3 run first; the provider is called only if names remain — a fully
deterministic resolution makes no model call. An empty name population
writes an empty cache and completes. The stat-block pass then runs over
the union of unresolved, LLM-resolved, and fuzzy-resolved names (under
custom_monsters: emit — off is the documented opt-out of the entire
custom path, and no veto fires under it): one transcription request per
name over its planned page set, with a name whose plan is empty (no
encounter pages, no text hits) cached as an explicit absent marker without
a model call. After the pass, the deterministic
stat_block_veto runs per LLM- and
fuzzy-resolved name, flipping contradicted picks to unresolved before
the cache writes — the cache is the record. stages/statblocks.json is
rewritten on every run — the knob echo plus an entry per pass-population
name; under off, the echo and an empty blocks. Both caches are single
atomic artifacts; no pre-clearing is needed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
workdir
|
Workdir
|
A workdir whose content stage is |
required |
provider
|
ModelProvider
|
The model provider. |
required |
Returns:
| Type | Description |
|---|---|
MonsterResolutions
|
The resolutions, as written to the cache. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the content stage is not |
ProviderError
|
On provider transport, auth, or rate-limit exhaustion. |
SchemaValidationError
|
If the provider exhausts its schema budget. |
monsters_schema
monsters_schema(names_with_candidates: Sequence[tuple[str, Sequence[tuple[str, str]]]]) -> dict[str, object]
Build the LLM tier's JSON Schema: one required property per name, enum-locked to its candidates.
The proven dynamic-schema pattern from content's key enums: the model cannot invent, misspell, or skip a name, and each answer is one of that name's candidate ids or null.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
names_with_candidates
|
Sequence[tuple[str, Sequence[tuple[str, str]]]]
|
|
required |
Returns:
| Type | Description |
|---|---|
dict[str, object]
|
The request schema. |
normalize_monster_name
Normalize an extracted monster name for resolution and cache keying.
Casefold, collapse every internal whitespace run to one space, strip. Not
the slug function — override keys keep their spaces ("hobgoblin chieftain").
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
text
|
str
|
The name as extracted. |
required |
Returns:
| Type | Description |
|---|---|
str
|
The normalized name. |
parse_statblock_response
parse_statblock_response(data: object) -> RawStatBlock | None
Parse one schema-valid stat-block response; found: false is the explicit absent marker.
Public for the same reason the request builders are: the extraction runner's recording session must produce a cache byte-identical to the stage's without duplicating parsing code.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
object
|
The response data, already validated against
|
required |
Returns:
| Type | Description |
|---|---|
RawStatBlock | None
|
The raw block, or |
printed_hd_profile
printed_hd_profile(block: RawStatBlock) -> tuple[int, int] | None
Return the printed block's structural HD (count, modifier), or None when neither form parses.
The veto's (and assembly's suspect judgment's) one reading of a printed
block's Hit Dice, pinned: a printed HD line takes precedence over a
class-level line when both print (mirroring
map_stat_block's own order); a
class-level block's level is its count (assembly's mapping), modifier 0;
a fractional printed parse normalizes to osrlib's own modeling — ½ →
count 1 (the catalog's die-4 shape), smaller fractions (¼, ⅛) → count
0 — so a module printing ½ against a ½-HD template is agreement, never
a Δ1 false veto.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
block
|
RawStatBlock
|
The cached raw block. |
required |
Returns:
| Type | Description |
|---|---|
tuple[int, int] | None
|
|
tuple[int, int] | None
|
class-level notation parses. |
stat_block_veto
stat_block_veto(name: str, block: RawStatBlock | None, template: MonsterTemplate) -> MonsterResolution | None
Judge one LLM- or fuzzy-resolved pick against its printed block; return the vetoed entry, or None.
The veto fires exactly when (a) the printed block is usable — an AC plus
an HD line or class-level notation, the same structural gate as
usable_stat_block, composed here
from the shared parsers because assembly sits above this stage in the
import graph — (b) both the printed HD and the template HD parse
structurally (printed_hd_profile
against MonsterHitDice.count), and (c) the HD count differs by ≥ 1.
AC never vetoes — single-value AC direction rests on a defaulted
assumption (complement_derived), and a false veto spends resolution
accuracy the corpus shows we don't have to spend; AC only flags, at
assembly. The vetoed entry preserves the discarded pick on
vetoed_template_id with the both-readings veto_detail.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
The normalized extracted name. |
required |
block
|
RawStatBlock | None
|
The name's cached raw block, or the absent marker. |
required |
template
|
MonsterTemplate
|
The picked catalog template. |
required |
Returns:
| Type | Description |
|---|---|
MonsterResolution | None
|
The |
MonsterResolution | None
|
the pick survives. |
statblock_page_plan
statblock_page_plan(
name: str, levels: Sequence[LevelContent], page_texts: Mapping[int, str], cap: int = STATBLOCK_PAGE_CAP
) -> tuple[int, ...]
Plan one unresolved name's page set: encounter pages union text-layer hits, capped.
The union of the name's encounter source_pages (every content-cache area
with an encounter normalizing to name) and every page whose extracted
text layer contains the name — a deterministic local search (casefolded,
whitespace collapsed), no model. The search is what catches the
printed-elsewhere pattern (stat blocks in a new-monsters appendix far from
the encounter); a scanned module with an empty text layer degrades to
encounter pages only. The cap keeps encounter pages first, then ascending
text hits.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
The normalized monster name. |
required |
levels
|
Sequence[LevelContent]
|
The content caches. |
required |
page_texts
|
Mapping[int, str]
|
Page number → that page's extracted text layer. |
required |
cap
|
int
|
The page budget. |
STATBLOCK_PAGE_CAP
|
Returns:
| Type | Description |
|---|---|
int
|
The planned pages, encounter pages (ascending) then text hits |
...
|
(ascending), capped. |
statblock_schema
statblock_tag
Return one name's stat-block request tag: statblock.<slug> within the tag charset.
Slugging is lossy ("orc chief" and "orc-chief" share a tag), which is
harmless: fixture filenames append the request fingerprint, so identity
never rests on the tag alone.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
The normalized monster name. |
required |
Returns:
| Type | Description |
|---|---|
str
|
The request tag. |