osrforge.mapread
Stage 4: the map-reading pass — one request per surveyed level over its printed map pages.
The map becomes extraction input here and only here: each request carries a
level's keyed-area list (printed forms) plus its unblanked map pages, and the
model proposes same-level adjacency pairs, doors on those pairs, and the
map-labeled entrance — nothing more. No content-stage output rides the
request: the map read is an independent second opinion on the prose's edge
facts, and feeding it prose connections would let one instrument contaminate
the other. Proposals cache as answered, raw — endpoint resolution happens
at reconcile time (osrforge.reconcile) in
deterministic code, so a reconciliation-policy fix never strands a recorded
fixture.
The stage plans its requests off the survey cache alone (SurveyLevel.map_pages
has fed the content pass's compass hint since phase 6); content and monsters
never touch the cache, so only a survey re-run clears it. Downstream-most of
the model stages by design: rerun mapread re-rolls only itself plus
assembly, and a map failure preserves the expensive content and monsters
caches.
MAPREAD_SYSTEM
module-attribute
MAPREAD_SYSTEM = 'You read one dungeon level\'s printed map from a tabletop adventure module. The user message names the dungeon and level and lists the level\'s keyed areas in printed order, then interleaves each map page\'s extracted text (each headed by a [page N] marker) with that page\'s image — the map is in the images; read them.\n\nRules:\n- Propose adjacency only between the provided printed keys. Two keyed areas are adjacent when the map joins them directly by a door, opening, or passage, or through unkeyed passage in consecutive order along it.\n- Report a door only where the map marks one; use "secret_door" only where the map\'s legend marks the door secret. "none" means the map joins the pair with no door marked.\n- "entrance" is the keyed area holding the way into the level from outside, only where the map labels it (an entrance arrow, a labeled stair from the surface, a marked cave mouth). Answer null when the map labels none.\n- Propose nothing that cannot be read: keys the map does not show, adjacencies you cannot trace, or an entrance the map does not label are omissions, never guesses.\n'
build_mapread_request
build_mapread_request(
dungeon_id: str, dungeon_name: str, level: SurveyLevel, parts: Sequence[TextPart | ImagePart]
) -> ModelRequest
Build one level's map-reading request.
Public and pure, like build_survey_request
— the extraction runner's targeted leg must build fingerprint-identical
requests without duplicating prompt code. The keyed-area list rides in
survey order as printed forms (source_label or key, the census's
printed-form recovery precedent), so the proposal universe and the
yardstick agree on spellings.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
dungeon_id
|
str
|
The canonical dungeon id. |
required |
dungeon_name
|
str
|
The dungeon's printed name (the id when unnamed). |
required |
level
|
SurveyLevel
|
The survey level whose map is being read. |
required |
parts
|
Sequence[TextPart | ImagePart]
|
The level's unblanked map pages' interleaved parts
( |
required |
Returns:
| Type | Description |
|---|---|
ModelRequest
|
The request, tagged |
Raises:
| Type | Description |
|---|---|
ValueError
|
If no parts are given — a level with no sendable pages never builds a request (programmer misuse; the stage records an unread reading instead). |
mapread
mapread(workdir: Workdir, provider: ModelProvider) -> MapReading
Run stage 4: read every surveyed level's map; write stages/mapread.json.
Per level, in survey order: pages in blank_page_renders are excluded
from the sent set (preprocess emitted them as blank white PNGs — sending
one buys nothing, and a blank read as an empty map would dispute every
prose edge); a level with no map pages at all records unread:no_map_pages
with no request; a nonempty map_pages whose every page is blanked
records unread:pages_blanked with no request; a level with no keyed
areas records unread:no_keyed_areas with no request (there are no
printed keys to propose between); a level with two or more keyed areas
whose answer proposes zero adjacencies records unread:empty_reading —
a degenerate read must not assert map silence against the whole level —
keeping the answer raw but discarding its assertions via the status; a
single-area level keeps a zero-pair reading and its entrance proposal.
Under map_reading: off the stage completes writing the knob-echoed
cache with zero levels — the prose-only pipeline, bit for bit. The cache
is a single atomic artifact; no pre-clearing is needed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
workdir
|
Workdir
|
A workdir whose monsters stage is |
required |
provider
|
ModelProvider
|
The model provider. |
required |
Returns:
| Type | Description |
|---|---|
MapReading
|
The reading, as written to the cache. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the monsters stage is not |
ProviderError
|
On provider transport, auth, or rate-limit exhaustion. |
SchemaValidationError
|
If the provider exhausts its schema budget. |
mapread_schema
Build the map-reading JSON Schema: flat adjacencies plus a nullable entrance.
Flat and tolerate-and-flag (the AreaConnection posture): endpoints are
free strings — the printed keys ride the request only through the prompt
text, because enum-locking the endpoints would reject an answer the
reconciler could still resolve by slug, and unresolvable answers drop at
reconcile time with a flag, never a crash. Level-independent as a
result: one schema serves every request.
Returns:
| Type | Description |
|---|---|
dict[str, object]
|
The request schema. |
mapread_tag
Return one level's request tag: mapread.<dungeon-id>.<level> within the tag charset.
The canonical slug alphabet guarantees the tag parses unambiguously — no dots in dungeon ids.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
dungeon_id
|
str
|
The canonical dungeon id. |
required |
level_number
|
int
|
The 1-based level number. |
required |
Returns:
| Type | Description |
|---|---|
str
|
The request tag. |
parse_mapread_response
parse_mapread_response(data: object) -> tuple[tuple[MapEdgeProposal, ...], str | None]
Parse one schema-valid map-reading response into raw proposals plus the entrance answer.
Public for the same reason the request builder is: the extraction runner's targeted leg 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 |
|---|---|
tuple[tuple[MapEdgeProposal, ...], str | None]
|
The proposals as answered, and the entrance key (or |