diff --git a/harness/bridge/README.md b/harness/bridge/README.md index 05aa7c6..0edb208 100644 --- a/harness/bridge/README.md +++ b/harness/bridge/README.md @@ -64,6 +64,20 @@ carries JSON between the two languages is **not here** — it is orchestration ( `observe()` against the canonical schema — proving the contract holds with **no game attached**. That is the T1 acceptance bar. +### Reference implementations + +| Impl | What | For | +|---|---|---| +| `NullBridge` | connects to no world; empty `Perception`, no-op `act()` | the contract smoke test (T1) | +| `SyntheticBridge` | a **deterministic in-memory toy world** (places, energy, gathering); not a game | developing the loop + drives (T3/T4/T5) before a real adapter exists, and CI | + +`SyntheticBridge` has no wall clock and no RNG (`observedAt` is a logical tick), so +a given action sequence always yields the same Perceptions. It also exposes +`operatorAct()` — a second actor mutating the *same* world — to exercise +**dual-control** deterministically (the Stardew P1 model: two actors, one world; +see `decisions/adr-0007-substrate-ladder.md`). Real game adapters still live in +`../adapters/`; `SyntheticBridge` is a fixture, not an adapter. + ## Out of scope — by design | Not here | Where | diff --git a/harness/bridge/index.ts b/harness/bridge/index.ts index 902a719..a723e43 100644 --- a/harness/bridge/index.ts +++ b/harness/bridge/index.ts @@ -4,3 +4,5 @@ export type { Action, ActionResult, ActionStatus } from "./action"; export type { Bridge } from "./bridge"; export { NullBridge } from "./null-bridge"; export type { NullState } from "./null-bridge"; +export { SyntheticBridge } from "./synthetic-bridge"; +export type { SyntheticState } from "./synthetic-bridge"; diff --git a/harness/bridge/synthetic-bridge.test.ts b/harness/bridge/synthetic-bridge.test.ts new file mode 100644 index 0000000..ea3398c --- /dev/null +++ b/harness/bridge/synthetic-bridge.test.ts @@ -0,0 +1,96 @@ +// Tests for SyntheticBridge — the deterministic toy-world fixture. +// Covers every affordance branch, the rejection paths, schema conformance, and +// dual-control (operatorAct mutating the shared world the agent then perceives). + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import Ajv2020 from "ajv/dist/2020.js"; +import { SyntheticBridge } from "./synthetic-bridge"; + +const here = dirname(fileURLToPath(import.meta.url)); +const schema = (name: string): object => + JSON.parse(readFileSync(join(here, "schema", name), "utf8")) as object; + +const ajv = new Ajv2020({ strict: true, allErrors: true }); +const validatePerception = ajv.compile(schema("perception.schema.json")); +const validateActionResult = ajv.compile(schema("action-result.schema.json")); + +test("observe() conforms to the canonical schema and is deterministic", async () => { + const world = new SyntheticBridge(); + const p = await world.observe(); + assert.ok(validatePerception(p), ajv.errorsText(validatePerception.errors)); + assert.equal(p.substrate, "synthetic"); + assert.equal(p.observedAt, 0); // logical clock starts at 0 + assert.equal(p.affordances.length, 4); + assert.deepEqual(p.state, { tick: 0, place: "meadow", energy: 100, gathered: 0 }); +}); + +test("each affordance mutates state as expected; results validate", async () => { + const world = new SyntheticBridge(); + + const move = await world.act({ affordance: "move", reason: "what is over there?" }); + assert.ok(validateActionResult(move), ajv.errorsText(validateActionResult.errors)); + assert.equal(move.status, "ok"); + let s = (await world.observe()).state; + assert.equal(s.place, "stream"); + assert.equal(s.energy, 95); + assert.equal(s.tick, 1); + + await world.act({ affordance: "gather" }); + s = (await world.observe()).state; + assert.equal(s.gathered, 1); + assert.equal(s.energy, 85); + assert.equal(s.tick, 2); + + await world.act({ affordance: "rest" }); + s = (await world.observe()).state; + assert.equal(s.energy, 100); // capped at 100 + assert.equal(s.tick, 3); + + await world.act({ affordance: "look" }); + s = (await world.observe()).state; + assert.equal(s.tick, 4); // look advances only the clock + assert.equal(s.energy, 100); +}); + +test("move wraps around the ring of places", async () => { + const world = new SyntheticBridge(); + for (let i = 0; i < 4; i++) await world.act({ affordance: "move" }); + assert.equal((await world.observe()).state.place, "meadow"); +}); + +test("gather is rejected when energy is exhausted, leaving state unchanged", async () => { + const world = new SyntheticBridge({ energy: 0 }); + const r = await world.act({ affordance: "gather" }); + assert.equal(r.status, "rejected"); + assert.match(r.detail ?? "", /tired/); + const s = (await world.observe()).state; + assert.equal(s.gathered, 0); + assert.equal(s.tick, 0); +}); + +test("an unknown affordance is rejected", async () => { + const world = new SyntheticBridge(); + const r = await world.act({ affordance: "teleport" }); + assert.equal(r.status, "rejected"); + assert.match(r.detail ?? "", /unknown affordance: teleport/); +}); + +test("operatorAct mutates the shared world — dual-control (agent perceives a change it didn't cause)", async () => { + const world = new SyntheticBridge(); + const before = (await world.observe()).state; + + const r = world.operatorAct("gather"); + assert.equal(r.status, "ok"); + + const after = (await world.observe()).state; + assert.equal(after.gathered, before.gathered + 1); + assert.equal(after.tick, before.tick + 1); +}); + +test("close() resolves", async () => { + await assert.doesNotReject(() => new SyntheticBridge().close()); +}); diff --git a/harness/bridge/synthetic-bridge.ts b/harness/bridge/synthetic-bridge.ts new file mode 100644 index 0000000..f6f29a4 --- /dev/null +++ b/harness/bridge/synthetic-bridge.ts @@ -0,0 +1,100 @@ +// SyntheticBridge — a deterministic in-memory toy world. NOT a game: a test +// fixture / synthetic substrate for developing the loop (T3/T4/T5) and the drive +// layer before a real adapter exists, and for deterministically exercising +// dual-control (the operator acting on the shared world — the Stardew P1 model). +// +// Determinism: there is no wall clock and no RNG. `observedAt` is a logical tick, +// so a given sequence of actions always yields the same Perceptions — which is +// what makes it usable in CI and in assertions. + +import type { Bridge } from "./bridge"; +import type { Perception, ActionSpec } from "./perception"; +import type { Action, ActionResult } from "./action"; + +/** The toy world's state. JSON-serializable, like any substrate's state. */ +export interface SyntheticState { + /** logical clock — advances by one per applied action */ + tick: number; + /** current location (one of PLACES) */ + place: string; + /** 0..100; falls on move/gather, rises on rest */ + energy: number; + /** resources collected so far */ + gathered: number; +} + +const PLACES = ["meadow", "stream", "grove", "ridge"] as const; + +const AFFORDANCES: ActionSpec[] = [ + { id: "move", label: "move to the next place" }, + { id: "gather", label: "gather here" }, + { id: "rest", label: "rest" }, + { id: "look", label: "look around" }, +]; + +export class SyntheticBridge implements Bridge { + private state: SyntheticState; + + constructor(init?: Partial) { + this.state = { tick: 0, place: PLACES[0], energy: 100, gathered: 0, ...init }; + } + + async observe(): Promise> { + return { + substrate: "synthetic", + observedAt: this.state.tick, + state: { ...this.state }, + affordances: AFFORDANCES.map((a) => ({ ...a })), + }; + } + + /** The agent acts. */ + async act(action: Action): Promise { + return this.applyAffordance(action.affordance); + } + + /** + * Dual-control: the operator acts on the *same* world (the co-op model — two + * actors, one world). The agent perceives the change on its next observe(), + * having not caused it. Synchronous because the fixture has no transport. + */ + operatorAct(affordance: string): ActionResult { + return this.applyAffordance(affordance); + } + + async close(): Promise { + // nothing to release + } + + private applyAffordance(id: string): ActionResult { + switch (id) { + case "move": { + const idx = Math.max(0, PLACES.indexOf(this.state.place as (typeof PLACES)[number])); + this.state.place = PLACES[(idx + 1) % PLACES.length]!; + this.state.energy = Math.max(0, this.state.energy - 5); + this.state.tick++; + return { status: "ok" }; + } + case "gather": { + if (this.state.energy <= 0) { + return { status: "rejected", detail: "too tired to gather" }; + } + this.state.gathered++; + this.state.energy = Math.max(0, this.state.energy - 10); + this.state.tick++; + return { status: "ok" }; + } + case "rest": { + this.state.energy = Math.min(100, this.state.energy + 20); + this.state.tick++; + return { status: "ok" }; + } + case "look": { + this.state.tick++; + return { status: "ok" }; + } + default: + return { status: "rejected", detail: `unknown affordance: ${id}` }; + } + } +}