Skip to content

Commit 01b74ff

Browse files
authored
Merge pull request #36 from Kiln-AI/claude/lucid-lovelace-wgcgml
Allow fixtures to start at a later `now=` instead of refusing it
2 parents 6b3819a + bc8b101 commit 01b74ff

14 files changed

Lines changed: 114 additions & 43 deletions

File tree

‎src/seahaven/cli/mcp.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,8 @@ def add_parser(subcommands: argparse._SubParsersAction[argparse.ArgumentParser])
145145
metavar="ISO",
146146
default=None,
147147
help=(
148-
f"the clock a blank instance starts at (${CONVENIENCE['now']}); "
149-
"not allowed with a fixture, which carries its own"
148+
f"the clock the instance starts at (${CONVENIENCE['now']}); "
149+
"with a fixture, no earlier than the fixture's own now"
150150
),
151151
)
152152
# A string, not `choices=`: an unknown mode is `world.instance()`'s one-line

‎src/seahaven/docs/clock.md‎

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ starts, the four modes that say how it moves, and what they mean for the timesta
66

77
## Modes
88

9-
An instance's clock starts at a **start instant**: the fixture's `now`, or for a blank instance the
10-
`now=` it was given, or else the wall time at creation. The instance's **clock mode** says how the
11-
clock moves from there:
9+
An instance's clock starts at a **start instant**: the `now=` it was given, or else the fixture's
10+
`now`, or else, for a blank instance, the wall time at creation. The instance's **clock mode** says
11+
how the clock moves from there:
1212

1313
| Mode | What the clock reads | Replays |
1414
|---|---|---|
@@ -78,7 +78,25 @@ clock does not move it. `wall` reads the host's clock at every reading.
7878
The clock is made *before* the blank database is built, not after, so a schema file that seeds
7979
reference rows of its own, such as `INSERT INTO plans VALUES ('free', ...)`, stamps them from the
8080
instance's clock as well. Freezing records the clock's reading at that moment as the fixture's
81-
`now`, and every instance of the fixture starts from there.
81+
`now`, and every instance of the fixture starts from there unless it is given a later `now=`.
82+
83+
### Starting a fixture later
84+
85+
Pass `now=` with a fixture to start the instance after the fixture's `now`. One fixture can then
86+
serve an eval at several times, such as three days later, when a deadline in the data has passed.
87+
The `small_startup` fixture of the reference world was frozen at `2026-06-01T09:00:00.000Z`:
88+
89+
```python
90+
import projecttracker
91+
92+
world = projecttracker.world
93+
94+
with world.instance("small_startup", now="2026-06-04T09:00:00.000Z", clock_mode="fixed") as inst:
95+
assert inst.clock.iso() == "2026-06-04T09:00:00.000Z"
96+
```
97+
98+
A `now=` earlier than the fixture's `now` is refused. The fixture's rows are dated by its clock, so
99+
an earlier start would give the instance rows created in its future.
82100

83101
## Timestamps are not a complete order
84102

‎src/seahaven/docs/concepts.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,9 +160,9 @@ other instance, no process.
160160

161161
Every instance has a clock of its own. World code reads it through `ctx.clock`, and SQL's date and
162162
time functions, such as `CURRENT_TIMESTAMP`, read the same clock. The clock starts at the fixture's
163-
`now`. The instance's clock mode says how the clock moves from there: `fixed`, `tick`, `running`
164-
(the default) or `wall`. [clock.md](clock.md) explains each mode, how to choose one, and what the
165-
mode means for the timestamps a world writes.
163+
`now`, or at a later `now=` given to `world.instance(...)`. The instance's clock mode says how the
164+
clock moves from there: `fixed`, `tick`, `running` (the default) or `wall`. [clock.md](clock.md)
165+
explains each mode, how to choose one, and what the mode means for the timestamps a world writes.
166166

167167
## Reproducibility
168168

‎src/seahaven/docs/db_schema_and_fixtures.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ world_version: 1.0.0
153153
| `id` | the fixture's name, and the directory it sits in |
154154
| `world`, `world_version` | the world it was frozen from |
155155
| `schema_hash` | the schema it conforms to |
156-
| `now` | the instance's clock reading when it was frozen, and where the clock of every instance of this fixture starts |
156+
| `now` | the instance's clock reading when it was frozen, and where the clock of every instance of this fixture starts unless it is given a later `now=` |
157157
| `parent_id` | the fixture it was forked from, or `null` |
158158
| `file_sha256` | the checksum of `state.sqlite`, verified before the first copy |
159159
| `created_at` | real wall-clock time, and one of only two wall-clock reads a world makes outside the `wall` clock mode |

