geoguesser-env / env /models.py
AdithyaSK's picture
AdithyaSK HF Staff
Deploy geoguesser environment with train/eval splits
ee44678 verified
Raw
History Blame Contribute Delete
15 kB
# SPDX-License-Identifier: BSD-3-Clause
"""Data models for the GeoGuesser environment.
An episode places the agent at an unknown street-level location. It may look
around, walk along the road, and pin candidate coordinates on a map before
committing to a final guess. Only the final guess is scored.
"""
from __future__ import annotations
from enum import Enum
from typing import Any, Literal
from openenv.core.env_server import Action, Observation, State
from pydantic import BaseModel, Field
class EpisodeMode(str, Enum):
"""How much of the tool surface an episode exposes.
Attributes:
SINGLE_SHOT:
One view, one guess. No pin loop, no navigation.
AGENTIC:
The full tool surface, subject to what the backend supports.
NMPZ:
No move, pan or zoom — the competitive "NMPZ" mode. Only pinning
and guessing.
"""
SINGLE_SHOT = "single_shot"
AGENTIC = "agentic"
NMPZ = "nmpz"
class RewardMode(str, Enum):
"""Which ground-truth granularity the reward is computed against."""
COORDS = "coords"
COUNTRY_ONLY = "country_only"
# =============================================================================
# Actions
# =============================================================================
class TypedAction(Action):
"""Base class for the ergonomic, per-operation action types.
These are what callers construct in Python. They are converted to the flat
[`GeoGuesserAction`] wire type by the client, because an HTTP environment
declares exactly one action schema and `Action` forbids unknown fields.
"""
class LookAction(TypedAction):
"""Render a perspective view out of the current panorama.
Args:
heading_deg (`float`):
Absolute compass heading in degrees, `0` being true north.
pitch_deg (`float`, *optional*, defaults to `0.0`):
Vertical angle in degrees; positive looks up.
fov_deg (`float`, *optional*, defaults to `90.0`):
Horizontal field of view. Smaller values zoom in.
"""
heading_deg: float = Field(default=0.0, ge=-3600.0, le=3600.0)
pitch_deg: float = Field(default=0.0, ge=-90.0, le=90.0)
fov_deg: float = Field(default=90.0, ge=10.0, le=120.0)
class PanAction(TypedAction):
"""Turn relative to the current heading.
Args:
delta_deg (`float`):
Degrees to turn; positive turns right.
"""
delta_deg: float = Field(ge=-3600.0, le=3600.0)
class ZoomAction(TypedAction):
"""Change the field of view without turning.
Args:
fov_deg (`float`):
New horizontal field of view in degrees.
"""
fov_deg: float = Field(ge=10.0, le=120.0)
class MoveAction(TypedAction):
"""Walk along the captured sequence.
Args:
direction (`str`):
Either `"forward"` or `"backward"` along the sequence.
meters (`float`, *optional*, defaults to `10.0`):
Requested distance. Frame spacing is irregular, so the observation
reports how far the move actually travelled.
"""
direction: str = Field(pattern="^(forward|backward)$")
meters: float = Field(default=10.0, gt=0.0, le=500.0)
class PinAction(TypedAction):
"""Place a candidate pin and receive a map of where it landed.
The response describes the pinned location only. It carries no information
about the true location.
Args:
lat (`float`):
Latitude of the candidate.
lon (`float`):
Longitude of the candidate.
label (`str`, *optional*):
Free-text note carried back in the pin list.
span_deg (`float`, *optional*, defaults to `7.0`):
Half-width in degrees of the map window returned with the pin.
Choosing the zoom matters: below roughly 4 degrees the map adds
roads, urban areas and town names, which is what makes aiming
within a city possible rather than guessing at its centre.
"""
lat: float = Field(ge=-90.0, le=90.0)
lon: float = Field(ge=-180.0, le=180.0)
label: str | None = None
span_deg: float = Field(default=7.0, gt=0.02, le=180.0)
class ViewMapAction(TypedAction):
"""Pan and zoom the map without committing a pin.
Args:
lat (`float`):
Latitude at the centre of the view.
lon (`float`):
Longitude at the centre of the view.
span_deg (`float`, *optional*, defaults to `7.0`):
Half-width of the window in degrees.
"""
lat: float = Field(ge=-90.0, le=90.0)
lon: float = Field(ge=-180.0, le=180.0)
span_deg: float = Field(default=7.0, gt=0.05, le=180.0)
class MeasureAction(TypedAction):
"""Great-circle distance between two of the agent's own coordinates."""
lat_a: float = Field(ge=-90.0, le=90.0)
lon_a: float = Field(ge=-180.0, le=180.0)
lat_b: float = Field(ge=-90.0, le=90.0)
lon_b: float = Field(ge=-180.0, le=180.0)
class GuessAction(TypedAction):
"""Commit a final guess. Terminal.
Either supply `response` and let the environment parse it, or supply
`lat`/`lon` directly. Passing the raw reply keeps extraction failures
visible in the score rather than hidden in the harness.
Args:
response (`str`, *optional*):
The model's unedited reply. Coordinates are extracted from it.
lat (`float`, *optional*):
Latitude, when the caller has already parsed the reply.
lon (`float`, *optional*):
Longitude, when the caller has already parsed the reply.
country (`str`, *optional*):
ISO-3166 alpha-2 code or country name, scored for partial credit.
confidence (`float`, *optional*):
Self-reported confidence in [0, 1], recorded for calibration.
reasoning (`str`, *optional*):
Free-text rationale, recorded but not scored.
"""
response: str | None = None
lat: float | None = Field(default=None, ge=-90.0, le=90.0)
lon: float | None = Field(default=None, ge=-180.0, le=180.0)
country: str | None = None
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
reasoning: str | None = None
# =============================================================================
# Wire action
# =============================================================================
class GeoGuesserAction(Action):
"""The single action schema the server accepts.
An HTTP environment declares one action class, so every operation travels
as this flat record with an `op` discriminator. Callers normally build a
[`TypedAction`] subclass instead and let the client convert.
Attributes:
op (`str`):
Which operation to perform: `"look"`, `"pan"`, `"zoom"`, `"move"`,
`"pin"`, `"view_map"`, `"measure"` or `"guess"`.
"""
op: Literal["look", "pan", "zoom", "move", "pin", "view_map", "measure", "guess"]
heading_deg: float | None = None
pitch_deg: float | None = None
fov_deg: float | None = None
delta_deg: float | None = None
direction: str | None = None
meters: float | None = None
lat: float | None = None
lon: float | None = None
label: str | None = None
span_deg: float | None = None
lat_a: float | None = None
lon_a: float | None = None
lat_b: float | None = None
lon_b: float | None = None
response: str | None = None
country: str | None = None
confidence: float | None = None
reasoning: str | None = None
_OP_BY_TYPE: dict[type, str] = {}
_TYPE_BY_OP: dict[str, type] = {}
def _register_ops() -> None:
pairs = [
(LookAction, "look"),
(PanAction, "pan"),
(ZoomAction, "zoom"),
(MoveAction, "move"),
(PinAction, "pin"),
(ViewMapAction, "view_map"),
(MeasureAction, "measure"),
(GuessAction, "guess"),
]
for cls, op in pairs:
_OP_BY_TYPE[cls] = op
_TYPE_BY_OP[op] = cls
_register_ops()
def to_wire(action: TypedAction) -> GeoGuesserAction:
"""
Convert a typed action into the flat wire action.
Args:
action ([`TypedAction`]):
The action to convert.
Returns:
[`GeoGuesserAction`]: The same action, flattened, with `op` set.
"""
op = _OP_BY_TYPE.get(type(action))
if op is None:
raise TypeError(f"No wire op registered for {type(action).__name__}")
payload = action.model_dump(exclude_none=True, exclude={"metadata"})
return GeoGuesserAction(op=op, **payload)
def from_wire(action: GeoGuesserAction) -> TypedAction:
"""
Rebuild the typed action a wire action stands for.
Args:
action ([`GeoGuesserAction`]):
The received wire action.
Returns:
[`TypedAction`]: The corresponding typed action, validated.
"""
cls = _TYPE_BY_OP.get(action.op)
if cls is None:
raise ValueError(f"Unknown op: {action.op!r}")
fields = set(cls.model_fields) - {"metadata"}
payload = {
k: v for k, v in action.model_dump(exclude_none=True).items() if k in fields
}
return cls(**payload)
# =============================================================================
# Observation
# =============================================================================
class Pin(BaseModel):
"""One candidate pin and what the environment could say about it.
Attributes:
index (`int`):
1-based position in the pin list.
lat (`float`):
Latitude of the candidate.
lon (`float`):
Longitude of the candidate.
label (`str` or `None`):
The note the agent attached, if any.
description (`str`):
What the environment could say about the pinned coordinate. Never
anything about the target.
"""
index: int
lat: float
lon: float
label: str | None = None
description: str = ""
class GeoGuesserObservation(Observation):
"""What the agent sees after a reset or a step.
The schema is identical across backends. A capability the backend lacks
shows up as an unregistered tool and an empty field, never as a different
shape, so one policy runs against every backend.
Attributes:
prompt (`str`):
Instructions, populated on reset.
image_base64 (`str` or `None`):
The most recent rendered image as base64 PNG or JPEG — a
perspective view, or a map after a pin.
image_kind (`str`):
Either `"view"`, `"map"` or `"none"`, saying what the image shows.
heading_deg (`float`):
Current compass heading in degrees.
pitch_deg (`float`):
Current vertical angle in degrees.
fov_deg (`float`):
Current field of view in degrees.
moved_meters (`float`):
Distance actually travelled by the last move.
total_moved_meters (`float`):
Cumulative distance travelled this episode.
can_move_forward (`bool`):
Whether a forward frame exists on the sequence.
can_move_backward (`bool`):
Whether a backward frame exists on the sequence.
available_tools (`list[str]`):
Tool names this backend actually registered.
steps_remaining (`int`):
Actions left before the episode is cut off.
pins (`list[Pin]`):
Candidates placed so far, in order.
feedback (`str`):
Text describing the result of the last action. For a pin, this
describes the pinned location and nothing about the target.
captured_at (`str`):
Capture date of the current panorama, `YYYY-MM` — a legitimate
meta clue, as in the real game.
distance_km (`float` or `None`):
Distance from guess to truth. Populated only after a guess.
score (`float` or `None`):
Distance score in [0, 1], before action costs. After a guess only.
action_cost (`float`):
Reward already spent on information gathering this episode. Visible
throughout, not only after the guess, so a policy can see what it
has committed.
true_lat (`float` or `None`):
Ground truth latitude, revealed only after a guess.
true_lon (`float` or `None`):
Ground truth longitude, revealed only after a guess.
parsed_ok (`bool`):
Whether coordinates could be extracted from the guess.
"""
prompt: str = ""
image_base64: str | None = None
image_kind: str = "none"
heading_deg: float = 0.0
pitch_deg: float = 0.0
fov_deg: float = 90.0
moved_meters: float = 0.0
total_moved_meters: float = 0.0
can_move_forward: bool = False
can_move_backward: bool = False
available_tools: list[str] = Field(default_factory=list)
steps_remaining: int = 0
pins: list[Pin] = Field(default_factory=list)
feedback: str = ""
captured_at: str = ""
distance_km: float | None = None
score: float | None = None
action_cost: float | None = None
true_lat: float | None = None
true_lon: float | None = None
parsed_ok: bool = True
# =============================================================================
# State
# =============================================================================
class GeoGuesserState(State):
"""Internal episode state. Never sent to the agent verbatim.
Attributes:
task_index (`int`):
Index into the frozen task list, `-1` before the first reset.
task_id (`str`):
Stable identifier of the sampled task.
frame_index (`int`):
Position within the task's sequence.
heading_deg (`float`):
Current heading in degrees.
pitch_deg (`float`):
Current pitch in degrees.
fov_deg (`float`):
Current field of view in degrees.
n_looks (`int`):
Count of view renders, for action cost.
n_maps (`int`):
Count of map renders that were not pins.
n_pins (`int`):
Count of pins placed.
n_moves (`int`):
Count of moves taken.
total_moved_meters (`float`):
Cumulative distance travelled.
submitted (`bool`):
Whether the single allowed guess has been made.
pins (`list[dict]`):
Pins placed so far.
"""
task_index: int = -1
task_id: str = ""
frame_index: int = 0
heading_deg: float = 0.0
pitch_deg: float = 0.0
fov_deg: float = 90.0
n_looks: int = 0
n_maps: int = 0
n_pins: int = 0
n_moves: int = 0
total_moved_meters: float = 0.0
submitted: bool = False
pins: list[dict[str, Any]] = Field(default_factory=list)