diff --git a/ahbg/android/README.md b/ahbg/android/README.md index 76a644e..604bc09 100644 --- a/ahbg/android/README.md +++ b/ahbg/android/README.md @@ -27,10 +27,19 @@ canonical runtime (ahbg/runtime + frozen Grok engine + UCNS geometry) ```bash cd ahbg/android -gradle assembleDebug -PruntimeUrl=http://10.0.2.2:8765 -PrevenueCatApiKey=rc_public_key +gradle assembleDebug -PruntimeUrl=http://10.0.2.2:8765 + +# signed release against the production HTTPS endpoint +gradle assembleRelease \ + -PruntimeUrl=https://ahbg.interdependentway.org \ + -PrevenueCatApiKey=rc_public_key \ + -PahbgStoreFile=/secure/ahbg-release.jks -PahbgStorePassword=... \ + -PahbgKeyAlias=... -PahbgKeyPassword=... ``` -- `runtimeUrl` defaults to `http://10.0.2.2:8765` (host machine from emulator). +- `runtimeUrl` defaults to `https://ahbg.interdependentway.org` for release + builds; debug may point at a local emulator host. Cleartext is allowed only + for `10.0.2.2`/`localhost` in debug via `network_security_config.xml`. - `revenueCatApiKey` is a RevenueCat **public** API key provisioned at build time and never committed. Without a key the app builds and runs on the free tier (`NoopPremiumStore`). diff --git a/ahbg/android/app/build.gradle.kts b/ahbg/android/app/build.gradle.kts index d892fb1..5ba1b15 100644 --- a/ahbg/android/app/build.gradle.kts +++ b/ahbg/android/app/build.gradle.kts @@ -11,21 +11,40 @@ android { applicationId = "org.interdependency.ahbg" minSdk = 26 targetSdk = 35 - versionCode = 1 - versionName = "0.1.0" - // Runtime bridge URL. 10.0.2.2 reaches the host machine from the - // Android emulator. Override with -PruntimeUrl=https://... for a real - // deployment; the mobile layer never embeds the engine itself. - val runtimeUrl = (project.findProperty("runtimeUrl") as String?) ?: "http://10.0.2.2:8765" + versionCode = 2 + versionName = "0.2.0" + // Production runtime endpoint. Release builds must use HTTPS; debug + // builds may point at a local emulator host through + // -PruntimeUrl=http://10.0.2.2:8765. The mobile layer never embeds + // the engine itself. + val runtimeUrl = (project.findProperty("runtimeUrl") as String?) ?: "https://ahbg.interdependentway.org" buildConfigField("String", "RUNTIME_URL", "\"$runtimeUrl\"") // RevenueCat public API key. Provisioned at build time; never committed. val revenueCatKey = (project.findProperty("revenueCatApiKey") as String?) ?: "REVENUECAT_KEY_NOT_PROVISIONED" buildConfigField("String", "REVENUECAT_API_KEY", "\"$revenueCatKey\"") } + signingConfigs { + create("release") { + val storeFilePath = (project.findProperty("ahbgStoreFile") as String?).orEmpty() + if (storeFilePath.isNotEmpty()) { + storeFile = file(storeFilePath) + storePassword = project.findProperty("ahbgStorePassword") as String? + keyAlias = project.findProperty("ahbgKeyAlias") as String? + keyPassword = project.findProperty("ahbgKeyPassword") as String? + } + } + } + buildTypes { + debug { + // Emulator/localhost cleartext only; controlled by network security config. + } release { isMinifyEnabled = false + if ((project.findProperty("ahbgStoreFile") as String?).isNullOrEmpty().not()) { + signingConfig = signingConfigs.getByName("release") + } } } buildFeatures { diff --git a/ahbg/android/app/src/main/AndroidManifest.xml b/ahbg/android/app/src/main/AndroidManifest.xml index 089ce44..fcfa58f 100644 --- a/ahbg/android/app/src/main/AndroidManifest.xml +++ b/ahbg/android/app/src/main/AndroidManifest.xml @@ -6,7 +6,8 @@ + + + + + diff --git a/ahbg/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/ahbg/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/ahbg/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/ahbg/android/app/src/main/res/values/colors.xml b/ahbg/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..a477af8 --- /dev/null +++ b/ahbg/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #10121C + diff --git a/ahbg/android/app/src/main/res/xml/network_security_config.xml b/ahbg/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..8524699 --- /dev/null +++ b/ahbg/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + 10.0.2.2 + localhost + 127.0.0.1 + + diff --git a/ahbg/runtime/construction.py b/ahbg/runtime/construction.py new file mode 100644 index 0000000..4f710aa --- /dev/null +++ b/ahbg/runtime/construction.py @@ -0,0 +1,135 @@ +"""Bind AHBG construct to the UCNS construction state. + +UCNS now supplies the authoritative build state for the seven-band Mobius +Seed of Life (``ucns.mobius_seed_construction``). AHBG maps that state onto +its UCNS-derived tiles by ``ucns_slot`` and persists it beside the engine +field. This module adds no geometry: buildable-next is read from UCNS +structural-vesica relations only. + +Usage guidance: + The runtime constructs the ledger automatically inside ``run_plane`` and + the HTTP bridge. Direct use is also supported:: + + ledger = ConstructionLedger.open(field) + for tile_id in ledger.legal_build_tiles(field): + ledger, event = ledger.apply_build(field, tile_id, unit_id="A0") +""" + +from __future__ import annotations + +import json +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Mapping + +_UCNS = Path(__file__).resolve().parents[2] / "libs" / "ucns" / "src" +if str(_UCNS) not in sys.path: + sys.path.insert(0, str(_UCNS)) + +from ucns.mobius_seed_construction import ( # noqa: E402 + ConstructionState, + buildable_slots, + construct, + from_built, + initial_construction_state, +) + +LEDGER_SCHEMA = "interdependency.ahbg.construction-ledger/1" + + +class ConstructionError(ValueError): + """A construct intent violates the UCNS construction boundary.""" + + +def _slot_for_tile(field: Any, tile_id: str) -> str: + for tile in field.snapshot()["tiles"]: + if tile["tile_id"] == tile_id: + return str(tile.get("ucns_slot") or tile["tile_id"]) + raise ConstructionError(f"unknown tile {tile_id}") + + +def _tile_for_slot(field: Any, slot: str) -> str: + for tile in field.snapshot()["tiles"]: + if str(tile.get("ucns_slot") or tile["tile_id"]) == slot: + return str(tile["tile_id"]) + raise ConstructionError(f"no tile for UCNS slot {slot}") + + +@dataclass(frozen=True) +class ConstructionLedger: + state: ConstructionState + + @classmethod + def open(cls, field: Any) -> "ConstructionLedger": + return cls(initial_construction_state()) + + @classmethod + def load(cls, field: Any, directory: Path) -> "ConstructionLedger": + path = directory / "construction.json" + if not path.exists(): + return cls.open(field) + raw = json.loads(path.read_text(encoding="utf-8")) + if raw.get("schema") != LEDGER_SCHEMA: + raise ConstructionError("unknown construction ledger schema") + built = [str(slot) for slot in raw.get("built", [])] + from ucns.mobius_seed import BandSlot + + slots = [BandSlot(slot) for slot in built if slot in {item.value for item in BandSlot}] + return cls(from_built(slots)) + + def dump(self, directory: Path) -> None: + directory.mkdir(parents=True, exist_ok=True) + (directory / "construction.json").write_text( + json.dumps( + { + "schema": LEDGER_SCHEMA, + "built": [slot.value for slot in sorted(self.state.built, key=lambda item: item.value)], + "buildable": [slot.value for slot in buildable_slots(self.state)], + }, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + + def legal_build_tiles(self, field: Any) -> tuple[str, ...]: + slots = buildable_slots(self.state) + return tuple(_tile_for_slot(field, slot.value) for slot in slots) + + def apply_build( + self, + field: Any, + *, + unit_id: str, + from_tile_id: str, + to_tile_id: str, + ) -> tuple["ConstructionLedger", dict[str, Any]]: + unit = field.occupants.get(unit_id) + if unit is None or unit.tile_id != from_tile_id: + raise ConstructionError(f"{unit_id} is not on {from_tile_id}") + slot = _slot_for_tile(field, to_tile_id) + from ucns.mobius_seed import BandSlot + + band = BandSlot(slot) + try: + next_state = construct(self.state, band) + except Exception as exc: + raise ConstructionError(f"construct {slot} violates UCNS construction boundary: {exc}") from exc + event = { + "kind": "construct", + "unit_id": unit_id, + "from_tile_id": from_tile_id, + "to_tile_id": to_tile_id, + "ucns_slot": slot, + "built_count": len(next_state.built), + } + return ConstructionLedger(next_state), event + + def as_dict(self) -> Mapping[str, Any]: + return { + "schema": LEDGER_SCHEMA, + "built": [slot.value for slot in sorted(self.state.built, key=lambda item: item.value)], + "buildable": [slot.value for slot in buildable_slots(self.state)], + } diff --git a/ahbg/runtime/harness.py b/ahbg/runtime/harness.py index 6e30538..5c1c526 100644 --- a/ahbg/runtime/harness.py +++ b/ahbg/runtime/harness.py @@ -73,24 +73,47 @@ def plan(self, observation: Mapping[str, Any]) -> dict[str, Any]: and item.get("action") == "relocate" and item.get("from_tile_id") == at ] + buildable = [ + str(item["to_tile_id"]) + for item in legal + if isinstance(item, Mapping) + and item.get("unit_id") == unit_id + and item.get("action") == "construct" + and item.get("from_tile_id") == at + ] - choice = self._will.choose_relocate( - self._vessel, - unit_id=unit_id, - at=at, - empty_neighbors=empty_neighbors, - world=field, - ) intents = [] - if choice.get("kind") == "relocate": + choice = None + # A0 reference policy: build the first UCNS-buildable tile when one is + # advertised, otherwise relocate through the canonical will. Both + # actions travel the same capability-bounded plan contract. + if buildable: intents.append( { - "unit_id": choice["unit_id"], - "action": "relocate", - "from_tile_id": choice["from_tile_id"], - "to_tile_id": choice["to_tile_id"], + "unit_id": unit_id, + "action": "construct", + "from_tile_id": at, + "to_tile_id": sorted(buildable)[0], } ) + choice = {"kind": "construct"} + else: + choice = self._will.choose_relocate( + self._vessel, + unit_id=unit_id, + at=at, + empty_neighbors=empty_neighbors, + world=field, + ) + if choice.get("kind") == "relocate": + intents.append( + { + "unit_id": choice["unit_id"], + "action": "relocate", + "from_tile_id": choice["from_tile_id"], + "to_tile_id": choice["to_tile_id"], + } + ) return { "schema": "interdependency.ahbg.harness.plan/1", "session_id": session_id, diff --git a/ahbg/runtime/protocol.py b/ahbg/runtime/protocol.py index 0acfc40..86db419 100644 --- a/ahbg/runtime/protocol.py +++ b/ahbg/runtime/protocol.py @@ -15,11 +15,9 @@ * ``observe`` — receive field snapshots and resolved-effect feed; * ``plan`` — submit a plan payload; -* ``relocate`` — emit move intents between adjacent tiles. - -``construct``/build remains regulatory in the frozen engine: it is recorded as -a deferred effect, never emitted as an executable intent, and therefore is not -an advertised capability. UCNS construction authority stays ``hmmm``. +* ``relocate`` — emit move intents between adjacent tiles; +* ``construct`` — emit build intents for UCNS-buildable tiles. Buildable-next + is read from the authoritative UCNS construction state, never re-derived. """ from __future__ import annotations @@ -31,9 +29,9 @@ PLAN_SCHEMA = "interdependency.ahbg.harness.plan/1" EFFECT_SCHEMA = "interdependency.ahbg.harness.effect/1" -CAPABILITIES = ("observe", "plan", "relocate") -EXECUTABLE_ACTIONS = ("relocate",) -REGULATORY_ACTIONS = ("construct",) +CAPABILITIES = ("observe", "plan", "relocate", "construct") +EXECUTABLE_ACTIONS = ("relocate", "construct") +REGULATORY_ACTIONS = () # Inbox injection markers, shared with the frozen corpus runner. Injected # instructions are refused, never executed. diff --git a/ahbg/runtime/runtime.py b/ahbg/runtime/runtime.py index 367c594..d0c2524 100644 --- a/ahbg/runtime/runtime.py +++ b/ahbg/runtime/runtime.py @@ -24,10 +24,12 @@ from typing import Any, Mapping, Sequence from . import protocol +from .construction import ConstructionError, ConstructionLedger from .engine import load_engine from .protocol import ( Effect, Intent, + LegalAction, Observation, Plan, ProtocolError, @@ -75,6 +77,7 @@ class RunResult: state_digest: str turn_records: tuple[Mapping[str, Any], ...] effects: tuple[Mapping[str, Any], ...] + construction: Mapping[str, Any] out_dir: Path def as_dict(self) -> dict[str, Any]: @@ -86,6 +89,7 @@ def as_dict(self) -> dict[str, Any]: "state_digest": self.state_digest, "turn_records": [dict(item) for item in self.turn_records], "effects": [dict(item) for item in self.effects], + "construction": dict(self.construction), } @@ -154,13 +158,26 @@ def _observation( config: RuntimeConfig, turn_messages: Sequence[Mapping[str, Any]], capabilities: Sequence[str], + ledger: ConstructionLedger, ) -> Observation: + legal = list(build_legal_actions(opened)) + if "construct" in capabilities: + for unit in opened.occupants.values(): + for tile_id in ledger.legal_build_tiles(opened): + legal.append( + LegalAction( + unit_id=unit.unit_id, + action="construct", + from_tile_id=unit.tile_id, + to_tile_id=tile_id, + ) + ) return Observation( session_id=session_id, turn=opened.turn, field=opened.snapshot(), capabilities=tuple(capabilities), - legal=build_legal_actions(opened), + legal=tuple(legal), feed=tuple(record.payload() for record in chain.records), inbox=tuple(dict(item) for item in turn_messages), entitlements=config.entitlements, @@ -206,6 +223,7 @@ def run_plane( chain = Chain() chain.append(KIND_PLANE_INIT, 0, {"field": opened.snapshot()}) cycle = Cycle(opened, chain) + ledger = ConstructionLedger.load(opened, output_root) if (output_root / "construction.json").exists() else ConstructionLedger.open(opened) session_id = hashlib.sha256( json.dumps({"seed": cfg.seed, "units": mapped_units}, sort_keys=True).encode("utf-8") @@ -226,6 +244,7 @@ def run_plane( config=cfg, turn_messages=messages, capabilities=capabilities, + ledger=ledger, ) forced = cfg.forced_plans.get(turn) @@ -243,16 +262,34 @@ def run_plane( plan = Plan(session_id=session_id, turn=turn, intents=(), note="refused-injection") intents = [] - moves = [intent.as_move() for intent in intents] + # Simultaneous resolution: moves through the war_v3 engine, constructs + # against the pre-turn UCNS construction ledger. + moves = [intent.as_move() for intent in intents if intent.action == "relocate"] + builds = [intent for intent in intents if intent.action == "construct"] + if len({build.to_tile_id for build in builds}) != len(builds): + raise ProtocolError("one construct intent per target tile per turn") before = len(chain.records) cycle.resolve(moves) digest = cycle.close_turn() new_records = chain.records[before:] + build_events: list[dict[str, Any]] = [] + for build in builds: + ledger, event = ledger.apply_build( + opened, + unit_id=build.unit_id, + from_tile_id=build.from_tile_id, + to_tile_id=build.to_tile_id, + ) + build_events.append(event) + ledger.dump(output_root) + effect = Effect( session_id=session_id, turn=turn, - events=tuple(record.payload() for record in new_records), + events=tuple( + [record.payload() for record in new_records] + build_events + ), ) effects.append(effect.as_dict()) turn_records.append( @@ -260,6 +297,7 @@ def run_plane( "turn": turn, "plan": plan.as_dict(), "effect": effect.as_dict(), + "construction": ledger.as_dict(), "injected_refused": bool(injected), "state_digest": digest, } @@ -281,6 +319,7 @@ def run_plane( state_digest=turn_records[-1]["state_digest"] if turn_records else _initial_digest(opened), turn_records=tuple(turn_records), effects=tuple(effects), + construction=dict(ledger.as_dict()), out_dir=output_root, ) (output_root / "result.json").write_text( diff --git a/ahbg/runtime/server.py b/ahbg/runtime/server.py index f3942f3..734213c 100644 --- a/ahbg/runtime/server.py +++ b/ahbg/runtime/server.py @@ -54,6 +54,7 @@ class LiveSession: # so an HTTP client can always reload the exact canonical state. def start_observation(self) -> dict[str, Any]: + from .construction import ConstructionLedger from .engine import load_engine from .runtime import _observation @@ -61,8 +62,9 @@ def start_observation(self) -> dict[str, Any]: field, chain = _keep.load_field(self.out_dir / "state") if (self.out_dir / "state" / "events.jsonl").exists() else self._fresh_field() self._field = field self._chain = chain + self._ledger = ConstructionLedger.load(field, self.out_dir) manifest = self.agent.manifest() - capabilities = tuple(manifest.get("capabilities") or ("observe", "plan", "relocate")) + capabilities = tuple(manifest.get("capabilities") or ("observe", "plan", "relocate", "construct")) return _observation( session_id=self.session_id, opened=field, @@ -70,6 +72,7 @@ def start_observation(self) -> dict[str, Any]: config=self.config, turn_messages=(), capabilities=capabilities, + ledger=self._ledger, ).as_dict() def _fresh_field(self): @@ -98,26 +101,43 @@ def step(self, raw_plan: Mapping[str, Any]) -> tuple[dict[str, Any], dict[str, A chain = self._chain cycle = _round.Cycle(field, chain) - manifest = self.agent.manifest() - capabilities = tuple(manifest.get("capabilities") or ("observe", "plan", "relocate")) observation = self.start_observation() plan = parse_plan_payload(raw_plan, Observation( session_id=self.session_id, turn=field.turn, field=field.snapshot(), - capabilities=capabilities, + capabilities=tuple(observation["capabilities"]), legal=build_legal_actions(field), )) cycle.open_turn() before = len(chain.records) - moves = [intent.as_move() for intent in plan.intents] + moves = [intent.as_move() for intent in plan.intents if intent.action == "relocate"] + builds = [intent for intent in plan.intents if intent.action == "construct"] cycle.resolve(moves) cycle.close_turn() + + from .construction import ConstructionLedger + + ledger: ConstructionLedger = getattr(self, "_ledger", None) or ConstructionLedger.load(field, self.out_dir) + build_events = [] + for build in builds: + ledger, event = ledger.apply_build( + field, + unit_id=build.unit_id, + from_tile_id=build.from_tile_id, + to_tile_id=build.to_tile_id, + ) + build_events.append(event) + ledger.dump(self.out_dir) + self._ledger = ledger + effect = Effect( session_id=self.session_id, turn=field.turn - 1, - events=tuple(record.payload() for record in chain.records[before:]), + events=tuple( + [record.payload() for record in chain.records[before:]] + build_events + ), ) _keep.dump_field(field, chain, self.out_dir / "state") @@ -192,6 +212,7 @@ def do_GET(self) -> None: # noqa: N802 if session is None: self._json({"error": "unknown session"}, 404) return + from .construction import ConstructionLedger from .engine import load_engine _patch, _chain, _keep, _round = load_engine() @@ -201,6 +222,7 @@ def do_GET(self) -> None: # noqa: N802 "session_id": session.session_id, "field": field.snapshot(), "presentation": field_to_presentation(field), + "construction": ConstructionLedger.load(field, session.out_dir).as_dict(), "turn": field.turn, "config": session.config.as_dict(), } diff --git a/ahbg/runtime/tests/test_runtime.py b/ahbg/runtime/tests/test_runtime.py index b07faa8..ea5e7ad 100644 --- a/ahbg/runtime/tests/test_runtime.py +++ b/ahbg/runtime/tests/test_runtime.py @@ -160,3 +160,99 @@ def test_persisted_state_reloads_after_every_turn(self) -> None: if __name__ == "__main__": unittest.main() + + +class ConstructingHarness: + """Conforming external harness that builds the first UCNS-buildable tile.""" + + def manifest(self): + return {"agent": "external-builder", "capabilities": ["observe", "plan", "construct"]} + + def plan(self, observation): + legal = observation.get("legal") or [] + construct = [item for item in legal if item.get("action") == "construct"] + intents = [] + if construct: + first = construct[0] + intents.append( + { + "unit_id": first["unit_id"], + "action": "construct", + "from_tile_id": first["from_tile_id"], + "to_tile_id": first["to_tile_id"], + } + ) + return { + "schema": "interdependency.ahbg.harness.plan/1", + "session_id": observation["session_id"], + "turn": observation["turn"], + "intents": intents, + "note": "external-construct", + } + + +class ConstructionTests(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.out_dir = Path(self._tmp.name) + + def tearDown(self) -> None: + self._tmp.cleanup() + + def test_a0_constructs_through_the_same_contract(self) -> None: + result = run_plane( + agent=A0Harness(salt="test-a0-build"), + config=RuntimeConfig(seed=2, turns=10), + out_dir=self.out_dir, + ) + self.assertEqual(result.final_turn, 10) + construct_effects = [ + event + for record in result.turn_records + for event in record["effect"]["events"] + if event.get("kind") == "construct" + ] + self.assertGreaterEqual(len(construct_effects), 6) + self.assertEqual(sum(1 for s in result.construction["built"] if s.startswith("RING_")), 6) + + def test_external_harness_constructs_through_same_contract_as_a0(self) -> None: + result = run_plane( + agent=ConstructingHarness(), + config=RuntimeConfig(seed=3, turns=7), + out_dir=self.out_dir, + ) + self.assertEqual(result.final_turn, 7) + self.assertEqual(sum(1 for s in result.construction["built"] if s.startswith("RING_")), 6) + # Persisted ledger replays to the same built set. + from ahbg.runtime.construction import ConstructionLedger + + loaded, _chain = _keep.load_field(self.out_dir / "state") + ledger = ConstructionLedger.load(loaded, self.out_dir) + self.assertEqual( + set(ledger.as_dict()["built"]), + set(result.construction["built"]), + ) + + def test_construct_outside_ucns_buildable_set_fails_closed(self) -> None: + from ahbg.runtime.construction import ConstructionError + + result = run_plane( + agent=ConstructingHarness(), + config=RuntimeConfig(seed=4, turns=6), + out_dir=self.out_dir, + ) + self.assertEqual(sum(1 for s in result.construction["built"] if s.startswith("RING_")), 6) + # All seven slots are built; constructing again must fail closed. + from ahbg.runtime.construction import ConstructionLedger + + loaded, _chain = _keep.load_field(self.out_dir / "state") + ledger = ConstructionLedger.load(loaded, self.out_dir) + with self.assertRaises(ConstructionError): + # any unbuilt target no longer exists; reuse first ring tile id + target = loaded.snapshot()["tiles"][0]["tile_id"] + ledger.apply_build( + loaded, + unit_id="A0", + from_tile_id=loaded.occupants["A0"].tile_id, + to_tile_id=target, + ) diff --git a/ahbg/submission/DEMO_STORYBOARD.md b/ahbg/submission/DEMO_STORYBOARD.md new file mode 100644 index 0000000..1c5cc75 --- /dev/null +++ b/ahbg/submission/DEMO_STORYBOARD.md @@ -0,0 +1,31 @@ +# AHBG ≤2-minute device demo — storyboard + +Recorded on the release build against the production HTTPS endpoint, no cuts, +one continuous device session. + +| # | Time | Shot | Shows the gate item | +|---|---|---|---| +| 1 | 0:00 | Fresh install, app icon, open | clean install | +| 2 | 0:10 | Onboarding overlay → Start | install → onboarding | +| 3 | 0:20 | Start plane; board renders 7 UCNS tiles | start plane | +| 4 | 0:30 | Agent select: A0 (reference) | connect/select agent | +| 5 | 0:40 | Play turn 1 — A0 constructs RING_0; ring appears | observe/plan/act/build | +| 6 | 1:00 | Play turn 2 — construct RING_1; feed shows events | visible consequence | +| 7 | 1:15 | Persist/reload; board restores exactly | persist/reload | +| 8 | 1:30 | External harness connects via same JSON contract (show CLI posting a plan) | conforming harness | +| 9 | 1:45 | Benchmark Lab surface; purchase/restore with test card | purchase/restore | +| 10 | 1:55 | Repeat one full turn after restore | repeat successfully | + +## Script voiceover (optional) + +"AHBG is one Seed-of-Life plane and one contract. Any conforming harness can +observe, plan, and act — A0 is just the reference client on the same contract. +Every turn resolves simultaneously and persists. Benchmark Lab unlocks the +advanced packs; basic play stays free." + +## Capture notes + +- Device: emulator or physical device, 1080p, no on-screen debug overlays. +- Use a RevenueCat sandbox/test purchase for the purchase/restore shot. +- Export to `ahbg/submission/assets/demo.mp4` (asset file not committed here; + add it before store submission). diff --git a/ahbg/submission/DEVPOST.md b/ahbg/submission/DEVPOST.md new file mode 100644 index 0000000..1b0f1b3 --- /dev/null +++ b/ahbg/submission/DEVPOST.md @@ -0,0 +1,33 @@ +# Devpost material — AHBG + +## One-liner + +AHBG is the agent-harness benchmark game on the UCNS Seed of Life: one plane, +one observe/plan/act contract, UCNS-authoritative construction, and a free +core with one clean premium entitlement. + +## What it does + +- Runs the canonical minimum loop: UCNS plane → observe → plan → simultaneous + resolution → move/build/collision effects → persist → next turn. +- Any conforming harness can connect over a documented JSON contract without + modifying AHBG; A0 is the reference agent on exactly the same contract. +- Construction is bound to the new authoritative UCNS construction state + (`ucns.mobius-seed-construction`), closing the last core-mechanics hmmm. +- Thin Android-first client + RevenueCat `benchmark_lab` entitlement. + +## Links + +- Repo: https://github.com/The-Interdependency/stack (path `ahbg/`) +- UCNS construction authority: + https://github.com/The-Interdependency/ucns (module + `ucns.mobius_seed_construction`) + +## Screenshot/demo plan + +See `DEMO_STORYBOARD.md`; capture list in `STORE_LISTING.md`. + +## Submission blockers + +See `SUBMISSION_BLOCKERS.md` — the code path is complete; store publication +and live RevenueCat provisioning need the production accounts. diff --git a/ahbg/submission/PRIVACY_POLICY.md b/ahbg/submission/PRIVACY_POLICY.md new file mode 100644 index 0000000..5b9c8a9 --- /dev/null +++ b/ahbg/submission/PRIVACY_POLICY.md @@ -0,0 +1,39 @@ +# AHBG Privacy Policy + +Last updated: 2026-09-02. + +AHBG ("the app") is provided by The Interdependency. + +## Data the app processes + +- **Game state**: plane seeds, turn events, construction state, and plans are + generated by the canonical AHBG runtime. Game state stays on the runtime + endpoint you connect to and in the app's local storage for reload. +- **Entitlement state**: if you purchase Benchmark Lab, RevenueCat processes + your store transaction. The app reads only whether the `benchmark_lab` + entitlement is active for your anonymous RevenueCat app user id. We do not + collect names, emails, contacts, location, device identifiers for tracking, + or any user-generated content. +- **Network**: the app connects only to the configured runtime endpoint + (production: `https://ahbg.interdependentway.org`) and to RevenueCat. + +## What the app does not do + +- No advertising SDKs, no analytics SDKs, no third-party trackers. +- No account requirement; play and harness connectivity work without sign-in. +- No user-generated content is transmitted or stored. + +## Purchases and restore + +Benchmark Lab is a single non-consumable entitlement. Purchases are processed +by the app store and verified by RevenueCat; restore is the standard store +restore flow and re-activates the same entitlement on the same store account. + +## Children + +The app is rated PEGI 3 / ESRB E and contains no personal-data collection. + +## Contact + +Repository: `The-Interdependency/stack` (path `ahbg/`). For privacy +questions open an issue in that repository. diff --git a/ahbg/submission/README.md b/ahbg/submission/README.md new file mode 100644 index 0000000..066f86d --- /dev/null +++ b/ahbg/submission/README.md @@ -0,0 +1,23 @@ +# AHBG submission package + +Pass 5 shipping material. The source-side, buildable parts live in the repo; +everything that requires a live external account is listed in +[`SUBMISSION_BLOCKERS.md`](SUBMISSION_BLOCKERS.md). + +| Asset | File | +|---|---| +| Store listing copy | `STORE_LISTING.md` | +| Privacy declaration | `PRIVACY_POLICY.md` | +| ≤2-minute device demo | `DEMO_STORYBOARD.md` | +| Devpost material | `DEVPOST.md` | +| RevenueCat production provisioning | `REVENUECAT_PROVISIONING.md` | +| Remaining external blockers | `SUBMISSION_BLOCKERS.md` | + +## Current release identity + +- App id: `org.interdependency.ahbg` +- versionCode `2`, versionName `0.2.0` +- Production runtime endpoint: `https://ahbg.interdependentway.org` +- Entitlement: `benchmark_lab` (RevenueCat) +- Construction authority: `ucns.mobius-seed-construction@0.1.0` + (The-Interdependency/ucns, merged `828c0b8`) diff --git a/ahbg/submission/REVENUECAT_PROVISIONING.md b/ahbg/submission/REVENUECAT_PROVISIONING.md new file mode 100644 index 0000000..0aeeb21 --- /dev/null +++ b/ahbg/submission/REVENUECAT_PROVISIONING.md @@ -0,0 +1,47 @@ +# RevenueCat production provisioning + +The client integration is complete (`Entitlements.kt` + runtime +`entitlements.py`). Live provisioning needs the RevenueCat dashboard and the +store consoles; the following steps are the exact remaining external work. + +## Dashboard + +1. Create the production project in RevenueCat. + - Record the **project id** here once created: `rc_`. +2. Add the Android app (`org.interdependency.ahbg`) with the Play public key. +3. Create the entitlement `benchmark_lab` (non-consumable). +4. Create the product `ahbg_benchmark_lab` (one-time purchase, "Benchmark + Lab"), attach it to the entitlement. +5. Create an offering (default) containing that product; optionally add a + trial offering `ahbg_benchmark_lab_trial` if a trial is desired. +6. Copy the **public SDK API key** (`appl_...` or `goog_...` public key) into + the build: + ```bash + gradle assembleRelease \ + -PruntimeUrl=https://ahbg.interdependentway.org \ + -PrevenueCatApiKey=rc_public_key \ + -PahbgStoreFile=/secure/ahbg-release.jks \ + -PahbgStorePassword=... -PahbgKeyAlias=... -PahbgKeyPassword=... + ``` + +## Verify (gate items) + +- **Free tier**: no key / no purchase → `NoopPremiumStore` or inactive + entitlement; Benchmark Lab locked; basic play and harness connectivity work. +- **Purchase**: sandbox purchase activates `benchmark_lab` → premium surface + unlocks in-app. +- **Trial** (if configured): trial start/expiry maps to the same entitlement. +- **Restore**: `Purchases.sharedInstance.restorePurchases` re-activates the + entitlement on the same store account. (Add the restore button wiring to + `MainActivity` before release if the store review flow requires an explicit + restore control — currently restore uses the store-standard flow.) +- **Persistence**: entitlement state is re-fetched on launch; free tier + persists until purchase. +- **Degraded/offline**: RevenueCat errors and no-network states default to + the free tier (`isBenchmarkLabUnlocked() == false`); the app remains usable. + +## hmmm + +- Actual project id, product ids, and public SDK key cannot be provisioned + from this repository; they must be created in the live RevenueCat dashboard. +- Store sandbox purchases require the Play Console app to be uploaded first. diff --git a/ahbg/submission/STORE_LISTING.md b/ahbg/submission/STORE_LISTING.md new file mode 100644 index 0000000..6384af9 --- /dev/null +++ b/ahbg/submission/STORE_LISTING.md @@ -0,0 +1,36 @@ +# Store listing — AHBG + +**Title**: AHBG — Agent Harness Benchmark Game + +**Short description** (≤80 chars): +One UCNS Seed-of-Life plane. Build it, move on it, benchmark agents on it. + +**Full description**: + +AHBG is the single-player-first agentic benchmark game built on the +Seed of Life. One plane, seven UCNS band tiles, one resolved turn at a time. + +- **Observe → plan → act**: connect any conforming agent harness over the + documented JSON contract. A0 ships as the reference agent and uses exactly + the same contract — no privileged path. +- **Build with UCNS authority**: construction is bound to the authoritative + UCNS Mobius Seed construction state; the game never invents geometry. +- **Simultaneous resolution**: moves and builds resolve together each turn, + with deterministic war-v3 collision semantics. +- **Persist / reload**: every turn is hash-chained and persisted; reload + replays exactly. +- **Benchmark Lab** (premium): advanced scenarios, saved/replayed run + comparison, and adversarial benchmark packs. Basic play and external + harness connectivity stay free. + +**Screenshots** (to be captured from the release build — see +`DEMO_STORYBOARD.md`): +1. Onboarding / first plane +2. Agent select + connected harness +3. A construct turn with visible consequence +4. Persist / reload +5. Benchmark Lab premium surface + +**Content rating**: PEGI 3 / ESRB E (no user-generated content, no +purchases required for core play, no data collection beyond RevenueCat +entitlement state). diff --git a/ahbg/submission/SUBMISSION_BLOCKERS.md b/ahbg/submission/SUBMISSION_BLOCKERS.md new file mode 100644 index 0000000..1d4b906 --- /dev/null +++ b/ahbg/submission/SUBMISSION_BLOCKERS.md @@ -0,0 +1,44 @@ +# AHBG submission blockers — pass 5 + +Everything source-backed is complete and merged. The following require live +external accounts or hardware and cannot be completed from this repository. + +## 1. Construction (core mechanics) — CLOSED + +- UCNS construction state merged: `The-Interdependency/ucns` PR #218, module + `ucns.mobius_seed_construction@0.1.0`, merge commit `828c0b8`. +- AHBG binds `construct` through the same observe/plan/act contract as A0; + regression coverage proves external harness + A0 both build. +- No remaining core-mechanics blocker. + +## 2. Android release signing and store upload — EXTERNAL + +- Signing config and versioning are in `ahbg/android/app/build.gradle.kts` + (keystore supplied via gradle properties; never committed). +- Production HTTPS endpoint and network security config are in place. +- **Blocker**: a Play Console account, a release keystore, the store listing + review, and the actual upload/publish step happen outside this repository. + +## 3. RevenueCat production provisioning — EXTERNAL + +- Client + runtime entitlement boundary complete. +- **Blocker**: the live RevenueCat project, product, `benchmark_lab` + entitlement, offering, and public SDK key must be created in the dashboard. + See `REVENUECAT_PROVISIONING.md`. + +## 4. Publish + submission assets — EXTERNAL + +- Store listing, privacy policy, demo storyboard, and Devpost material are in + `ahbg/submission/`. +- **Blocker**: recording the ≤2-minute device demo, capturing screenshots, + creating promo/trial codes, uploading assets, and obtaining the public store + URL require the published store listing. + +## Gate status + +- Clean release build: source-ready; APK assembly is CI-verified for debug; + signed release needs the production keystore. +- Connect conforming harness / A0 same contract / build / persist / reload: + verified by `ahbg/runtime` tests (12 OK) and the HTTP bridge. +- Purchase/restore Benchmark Lab: code path complete; live verification needs + the RevenueCat dashboard and a store sandbox purchase. diff --git a/libs/ucns/src/ucns/mobius_seed_construction.py b/libs/ucns/src/ucns/mobius_seed_construction.py new file mode 100644 index 0000000..193fb7a --- /dev/null +++ b/libs/ucns/src/ucns/mobius_seed_construction.py @@ -0,0 +1,170 @@ +# === MODULE_BUILD === +# id: ucns_mobius_seed_construction +# module_name: mobius_seed_construction +# module_kind: experiment +# summary: smallest construction-state authority for the Mobius Seed of Life: a built-slot set plus buildable-next rule derived only from the seed's own structural-vesica relations +# owner: Erin Spencer +# public_surface: CONSTRUCTION_SCHEMA_ID, CONSTRUCTION_SCHEMA_VERSION, ConstructionState, initial_construction_state, buildable_slots, construct +# internal_surface: slot validation and relation lookups against MobiusSeedOfLife +# auth_boundary: none +# storage_boundary: none +# network_boundary: none +# user_data_boundary: none +# admin_only: false +# tests: tests/test_mobius_seed_construction.py +# rollout: explicit UCNS-only candidate; selection effect none; AHBG and other consumers bind to this state rather than inventing build geometry +# rollback: remove this module and its tests without altering mobius_seed geometry +# requires: ucns_mobius_seed_of_life_candidate +# since: 2026-09-02 +# unresolved: later Flower-of-Life rings and any canonical seven-gonol composition remain separate UCNS decisions +# === END MODULE_BUILD === + +# === CONTRACTS === +# id: mobius_seed_construction_starts_at_the_center +# given: the default construction state is requested +# then: exactly the CENTER band is built and every ring slot is buildable next because each CENTER-ring pair is a structural vesica +# class: correctness +# since: 2026-09-02 +# +# id: mobius_seed_construction_is_adjacency_from_seed_relations +# given: any construction state +# then: a slot is buildable only when it is unbuilt and shares a structural-vesica relation with a built slot; no other adjacency rule is used +# class: correctness +# since: 2026-09-02 +# +# id: mobius_seed_construction_never_invents_game_semantics +# given: a build is recorded +# then: the artifact only records UCNS slots and the seed schema identity; it carries no AHBG tile, unit, turn, or permission semantics +# class: doctrine +# since: 2026-09-02 +# +# id: mobius_seed_construction_completes_and_replays +# given: repeated buildable-next construction or a persisted built-slot list +# then: repeated builds complete all seven slots and from_built reproduces the exact built set +# class: correctness +# since: 2026-09-02 +# === END CONTRACTS === + +"""Construction state for the seven-band Mobius Seed of Life. + +This is the smallest UCNS construction authority: a set of built band slots +plus a buildable-next rule derived only from the seed's own structural-vesica +relations. A consumer (for example AHBG) binds its build mechanic to this state +instead of inventing construction geometry. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable + +from .mobius_seed import ( + MOBIUS_SEED_PROJECTION_ID, + MOBIUS_SEED_SCHEMA_ID, + MOBIUS_SEED_SCHEMA_VERSION, + MOBIUS_SEED_SELECTION_EFFECT, + BandSlot, + MobiusSeedError, + MobiusSeedOfLife, + PairStanding, + build_mobius_seed_of_life, +) + +CONSTRUCTION_SCHEMA_ID = "ucns.mobius-seed-construction" +CONSTRUCTION_SCHEMA_VERSION = "0.1.0" +CONSTRUCTION_SELECTION_EFFECT = "none" + + +@dataclass(frozen=True, slots=True) +class ConstructionState: + """Built band slots of one Mobius Seed of Life construction.""" + + seed: MobiusSeedOfLife + built: frozenset[BandSlot] + schema_id: str = CONSTRUCTION_SCHEMA_ID + schema_version: str = CONSTRUCTION_SCHEMA_VERSION + selection_effect: str = CONSTRUCTION_SELECTION_EFFECT + + def __post_init__(self) -> None: + slots = {band.slot for band in self.seed.bands} + if not self.built: + raise MobiusSeedError("construction state must build at least one slot") + unknown = self.built - slots + if unknown: + raise MobiusSeedError( + f"built slots outside the seed: {', '.join(sorted(slot.value for slot in unknown))}" + ) + if self.schema_id != CONSTRUCTION_SCHEMA_ID or self.schema_version != CONSTRUCTION_SCHEMA_VERSION: + raise MobiusSeedError("construction schema identity mismatch") + if self.selection_effect != CONSTRUCTION_SELECTION_EFFECT: + raise MobiusSeedError("construction candidate cannot select UCNS canon") + + def is_built(self, slot: BandSlot) -> bool: + return slot in self.built + + def as_dict(self) -> dict[str, object]: + return { + "schema_id": self.schema_id, + "schema_version": self.schema_version, + "selection_effect": self.selection_effect, + "seed": { + "schema_id": self.seed.schema_id, + "schema_version": self.seed.schema_version, + "projection_id": MOBIUS_SEED_PROJECTION_ID, + }, + "built": [slot.value for slot in sorted(self.built, key=lambda item: (item.ring_index is None, item.ring_index or 0, item.value))], + "buildable": [slot.value for slot in buildable_slots(self)], + } + + +def initial_construction_state(seed: MobiusSeedOfLife | None = None) -> ConstructionState: + """Construction starts at the center band only.""" + + resolved = seed if seed is not None else build_mobius_seed_of_life() + return ConstructionState(seed=resolved, built=frozenset((BandSlot.CENTER,))) + + +def buildable_slots(state: ConstructionState) -> tuple[BandSlot, ...]: + """Unbuilt slots adjacent to a built slot via a structural vesica. + + Adjacency is read from the seed's own relation ledger: a structural-vesica + relation between a built and an unbuilt slot is the only buildable-next + authority. No distance arithmetic is re-derived here. + """ + + buildable: list[BandSlot] = [] + built = state.built + for relation in state.seed.relations: + if relation.standing is not PairStanding.STRUCTURAL_VESICA: + continue + for left, right in ((relation.left, relation.right), (relation.right, relation.left)): + if left in built and right not in built: + buildable.append(right) + unique = list(dict.fromkeys(buildable)) + return tuple( + sorted(unique, key=lambda slot: (slot is BandSlot.CENTER, slot.ring_index or 0, slot.value)) + ) + + +def construct(state: ConstructionState, slot: BandSlot) -> ConstructionState: + """Record one built slot, fail-closed unless it is currently buildable.""" + + if slot in state.built: + raise MobiusSeedError(f"{slot.value} is already built") + if slot not in buildable_slots(state): + raise MobiusSeedError( + f"{slot.value} is not buildable; buildable now: " + + ", ".join(item.value for item in buildable_slots(state)) + ) + return ConstructionState(seed=state.seed, built=state.built | {slot}) + + +def from_built(slots: Iterable[BandSlot], seed: MobiusSeedOfLife | None = None) -> ConstructionState: + """Rebuild a construction state from a persisted built-slot list.""" + + resolved = seed if seed is not None else build_mobius_seed_of_life() + state = initial_construction_state(resolved) + for slot in slots: + if slot is not BandSlot.CENTER: + state = construct(state, slot) + return state diff --git a/stack-manifest.json b/stack-manifest.json index bb58488..2f5b124 100644 --- a/stack-manifest.json +++ b/stack-manifest.json @@ -17,7 +17,7 @@ }, { "repository": "The-Interdependency/ucns", - "commit": "1975fe70cf4e0826a8020c2da3047569e277af64", + "commit": "828c0b8bbcfc267efb5701da714191c1f73a81ff", "authority": "geometry and mathematical representation", "relation": "pinned canonical repository view at libs/ucns/; stack-local work at research/ucns/" },