‎src/seahaven/docs/reference/api.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ a NUL, an accent, a non-Latin script. A **fixture id** follows the same rule, an
130130
| `world.add_world(other, *, name=None, store=None, tool_prefix=None, tool_allow_list=None, tool_block_list=None, startup=None)` | add another world: its tools join this world's surface, its store becomes a node of every instance. Call it; there is nothing to decorate |
131131
| `world.state_format(name)` | register a state format of this world's own, as a decorator. The name is `<family>/<major>` and may not begin with `seahaven.` |
132132
| `world.resolve_state_format(name)` | the formatter a name answers to: a built-in, or one this world registered. Asked of the root of an instance and of nothing else |
133-
| `world.instance(fixture=None, *, seed=None, now=None, clock_mode=None, state_format=None, control_tools=False, startup=None)` | make an instance; a context manager. `now` is where a blank instance's clock starts. `clock_mode` is how the clock moves, `None` for `world.default_clock_mode`. `state_format` answers in another of this world's formats, in place of the pin. `control_tools=True` makes the framework's own control tool callable on the instance. `startup` is the world's own keywords, passed to the startup hooks that name them |
133+
| `world.instance(fixture=None, *, seed=None, now=None, clock_mode=None, state_format=None, control_tools=False, startup=None)` | make an instance; a context manager. `now` is where the clock starts, no earlier than the fixture's `now`. `clock_mode` is how the clock moves, `None` for `world.default_clock_mode`. `state_format` answers in another of this world's formats, in place of the pin. `control_tools=True` makes the framework's own control tool callable on the instance. `startup` is the world's own keywords, passed to the startup hooks that name them |
134134
| `world.fixtures()` | every fixture in the fixtures directory, as a list sorted by id. A world with no fixtures directory has none, which is not an error |
135135
| `copy.copy(world)` | this world with the same registrations and its own instances: set `fixtures_dir` on the copy to freeze somewhere else without moving the imported world's |
136136
| `world.tools` | the registry, in registration order. Read-only, and this world's **own** tools: the composite surface an agent sees is `inst.tools()`, or `world.composition().tools` |

‎src/seahaven/docs/reference/cli.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ command says so and exits 1.
204204
|---|---|---|---|
205205
| `--fixture NAME` | `SEAHAVEN_FIXTURE` | a blank instance | the fixture the instance starts from |
206206
| `--seed N` | `SEAHAVEN_SEED` | a random seed, written to stderr | the caller seed, an integer; the framework refuses a negative one, and one that does not fit in 8 bytes |
207-
| `--now ISO` | `SEAHAVEN_NOW` | wall time at creation | the clock a blank instance starts at; the framework refuses it together with a fixture |
207+
| `--now ISO` | `SEAHAVEN_NOW` | the fixture's `now`, or wall time at creation for a blank instance | where the instance's clock starts; with a fixture, the framework refuses an instant earlier than the fixture's `now` |
208208
| `--clock-mode MODE` | `SEAHAVEN_CLOCK_MODE` | the world's default | how the instance's clock moves: `fixed`, `tick`, `running` or `wall` |
209209
| `--reset-options JSON` | `SEAHAVEN_RESET_OPTIONS` | none | a JSON object of the keyword arguments `world.instance()` is called with; not allowed with `--fixture`, `--seed`, `--now` or `--clock-mode` |
210210
| `--world module:attr` | -- | the convention | which world to serve |

‎src/seahaven/docs/serving_and_openenv.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,8 @@ A WebSocket connection is one session, and one session holds one instance.
116116

