Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,42 @@ python -m spacy download en_core_web_sm
- [Utilities](https://jericho-py.readthedocs.io/en/latest/util.html)
- [Defines](https://jericho-py.readthedocs.io/en/latest/defines.html)

## Breaking changes in Jericho 4.0

Prior to version 4.0, creating an environment without specifying a seed would silently
use the game's walkthrough seed (when known), making episodes deterministic. As described
in the [Jericho paper](http://arxiv.org/abs/1909.05398), a fixed random seed is a *handicap*
that should be chosen and disclosed explicitly. Starting with version 4.0:

- `FrotzEnv(rom)` (i.e. without a seed) now uses a time-dependent seed, i.e. episodes are stochastic.
- `FrotzEnv.reset()` accepts a `use_walkthrough_seed` argument to seed the emulator with the
game's walkthrough seed, which is needed to reproduce the walkthrough.
- `FrotzEnv.walkthrough_seed` returns the game's walkthrough seed, if it is known, otherwise `None`.
- An `ImplicitRandomSeedWarning` is issued (once per environment) when the first episode of a
game that has a walkthrough seed begins — via `reset()` or a direct `step()` — without an
explicit seeding choice. Providing any seed (e.g. `seed=-1` for time-dependent randomness),
calling `env.seed()`, or resetting with `use_walkthrough_seed=True` silences it.

To keep the old behavior (e.g. to reproduce results published with Jericho ≤ 3.x), either pin
`pip install 'jericho<4'` or seed explicitly: `env.seed(env.walkthrough_seed)` before `env.reset()`.

> [!NOTE]
> The time-dependent seed has one-second resolution, so unseeded environments created within
> the same second play identical episodes. For parallel or vectorized runs, pass a distinct
> explicit seed to each environment.

```python
from jericho import FrotzEnv

env = FrotzEnv("zork1.z5") # Stochastic (time-dependent seed).
env = FrotzEnv("zork1.z5", seed=-1) # Stochastic, explicitly (no warning).
env = FrotzEnv("zork1.z5", seed=42) # Deterministic with seed 42.

env.reset() # Uses the seed above.
env.reset(use_walkthrough_seed=True) # Deterministic, reproduces env.get_walkthrough().
print(env.walkthrough_seed) # 12
```

## Agents

- [Reading Comprehension Deep Q-Network (RCDQN)](https://github.com/XiaoxiaoGuo/rcdqn)
Expand Down
6 changes: 5 additions & 1 deletion docs/source/tutorial_quick.rst
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ Jericho implements a reinforcement learning interface in which the agent provide

from jericho import *
# Create the environment, optionally specifying a random seed
# (by default, the emulator is seeded with the current time).
env = FrotzEnv("z-machine-games-master/jericho-game-suite/zork1.z5")
initial_observation, info = env.reset()
done = False
Expand Down Expand Up @@ -127,12 +128,15 @@ One of the most common difficulties with parser-based text games is identifying
Walkthroughs
------------

Jericho provides walkthroughs for supported games using :meth:`jericho.FrotzEnv.get_walkthrough`. To use the walkthrough, it is necessary to reset the environment with the desired seed:
Jericho provides walkthroughs for supported games using :meth:`jericho.FrotzEnv.get_walkthrough`. To reproduce a walkthrough, it is necessary to reset the environment with the game's walkthrough seed, which is available via :attr:`jericho.FrotzEnv.walkthrough_seed`:

.. code-block:: python

>>> from jericho import *
>>> env = FrotzEnv("z-machine-games-master/jericho-game-suite/zork1.z5")
>>> walkthrough = env.get_walkthrough()
>>> env.reset(use_walkthrough_seed=True) # Applies the walkthrough seed to this episode only.
>>> for act in walkthrough:
>>> env.step(act)

.. note:: Since Jericho 4.0, an environment created without an explicit seed is stochastic, i.e. the emulator's random number generator is seeded with the current time. Seeding the emulator (e.g. with the walkthrough seed) is a *handicap*, as defined in the `Jericho paper <https://arxiv.org/abs/1909.05398>`_, and should be disclosed when reporting results.
156 changes: 141 additions & 15 deletions jericho/jericho.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import os
import shutil
import tempfile
import operator
import warnings
import hashlib

Expand Down Expand Up @@ -368,17 +369,42 @@ class TruncatedInputActionWarning(UserWarning):
pass


class ImplicitRandomSeedWarning(UserWarning):
pass


def _resolve_seed(seed):
'''
Resolves a user-provided seed to the int handed to the emulator.

The emulator receives the seed as a C int; without a range check, a value
like 2**32-1 (e.g. from np.random.randint(2**32)) would silently wrap to
the -1 "time-dependent" sentinel, making an explicitly seeded env stochastic.
'''
if seed is None:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open question: the -1 sentinel makes the emulator seed itself from time(0) & 0x7fff (see os_random_seed in dumb_init.c), i.e. one-second resolution and a 15-bit space. Unseeded envs created in the same second play identical episodes, which particularly affects parallel and vectorized runs, arguably the main audience for stochastic-by-default. I've documented the caveat in the README for now, but a stronger fix would be resolving seed=None on the Python side (e.g. from os.urandom) into a concrete random seed and passing that down, which also makes the episode's seed inspectable after the fact. That changes the meaning of the -1 sentinel though, so I left it out of this PR. Thoughts?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great catch. I like your proposed idea, dealing with it on the Python does make things more reproducible, we should also probably display which seed was obtained from Python's RNG and used to seed the z-machine. If you can implement this, that would be awesome.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, can you PTAL?

Unseeded envs now draw a new 31-bit seed per episode on the Python side, so -1 never reaches the emulator and same-second envs no longer collide. The selected seed is surfaced in three places:

  • a new FrotzEnv.episode_seed property,
  • a seed key in reset()'s info dict,
  • the ImplicitRandomSeedWarning message

The goal is to make any episode reproducible after the fact with FrotzEnv(rom, seed=env.episode_seed).

There's one deviation from the "Python's RNG" suggestion in the comment above: seeds are drawn from random.SystemRandom (backed by OS entropy) rather than the global random module.

Two reasons:

The cost is that unseeded episodes aren't reproducible via global seeding. I think that's acceptable, given what we gain, and ultimately, that's what episode_seed is for. However, let me know if you disagree or you'd like to take this in a different direction.

I kept -1 as the documented "explicitly stochastic" sentinel (it now means "draw a random seed per episode" rather than "let the emulator use the clock"), since the warning message and docs already point users at it. I tried to update all comments accordingly, but please let me know if I missed any.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's reasonable. Thank you again for the PR.

return -1
seed = operator.index(seed) # Accepts any integer type; rejects e.g. floats.
if not -2**31 <= seed < 2**31:
raise ValueError("seed must fit in a signed 32-bit integer, got {}.".format(seed))
return seed


class FrotzEnv():
"""
The Frotz Environment is a fast interface to Z-Machine games.

:param story_file: Path to a Z-machine rom file (.z3/.z5/.z6/.z8).
:param seed: Seed the random number generator used by the emulator.
Default: use walkthrough's seed if it exists,
otherwise use value of -1 which changes with time.
Default: -1, i.e. the emulator's random number generator is
seeded with the current time, making episodes stochastic.
:type story_file: path
:type seed: int

.. note:: Since Jericho 4.0, the seed needed to reproduce a game's walkthrough
is no longer used by default. To reproduce a walkthrough, either call
:meth:`jericho.FrotzEnv.reset` with `use_walkthrough_seed=True` or
provide :attr:`jericho.FrotzEnv.walkthrough_seed` as the `seed` argument.

"""
def __init__(self, story_file, seed=None):
self._cache = {}
Expand All @@ -397,8 +423,8 @@ def load(self, story_file, seed=None):

:param story_file: Path to a Z-machine rom file (.z3/.z5/.z6/.z8).
:param seed: Seed the random number generator used by the emulator.
Default: use walkthrough's seed if it exists,
otherwise use value of -1 which changes with time.
Default: -1, i.e. the emulator's random number generator is
seeded with the current time, making episodes stochastic.
:type story_file: path
:type seed: int
'''
Expand All @@ -425,39 +451,124 @@ def load(self, story_file, seed=None):

rom, self._bindings, self.act_gen = self._cache[story_file]

self.seed(seed)
# Track seed explicitness here rather than via seed(): a direct call
# to seed() is always an explicit choice, but the constructor default
# (seed=None) is not.
self._seed_is_explicit = seed is not None
self._seed = _resolve_seed(seed)
self._warned_implicit_seed = False
self._episode_seed_implicit = not self._seed_is_explicit
self.frotz_lib.setup(self.story_file, self._seed, rom, len(rom))
self.player_obj_num = self.frotz_lib.get_self_object_num()

def _maybe_warn_implicit_seed(self, stacklevel):
'''
Warns (at most once per loaded game) when an episode is played without
an explicit seeding choice for a game whose walkthrough seed would
have been silently applied prior to Jericho 4.0. Called at the start
of the first episode interaction — reset() or, since stepping is
possible without calling reset(), the first step() — so that correct
usage such as `FrotzEnv(rom)` followed by
`reset(use_walkthrough_seed=True)` is never flagged.
'''
if not self._episode_seed_implicit or self._warned_implicit_seed:
return
if self.walkthrough_seed is None:
return
# Mark as warned *before* warning: under warnings.simplefilter("error")
# the user gets a single exception, not one per reset()/step() forever.
self._warned_implicit_seed = True
msg = ("Since Jericho 4.0, the walkthrough seed ({}) of game '{}' is no longer used"
" by default, i.e. this episode is stochastic (time-dependent seed)."
" Call reset(use_walkthrough_seed=True) to reproduce the walkthrough,"
" or make stochasticity explicit (e.g. FrotzEnv(rom, seed=-1) or env.seed(-1))"
" to silence this warning.").format(self.walkthrough_seed, self.story_file.decode())
warnings.warn(msg, ImplicitRandomSeedWarning, stacklevel=stacklevel)

def seed(self, seed=None):
'''
Changes seed used for the emulator's random number generator.

:param seed: Seed the random number generator used by the emulator.
Default: use walkthrough's seed if it exists,
otherwise use value of -1 which changes with time.
Default: -1, i.e. the emulator's random number generator is
seeded with the current time, making episodes stochastic.
:returns: The value of the seed.

.. note:: :meth:`jericho.FrotzEnv.reset()` must be called before the seed takes effect.

.. note:: Since Jericho 4.0, calling this method without a seed no longer
silently uses the game's walkthrough seed. Use
:attr:`jericho.FrotzEnv.walkthrough_seed` or
:meth:`jericho.FrotzEnv.reset` with `use_walkthrough_seed=True`
to reproduce a walkthrough.

.. note:: Calling this method counts as an explicit seeding choice, even
without an argument (i.e. deliberately requesting a
time-dependent seed), so subsequent episodes do not raise
:class:`jericho.ImplicitRandomSeedWarning`.

'''
seed = seed or self.bindings.get('seed', -1)
self._seed = seed
return seed
self._seed_is_explicit = True
self._seed = _resolve_seed(seed)
return self._seed

def reset(self):
@property
def walkthrough_seed(self):
'''
Seed needed to reproduce this game's walkthrough, if it is known.

:returns: The walkthrough's seed, or `None` if the game has no known walkthrough seed.

:Example:

>>> import jericho
>>> env = jericho.FrotzEnv('zork1.z5')
>>> env.walkthrough_seed
12
>>> env.reset(use_walkthrough_seed=True) # Applies the walkthrough seed to this episode only.

'''
return self.bindings.get('seed')

def reset(self, use_walkthrough_seed=False):
'''
Resets the game.

:param use_walkthrough_seed: Seed the emulator to reproduce the walkthrough.
Default: `False`, i.e. use the seed set with
:meth:`jericho.FrotzEnv.seed` (a time-dependent
seed, unless one was explicitly provided).
:type use_walkthrough_seed: bool
:returns: A tuple containing the initial observation,\
and a dictionary of info.
:rtype: string, dictionary

.. note:: Using `use_walkthrough_seed=True` makes the game deterministic.
As described in the Jericho paper, this is a *handicap* that
should be disclosed when reporting results.

.. note:: `use_walkthrough_seed=True` applies to this episode only: it does
not modify the seed set with :meth:`jericho.FrotzEnv.seed`, so a
subsequent plain `reset()` reverts to that seed. To make the
walkthrough seed persistent, use `env.seed(env.walkthrough_seed)`.

'''
seed = self._seed
if use_walkthrough_seed:
if self.walkthrough_seed is None:
msg = ("No walkthrough seed is known for game '{}',"
" using the environment's seed instead.").format(self.story_file.decode())

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open question: when the caller explicitly requests use_walkthrough_seed=True but the game has no walkthrough seed, this warns and proceeds with the environment's configured seed. I kept that behavior from the original version (with the message corrected to say what actually happens), but I wonder if it should raise ValueError instead: the caller explicitly asked for a specific deterministic setup, and silently getting different behavior is the same trap #84 is about. Since this is a new API in a major release there's no compatibility cost, and callers can check env.walkthrough_seed is None to handle it gracefully. Happy to change it if you agree.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a great point. I agree, it is better to raise when possible. I'm not a big fan of warning messages since most of the time, they can easily go undetected. So, +1 for raising a ValueError.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great, done!

reset(use_walkthrough_seed=True) now raises ValueError when the game has no known walkthrough seed, with a message pointing callers at checking env.walkthrough_seed is None. Raises before any emulator side effects, so any failed calls leave the running episode untouched.

warnings.warn(msg, UnsupportedGameWarning, stacklevel=2)
else:
seed = self.walkthrough_seed

episode_explicit = self._seed_is_explicit or (use_walkthrough_seed and self.walkthrough_seed is not None)
self._episode_seed_implicit = not episode_explicit
self._maybe_warn_implicit_seed(stacklevel=3)

self.close()
rom, _, _ = self._cache[self.story_file.decode()]
obs_ini = self.frotz_lib.setup(self.story_file, self._seed, rom, len(rom)).decode('cp1252')
obs_ini = self.frotz_lib.setup(self.story_file, seed, rom, len(rom)).decode('cp1252')
score = self.frotz_lib.get_score()
return obs_ini, {'moves':self.get_moves(), 'score':score}

Expand All @@ -476,6 +587,9 @@ def step(self, action):
Note:
- The action is converted to bytes and truncated to 198 characters.
'''
# The env is playable without calling reset() first, so the implicit-seed
# warning must also cover episodes that begin with a step().
self._maybe_warn_implicit_seed(stacklevel=3)
action_bytes = action.encode('utf-8')
if len(action_bytes) > INPUT_BUFFER_SIZE:
action_bytes = action_bytes[:INPUT_BUFFER_SIZE]
Expand Down Expand Up @@ -579,7 +693,7 @@ def set_state(self, state):
'''
Sets the game's internal state.

:param state: Tuple of (ram, stack, pc, sp, fp, frame_count, rng) as\
:param state: Tuple of (ram, stack, pc, sp, fp, frame_count, opcode, rng, narrative) as\
obtained by :meth:`jericho.FrotzEnv.get_state`.
:type state: tuple

Expand Down Expand Up @@ -607,7 +721,7 @@ def get_state(self):
Returns the internal game state. This state can be subsequently restored
using :meth:`jericho.FrotzEnv.set_state`.

:returns: Tuple of (ram, stack, pc, sp, fp, frame_count, rng).
:returns: Tuple of (ram, stack, pc, sp, fp, frame_count, opcode, rng, narrative).

>>> from jericho import *
>>> env = FrotzEnv(rom_path)
Expand Down Expand Up @@ -636,9 +750,21 @@ def get_max_score(self):
return self.frotz_lib.get_max_score()

def copy(self):
''' Forks this FrotzEnv instance. '''
''' Forks this FrotzEnv instance.

The copy replays the current game faithfully (the emulator's RNG
registers are part of the copied state), but like the original, a
subsequent :meth:`jericho.FrotzEnv.reset` uses the seed set with
:meth:`jericho.FrotzEnv.seed` — not the seed of the episode being
copied, if that episode was started with `reset(use_walkthrough_seed=True)`.
'''
state = self.get_state()
env = FrotzEnv(self.story_file.decode(), seed=self._seed)
# Passing seed= above would make the copy count as explicitly seeded;
# carry over the original's bookkeeping instead.
env._seed_is_explicit = self._seed_is_explicit
env._warned_implicit_seed = self._warned_implicit_seed
env._episode_seed_implicit = self._episode_seed_implicit
env.set_state(state)
return env

Expand Down
2 changes: 1 addition & 1 deletion jericho/version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = '3.3.1'
__version__ = '4.0.0'
16 changes: 8 additions & 8 deletions tests/test_jericho.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ def test_multiple_instances():
gamefile2 = pjoin(DATA_PATH, "tw-game.z8")

# Make sure both frotz_lib have different handles.
env1 = jericho.FrotzEnv(gamefile1)
env1 = jericho.FrotzEnv(gamefile1, seed=-1)
env2 = jericho.FrotzEnv(gamefile2)
assert env1.frotz_lib._handle != env2.frotz_lib._handle

Expand Down Expand Up @@ -47,15 +47,15 @@ def _get_mem():
unit = 1024 * 1024

gamefile1 = pjoin(DATA_PATH, "905.z5")
env1 = jericho.FrotzEnv(gamefile1)
env1 = jericho.FrotzEnv(gamefile1, seed=-1)
env1.reset()
del env1

mem_start = _get_mem()
print('Memory usage: {:.1f}MB'.format(mem_start / unit))
for _ in range(1000):
# Make sure we don't have memory leak.
env1 = jericho.FrotzEnv(gamefile1)
env1 = jericho.FrotzEnv(gamefile1, seed=-1)
env1.reset()
del env1

Expand All @@ -64,7 +64,7 @@ def _get_mem():
mem_mid / unit, (mem_mid-mem_start) / unit ))

for _ in range(1000):
env1 = jericho.FrotzEnv(gamefile1)
env1 = jericho.FrotzEnv(gamefile1, seed=-1)
env1.reset()
del env1

Expand All @@ -80,12 +80,12 @@ def _get_mem():
def test_copy():
rom = pjoin(DATA_PATH, "905.z5")
env = jericho.FrotzEnv(rom)
env.reset()
env.reset(use_walkthrough_seed=True)

walkthrough = env.get_walkthrough()
expected = [env.step(act) for act in walkthrough]

env.reset()
env.reset(use_walkthrough_seed=True)
for i, act in enumerate(walkthrough):
obs, rew, done, info = env.step(act)

Expand Down Expand Up @@ -113,7 +113,7 @@ def test_saving_opcode_in_state():
]

rom = pjoin(DATA_PATH, "roms", "yomomma.z8")
env = jericho.FrotzEnv(rom)
env = jericho.FrotzEnv(rom, seed=-1)
env.reset()

state = None
Expand All @@ -129,7 +129,7 @@ def test_saving_opcode_in_state():

def test_very_long_action():
rom = pjoin(DATA_PATH, "905.z5")
env = jericho.FrotzEnv(rom)
env = jericho.FrotzEnv(rom, seed=-1)
env.reset()

long_command = "It's a " + "very " * 36 + "long action!"
Expand Down
Loading
Loading