Skip to content

Commit cafa636

Browse files
Project legal plane observations into AHBG presentation snapshots
Grok presentation maps engine observations and resolved move events into snapshot motions. Seed and schema stay out. Adjacency is still engine law.
1 parent dad485c commit cafa636

4 files changed

Lines changed: 220 additions & 3 deletions

File tree

ahbg/README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ monetization, or other deferred architecture.
2121
| Plane, event log, persistence, replay | Codex engine | implemented candidate |
2222
| Canonical v1 `move` (one axial step onto empty tile) | Codex engine | implemented candidate |
2323
| Construction, spawn, absence, loyalty, War, DM rolls | Codex engine | `hmmm` / fail-closed |
24-
| A0 inhabiting the plane | DeepSeek | declared, not this folder |
24+
| Observation → presentation snapshot | Grok presentation | implemented candidate |
25+
| A0 inhabiting the plane | DeepSeek | declared; `ahbg/deepseek/` is a calibration workspace |
2526
| Triplicate calibration builds | Grok / Codex / DeepSeek workspaces | declared program |
2627

2728
The presentation snapshot is `ahbg.presentation.snapshot`, not plane state.
@@ -90,7 +91,9 @@ unknown action kinds fail closed.
9091

9192
Presentation consumes `ahbg.presentation.snapshot` only. `motions` are optional
9293
visual traces with `unit`, `from`, and `to`; they validate referenced ids but do
93-
not validate adjacency or legality.
94+
not validate adjacency or legality. `presentation/project.py` maps a legal
95+
observation plus resolved `move` events into that snapshot and drops seed and
96+
schema. It is not a 1:1 identity with plane state.
9497

9598
## Tool responsibilities
9699

@@ -277,7 +280,8 @@ it does not establish phenomenal consciousness.
277280
## hmmm
278281

279282
- exact UCNS geometric operations not yet admitted into gameplay;
280-
- whether Codex plane state maps 1:1 onto the presentation snapshot;
283+
- whether Codex plane state maps 1:1 onto the presentation snapshot (a projector exists; identity is not claimed);
284+
- engine still uses its own axial adjacency rather than a UCNS adapter;
281285
- construction animation once the engine emits construction events;
282286
- War collision resolver, occupied-tile moves, dual-target moves;
283287
- the exact regulatory cost function, calibration thresholds, coupling

ahbg/presentation/README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ is not identity.
1414
- Codex owns engine state. This snapshot is `ahbg.presentation.snapshot`, not plane state.
1515
- A tile is the centerpoint. The circle around it is geometry, not the tile.
1616
- Optional `motions` are graphics of engine-emitted `move` events. They do not decide adjacency or legality.
17+
- `project.py` maps a legal observation (and optional resolved `move` events) into this snapshot. It drops seed, schema, and other engine internals.
1718

1819
## Usage
1920

@@ -33,6 +34,40 @@ python3 -m http.server 8765 --bind 127.0.0.1
3334

3435
`board.html` also runs from a file URL by embedding the sample snapshot.
3536

37+
Project a live engine observation (does not decide legality):
38+
39+
```bash
40+
cd ahbg/presentation
41+
python3 - <<'PY'
42+
import sys
43+
from pathlib import Path
44+
sys.path.insert(0, str(Path("../..").resolve()))
45+
sys.path.insert(0, ".")
46+
from ahbg.engine import Action, Plan, TurnEngine, legal_observation, new_game
47+
from project import snapshot_from_observation
48+
49+
tiles = [
50+
{"tile_id": "c", "q": 0, "r": 0},
51+
{"tile_id": "ne", "q": 1, "r": -1},
52+
{"tile_id": "e", "q": 1, "r": 0},
53+
{"tile_id": "se", "q": 0, "r": 1},
54+
{"tile_id": "sw", "q": -1, "r": 1},
55+
{"tile_id": "w", "q": -1, "r": 0},
56+
{"tile_id": "nw", "q": 0, "r": -1},
57+
]
58+
plane, log = new_game(seed=7, tiles=tiles, units=[{"unit_id": "A0", "tile_id": "c", "label": "A0"}])
59+
engine = TurnEngine(plane=plane, log=log)
60+
engine.begin_turn()
61+
events = engine.resolve([Plan(turn=0, actions=(Action("move", {"unit_id": "A0", "to_tile_id": "ne"}),))])
62+
engine.end_turn()
63+
print(snapshot_from_observation(
64+
legal_observation(plane).to_dict(),
65+
plane_id="plane-0",
66+
move_events=[event.canonical_dict() for event in events],
67+
)["motions"])
68+
PY
69+
```
70+
3671
## Snapshot contract
3772