117117
- **`reset(fixture=..., seed=..., startup=...)`** creates the instance. `reset()` with no fixture
118118
creates a blank instance from the schema, whose clock starts at wall time unless `now=` says
119-
otherwise. Passing `now=` together with a fixture is refused, because the fixture carries its own
120-
start instant.
119+
otherwise. An instance of a fixture starts at the fixture's `now`, and `now=` can move that start
120+
later ([clock.md](clock.md#starting-a-fixture-later)).
121121
Everything is passed straight to `world.instance(...)`, so the rules are the ones you already know
122122
from running in process.
123123
- **A second `reset`** destroys the current instance before making the new one, so a session never
@@ -132,7 +132,7 @@ A WebSocket connection is one session, and one session holds one instance.
132132
|---|---|
133133
| `fixture=` | the frozen starting state to copy. Omit it for a blank instance, built from the world's schema |
134134
| `seed=` | the seed behind `ctx.ids`, and behind SQL's `random()` and `randomblob()` |
135-
| `now=` | where the clock starts, for a blank instance only. A fixture carries its own, and `now=` with one is refused |
135+
| `now=` | where the clock starts. Omit it for the fixture's `now`, or for wall time on a blank instance. With a fixture, it must not be earlier than the fixture's `now` |
136136
| `clock_mode=` | how the clock moves: `fixed`, `tick`, `running` or `wall`. Omit it for the world's default ([clock.md](clock.md)) |
137137
| `episode_id=` | your own id for the episode, echoed back on `state` so a trajectory ties to your run |
138138
| `state_format=` | the format the `state` message answers in, in place of the world's pin ([state.md](state.md)) |

‎src/seahaven/fixtures.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ def id(self) -> str:
191191

192192
@property
193193
def now(self) -> str:
194-
"""The clock every instance of this fixture starts at."""
194+
"""Where every instance of this fixture starts its clock, unless `now=` is later."""
195195
return self.meta.now
196196

197197
@property

‎src/seahaven/instances.py‎

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1038,9 +1038,9 @@ def create(
10381038
# exists: an unknown startup keyword, a startup value no state document
10391039
# could carry, a clock mode that does not exist, a format nothing
10401040
# registered, an id that is not an id, a fixture that is missing,
1041-
# modified or frozen from another schema, and `now=` where the fixture
1042-
# already carries the clock. A creation that cannot succeed copies
1043-
# nothing and leaves nothing behind.
1041+
# modified or frozen from another schema, and a `now=` earlier than the
1042+
# fixture's own. A creation that cannot succeed copies nothing and leaves
1043+
# nothing behind.
10441044
_check_startup_keywords(composition, keywords)
10451045
serialised_startup = _serialised_startup(keywords)
10461046
# On the root, and only the root: an added world's registrations are
@@ -1067,10 +1067,10 @@ def create(
10671067
base = instance_seed(fixture_id if fixture_id is not None else world.name, seed)
10681068
# Made before anything is built or copied: a `running` clock counts
10691069
# from here, and a blank instance's schema reads it.
1070-
if fixture is not None:
1071-
clock = Clock.from_iso(fixture.now, mode)
1072-
elif now is not None:
1070+
if now is not None:
10731071
clock = _clock_from(now, mode)
1072+
elif fixture is not None:
1073+
clock = Clock.from_iso(fixture.now, mode)
10741074
else:
10751075
clock = Clock(Clock.wall().now(), mode)
10761076
if fixture is None:
@@ -1241,7 +1241,7 @@ def _fixture(
12411241
for difference in check_composition(fixture.meta, composition):
12421242
_log.info("fixture %s of world %s: %s", fixture_id, self._world.name, difference)
12431243
if now is not None:
1244-
raise WorldBug("now= applies to blank instances only: a fixture carries its own clock")
1244+
_check_not_before_fixture(fixture, now)
12451245
return fixture
12461246

12471247
def _make_instance_dir(self, instance_id: str) -> Path:
@@ -1327,6 +1327,22 @@ def _clock_from(now: str | datetime, mode: ClockMode) -> Clock:
13271327
return Clock.from_iso(now, mode) if isinstance(now, str) else Clock(now, mode)
13281328

13291329

1330+
def _check_not_before_fixture(fixture: Fixture, now: str | datetime) -> None:
1331+
"""Refuse a start instant earlier than the fixture's own `now`.
1332+
1333+
The fixture's rows are dated by its clock, so an earlier start would give the
1334+
instance rows created in its future.
1335+
"""
1336+
requested = _clock_from(now, "fixed")
1337+
frozen = Clock.from_iso(fixture.now)
1338+
if requested.now() < frozen.now():
1339+
raise WorldBug(
1340+
f"now={requested.iso()} is earlier than {frozen.iso()}, the now of fixture "
1341+
f"{fixture.id!r}: the fixture's rows are dated by its clock, so start the instance "
1342+
"at or after it"
1343+
)
1344+
1345+
13301346
def node_seed(base: bytes, path: str) -> bytes:
13311347
"""The seed one node's id stream is drawn from.
13321348

‎src/seahaven/openenv/env.py‎

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -373,8 +373,7 @@ class SeahavenResetRequest(ResetRequest):
373373
now: str | None = Field(
374374
default=None,
375375
description=(
376-
"Where a blank instance's clock starts, as an ISO-8601 instant, e.g. "
377-
"2024-03-05T12:00:00Z."
376+
"Where the instance's clock starts, as an ISO-8601 instant, e.g. 2024-03-05T12:00:00Z."
378377
),
379378
)
380379
clock_mode: _ClockModeField | None = Field(
@@ -506,9 +505,9 @@ def reset(
506505
`done` is `False` and `reward` is `None`, as on every observation here.
507506
508507
`fixture=None` is a blank instance built from the world's DDL, whose clock
509-
starts at the wall time unless `now=` says otherwise; a fixture carries
510-
its own start and `now=` with one is refused. `clock_mode=` is the mode
511-
the clock runs in, the world's default when it is not given.
508+
starts at the wall time unless `now=` says otherwise; a fixture's clock
509+
starts at the fixture's own `now`, or at a later `now=`. `clock_mode=`
510+
is the mode the clock runs in, the world's default when it is not given.
512511
`state_format=` answers this episode's `state` message in another of the
513512
root world's formats, in place of the world's pin. An unknown mode or an
514513
unregistered format is refused before anything is copied. Those are the
@@ -538,8 +537,8 @@ def reset(
538537
it runs before `_forget()` and the session keeps the episode it had,
539538
which is what the same call does in process: an unknown keyword fails at
540539
argument binding, before the body. Everything the world judges -- an
541-
unknown startup keyword, `now=` with a fixture -- is judged while the
542-
instance is being made, after the old one is gone.
540+
unknown startup keyword, a `now=` before the fixture's -- is judged
541+
while the instance is being made, after the old one is gone.
543542
544543
The old instance is destroyed *before* the new one is made, so the
545544
session never holds two at once. A creation that then fails leaves the

0 commit comments

Comments
 (0)