Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
14 changes: 14 additions & 0 deletions harness/bridge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions harness/bridge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
96 changes: 96 additions & 0 deletions harness/bridge/synthetic-bridge.test.ts
Original file line number Diff line number Diff line change
@@ -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());
});
100 changes: 100 additions & 0 deletions harness/bridge/synthetic-bridge.ts
Original file line number Diff line number Diff line change
@@ -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<SyntheticState> {
private state: SyntheticState;

constructor(init?: Partial<SyntheticState>) {
this.state = { tick: 0, place: PLACES[0], energy: 100, gathered: 0, ...init };
}

async observe(): Promise<Perception<SyntheticState>> {
return {
substrate: "synthetic",
observedAt: this.state.tick,
state: { ...this.state },
affordances: AFFORDANCES.map((a) => ({ ...a })),
};
}

/** The agent acts. */
async act(action: Action): Promise<ActionResult> {
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<void> {
// 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}` };
}
}
}
Loading