3873
A presentation snapshot must include:

ahbg/presentation/project.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""Project a legal plane view into an AHBG presentation snapshot.
2+
3+
This is graphics. It does not decide adjacency, legality, or turn resolution.
4+
Unknown observation fields are ignored. Seed, RNG, and event-log internals
5+
are not copied into the snapshot.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from typing import Any, Mapping, Sequence
11+
12+
from snapshot import KIND, STANDING, PresentationSnapshotError, validate_snapshot
13+
14+
15+
def snapshot_from_observation(
16+
observation: Mapping[str, Any],
17+
*,
18+
plane_id: str,
19+
selected_tile: str | None = None,
20+
feed: Sequence[Mapping[str, Any]] = (),
21+
move_events: Sequence[Mapping[str, Any]] = (),
22+
) -> dict[str, Any]:
23+
"""Map a public observation (and optional resolved move events) to a snapshot.
24+
25+
``observation`` is the legal view: ``turn``, ``tiles``, ``units``. A full
26+
plane dict is also accepted; ``seed`` and ``schema`` are dropped.
27+
``move_events`` are already-resolved ``move`` payloads with ``unit_id``,
28+
``from_tile_id``, and ``to_tile_id``.
29+
"""
30+
31+
if not isinstance(observation, Mapping):
32+
raise PresentationSnapshotError("observation must be an object")
33+
if not isinstance(plane_id, str) or not plane_id:
34+
raise PresentationSnapshotError("plane_id must be exact non-empty text")
35+
turn = observation.get("turn")
36+
raw_tiles = observation.get("tiles")
37+
raw_units = observation.get("units")
38+
if not isinstance(raw_tiles, list) or not raw_tiles:
39+
raise PresentationSnapshotError("observation tiles must be a non-empty list")
40+
if not isinstance(raw_units, list):
41+
raise PresentationSnapshotError("observation units must be a list")
42+
43+
tiles: list[dict[str, Any]] = []
44+
for tile in raw_tiles:
45+
if not isinstance(tile, Mapping):
46+
raise PresentationSnapshotError("each observation tile must be an object")
47+
tile_id = tile.get("tile_id", tile.get("id"))
48+
if not isinstance(tile_id, str) or not tile_id:
49+
raise PresentationSnapshotError("observation tile id must be exact non-empty text")
50+
presented: dict[str, Any] = {"id": tile_id, "q": tile.get("q"), "r": tile.get("r")}
51+
label = tile.get("label")
52+
if isinstance(label, str) and label:
53+
presented["label"] = label
54+
tiles.append(presented)
55+
56+
units: list[dict[str, Any]] = []
57+
for unit in raw_units:
58+
if not isinstance(unit, Mapping):
59+
raise PresentationSnapshotError("each observation unit must be an object")
60+
unit_id = unit.get("unit_id", unit.get("id"))
61+
tile_id = unit.get("tile_id", unit.get("tile"))
62+
if not isinstance(unit_id, str) or not unit_id:
63+
raise PresentationSnapshotError("observation unit id must be exact non-empty text")
64+
presented_unit: dict[str, Any] = {"id": unit_id, "tile": tile_id}
65+
label = unit.get("label")
66+
if isinstance(label, str) and label:
67+
presented_unit["label"] = label
68+
units.append(presented_unit)
69+
70+
motions: list[dict[str, str]] = []
71+
for event in move_events:
72+
if not isinstance(event, Mapping):
73+
raise PresentationSnapshotError("each move event must be an object")
74+
kind = event.get("kind")
75+
data = event.get("data", event)
76+
if kind not in (None, "move"):
77+
continue
78+
if not isinstance(data, Mapping):
79+
raise PresentationSnapshotError("move event data must be an object")
80+
motions.append(
81+
{
82+
"unit": str(data.get("unit_id", "")),
83+
"from": str(data.get("from_tile_id", "")),
84+
"to": str(data.get("to_tile_id", "")),
85+
}
86+
)
87+
88+
if selected_tile is None and units:
89+
selected_tile = units[0].get("tile") if isinstance(units[0].get("tile"), str) else None
90+
91+
snapshot = {
92+
"kind": KIND,
93+
"standing": STANDING,
94+
"plane_id": plane_id,
95+
"turn": turn,
96+
"tiles": tiles,
97+
"units": units,
98+
"selected_tile": selected_tile,
99+
"feed": [dict(item) for item in feed],
100+
"motions": motions,
101+
}
102+
if not motions:
103+
snapshot.pop("motions")
104+
return dict(validate_snapshot(snapshot))
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
import unittest
5+
from pathlib import Path
6+
7+
STACK = Path(__file__).resolve().parents[3]
8+
PRESENTATION = Path(__file__).resolve().parents[1]
9+
sys.path.insert(0, str(STACK))
10+
sys.path.insert(0, str(PRESENTATION))
11+
12+
from ahbg.engine import Action, Plan, TurnEngine, legal_observation, new_game
13+
from project import snapshot_from_observation
14+
from snapshot import KIND, validate_snapshot
15+
16+
17+
SEED_TILES = [
18+
{"tile_id": "c", "q": 0, "r": 0},
19+
{"tile_id": "ne", "q": 1, "r": -1},
20+
{"tile_id": "e", "q": 1, "r": 0},
21+
{"tile_id": "se", "q": 0, "r": 1},
22+
{"tile_id": "sw", "q": -1, "r": 1},
23+
{"tile_id": "w", "q": -1, "r": 0},
24+
{"tile_id": "nw", "q": 0, "r": -1},
25+
]
26+
UNITS = [{"unit_id": "A0", "tile_id": "c", "label": "A0"}]
27+
28+
29+
class ObservationProjectionTest(unittest.TestCase):
30+
def test_new_game_observation_projects_without_seed_or_motions(self) -> None:
31+
plane, _log = new_game(seed=7, tiles=SEED_TILES, units=UNITS)
32+
snapshot = snapshot_from_observation(
33+
legal_observation(plane).to_dict(),
34+
plane_id="plane-0",
35+
feed=[{"turn": 0, "text": "plane loaded; A0 at origin"}],
36+
)
37+
validate_snapshot(snapshot)
38+
self.assertEqual(snapshot["kind"], KIND)
39+
self.assertEqual(snapshot["standing"], "not-mechanics")
40+
self.assertEqual(snapshot["turn"], 0)
41+
self.assertEqual(snapshot["units"][0]["tile"], "c")
42+
self.assertNotIn("seed", snapshot)
43+
self.assertNotIn("schema", snapshot)
44+
self.assertNotIn("motions", snapshot)
45+
46+
def test_resolved_move_projects_as_visual_trace(self) -> None:
47+
plane, log = new_game(seed=7, tiles=SEED_TILES, units=UNITS)
48+
engine = TurnEngine(plane=plane, log=log)
49+
engine.begin_turn()
50+
events = engine.resolve(
51+
[Plan(turn=0, actions=(Action("move", {"unit_id": "A0", "to_tile_id": "ne"}),))]
52+
)
53+
engine.end_turn()
54+
snapshot = snapshot_from_observation(
55+
legal_observation(plane).to_dict(),
56+
plane_id="plane-0",
57+
feed=[{"turn": 1, "text": "A0 trace origin to ne"}],
58+
move_events=[event.canonical_dict() for event in events],
59+
)
60+
self.assertEqual(snapshot["turn"], 1)
61+
self.assertEqual(snapshot["units"][0]["tile"], "ne")
62+
self.assertEqual(snapshot["motions"], [{"unit": "A0", "from": "c", "to": "ne"}])
63+
self.assertNotIn("adjacent", str(snapshot).lower())
64+
65+
def test_plane_dict_drops_internal_fields(self) -> None:
66+
plane, _log = new_game(seed=99, tiles=SEED_TILES, units=UNITS)
67+
snapshot = snapshot_from_observation(plane.canonical_dict(), plane_id="plane-0")
68+
self.assertNotIn("seed", snapshot)
69+
self.assertEqual(snapshot["plane_id"], "plane-0")
70+
self.assertEqual({tile["id"] for tile in snapshot["tiles"]}, {item["tile_id"] for item in SEED_TILES})
71+
72+
73+
if __name__ == "__main__":
74+
unittest.main()

0 commit comments

Comments
 (0)