diff --git a/.github/workflows/cultmesh-portability.yml b/.github/workflows/cultmesh-portability.yml
deleted file mode 100644
index ce361fc8..00000000
--- a/.github/workflows/cultmesh-portability.yml
+++ /dev/null
@@ -1,97 +0,0 @@
-name: CultMesh portability and performance
-
-on:
- push:
- branches:
- - main
- - "codex/**"
- pull_request:
- workflow_dispatch:
-
-jobs:
- getting-started:
- name: ${{ matrix.os }} one-command multiplayer onboarding
- runs-on: ${{ matrix.os }}
- timeout-minutes: 30
- strategy:
- fail-fast: false
- matrix:
- os: [windows-latest, ubuntu-latest]
-
- steps:
- - name: Check out CultLib
- uses: actions/checkout@v4
- with:
- path: CultLib
-
- - name: Check out the matching Eve contract
- uses: actions/checkout@v4
- with:
- repository: GameCult/Eve
- # Eve main is the contract this sample is written against; the codex
- # branch this used to pin was never merged and stopped on 2026-08-25.
- ref: ${{ vars.EVE_INTEGRATION_REF || 'main' }}
- path: Eve
-
- - name: Set up .NET
- uses: actions/setup-dotnet@v4
- with:
- dotnet-version: 10.0.x
-
- - name: Set up Node.js
- uses: actions/setup-node@v4
- with:
- node-version: 24
-
- - name: Run the documented one-command checkpoint
- shell: pwsh
- run: ./CultLib/scripts/verify-eve-getting-started.ps1 -EveRoot ./Eve
-
- reactive-and-resource-lifetime:
- name: ${{ matrix.os }} reactive semantics and leases
- runs-on: ${{ matrix.os }}
- timeout-minutes: 30
- strategy:
- fail-fast: false
- matrix:
- os: [windows-latest, ubuntu-latest]
-
- steps:
- - name: Check out CultLib
- uses: actions/checkout@v4
-
- - name: Set up .NET
- uses: actions/setup-dotnet@v4
- with:
- dotnet-version: 10.0.x
-
- - name: Set up Node.js
- uses: actions/setup-node@v4
- with:
- node-version: 24
- cache: npm
-
- - name: Set up Python
- uses: actions/setup-python@v5
- with:
- python-version: "3.12"
-
- - name: Install TypeScript workspace dependencies
- run: npm install --no-package-lock
-
- - name: Install Python CultLib packages
- run: python -m pip install -e packages/cultcache-py -e packages/cultnet-py -e packages/cultmesh-py
-
- - name: Verify explicit reactive scheduling across runtimes
- shell: pwsh
- run: ./scripts/verify-reactive-document-scaling.ps1
-
- - name: Measure representative reactive workload
- shell: pwsh
- run: ./scripts/measure-reactive-document-performance.ps1 -Quick -SkipRuntimeBuild
-
- - name: Churn 100,000 distinct document leases
- run: >-
- dotnet test tests/GameCult.Mesh.Tests/GameCult.Mesh.Tests.csproj
- --filter FullyQualifiedName~Document_OneHundredThousandDistinctLeaseCyclesLeaveNoResourcesOrCallbacks
- --logger "console;verbosity=minimal"
diff --git a/.github/workflows/cultnet-interop.yml b/.github/workflows/cultnet-interop.yml
index 6876a76a..1bde67ef 100644
--- a/.github/workflows/cultnet-interop.yml
+++ b/.github/workflows/cultnet-interop.yml
@@ -33,6 +33,10 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 24
+ cache: npm
+ cache-dependency-path: |
+ packages/cultcache-ts/package-lock.json
+ packages/cultnet-ts/package-lock.json
- name: Set up Python
uses: actions/setup-python@v5
@@ -69,7 +73,7 @@ jobs:
run: npm install --no-package-lock
- name: Install Python package dependencies
- run: python -m pip install -e packages/cultcache-py -e packages/cultnet-py -e packages/cultmesh-py
+ run: python -m pip install -e packages/cultcache-py
- name: Build TypeScript workspace dependencies
run: npm run build --workspace packages/cultcache-ts
@@ -83,24 +87,6 @@ jobs:
npm run build --workspace packages/cultnet-ts
npx tsc -p packages/cultnet-ts/tsconfig.test.json --pretty false
- - name: Verify TypeScript RUDP reliability
- run: npm test --workspace packages/cultnet-ts
-
- - name: Verify Python RUDP reliability
- run: |
- python -m unittest discover -s packages/cultcache-py/tests
- python -m unittest discover -s packages/cultnet-py/tests
- python -m unittest discover -s packages/cultmesh-py/tests
-
- - name: Verify Rust RUDP reliability
- run: cargo test --manifest-path packages/cultnet-rs/Cargo.toml rudp
-
- - name: Verify Rust CultMesh runtime-local surface
- run: cargo test --manifest-path packages/cultmesh-rs/Cargo.toml
-
- - name: Verify C# RUDP reliability
- run: dotnet test tests/GameCult.Networking.Tests/GameCult.Networking.Tests.csproj --filter "FullyQualifiedName~Rudp"
-
- name: Interop RUDP Python lane
timeout-minutes: 8
run: node --test --test-name-pattern "TypeScript and Python|Python and TypeScript" packages/cultnet-ts/dist-test/test/interop/cultnet-interop.test.js
diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml
deleted file mode 100644
index 6061ca7a..00000000
--- a/.github/workflows/publish-packages.yml
+++ /dev/null
@@ -1,146 +0,0 @@
-name: Publish packages
-
-# Non-Rust consumers cannot take a package from a subdirectory of a git repo the
-# way cargo can, which is why they ended up vendoring forks instead. This gives
-# them a versioned artifact to depend on so CultLib stays the only source.
-#
-# Rust needs nothing here: cargo git dependencies pin a CultLib revision
-# directly, which is already how Ghostlight, Epiphany, CodexConnector and Odin
-# consume the core stack.
-#
-# Tag a release to publish, for example `cultcache-ts-v0.13.6` or
-# `cultnet-py-v0.1.0`. The tag's version must match the version in the package
-# manifest; the workflow refuses the publish otherwise rather than shipping a
-# mislabelled artifact.
-
-on:
- push:
- tags:
- - "cultcache-ts-v*"
- - "cultcache-py-v*"
- - "cultnet-py-v*"
- - "cultmesh-py-v*"
- workflow_dispatch:
- inputs:
- package:
- description: Package to build and test (publishes nothing on manual runs)
- required: true
- type: choice
- options:
- - cultcache-ts
- - cultcache-py
- - cultnet-py
- - cultmesh-py
-
-permissions:
- contents: read
-
-jobs:
- cultcache-ts:
- name: "@gamecult/cultcache-ts to npm"
- if: startsWith(github.ref, 'refs/tags/cultcache-ts-v') || inputs.package == 'cultcache-ts'
- runs-on: ubuntu-latest
- timeout-minutes: 20
- steps:
- - name: Check out CultLib
- uses: actions/checkout@v4
-
- - name: Set up Node
- uses: actions/setup-node@v4
- with:
- node-version: 24.x
- registry-url: https://registry.npmjs.org
-
- - name: Install workspace dependencies
- run: npm ci
-
- - name: Verify the tag matches the manifest version
- if: startsWith(github.ref, 'refs/tags/cultcache-ts-v')
- run: |
- manifest="$(node -p "require('./packages/cultcache-ts/package.json').version")"
- tagged="${GITHUB_REF_NAME#cultcache-ts-v}"
- if [ "$manifest" != "$tagged" ]; then
- echo "tag $GITHUB_REF_NAME does not match manifest version $manifest" >&2
- exit 1
- fi
-
- - name: Build and test
- working-directory: packages/cultcache-ts
- run: npm test
-
- # Scoped packages default to restricted even on public npm, and this is
- # MIT like the rest of the repo, so say public explicitly.
- - name: Publish
- if: startsWith(github.ref, 'refs/tags/cultcache-ts-v')
- working-directory: packages/cultcache-ts
- run: npm publish --access public
- env:
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
-
- # The three Python packages layer cultcache-py < cultnet-py < cultmesh-py, the
- # same way the Rust crates and npm packages do. One job shape serves all
- # three; the matrix entry names the package and the tag prefix that releases it.
- python:
- name: ${{ matrix.package }} to PyPI
- if: startsWith(github.ref, 'refs/tags/cultcache-py-v') || startsWith(github.ref, 'refs/tags/cultnet-py-v') || startsWith(github.ref, 'refs/tags/cultmesh-py-v') || endsWith(inputs.package, '-py')
- strategy:
- fail-fast: false
- matrix:
- package: [cultcache-py, cultnet-py, cultmesh-py]
- runs-on: ubuntu-latest
- timeout-minutes: 20
- environment: pypi
- permissions:
- contents: read
- # PyPI trusted publishing exchanges this for a short-lived token, so no
- # long-lived API token is stored in the repository.
- id-token: write
- steps:
- - name: Skip packages this run does not name
- id: selected
- run: |
- if [ "${GITHUB_REF_NAME#${{ matrix.package }}-v}" != "$GITHUB_REF_NAME" ] || [ "${{ inputs.package }}" = "${{ matrix.package }}" ]; then
- echo "run=true" >> "$GITHUB_OUTPUT"
- else
- echo "run=false" >> "$GITHUB_OUTPUT"
- fi
-
- - name: Check out CultLib
- if: steps.selected.outputs.run == 'true'
- uses: actions/checkout@v4
-
- - name: Set up Python
- if: steps.selected.outputs.run == 'true'
- uses: actions/setup-python@v5
- with:
- python-version: "3.12"
-
- - name: Verify the tag matches the manifest version
- if: steps.selected.outputs.run == 'true' && startsWith(github.ref, 'refs/tags/')
- run: |
- manifest="$(python -c "import tomllib,pathlib;print(tomllib.loads(pathlib.Path('packages/${{ matrix.package }}/pyproject.toml').read_text())['project']['version'])")"
- tagged="${GITHUB_REF_NAME#${{ matrix.package }}-v}"
- if [ "$manifest" != "$tagged" ]; then
- echo "tag $GITHUB_REF_NAME does not match manifest version $manifest" >&2
- exit 1
- fi
-
- # Lower layers install from source so a release of one package is tested
- # against the sibling revision it was cut with, not whatever PyPI has.
- - name: Install and test
- if: steps.selected.outputs.run == 'true'
- run: |
- python -m pip install -e packages/cultcache-py -e packages/cultnet-py -e packages/cultmesh-py
- python -m unittest discover -s packages/${{ matrix.package }}/tests
-
- - name: Build sdist and wheel
- if: steps.selected.outputs.run == 'true'
- run: |
- python -m pip install --upgrade build
- python -m build packages/${{ matrix.package }}
-
- - name: Publish
- if: steps.selected.outputs.run == 'true' && startsWith(github.ref, 'refs/tags/')
- uses: pypa/gh-action-pypi-publish@release/v1
- with:
- packages-dir: packages/${{ matrix.package }}/dist
diff --git a/.gitignore b/.gitignore
index e4661501..d639c709 100644
--- a/.gitignore
+++ b/.gitignore
@@ -303,22 +303,10 @@ node_modules/
!packages/cultmesh-ts/**
!packages/cultcache-py/
!packages/cultcache-py/**
-!packages/cultnet-py/
-!packages/cultnet-py/**
-!packages/cultmesh-py/
-!packages/cultmesh-py/**
!packages/cultcache-rs/
!packages/cultcache-rs/**
!packages/cultnet-rs/
!packages/cultnet-rs/**
-!packages/cultmesh-rs/
-!packages/cultmesh-rs/**
-!packages/cultmath/
-!packages/cultmath/**
-!packages/cultmesh-browser/
-!packages/cultmesh-browser/**
-!packages/cultmesh-kotlin/
-!packages/cultmesh-kotlin/**
packages/**/node_modules/
packages/**/dist/
packages/**/dist-test/
@@ -516,7 +504,3 @@ $RECYCLE.BIN/
# Vim temporary swap files
*.swp
-
-# Python package build output
-packages/*-py/build/
-*.egg-info/
diff --git a/.voidbot/status/void-memory-maintenance.json b/.voidbot/status/void-memory-maintenance.json
new file mode 100644
index 00000000..0d0b876a
--- /dev/null
+++ b/.voidbot/status/void-memory-maintenance.json
@@ -0,0 +1,7 @@
+{
+ "status": "starting",
+ "startedAt": "2026-06-14T19:46:12.715Z",
+ "stateFile": "E:\\Projects\\CultLib\\.voidbot\\state\\libby.cc",
+ "launcher": "repo_face_heartbeat",
+ "reason": "repo_face_stale_short_term_memory"
+}
diff --git a/CLAUDE.md b/CLAUDE.md
deleted file mode 100644
index 640c1a14..00000000
--- a/CLAUDE.md
+++ /dev/null
@@ -1,3 +0,0 @@
-Repository doctrine for this project lives in AGENTS.md, shared with Codex.
-
-@AGENTS.md
diff --git a/CultLib.sln b/CultLib.sln
index fbc20922..f6ae25e9 100644
--- a/CultLib.sln
+++ b/CultLib.sln
@@ -11,6 +11,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching", "src\Gam
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.MessagePack", "src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj", "{26EE4C38-F06E-4E7A-A89C-908318BCB0DB}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.MessagePack.Generator", "src\GameCult.Caching.MessagePack.Generator\GameCult.Caching.MessagePack.Generator.csproj", "{0E990C96-3808-450E-9349-A0C3262195ED}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.MessagePack.Analyzers", "src\GameCult.Caching.MessagePack.Analyzers\GameCult.Caching.MessagePack.Analyzers.csproj", "{DBE06954-EC85-4034-9049-72C7D27556DF}"
+EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Networking", "src\GameCult.Networking\GameCult.Networking.csproj", "{67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Mesh", "src\GameCult.Mesh\GameCult.Mesh.csproj", "{8D1B01A7-BF9E-44E6-88E1-B17255728B42}"
@@ -27,28 +31,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Geometry.Tests", "
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Mesh.Tests", "tests\GameCult.Mesh.Tests\GameCult.Mesh.Tests.csproj", "{F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Mesh.Quic", "src\GameCult.Mesh.Quic\GameCult.Mesh.Quic.csproj", "{B67042B8-1135-4E54-B1EB-D45342C720D7}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Mesh.Quic.Tests", "tests\GameCult.Mesh.Quic.Tests\GameCult.Mesh.Quic.Tests.csproj", "{21F26329-2119-4037-93CC-DD1801A1AB78}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Networking.WebSockets", "src\GameCult.Networking.WebSockets\GameCult.Networking.WebSockets.csproj", "{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Networking.WebSockets.Tests", "tests\GameCult.Networking.WebSockets.Tests\GameCult.Networking.WebSockets.Tests.csproj", "{B9A441EC-DADE-4500-B092-C635F3EB296A}"
-EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EveBrowserNetworkSample", "samples\eve-browser-network\EveBrowserNetworkSample.csproj", "{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}"
-EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "packages", "packages", "{809F86A1-1C4C-B159-0CD4-DF9D33D876CE}"
-EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "cultmath", "cultmath", "{B48EAE30-152F-01E3-6E31-82B350494D3C}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CultMath", "packages\cultmath\src\CultMath\CultMath.csproj", "{54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CultMath.Tests", "packages\cultmath\tests\CultMath.Tests\CultMath.Tests.csproj", "{FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.ConsumerBuild", "tests\GameCult.Caching.ConsumerBuild\GameCult.Caching.ConsumerBuild.csproj", "{D3138F3C-9531-4906-A5D3-74BD857D0685}"
-EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -95,6 +77,30 @@ Global
{26EE4C38-F06E-4E7A-A89C-908318BCB0DB}.Release|x64.Build.0 = Release|Any CPU
{26EE4C38-F06E-4E7A-A89C-908318BCB0DB}.Release|x86.ActiveCfg = Release|Any CPU
{26EE4C38-F06E-4E7A-A89C-908318BCB0DB}.Release|x86.Build.0 = Release|Any CPU
+ {0E990C96-3808-450E-9349-A0C3262195ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0E990C96-3808-450E-9349-A0C3262195ED}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0E990C96-3808-450E-9349-A0C3262195ED}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {0E990C96-3808-450E-9349-A0C3262195ED}.Debug|x64.Build.0 = Debug|Any CPU
+ {0E990C96-3808-450E-9349-A0C3262195ED}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {0E990C96-3808-450E-9349-A0C3262195ED}.Debug|x86.Build.0 = Debug|Any CPU
+ {0E990C96-3808-450E-9349-A0C3262195ED}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0E990C96-3808-450E-9349-A0C3262195ED}.Release|Any CPU.Build.0 = Release|Any CPU
+ {0E990C96-3808-450E-9349-A0C3262195ED}.Release|x64.ActiveCfg = Release|Any CPU
+ {0E990C96-3808-450E-9349-A0C3262195ED}.Release|x64.Build.0 = Release|Any CPU
+ {0E990C96-3808-450E-9349-A0C3262195ED}.Release|x86.ActiveCfg = Release|Any CPU
+ {0E990C96-3808-450E-9349-A0C3262195ED}.Release|x86.Build.0 = Release|Any CPU
+ {DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|x64.Build.0 = Debug|Any CPU
+ {DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|x86.Build.0 = Debug|Any CPU
+ {DBE06954-EC85-4034-9049-72C7D27556DF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {DBE06954-EC85-4034-9049-72C7D27556DF}.Release|Any CPU.Build.0 = Release|Any CPU
+ {DBE06954-EC85-4034-9049-72C7D27556DF}.Release|x64.ActiveCfg = Release|Any CPU
+ {DBE06954-EC85-4034-9049-72C7D27556DF}.Release|x64.Build.0 = Release|Any CPU
+ {DBE06954-EC85-4034-9049-72C7D27556DF}.Release|x86.ActiveCfg = Release|Any CPU
+ {DBE06954-EC85-4034-9049-72C7D27556DF}.Release|x86.Build.0 = Release|Any CPU
{67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -179,102 +185,6 @@ Global
{F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}.Release|x64.Build.0 = Release|Any CPU
{F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}.Release|x86.ActiveCfg = Release|Any CPU
{F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}.Release|x86.Build.0 = Release|Any CPU
- {B67042B8-1135-4E54-B1EB-D45342C720D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {B67042B8-1135-4E54-B1EB-D45342C720D7}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {B67042B8-1135-4E54-B1EB-D45342C720D7}.Debug|x64.ActiveCfg = Debug|Any CPU
- {B67042B8-1135-4E54-B1EB-D45342C720D7}.Debug|x64.Build.0 = Debug|Any CPU
- {B67042B8-1135-4E54-B1EB-D45342C720D7}.Debug|x86.ActiveCfg = Debug|Any CPU
- {B67042B8-1135-4E54-B1EB-D45342C720D7}.Debug|x86.Build.0 = Debug|Any CPU
- {B67042B8-1135-4E54-B1EB-D45342C720D7}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {B67042B8-1135-4E54-B1EB-D45342C720D7}.Release|Any CPU.Build.0 = Release|Any CPU
- {B67042B8-1135-4E54-B1EB-D45342C720D7}.Release|x64.ActiveCfg = Release|Any CPU
- {B67042B8-1135-4E54-B1EB-D45342C720D7}.Release|x64.Build.0 = Release|Any CPU
- {B67042B8-1135-4E54-B1EB-D45342C720D7}.Release|x86.ActiveCfg = Release|Any CPU
- {B67042B8-1135-4E54-B1EB-D45342C720D7}.Release|x86.Build.0 = Release|Any CPU
- {21F26329-2119-4037-93CC-DD1801A1AB78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {21F26329-2119-4037-93CC-DD1801A1AB78}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {21F26329-2119-4037-93CC-DD1801A1AB78}.Debug|x64.ActiveCfg = Debug|Any CPU
- {21F26329-2119-4037-93CC-DD1801A1AB78}.Debug|x64.Build.0 = Debug|Any CPU
- {21F26329-2119-4037-93CC-DD1801A1AB78}.Debug|x86.ActiveCfg = Debug|Any CPU
- {21F26329-2119-4037-93CC-DD1801A1AB78}.Debug|x86.Build.0 = Debug|Any CPU
- {21F26329-2119-4037-93CC-DD1801A1AB78}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {21F26329-2119-4037-93CC-DD1801A1AB78}.Release|Any CPU.Build.0 = Release|Any CPU
- {21F26329-2119-4037-93CC-DD1801A1AB78}.Release|x64.ActiveCfg = Release|Any CPU
- {21F26329-2119-4037-93CC-DD1801A1AB78}.Release|x64.Build.0 = Release|Any CPU
- {21F26329-2119-4037-93CC-DD1801A1AB78}.Release|x86.ActiveCfg = Release|Any CPU
- {21F26329-2119-4037-93CC-DD1801A1AB78}.Release|x86.Build.0 = Release|Any CPU
- {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|x64.ActiveCfg = Debug|Any CPU
- {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|x64.Build.0 = Debug|Any CPU
- {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|x86.ActiveCfg = Debug|Any CPU
- {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|x86.Build.0 = Debug|Any CPU
- {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|Any CPU.Build.0 = Release|Any CPU
- {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|x64.ActiveCfg = Release|Any CPU
- {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|x64.Build.0 = Release|Any CPU
- {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|x86.ActiveCfg = Release|Any CPU
- {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|x86.Build.0 = Release|Any CPU
- {B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|x64.ActiveCfg = Debug|Any CPU
- {B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|x64.Build.0 = Debug|Any CPU
- {B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|x86.ActiveCfg = Debug|Any CPU
- {B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|x86.Build.0 = Debug|Any CPU
- {B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|Any CPU.Build.0 = Release|Any CPU
- {B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x64.ActiveCfg = Release|Any CPU
- {B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x64.Build.0 = Release|Any CPU
- {B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x86.ActiveCfg = Release|Any CPU
- {B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x86.Build.0 = Release|Any CPU
- {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x64.ActiveCfg = Debug|Any CPU
- {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x64.Build.0 = Debug|Any CPU
- {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x86.ActiveCfg = Debug|Any CPU
- {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x86.Build.0 = Debug|Any CPU
- {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|Any CPU.Build.0 = Release|Any CPU
- {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x64.ActiveCfg = Release|Any CPU
- {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x64.Build.0 = Release|Any CPU
- {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x86.ActiveCfg = Release|Any CPU
- {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x86.Build.0 = Release|Any CPU
- {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|x64.ActiveCfg = Debug|Any CPU
- {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|x64.Build.0 = Debug|Any CPU
- {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|x86.ActiveCfg = Debug|Any CPU
- {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|x86.Build.0 = Debug|Any CPU
- {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Release|Any CPU.Build.0 = Release|Any CPU
- {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Release|x64.ActiveCfg = Release|Any CPU
- {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Release|x64.Build.0 = Release|Any CPU
- {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Release|x86.ActiveCfg = Release|Any CPU
- {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Release|x86.Build.0 = Release|Any CPU
- {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Debug|x64.ActiveCfg = Debug|Any CPU
- {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Debug|x64.Build.0 = Debug|Any CPU
- {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Debug|x86.ActiveCfg = Debug|Any CPU
- {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Debug|x86.Build.0 = Debug|Any CPU
- {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Release|Any CPU.Build.0 = Release|Any CPU
- {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Release|x64.ActiveCfg = Release|Any CPU
- {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Release|x64.Build.0 = Release|Any CPU
- {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Release|x86.ActiveCfg = Release|Any CPU
- {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Release|x86.Build.0 = Release|Any CPU
- {D3138F3C-9531-4906-A5D3-74BD857D0685}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {D3138F3C-9531-4906-A5D3-74BD857D0685}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {D3138F3C-9531-4906-A5D3-74BD857D0685}.Debug|x64.ActiveCfg = Debug|Any CPU
- {D3138F3C-9531-4906-A5D3-74BD857D0685}.Debug|x64.Build.0 = Debug|Any CPU
- {D3138F3C-9531-4906-A5D3-74BD857D0685}.Debug|x86.ActiveCfg = Debug|Any CPU
- {D3138F3C-9531-4906-A5D3-74BD857D0685}.Debug|x86.Build.0 = Debug|Any CPU
- {D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|Any CPU.Build.0 = Release|Any CPU
- {D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|x64.ActiveCfg = Release|Any CPU
- {D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|x64.Build.0 = Release|Any CPU
- {D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|x86.ActiveCfg = Release|Any CPU
- {D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -283,6 +193,8 @@ Global
{72AEDD42-E881-4EB7-99A8-FD3B9667189C} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{9DDA917D-B4DB-4AC1-9B9A-4ECF06A5ABA1} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{26EE4C38-F06E-4E7A-A89C-908318BCB0DB} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
+ {0E990C96-3808-450E-9349-A0C3262195ED} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
+ {DBE06954-EC85-4034-9049-72C7D27556DF} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{8D1B01A7-BF9E-44E6-88E1-B17255728B42} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{E2DC8FF6-D39E-4FCA-9549-1F7681F0AA81} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
@@ -290,14 +202,5 @@ Global
{94C086DA-3359-424B-9688-29A198806481} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{C95A9A21-34FA-4D82-BE5B-88FBDA836D31} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
{F8A5060A-FB0B-4B8B-AE06-5147BB4CA127} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
- {B67042B8-1135-4E54-B1EB-D45342C720D7} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
- {21F26329-2119-4037-93CC-DD1801A1AB78} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
- {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
- {B9A441EC-DADE-4500-B092-C635F3EB296A} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
- {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
- {B48EAE30-152F-01E3-6E31-82B350494D3C} = {809F86A1-1C4C-B159-0CD4-DF9D33D876CE}
- {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F} = {B48EAE30-152F-01E3-6E31-82B350494D3C}
- {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D} = {B48EAE30-152F-01E3-6E31-82B350494D3C}
- {D3138F3C-9531-4906-A5D3-74BD857D0685} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
EndGlobalSection
EndGlobal
diff --git a/CultLib.txt b/CultLib.txt
new file mode 100644
index 00000000..4755ac07
--- /dev/null
+++ b/CultLib.txt
@@ -0,0 +1,132 @@
+
+
+// E:\Projects\CultLib\src\GameCult.Caching\GameCult.Caching.csproj
+
+
+
+ CultCache: Concurrent polymorphic cache with modular backing stores for .NET
+
+
+
+
+
+
+
+
+
+
+// E:\Projects\CultLib\src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj
+
+
+
+ File-based MessagePack backing store for CultCache
+
+
+
+
+
+
+
+
+
+// E:\Projects\CultLib\src\GameCult.Caching.MessagePack.Generator\GameCult.Caching.MessagePack.Generator.csproj
+
+
+
+
+
+ true
+ enable
+ true
+ false
+
+
+ Source generator that emits MessagePack formatters for DatabaseEntry subclasses.
+ MessagePack;SourceGenerator;DatabaseEntry
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+// E:\Projects\CultLib\src\GameCult.Logging\GameCult.Logging.csproj
+
+
+
+ Simple pluggable logging interface for CultLib
+
+
+
+// E:\Projects\CultLib\src\GameCult.Logging.Unity\GameCult.Logging.Unity.csproj
+
+
+
+ Unity integration for GameCult.Logging
+
+
+
+
+
+
+
+
+// E:\Projects\CultLib\src\GameCult.Networking\GameCult.Networking.csproj
+
+
+
+ CultNet: Secure, high-performance messaging for games/servers
+
+
+
+
+
+
+
+
+
+
+
+
+
+// E:\Projects\CultLib\tests\GameCult.Networking.Tests\GameCult.Networking.Tests.csproj
+
+
+
+
+ net8.0
+ false
+ GameCult.Networking.Tests
+
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+ all
+
+
+
+
diff --git a/Directory.Build.props b/Directory.Build.props
index 10c8d749..519e7a93 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -14,8 +14,7 @@
MIT
© Meta Vens and contributors
true
- 1.0.35
- $(CultLibPackageVersion)
+ 1.0.3
true
diff --git a/README.md b/README.md
index 4977a02a..d6e3778c 100644
--- a/README.md
+++ b/README.md
@@ -11,9 +11,8 @@ The stack is less a single product than a set of capabilities:
- logging primitives and implementations
- CultCache typed documents, `.cc` persistence, and SoA memory access
-- CultNet schema-v0 messages over TCP and LiteNetLib adapters, a bounded
- ASP.NET Core WebSocket host adapter, and CultLib's native cross-runtime RUDP
- pipe
+- CultNet schema-v0 messages over TCP, WebSocket, LiteNetLib adapters, and
+ CultLib's native cross-runtime RUDP pipe
- CultMesh distributed realtime database, Verse discovery, authority leases,
shard replication, and witness-authoritative simulation consensus
- typed geometry domain/chunk documents for distributed CSG and LOD streaming
@@ -38,9 +37,8 @@ and pretending the label makes it furniture.
| Job | Start With | Owns | Use When | Do Not Use It For |
| --- | --- | --- | --- | --- |
| Local typed state | `GameCult.Caching` / CultCache | Document identity, schema compatibility, record keys, indexes, globals, and local persistence | You need a typed cache, file-compatible save data, local reactive reads, or a stable domain document model | Peer discovery, transport security, shard routing, or mesh consensus |
-| Portable math | `GameCult.Math` / CultMath | Shader-shaped numeric primitives, deterministic random, noise, and CPU/GPU parity helpers | Shared simulation and geometry code must not depend on an engine's math or RNG types | Domain-specific geometry, rendering, or world authority |
-| Procedural geometry | `GameCult.Geometry` / CultGeometry | Isosurface extraction plus CultCache-native domains, LOD requests, selected cuts, and mesh artifacts | C#, Rust, Godot, or remote workers need shared geometry algorithms and typed results | Transport policy, peer discovery, or gameplay authority |
-| Network transport and database plumbing | `GameCult.Networking` / CultNet | Native RUDP sessions, LiteNetLib/TCP adapters, an authenticated WebSocket host adapter, schema-v0 wire contracts, shard authority, raw document replication, snapshots, and subscriptions | You need a client/server pipe, login/session flow, schema discovery, reliable UDP, or a low-level distributed CultCache lane | Gameplay-facing mesh ergonomics, Verse policy, mod branches, or simulation consensus composition |
+| Procedural geometry state | `GameCult.Geometry` | CultCache-native domain trees, LOD build requests, selected-cut diagnostics, and chunk artifact payloads | Rust, Unity, or remote workers need to share CSG/LOD geometry and graph metadata as typed state | Transport policy, peer discovery, or gameplay authority |
+| Network transport and database plumbing | `GameCult.Networking` / CultNet | Native RUDP sessions, LiteNetLib/TCP/WebSocket adapters, authentication, schema-v0 wire contracts, shard authority, raw document replication, snapshots, and subscriptions | You need a client/server pipe, login/session flow, schema discovery, reliable UDP, or a low-level distributed CultCache lane | Gameplay-facing mesh ergonomics, Verse policy, mod branches, or simulation consensus composition |
| Distributed realtime gameplay state | `GameCult.Mesh` / CultMesh | Public mesh entrypoints, Verse discovery, peer exchange, shard replication defaults, authority leases, client prediction, and witness consensus | You want the game to treat clients and servers as one reactive database for persistent state, input state, and simulation facts | A tiny local-only tool, a bare transport client, or a storage format contract |
| Realtime media/frame streams | `GameCult.Mesh` / CultMesh streaming mode | Stream identity, authority, clock metadata, body transport negotiation, frame cursors, and backpressure state | Audio/video/tensor frames need to move between runtimes through shared memory, GPU handles, platform buffers, or CultCache page refs | Durable document mutation, mesh consensus facts, or pretending inline bytes are zero-copy |
| Daemon-published UI | Eve / CultUI | Typed operator and user-facing surfaces over CultMesh state | A daemon should expose controls, inspection, or workflow UI that can lower into GUI, TUI, web, native, overlay, or agent clients | One-off vendor dashboards, untyped debug panels, or runtime-local UI that cannot travel with the daemon |
@@ -48,10 +46,8 @@ and pretending the label makes it furniture.
Quick rule:
- Choose CultCache when the problem is "how do I model and persist typed state?"
-- Choose GameCult.Math when the problem is "which portable numeric and random
- semantics do these runtimes share?"
-- Choose GameCult.Geometry when the problem is "how do geometry workers build
- and share engine-neutral geometry?"
+- Choose GameCult.Geometry when the problem is "how do geometry workers share
+ domain trees, LOD build requests, and mesh chunks as typed state?"
- Choose CultNet when the problem is "how do peers exchange authenticated,
schema-aware database messages?"
- Choose CultMesh when the problem is "how does a game join a Verse and share
@@ -66,25 +62,8 @@ the stack. If a design needs another peer-to-peer category, first check whether
it is really Verse policy, peer exchange, or shard authority wearing a fake
mustache.
-If you want to build an application rather than tour the component drawer,
-start with the maintained
-[`CultMesh And Eve Getting Started`](src/GameCult.Mesh/docs/getting-started/README.md)
-series. Its first executable package-consumer checkpoint is
-[`samples/eve-two-runtime`](samples/eve-two-runtime/README.md).
-The real-process browser continuation is
-[`samples/eve-browser-network`](samples/eve-browser-network/README.md).
-One documented command runs both layers:
-
-```powershell
-pwsh -File ./scripts/verify-eve-getting-started.ps1
-```
-
-The implemented browser client boundary and remaining deployed-infrastructure
-gates are mapped explicitly in
-[`browser-verse-transport.md`](src/GameCult.Mesh/docs/browser-verse-transport.md).
-
-If you want only the smallest durable "open node -> put typed doc -> get typed doc"
-path, use
+If you want the smallest durable "open node -> put typed doc -> get typed doc"
+path, start with
[`src/GameCult.Mesh/docs/durable-node-quickstart.md`](src/GameCult.Mesh/docs/durable-node-quickstart.md).
If you want the lower-level "typed local document -> raw wire document ->
mesh/runtime watch surface" handoff, follow it with
@@ -98,10 +77,10 @@ The solution includes:
- `GameCult.Caching`: `DatabaseEntry`-based cache, indexes, global entries, and backing-store abstractions
- `GameCult.Caching.MessagePack`: MessagePack-backed persistence for the cache
- `GameCult.Caching.NewtonsoftJson`: Newtonsoft.Json-backed persistence for the cache
-- `GameCult.Math` / CultMath: portable numeric primitives, deterministic random, noise, and shader-parity helpers
-- `GameCult.Geometry` / CultGeometry: engine-neutral geometry algorithms plus CultCache-native domain, selected-cut, and mesh artifact documents
+- `GameCult.Caching.MessagePack.Generator`: source generator for MessagePack formatters for cache models
+- `GameCult.Caching.MessagePack.Analyzers`: packaging project that delivers the generator to consuming projects
+- `GameCult.Geometry`: CultCache-native geometry domain, selected-cut, and chunk artifact documents for VibeGeometry/Fensalir-style pipelines
- `GameCult.Networking`: encrypted login/register/verify flows, schema-v0 contracts, transport adapters, and native RUDP sessions
-- `GameCult.Networking.WebSockets`: bounded ASP.NET Core binary WebSocket host adapter for browser CultMesh clients
- `GameCult.Mesh`: CultMesh package home for distributed realtime database, shard replication, client prediction, Verse discovery, and mesh witness consensus
- `GameCult.Caching.Tests`: NUnit tests for cache and backing-store behavior
- `GameCult.Networking.Tests`: NUnit tests for networking behavior
@@ -109,10 +88,7 @@ The solution includes:
- `packages/cultcache-ts`: TypeScript CultCache with MessagePack persistence and inspector tooling
- `packages/cultnet-ts`: TypeScript CultNet schema-v0 contracts, framing, discovery, raw document replication, and interop tests
- `packages/cultmesh-ts`: TypeScript CultMesh local node and mesh catalog surface for local runtimes such as VoidBot
-- `packages/cultmesh-browser`: browser-safe, read-only CultMesh document leases and typed operations over an Odin-resolved WebSocket route
-- `packages/cultcache-py`: Python CultCache with CultCache v1 wire parity
-- `packages/cultnet-py`: Python CultNet contracts, framing, RUDP transport, replication, and interop peer
-- `packages/cultmesh-py`: Python CultMesh local node, discovery, sessions, local server, and daemon
+- `packages/cultcache-py`: Python CultCache/CultNet/CultMesh package with CultCache v1 wire parity
- `packages/cultcache-rs`: Rust CultCache and derive macro
- `packages/cultnet-rs`: Rust CultNet contracts, framing, discovery, and interop peer
- `packages/cultmesh-kotlin`: Kotlin/JVM CultMesh and CultNet surface for Android-adjacent runtimes
@@ -126,9 +102,10 @@ src/
GameCult.Caching/
GameCult.Caching.MessagePack/
GameCult.Caching.NewtonsoftJson/
+ GameCult.Caching.MessagePack.Generator/
+ GameCult.Caching.MessagePack.Analyzers/
GameCult.Geometry/
GameCult.Networking/
- GameCult.Networking.WebSockets/
GameCult.Mesh/
GameCult.Unity/
tests/
@@ -136,15 +113,11 @@ tests/
GameCult.Geometry.Tests/
GameCult.Networking.Tests/
packages/
- cultmath/
cultcache-ts/
cultnet-ts/
cultmesh-ts/
- cultmesh-browser/
cultmesh-kotlin/
cultcache-py/
- cultnet-py/
- cultmesh-py/
cultcache-rs/
cultnet-rs/
```
@@ -168,22 +141,8 @@ npm test --workspace packages/cultcache-ts
npm test --workspace packages/cultnet-ts
npm run test:interop --workspace packages/cultnet-ts
npm test --workspace packages/cultmesh-ts
-npm test --workspace packages/cultmesh-browser
```
-Measured reactive, retained-session, and shared-memory hot-body gates:
-
-```powershell
-pwsh -File ./scripts/measure-reactive-document-performance.ps1 -Quick
-pwsh -File ./scripts/verify-eve-getting-started.ps1
-```
-
-The first command measures the same 16 KiB document workload in C#,
-TypeScript, and Python and includes a mapped hot-body probe with explicit copy
-telemetry on Windows named maps and Unix file-backed maps. The second includes
-10,000 real WebSocket operations on one retained
-identity-owned C# session after provider route replacement.
-
Rust package tests:
```powershell
@@ -233,23 +192,41 @@ public class ItemData : DatabaseEntry, INamedEntry
- backing stores are persistence adapters
- indexes and name maps are maintained inside the cache, not inside the store
-### Multiple Backing Stores
+### Important: Multiple Backing Stores
-Every document type has exactly one home store. There are no mirrors.
+When multiple backing stores are added, behavior depends on how they are registered.
+
+If a store is added with domain types:
```csharp
-cache.AddBackingStore(catalogStore, typeof(ItemData), typeof(Faction));
-cache.AddBackingStore(runStore, typeof(SavedGame));
+cache.AddBackingStore(playerStore, typeof(PlayerData));
+cache.AddBackingStore(settingsStore, typeof(AppSettings));
```
-- the home is chosen by the document's runtime type: the most derived routed type wins, otherwise the untyped store
-- at most one untyped store; a type claimed by two stores is an error
-- a write with no home is an error once any store is attached
-- attaching a store reads it; attach routed stores first and the untyped store (if any) last, because a record's home cannot move after it is loaded
-- loading never writes: opening and flushing without a mutation leaves every file byte-identical
-- a read-only store refuses writes and is skipped by flush
+then that store becomes the direct persistence target for those types.
-The contract is `src/GameCult.Caching/Contracts/cultcache-store-composition.md`. Until the C# cache implements it, `AddBackingStore` takes no home types and every attached store receives every record; attach one store per cache.
+If a store is added without domain types:
+
+```csharp
+cache.AddBackingStore(primaryStore);
+cache.AddBackingStore(mirrorStore);
+```
+
+then the first generic store acts as the primary writable store for non-domain-specific entries. Additional generic stores subscribe to the existing stores and mirror their change events.
+
+Implications:
+
+- order matters for generic stores
+- `AddAsync` writes to the type-specific store when one exists
+- otherwise `AddAsync` writes to the first generic store
+- later generic stores do not become co-primaries; they mirror earlier stores
+- `PullAllBackingStoresAsync` pulls from every registered store
+
+Recommended patterns:
+
+- use one generic primary store if you want simple persistence
+- use domain-specific stores when different entry types belong in different persistence layers
+- treat additional generic stores as mirrors or downstream replicas, not independent write targets
## Example: Cache + Networking
@@ -345,6 +322,8 @@ Each subproject has a local README with package-specific detail:
- [GameCult.Caching](src/GameCult.Caching/README.md)
- [GameCult.Caching.MessagePack](src/GameCult.Caching.MessagePack/README.md)
- [GameCult.Caching.NewtonsoftJson](src/GameCult.Caching.NewtonsoftJson/README.md)
+- [GameCult.Caching.MessagePack.Generator](src/GameCult.Caching.MessagePack.Generator/README.md)
+- [GameCult.Caching.MessagePack.Analyzers](src/GameCult.Caching.MessagePack.Analyzers/README.md)
- [GameCult.Geometry](src/GameCult.Geometry/README.md)
- [GameCult.Networking](src/GameCult.Networking/README.md)
- [GameCult.Mesh](src/GameCult.Mesh/README.md)
diff --git a/docs/cultmesh-provider-session.md b/docs/cultmesh-provider-session.md
deleted file mode 100644
index 3e45c799..00000000
--- a/docs/cultmesh-provider-session.md
+++ /dev/null
@@ -1,61 +0,0 @@
-# CultMesh Provider Session
-
-## Authority map
-
-- Owner: the provider-session broker owns registration, active lease fencing,
- accepted publication membership, command retention, receipt acceptance, and
- withdrawal.
-- Inputs: a physical CultNet session, an explicit registration-authorization
- decision, correlated typed operations, the broker clock, provider commands,
- and persistence callbacks. The current RUDP adapter does not itself provide
- an authenticated claim.
-- Outputs: leases, application acceptances, accepted raw documents, pushed
- typed commands, persisted receipts, and deterministic removals.
-- Derived state: socket sessions, retry schedules, connection health, and
- provider projections are observations. They do not authorize publications.
-- Forbidden writers: RUDP packet acknowledgements, source addresses, direct raw
- document callbacks, renderer-owned catalogs, and arrival-time freshness maps
- cannot create accepted provider truth.
-- Shared paths: initial connect and reconnect use `provider.register`; initial,
- replayed, and live documents use `provider.publication.put`; explicit
- withdrawal and lease expiry remove the broker-owned publication set; command
- retry ends only when `provider.receipt.put` is accepted.
-- Cut line: a receiver must stop accepting unleased provider raw puts before it
- claims this protocol as its ingress authority.
-
-## Wire contract
-
-Provider lifecycle operations use the reliable ordered CultNet schema channel
-and the existing `cultnet.operation_request.v0` / `cultnet.operation_response.v0`
-envelopes with service id `gamecult.mesh.provider_session`. Inner payloads are
-camel-case MessagePack maps encoded as `messagepack-base64`.
-
-The v1 operations are:
-
-| Operation | Request payload | Successful payload |
-| --- | --- | --- |
-| `provider.register` | `gamecult.mesh.provider_registration.v1` | `gamecult.mesh.provider_lease.v1` |
-| `provider.renew` | `gamecult.mesh.provider_lease_renewal.v1` | `gamecult.mesh.provider_lease.v1` |
-| `provider.publication.put` | `gamecult.mesh.provider_publication_put.v1` | `gamecult.mesh.provider_mutation_acceptance.v1` |
-| `provider.publication.delete` | `gamecult.mesh.provider_publication_delete.v1` | `gamecult.mesh.provider_mutation_acceptance.v1` |
-| `provider.receipt.put` | `gamecult.mesh.provider_receipt_put.v1` | `gamecult.mesh.provider_mutation_acceptance.v1` |
-| `provider.withdraw` | `gamecult.mesh.provider_withdrawal.v1` | `gamecult.mesh.provider_mutation_acceptance.v1` |
-
-Broker-to-provider commands are raw typed documents using
-`gamecult.mesh.provider_command.v1`. Asset bodies do not travel inside these
-operation payloads.
-
-Responses use `ok`, `conflict`, `expired`, `denied`, or `invalid`. A transport
-ACK is never an application acceptance. Renewal issues a new lease id and
-immediately fences the previous id. The broker derives the set removed by full
-withdrawal; a provider cannot use a supplied list to hide owned publications.
-
-Public deployment additionally requires a CultNet authentication claim that
-authorizes the provider identity. Until that claim is wired and checked, the
-TypeScript RUDP adapter is a private-development transport.
-
-`CultMeshProviderSessionBroker` is an in-memory reference broker and socket
-proof. Its command and receipt retention does not survive process restart.
-Production receivers must persist that control-plane state atomically with
-accepted publication ownership; Odin must not substitute these volatile maps
-for its durable broker authority.
diff --git a/docs/cultnet-transport-parity.md b/docs/cultnet-transport-parity.md
index d0779196..5f0fec91 100644
--- a/docs/cultnet-transport-parity.md
+++ b/docs/cultnet-transport-parity.md
@@ -31,7 +31,7 @@ CultNet owns cross-runtime transport semantics:
| C# interop peer | Schema-v0 MessagePack over TCP-framed or shared RUDP transport | UDP multicast probe/announce with TCP and RUDP transport profiles | Test harness transport parity |
| TypeScript `cultnet-ts` | `CultNetPeer` over any Node `Duplex`, TCP-framed transport, or single-peer RUDP socket transport; interop uses TCP | UDP multicast probe/announce in the interop peer | First UDP socket binding for the shared RUDP reliability owner |
| Rust `cultnet-rs` | Interop example serves and dials schema-v0 MessagePack over TCP-framed or shared RUDP transport; single-peer RUDP socket transport exists in the library | UDP multicast probe/announce with TCP and RUDP transport profiles | UDP socket binding for the shared RUDP reliability owner |
-| Python `cultnet-py` | TCP sockets with 4-byte length-prefixed MessagePack frames for local CultMesh/CultNet server and client; single-peer RUDP socket transport exists in the library | Endpoint lists and CultMesh peer/Verse catalogs | UDP socket binding for the shared RUDP reliability owner |
+| Python `cultcache-py` | TCP sockets with 4-byte length-prefixed MessagePack frames for local CultMesh/CultNet server and client; single-peer RUDP socket transport exists in the library | Endpoint lists and CultMesh peer/Verse catalogs | UDP socket binding for the shared RUDP reliability owner |
| Kotlin `cultmesh-kotlin` | Channel-aware WebSocket transport connection for reliable ordered `schema` frames; single-peer RUDP socket transport exists in the library; interop CLI serves and dials schema-v0 MessagePack over TCP-framed or shared RUDP transport | CultMesh Verse and peer catalogs, plus endpoint lists carried in schema-v0 catalog messages | WebSocket/TCP adapters plus UDP socket binding for the shared RUDP reliability owner, with build-script and TypeScript harness proof |
The live split is therefore: payload language and the native RUDP packet/session
@@ -258,19 +258,11 @@ Current progress:
echo against C#/Kotlin/Python/Rust UDP peers; local runtime tests prove timeout
state transitions.
- TypeScript, C#, Rust, Python, and Kotlin now share bounded reliable-send
- backpressure for RUDP sessions. At most 32 reliable packets are admitted to
- the wire at once; later fragments remain in a session-owned FIFO and
- acknowledgements promote the next packets. Receivers emit a cumulative ACK
- for packets inside the 32-sequence ACK horizon and an exact ACK for an older
- retransmit, so unrelated unreliable traffic cannot strand reliable work.
- This keeps the send frontier well
- inside the 4,096-sequence receive history instead of blasting an entire large
- document and hoping the UDP buffers impersonate flow control.
- `maxPendingReliablePackets` bounds both queued and in-flight packets before a
- reliable frame is accepted. Socket transports expose an explicit reliable
- flush where the runtime supports one, so callers that require confirmed
- transport delivery do not substitute a fixed sleep. TCP-framed profiles do
- not publish the field because they do not own the RUDP pending-reliable queue.
+ backpressure for RUDP sessions. `maxPendingReliablePackets` is advertised on
+ RUDP transport-profile channels and enforced by the session before reliable
+ connect, accept, single-packet sends, or fragmented sends enqueue anything;
+ TCP-framed profiles do not publish the field because they do not own the RUDP
+ pending-reliable queue.
- TypeScript, C#, Rust, Python, and Kotlin now share a portable
`cultnet.reconnect_policy.v0` document and deterministic exponential delay
helper, and RUDP transport profiles advertise that policy under
diff --git a/docs/nuget-packaging.md b/docs/nuget-packaging.md
index afc94570..88eb4a2d 100644
--- a/docs/nuget-packaging.md
+++ b/docs/nuget-packaging.md
@@ -15,9 +15,3 @@ a clean .NET consumer using only `PackageReference Include="GameCult.Mesh"`.
The Unity distribution remains `org.gamecult.cultlib`, built by
`scripts/build-unity-package.ps1`, because Unity consumes the managed assembly
closure through UPM rather than NuGet restore.
-
-Install the Unity package from the immutable release tag:
-
-```text
-https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.46
-```
diff --git a/docs/parked-features.md b/docs/parked-features.md
deleted file mode 100644
index aa65294b..00000000
--- a/docs/parked-features.md
+++ /dev/null
@@ -1,32 +0,0 @@
-# Parked Features
-
-A parked feature was built for a stated reason, has no consumer, and was
-removed from the tree. Its last working form lives at an annotated tag so it
-can be restored instead of reinvented.
-
-## CultCache SoA table
-
-- Tag: `parked/cultcache-soa`
-- Intent: ECS-style structure-of-arrays columns over cached documents, for
- hot-loop performance without giving up POCO document ergonomics.
-- Span at the tag: `src/GameCult.Caching/CultManagedDocument.cs:133-414`
- (`CultSoaTable`, `CultSoaColumn`, `CultCacheSoaStore`,
- `CultCacheSoaTypeTable`, `CultCacheSoaMember`); `CultCache.Soa()` and the
- `_soa` field with its upsert/remove hooks in `CultCache.cs`; the SoA tests in
- `tests/GameCult.Caching.Tests/BackingStoreTests.cs` and
- `tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs`.
-- Managed document: `CultManagedDocument` (`CultManagedDocument.cs:72-131` at
- the tag) with `CultCache.Document` and `CultNetDatabase.Document`, the
- reactive POCO handle added with the SoA storage in 5428870 "Make SoA
- cache-managed document storage". Same tag, same intent: the document stays a
- POCO while the cache owns its columnar storage.
-- Tag message correction: the `parked/cultcache-soa` tag message says
- `CultManagedDocument` was "deleted separately, not parked". This note
- supersedes it: `CultManagedDocument` is parked under the same tag and is
- restored alongside SoA. The tag itself is left as written.
-- Consumers when parked: none outside tests.
-- Restore: `git show parked/cultcache-soa:src/GameCult.Caching/CultManagedDocument.cs`,
- take the SoA types and `CultManagedDocument`, restore both `Document`
- openers from `CultCache.cs` and `CultNetDatabase.cs` at the tag, and re-hook a
- `CultCacheSoaStore` into the cache's single
- admission and eviction paths (`Admit`/`Evict`).
diff --git a/docs/persisted-record-encoding.md b/docs/persisted-record-encoding.md
deleted file mode 100644
index b8e13f8a..00000000
--- a/docs/persisted-record-encoding.md
+++ /dev/null
@@ -1,68 +0,0 @@
-# The encoding of a persisted CultCache record is part of its contract
-
-Changing how a stored record serializes is never a flag flip in this estate.
-Consumers verify canonicality by **re-encoding what they decoded and comparing
-bytes**, so a record that reads back correctly but re-encodes differently is
-refused — and the refusal happens on live data, at startup or on the next read.
-
-Three guards, three separate stores, one idiom:
-
-| store | guard | refusal |
-|---|---|---|
-| Idunn runtime projection | `Idunn/src/drivers.rs:3611` | `service trust-anchor projection is noncanonical` |
-| Idunn deployment brake | `Idunn/src/control_plane.rs` (`read_deployment_brake`) | `deployment brake is noncanonical or keyed by another authority` |
-| Idunn trust bindings | `Idunn/src/provisioning.rs:546`, `:630` | `trust store contains a noncanonical or mismatched binding` |
-
-Idunn's control store applies the same rule to its own transactions. Assume the
-guard exists and go looking for it before changing any persisted record's
-serialization; three independent instances is a house pattern, not a
-coincidence.
-
-## What this means for `#[cultcache(key = N, bytes)]`
-
-The attribute switches a `Vec` slot from a MessagePack array-of-integers
-(serde's default) to a real `bin`. Measured, not assumed
-(`packages/cultnet-rs/tests/byte_slot_migration.rs`):
-
-- **Reading stays compatible in both directions.** `serde_bytes::ByteBuf`
- accepts an array marker, and a plain `Vec` accepts `bin`. No dual-read
- path is needed, and reverting a binary is survivable.
-- **Reproduction does not.** A record written before the flip can be read but
- can never re-encode to the bytes it was stored as, and its `canonical_sha256`
- moves with it.
-
-So the cost lands entirely on the guards above, not on readability. A field with
-persisted records must flip the attribute **and** rewrite its store in the same
-change, one target at a time, and never during a deployment — the projection
-store is what the observer reads, and refusing it takes the observer down.
-
-A field with no live producer is free. Check for one before assuming a
-migration: `IdunnAuthenticatedProviderHealthProjectionRecord` is constructed
-only inside a test module, so its byte field costs nothing to convert.
-
-Size, for whether it is worth doing at all: 406 bytes as an array against 278 as
-`bin` for a 256-byte payload, and 1234 against 851 for an 848-byte one. About
-1.45x on every byte, which matters on a media path and is noise on a control
-record.
-
-## `Option>` cannot take the attribute yet
-
-The derive emits `serde_bytes::Bytes::new(&self.field)`, which needs `&[u8]`,
-and builds the field via `.into_vec()`, which yields `Vec`. Both are type
-errors against an `Option>`, so the derive fails loudly rather than
-collapsing "absent slot" into "unsigned". `IdunnDeploymentBrakeRecord.signature`
-needs an `Option`-aware path in `cultcache-rs-derive` before it can move.
-
-## Fields validated for shape and produced by nothing
-
-`trust_binding_sha256` is declared on two records with different schemas
-(`idunn.authenticated_provider_health_projection.v1` in cultnet-rs,
-`idunn.authenticated_daemon_health_admission.v1` in odin-core), shape-validated
-in both, and computed by nothing in CultLib, Idunn or Odin.
-
-Do not assume that means "omission". It may be a fossil — a contract whose
-producer was deleted. The estate has form: Muninn's receiver runs a Reed-Solomon
-audio FEC path against parity shards the Rust sender has never emitted, while an
-acceptance ledger describes that FEC as having worked. Settling which requires
-reading history, not source, and the answer changes whether the right move is to
-implement the field or delete it.
diff --git a/docs/runtime-parity-scope.md b/docs/runtime-parity-scope.md
index 44715c7c..cd3761d7 100644
--- a/docs/runtime-parity-scope.md
+++ b/docs/runtime-parity-scope.md
@@ -21,23 +21,20 @@ Cross-runtime parity has three layers:
transport helpers instead of assembling raw maps and channel strings.
SoA performance parity is deliberately not a cross-runtime requirement. C#
-owns the shared-memory streaming body path. The C# CultCache SoA table (ECS-style
-structure-of-arrays columns over cached documents) is parked at the tag
-`parked/cultcache-soa`: it was built for hot-loop performance, had no consumer
-outside tests, and is restorable from that tag (`docs/parked-features.md`).
-It is not a supported surface in the current tree. Other runtimes may add native hot paths when their
-runtime role earns them, but they do not need to mimic Unity/C# memory layout
-to speak CultNet or participate in CultMesh.
+owns the CPU-local SoA table and shared-memory streaming body path. Other
+runtimes may add native hot paths when their runtime role earns them, but they
+do not need to mimic Unity/C# memory layout to speak CultNet or participate in
+CultMesh.
## Current Targeted Runtimes
| Runtime | Role | Claimed parity | Not claimed |
| --- | --- | --- | --- |
-| C# | Reference implementation and production game/server substrate | CultCache typed documents, CultNet schema-v0, LiteNetLib production path with adapter profile, shared transport profiles, shared RUDP socket transport, C# multi-peer RUDP listener and schema service host, C# interop RUDP serve/dial path, connected RUDP CultMesh client helpers, RUDP-backed schema-client selection for `rudp://` service endpoints, RUDP reconnect policy/controller and reconnect-loop helper, LiteNetLib client reconnect-controller adoption, schema-client transport port for service request/response clients, CultMesh node/session/catalog/stream/simulation/RUDP surfaces, authorized peer lookup | No need to abandon LiteNetLib before the CultNet adapter fully owns production service adoption |
+| C# | Reference implementation and production game/server substrate | CultCache typed documents and SoA, CultNet schema-v0, LiteNetLib production path with adapter profile, shared transport profiles, shared RUDP socket transport, C# multi-peer RUDP listener and schema service host, C# interop RUDP serve/dial path, connected RUDP CultMesh client helpers, RUDP-backed schema-client selection for `rudp://` service endpoints, RUDP reconnect policy/controller and reconnect-loop helper, LiteNetLib client reconnect-controller adoption, schema-client transport port for service request/response clients, CultMesh node/session/catalog/stream/simulation/RUDP surfaces, authorized peer lookup | No need to abandon LiteNetLib before the CultNet adapter fully owns production service adoption |
| Python | Package runtime, local daemon endpoint, raw interop peer, typed state participant | CultCache/CultNet/CultMesh package surface, branded CultMesh node/schema/shard/Verse/peer/authority/stream/RUDP ergonomics, authorized peer lookup, shard catalogs/logs, schema-transport factory for raw client request/subscription flows, local TCP and RUDP CultMesh server listeners, Python interop RUDP serve/dial path, session/prediction/simulation fact helpers, RUDP codec/session/socket transport, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence, benchmark evidence against C# cache paths | Universal C# throughput, full C# daemon/server stack, and RUDP as the only public service pipe |
| TypeScript | Node/browser-adjacent projection and interop runtime | CultCache parity tests, CultNet schema-v0 peer, advertised schema/shard catalog wire contracts plus request/sync helpers, transport profiles, TCP-framed transport port and peer helper, shared RUDP socket transport, RUDP interop serve/dial path, RUDP reconnect policy/controller, RUDP reconnect-loop helper, branded CultMesh node/schema/shard/Verse/peer/authority/stream/RUDP ergonomics, authorized peer lookup | Full C# game session/server stack |
-| Rust | Low-level cache/transport/runtime substrate | CultCache/CultNet interop lane, schema and shard catalog helpers, typed same-schema alias hydration from raw snapshots, transport profiles, TCP-framed port, shared RUDP codec/session/socket transport, Rust interop RUDP serve/dial path, connected RUDP CultMesh client helpers, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence, small CultMesh facade for schema registry, shard catalog, peer cards, authority leases, authorized peer lookup, RUDP endpoint parsing, and peer-card/authorized-peer RUDP client construction | Full CultMesh game-session/server facade or production game-session server. `packages/cultmesh-rs` now holds a Rust stream and session surface -- node, document sets, RUDP document publication, stream descriptors, negotiation, stream catalog, and shared-memory frame rings -- but no interop lane exercises it, so it carries no cross-runtime parity claim |
-| Kotlin | JVM/Android client substrate | Typed MessagePack cache, one-call typed snapshot document sync through local same-schema aliases, built-in schema catalog advertisement, schema/shard catalogs with shared schema-message transport fetch/sync helpers, authority leases, authorized peer lookup, stream catalog, branded `CultMesh` facade, WebSocket transport profile/connection helper, RUDP client/server sugar, connected RUDP CultMesh client helpers, Kotlin interop RUDP serve/dial path, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence | C# SoA store (parked at `parked/cultcache-soa`), full C# server daemon, or Unity hot-loop memory layout |
+| Rust | Low-level cache/transport/runtime substrate | CultCache/CultNet interop lane, schema and shard catalog helpers, typed same-schema alias hydration from raw snapshots, transport profiles, TCP-framed port, shared RUDP codec/session/socket transport, Rust interop RUDP serve/dial path, connected RUDP CultMesh client helpers, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence, small CultMesh facade for schema registry, shard catalog, peer cards, authority leases, authorized peer lookup, RUDP endpoint parsing, and peer-card/authorized-peer RUDP client construction | Full CultMesh game-session/server facade, stream catalog, or production game-session server |
+| Kotlin | JVM/Android client substrate | Typed MessagePack cache, one-call typed snapshot document sync through local same-schema aliases, built-in schema catalog advertisement, schema/shard catalogs with shared schema-message transport fetch/sync helpers, authority leases, authorized peer lookup, stream catalog, branded `CultMesh` facade, WebSocket transport profile/connection helper, RUDP client/server sugar, connected RUDP CultMesh client helpers, Kotlin interop RUDP serve/dial path, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence | C# SoA store, full C# server daemon, or Unity hot-loop memory layout |
## Evidence Gates
@@ -55,7 +52,7 @@ to speak CultNet or participate in CultMesh.
`reconnectPolicy` profile field from C#, Rust, Python, and Kotlin peers.
Runtime-local tests cover the deterministic delay helper and reconnect
controller in C#, TypeScript, Rust, Python, and Kotlin.
-- `docs/python-runtime-parity.md` is the Python-specific audit ledger. Its
+- `packages/cultcache-py/PARITY.md` is the Python-specific audit ledger. Its
"Still Not Claimed" section is part of the contract, not an apology.
- `docs/cultnet-transport-parity.md` is the transport ownership map. It tracks
the remaining work to lower older LiteNetLib/WebSocket bodies behind the
@@ -88,16 +85,6 @@ server body, is deliberately not made.
backfilling C# server responsibilities it should not own. Kotlin WebSocket
and RUDP adapters now share one `CultNetSchemaMessageTransport` helper shape
for schema/shard catalog request and sync flows.
-- Bring `packages/cultmesh-rs` under the wire claim. It arrived from a consumer repo where it grew
- outside any conformance harness, and its stream, negotiation, and shared-memory frame-ring
- surfaces have never been checked against the browser, Kotlin, and TypeScript CultMesh bodies
- they must interoperate with. Runtime-local tests pass; that is not a parity claim.
-- Carry the RUDP receive-path bounds to the other runtimes. Rust now caps tracked ordered
- and sequenced channels, caps the out-of-order hold buffer by frames and bytes, refuses a
- packet far ahead of the highest received so a forged sequence cannot starve a session, and
- reports reliable sequence exhaustion instead of aborting. C#, TypeScript, Python and Kotlin
- still accept all of it. These are refusals rather than wire-format changes, so the interop
- lanes stay green either way -- which is exactly why the gap will not announce itself.
- Keep performance claims evidence-bound: C# owns SoA; Python has benchmark
comparison gates; other runtimes should advertise measured native hot paths
only when those paths exist.
diff --git a/native/GameCult.Mesh.Quic.Native/CMakeLists.txt b/native/GameCult.Mesh.Quic.Native/CMakeLists.txt
deleted file mode 100644
index 2850951d..00000000
--- a/native/GameCult.Mesh.Quic.Native/CMakeLists.txt
+++ /dev/null
@@ -1,20 +0,0 @@
-cmake_minimum_required(VERSION 3.24)
-project(gamecult_mesh_quic_native LANGUAGES CXX)
-
-if(NOT WIN32)
- message(FATAL_ERROR "The current CultMesh native QUIC connector supports Windows desktop only")
-endif()
-if(NOT DEFINED MSQUIC_ROOT)
- message(FATAL_ERROR "MSQUIC_ROOT must point at the extracted Microsoft.Native.Quic.MsQuic.Schannel package build/native directory")
-endif()
-
-add_library(gamecult_mesh_quic_native SHARED cultmesh_quic_native.cpp)
-target_compile_features(gamecult_mesh_quic_native PRIVATE cxx_std_20)
-target_compile_definitions(gamecult_mesh_quic_native PRIVATE GAMECULT_MESH_QUIC_NATIVE_EXPORTS WIN32_LEAN_AND_MEAN NOMINMAX)
-target_include_directories(gamecult_mesh_quic_native PRIVATE "${MSQUIC_ROOT}/include")
-target_link_directories(gamecult_mesh_quic_native PRIVATE "${MSQUIC_ROOT}/lib/x64")
-target_link_libraries(gamecult_mesh_quic_native PRIVATE msquic crypt32 bcrypt)
-
-set_target_properties(gamecult_mesh_quic_native PROPERTIES
- OUTPUT_NAME "gamecult_mesh_quic_native"
- RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin")
diff --git a/native/GameCult.Mesh.Quic.Native/README.md b/native/GameCult.Mesh.Quic.Native/README.md
deleted file mode 100644
index 875ebb3b..00000000
--- a/native/GameCult.Mesh.Quic.Native/README.md
+++ /dev/null
@@ -1,10 +0,0 @@
-# GameCult.Mesh.Quic.Native
-
-This Windows desktop bridge keeps MsQuic's callback-heavy C API below the
-managed runtime boundary. It accepts a discovery-authorized SHA-256 certificate
-pin, negotiates `cultmesh-state-v1`, and emits complete CultMesh realtime frame
-payloads. It owns transport only; frame meaning remains in `GameCult.Mesh`.
-
-The build consumes Microsoft's pinned
-`Microsoft.Native.Quic.MsQuic.Schannel` package. It does not contain
-Aetheria-specific code.
diff --git a/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp b/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp
deleted file mode 100644
index bbb6e193..00000000
--- a/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp
+++ /dev/null
@@ -1,440 +0,0 @@
-#include
-#include
-#include
-#include
-
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-
-#if defined(GAMECULT_MESH_QUIC_NATIVE_EXPORTS)
-#define CULTMESH_API extern "C" __declspec(dllexport)
-#else
-#define CULTMESH_API extern "C" __declspec(dllimport)
-#endif
-
-namespace {
-
-constexpr uint32_t kApiVersion = 2;
-constexpr uint64_t kConnectionCloseCode = 0x43554c54;
-constexpr uint64_t kStreamAbortCode = 0x53544154;
-constexpr uint8_t kReliableStream = 1;
-constexpr uint8_t kLatestOnlyStream = 2;
-constexpr uint32_t kMaximumEncodedFrameBytes = (64u * 1024u * 1024u) + 37u + (3u * 65535u);
-constexpr char kAlpn[] = "cultmesh-state-v1";
-
-enum class ClientState : int32_t {
- Connecting = 0,
- Connected = 1,
- Failed = 2,
- Closed = 3,
-};
-
-struct Client;
-
-struct StreamContext {
- explicit StreamContext(Client* client) : owner(client) {}
- Client* owner;
- std::vector buffered;
- uint8_t kind = 0;
- bool has_kind = false;
-};
-
-struct Client {
- const QUIC_API_TABLE* api = nullptr;
- HQUIC registration = nullptr;
- HQUIC configuration = nullptr;
- HQUIC connection = nullptr;
- std::array certificate_pin{};
- std::atomic state{ClientState::Connecting};
- std::atomic closing{false};
- std::mutex gate;
- std::deque> received;
- std::string error;
- std::string trace;
-};
-
-void AppendTrace(Client* client, const std::string& event) {
- if (client == nullptr || client->state.load() == ClientState::Connected) return;
- std::lock_guard lock(client->gate);
- if (!client->trace.empty()) client->trace += ",";
- client->trace += event;
-}
-
-std::string TraceSnapshot(Client* client) {
- if (client == nullptr) return "none";
- std::lock_guard lock(client->gate);
- return client->trace.empty() ? "none" : client->trace;
-}
-
-void SetFailure(Client* client, const std::string& message) {
- if (client == nullptr || client->closing.load()) return;
- {
- std::lock_guard lock(client->gate);
- if (client->error.empty()) client->error = message;
- }
- client->state.store(ClientState::Failed);
-}
-
-bool ParseHexNibble(char value, uint8_t& nibble) {
- if (value >= '0' && value <= '9') nibble = static_cast(value - '0');
- else if (value >= 'a' && value <= 'f') nibble = static_cast(value - 'a' + 10);
- else if (value >= 'A' && value <= 'F') nibble = static_cast(value - 'A' + 10);
- else return false;
- return true;
-}
-
-bool ParsePin(const char* value, std::array& pin) {
- if (value == nullptr || std::strlen(value) != 64) return false;
- for (size_t index = 0; index < pin.size(); ++index) {
- uint8_t high = 0;
- uint8_t low = 0;
- if (!ParseHexNibble(value[index * 2], high) || !ParseHexNibble(value[(index * 2) + 1], low))
- return false;
- pin[index] = static_cast((high << 4) | low);
- }
- return true;
-}
-
-uint32_t ReadUInt32LittleEndian(const uint8_t* value) {
- return static_cast(value[0]) |
- (static_cast(value[1]) << 8) |
- (static_cast(value[2]) << 16) |
- (static_cast(value[3]) << 24);
-}
-
-void FailStream(StreamContext* stream, const char* message) {
- SetFailure(stream->owner, message);
- if (stream->owner != nullptr && stream->owner->api != nullptr && stream->owner->connection != nullptr)
- stream->owner->api->ConnectionShutdown(
- stream->owner->connection,
- QUIC_CONNECTION_SHUTDOWN_FLAG_SILENT,
- kStreamAbortCode);
-}
-
-bool ConsumeFrames(StreamContext* stream) {
- auto& bytes = stream->buffered;
- if (!stream->has_kind) {
- if (bytes.empty()) return true;
- stream->kind = bytes.front();
- bytes.erase(bytes.begin());
- stream->has_kind = true;
- if (stream->kind != kReliableStream && stream->kind != kLatestOnlyStream) {
- FailStream(stream, "CultMesh QUIC stream kind is invalid.");
- return false;
- }
- }
-
- while (bytes.size() >= sizeof(uint32_t)) {
- const uint32_t length = ReadUInt32LittleEndian(bytes.data());
- if (length == 0 || length > kMaximumEncodedFrameBytes) {
- FailStream(stream, "CultMesh QUIC frame length is invalid.");
- return false;
- }
- const size_t framed_length = sizeof(uint32_t) + static_cast(length);
- if (bytes.size() < framed_length) return true;
- std::vector frame(length);
- std::memcpy(frame.data(), bytes.data() + sizeof(uint32_t), length);
- {
- std::lock_guard lock(stream->owner->gate);
- stream->owner->received.emplace_back(std::move(frame));
- }
- bytes.erase(bytes.begin(), bytes.begin() + static_cast(framed_length));
- if (stream->kind == kLatestOnlyStream && !bytes.empty()) {
- FailStream(stream, "CultMesh latest-only QUIC stream carried multiple frames.");
- return false;
- }
- }
- return true;
-}
-
-QUIC_STATUS QUIC_API StreamCallback(HQUIC stream_handle, void* context, QUIC_STREAM_EVENT* event) {
- auto* stream = static_cast(context);
- switch (event->Type) {
- case QUIC_STREAM_EVENT_RECEIVE:
- for (uint32_t index = 0; index < event->RECEIVE.BufferCount; ++index) {
- const auto& buffer = event->RECEIVE.Buffers[index];
- stream->buffered.insert(stream->buffered.end(), buffer.Buffer, buffer.Buffer + buffer.Length);
- }
- ConsumeFrames(stream);
- break;
- case QUIC_STREAM_EVENT_PEER_SEND_SHUTDOWN:
- if (!stream->buffered.empty())
- FailStream(stream, "CultMesh QUIC stream ended with a truncated frame.");
- break;
- case QUIC_STREAM_EVENT_SHUTDOWN_COMPLETE:
- if (!event->SHUTDOWN_COMPLETE.AppCloseInProgress)
- stream->owner->api->StreamClose(stream_handle);
- delete stream;
- break;
- default:
- break;
- }
- return QUIC_STATUS_SUCCESS;
-}
-
-bool CertificateMatchesPin(Client* client, QUIC_CERTIFICATE* certificate) {
- if (certificate == nullptr) return false;
- const auto* encoded = reinterpret_cast(certificate);
- if (encoded->Buffer == nullptr || encoded->Length == 0) return false;
- BYTE digest[32]{};
- DWORD digest_length = sizeof(digest);
- if (!CryptHashCertificate2(
- BCRYPT_SHA256_ALGORITHM,
- 0,
- nullptr,
- encoded->Buffer,
- encoded->Length,
- digest,
- &digest_length) || digest_length != sizeof(digest))
- return false;
- return std::memcmp(digest, client->certificate_pin.data(), sizeof(digest)) == 0;
-}
-
-std::string Hex(uint64_t value) {
- std::ostringstream text;
- text << "0x" << std::hex << std::uppercase << value;
- return text.str();
-}
-
-std::string LoadedMsQuicPath() {
- const auto module = GetModuleHandleW(L"msquic.dll");
- if (module == nullptr) return "unresolved";
- std::array path{};
- const auto length = GetModuleFileNameW(module, path.data(), static_cast(path.size()));
- if (length == 0 || length >= path.size()) return "unresolved";
- const auto required = WideCharToMultiByte(
- CP_UTF8, 0, path.data(), static_cast(length), nullptr, 0, nullptr, nullptr);
- if (required <= 0) return "unresolved";
- std::string utf8(static_cast(required), '\0');
- WideCharToMultiByte(
- CP_UTF8, 0, path.data(), static_cast(length), utf8.data(), required, nullptr, nullptr);
- return utf8;
-}
-
-QUIC_STATUS QUIC_API ConnectionCallback(HQUIC connection, void* context, QUIC_CONNECTION_EVENT* event) {
- auto* client = static_cast(context);
- AppendTrace(client, "event=" + std::to_string(static_cast(event->Type)));
- switch (event->Type) {
- case QUIC_CONNECTION_EVENT_CONNECTED:
- client->state.store(ClientState::Connected);
- break;
- case QUIC_CONNECTION_EVENT_PEER_STREAM_STARTED: {
- auto* stream = new (std::nothrow) StreamContext(client);
- if (stream == nullptr) return QUIC_STATUS_OUT_OF_MEMORY;
- client->api->SetCallbackHandler(
- event->PEER_STREAM_STARTED.Stream,
- reinterpret_cast(StreamCallback),
- stream);
- break;
- }
- case QUIC_CONNECTION_EVENT_PEER_CERTIFICATE_RECEIVED:
- {
- AppendTrace(
- client,
- "certificate-status=" +
- Hex(static_cast(event->PEER_CERTIFICATE_RECEIVED.DeferredStatus)));
- const auto matches = CertificateMatchesPin(
- client,
- event->PEER_CERTIFICATE_RECEIVED.Certificate);
- if (!matches) {
- SetFailure(client, "CultMesh QUIC provider certificate does not match the advertised SHA-256 pin.");
- }
- const auto completion = client->api->ConnectionCertificateValidationComplete(
- connection,
- matches ? TRUE : FALSE,
- matches ? QUIC_TLS_ALERT_CODE_SUCCESS : QUIC_TLS_ALERT_CODE_BAD_CERTIFICATE);
- if (QUIC_FAILED(completion)) {
- SetFailure(
- client,
- "CultMesh QUIC certificate validation completion failed (status=" +
- Hex(static_cast(completion)) + ").");
- return completion;
- }
- return QUIC_STATUS_PENDING;
- }
- case QUIC_CONNECTION_EVENT_SHUTDOWN_INITIATED_BY_TRANSPORT:
- if (!client->closing.load()) {
- SetFailure(
- client,
- "CultMesh QUIC connection was shut down by the transport "
- "(status=" + Hex(static_cast(event->SHUTDOWN_INITIATED_BY_TRANSPORT.Status)) +
- ", error=" + Hex(event->SHUTDOWN_INITIATED_BY_TRANSPORT.ErrorCode) +
- ", msquic=" + LoadedMsQuicPath() +
- ", events=" + TraceSnapshot(client) + ").");
- }
- break;
- case QUIC_CONNECTION_EVENT_SHUTDOWN_INITIATED_BY_PEER:
- if (!client->closing.load())
- SetFailure(client, "CultMesh QUIC connection was shut down by the provider.");
- break;
- case QUIC_CONNECTION_EVENT_SHUTDOWN_COMPLETE:
- if (client->closing.load()) client->state.store(ClientState::Closed);
- break;
- default:
- break;
- }
- return QUIC_STATUS_SUCCESS;
-}
-
-void CloseHandles(Client* client) {
- if (client->connection != nullptr) {
- client->api->ConnectionClose(client->connection);
- client->connection = nullptr;
- }
- if (client->configuration != nullptr) {
- client->api->ConfigurationClose(client->configuration);
- client->configuration = nullptr;
- }
- if (client->registration != nullptr) {
- client->api->RegistrationClose(client->registration);
- client->registration = nullptr;
- }
- if (client->api != nullptr) {
- MsQuicClose(client->api);
- client->api = nullptr;
- }
-}
-
-} // namespace
-
-CULTMESH_API int32_t cultmesh_quic_open(
- const char* host,
- uint16_t port,
- const char* certificate_sha256,
- Client** result) {
- if (host == nullptr || *host == '\0' || port == 0 || result == nullptr) return -1;
- *result = nullptr;
- auto client = std::make_unique();
- if (!ParsePin(certificate_sha256, client->certificate_pin)) return -2;
-
- QUIC_STATUS status = MsQuicOpenVersion(
- kApiVersion,
- reinterpret_cast(&client->api));
- if (QUIC_FAILED(status)) return status;
-
- const QUIC_REGISTRATION_CONFIG registration_config = {
- "GameCult.Mesh.Quic.Native",
- QUIC_EXECUTION_PROFILE_TYPE_REAL_TIME
- };
- status = client->api->RegistrationOpen(®istration_config, &client->registration);
- if (QUIC_FAILED(status)) {
- CloseHandles(client.get());
- return status;
- }
-
- QUIC_SETTINGS settings{};
- settings.IdleTimeoutMs = 30000;
- settings.IsSet.IdleTimeoutMs = TRUE;
- settings.KeepAliveIntervalMs = 5000;
- settings.IsSet.KeepAliveIntervalMs = TRUE;
- settings.PeerUnidiStreamCount = 1024;
- settings.IsSet.PeerUnidiStreamCount = TRUE;
- const QUIC_BUFFER alpn = {
- static_cast(sizeof(kAlpn) - 1),
- reinterpret_cast(const_cast(kAlpn))
- };
- status = client->api->ConfigurationOpen(
- client->registration,
- &alpn,
- 1,
- &settings,
- sizeof(settings),
- nullptr,
- &client->configuration);
- if (QUIC_FAILED(status)) {
- CloseHandles(client.get());
- return status;
- }
-
- QUIC_CREDENTIAL_CONFIG credentials{};
- credentials.Type = QUIC_CREDENTIAL_TYPE_NONE;
- credentials.Flags = static_cast(
- QUIC_CREDENTIAL_FLAG_CLIENT |
- QUIC_CREDENTIAL_FLAG_INDICATE_CERTIFICATE_RECEIVED |
- QUIC_CREDENTIAL_FLAG_DEFER_CERTIFICATE_VALIDATION |
- QUIC_CREDENTIAL_FLAG_USE_PORTABLE_CERTIFICATES);
- status = client->api->ConfigurationLoadCredential(client->configuration, &credentials);
- if (QUIC_FAILED(status)) {
- CloseHandles(client.get());
- return status;
- }
-
- status = client->api->ConnectionOpen(
- client->registration,
- ConnectionCallback,
- client.get(),
- &client->connection);
- if (QUIC_FAILED(status)) {
- CloseHandles(client.get());
- return status;
- }
- status = client->api->ConnectionStart(
- client->connection,
- client->configuration,
- QUIC_ADDRESS_FAMILY_UNSPEC,
- host,
- port);
- if (QUIC_FAILED(status)) {
- CloseHandles(client.get());
- return status;
- }
-
- *result = client.release();
- return 0;
-}
-
-CULTMESH_API int32_t cultmesh_quic_state(Client* client) {
- if (client == nullptr) return static_cast(ClientState::Failed);
- return static_cast(client->state.load());
-}
-
-CULTMESH_API int32_t cultmesh_quic_poll(
- Client* client,
- uint8_t* destination,
- int32_t destination_length,
- int32_t* required_length) {
- if (client == nullptr || required_length == nullptr || destination_length < 0) return -1;
- std::lock_guard lock(client->gate);
- if (client->received.empty()) {
- *required_length = 0;
- return client->state.load() == ClientState::Failed ? -2 : 0;
- }
- const auto& frame = client->received.front();
- if (frame.size() > static_cast(INT32_MAX)) return -3;
- *required_length = static_cast(frame.size());
- if (destination == nullptr || destination_length < *required_length) return 2;
- std::memcpy(destination, frame.data(), frame.size());
- client->received.pop_front();
- return 1;
-}
-
-CULTMESH_API int32_t cultmesh_quic_error(
- Client* client,
- char* destination,
- int32_t destination_length) {
- if (client == nullptr || destination == nullptr || destination_length <= 0) return -1;
- std::lock_guard lock(client->gate);
- const size_t count = (std::min)(client->error.size(), static_cast(destination_length - 1));
- std::memcpy(destination, client->error.data(), count);
- destination[count] = '\0';
- return static_cast(count);
-}
-
-CULTMESH_API void cultmesh_quic_close(Client* client) {
- if (client == nullptr) return;
- client->closing.store(true);
- if (client->connection != nullptr && client->api != nullptr)
- client->api->ConnectionShutdown(client->connection, QUIC_CONNECTION_SHUTDOWN_FLAG_SILENT, kConnectionCloseCode);
- CloseHandles(client);
- client->state.store(ClientState::Closed);
- delete client;
-}
diff --git a/package-lock.json b/package-lock.json
index f027ecd5..20910067 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,20 +8,11 @@
"workspaces": [
"packages/cultcache-ts",
"packages/cultnet-ts",
- "packages/cultmesh-ts",
- "packages/cultmesh-browser"
- ],
- "devDependencies": {
- "esbuild": "^0.28.1",
- "fast-uri": "3.1.5",
- "js-yaml": "4.3.1",
- "playwright-core": "^1.62.1"
- }
+ "packages/cultmesh-ts"
+ ]
},
"node_modules/@apidevtools/json-schema-ref-parser": {
"version": "11.9.3",
- "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz",
- "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -36,463 +27,13 @@
"url": "https://github.com/sponsors/philsturgeon"
}
},
- "node_modules/@esbuild/aix-ppc64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
- "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "aix"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-arm": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
- "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-arm64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
- "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-x64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
- "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/darwin-arm64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
- "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/darwin-x64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
- "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/freebsd-arm64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
- "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/freebsd-x64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
- "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-arm": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
- "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-arm64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
- "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-ia32": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
- "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-loong64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
- "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-mips64el": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
- "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
- "cpu": [
- "mips64el"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-ppc64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
- "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-riscv64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
- "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-s390x": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
- "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-x64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
- "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/netbsd-arm64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
- "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/netbsd-x64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
- "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openbsd-arm64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
- "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openbsd-x64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
- "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openharmony-arm64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
- "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/sunos-x64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
- "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "sunos"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-arm64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
- "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-ia32": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
- "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-x64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
- "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@gamecult/cultcache-ts": {
- "resolved": "packages/cultcache-ts",
- "link": true
- },
"node_modules/@jsdevtools/ono": {
"version": "7.1.3",
- "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
- "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
"dev": true,
"license": "MIT"
},
"node_modules/@msgpack/msgpack": {
"version": "3.1.3",
- "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz",
- "integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==",
"license": "ISC",
"engines": {
"node": ">= 18"
@@ -500,22 +41,16 @@
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
- "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
- "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.24",
- "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
- "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.13.3",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
- "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -524,8 +59,6 @@
},
"node_modules/ajv": {
"version": "8.20.0",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
- "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -540,13 +73,11 @@
},
"node_modules/argparse": {
"version": "2.0.1",
- "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
- "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
- "node_modules/cultmesh-browser": {
- "resolved": "packages/cultmesh-browser",
+ "node_modules/cultcache-ts": {
+ "resolved": "packages/cultcache-ts",
"link": true
},
"node_modules/cultmesh-ts": {
@@ -557,58 +88,12 @@
"resolved": "packages/cultnet-ts",
"link": true
},
- "node_modules/esbuild": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
- "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "bin": {
- "esbuild": "bin/esbuild"
- },
- "engines": {
- "node": ">=18"
- },
- "optionalDependencies": {
- "@esbuild/aix-ppc64": "0.28.1",
- "@esbuild/android-arm": "0.28.1",
- "@esbuild/android-arm64": "0.28.1",
- "@esbuild/android-x64": "0.28.1",
- "@esbuild/darwin-arm64": "0.28.1",
- "@esbuild/darwin-x64": "0.28.1",
- "@esbuild/freebsd-arm64": "0.28.1",
- "@esbuild/freebsd-x64": "0.28.1",
- "@esbuild/linux-arm": "0.28.1",
- "@esbuild/linux-arm64": "0.28.1",
- "@esbuild/linux-ia32": "0.28.1",
- "@esbuild/linux-loong64": "0.28.1",
- "@esbuild/linux-mips64el": "0.28.1",
- "@esbuild/linux-ppc64": "0.28.1",
- "@esbuild/linux-riscv64": "0.28.1",
- "@esbuild/linux-s390x": "0.28.1",
- "@esbuild/linux-x64": "0.28.1",
- "@esbuild/netbsd-arm64": "0.28.1",
- "@esbuild/netbsd-x64": "0.28.1",
- "@esbuild/openbsd-arm64": "0.28.1",
- "@esbuild/openbsd-x64": "0.28.1",
- "@esbuild/openharmony-arm64": "0.28.1",
- "@esbuild/sunos-x64": "0.28.1",
- "@esbuild/win32-arm64": "0.28.1",
- "@esbuild/win32-ia32": "0.28.1",
- "@esbuild/win32-x64": "0.28.1"
- }
- },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
- "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
- "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-uri": {
- "version": "3.1.5",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
- "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
+ "version": "3.1.3",
"funding": [
{
"type": "github",
@@ -623,8 +108,6 @@
},
"node_modules/fdir": {
"version": "6.5.0",
- "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
- "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -641,8 +124,6 @@
},
"node_modules/is-extglob": {
"version": "2.1.1",
- "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
- "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -651,8 +132,6 @@
},
"node_modules/is-glob": {
"version": "4.0.3",
- "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
- "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -663,9 +142,7 @@
}
},
"node_modules/js-yaml": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
- "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
+ "version": "4.3.0",
"dev": true,
"funding": [
{
@@ -687,8 +164,6 @@
},
"node_modules/json-schema-to-typescript": {
"version": "15.0.4",
- "resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-15.0.4.tgz",
- "integrity": "sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -711,21 +186,15 @@
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
- "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
- "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/lodash": {
"version": "4.18.1",
- "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
- "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/minimist": {
"version": "1.2.8",
- "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
- "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -734,8 +203,6 @@
},
"node_modules/picomatch": {
"version": "4.0.5",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
- "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -745,23 +212,8 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
- "node_modules/playwright-core": {
- "version": "1.62.1",
- "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
- "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
- "dev": true,
- "license": "Apache-2.0",
- "bin": {
- "playwright-core": "cli.js"
- },
- "engines": {
- "node": ">=20"
- }
- },
"node_modules/prettier": {
"version": "3.9.5",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz",
- "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
"dev": true,
"license": "MIT",
"bin": {
@@ -776,8 +228,6 @@
},
"node_modules/require-from-string": {
"version": "2.0.2",
- "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
- "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -785,8 +235,6 @@
},
"node_modules/tinyglobby": {
"version": "0.2.17",
- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
- "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -802,8 +250,6 @@
},
"node_modules/typescript": {
"version": "5.9.3",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
- "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -816,23 +262,18 @@
},
"node_modules/undici-types": {
"version": "7.18.2",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
- "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
"node_modules/zod": {
"version": "3.25.76",
- "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
- "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"packages/cultcache-ts": {
- "name": "@gamecult/cultcache-ts",
- "version": "0.14.0",
+ "version": "0.13.5",
"license": "MIT",
"dependencies": {
"@msgpack/msgpack": "^3.1.3",
@@ -841,59 +282,35 @@
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^5.9.0"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "packages/cultmesh-browser": {
- "version": "0.3.0",
- "license": "MIT",
- "dependencies": {
- "@msgpack/msgpack": "^3.1.3",
- "cultnet-ts": "^0.2.0"
- },
- "devDependencies": {
- "@types/node": "^24.0.0",
- "typescript": "^5.9.0"
- },
- "engines": {
- "node": ">=18"
}
},
"packages/cultmesh-ts": {
- "version": "0.1.1",
+ "version": "0.1.0",
"license": "MIT",
"dependencies": {
- "@gamecult/cultcache-ts": "^0.14.0",
"@msgpack/msgpack": "^3.1.2",
- "cultnet-ts": "^0.2.0"
+ "cultcache-ts": "file:../cultcache-ts",
+ "cultnet-ts": "file:../cultnet-ts"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^5.9.0",
"zod": "^3.25.76"
- },
- "engines": {
- "node": ">=18"
}
},
"packages/cultnet-ts": {
- "version": "0.2.0",
+ "version": "0.1.0",
"license": "MIT",
"dependencies": {
- "@gamecult/cultcache-ts": "^0.14.0",
"@msgpack/msgpack": "^3.1.3",
- "ajv": "^8.17.1"
+ "ajv": "^8.17.1",
+ "cultcache-ts": "file:../cultcache-ts"
},
"devDependencies": {
"@types/node": "^24.0.0",
"json-schema-to-typescript": "^15.0.4",
"typescript": "^5.9.0",
"zod": "^3.25.76"
- },
- "engines": {
- "node": ">=18"
}
}
}
diff --git a/package.json b/package.json
index c9b79e47..f1b995a2 100644
--- a/package.json
+++ b/package.json
@@ -4,19 +4,10 @@
"workspaces": [
"packages/cultcache-ts",
"packages/cultnet-ts",
- "packages/cultmesh-ts",
- "packages/cultmesh-browser"
+ "packages/cultmesh-ts"
],
"scripts": {
- "build:ts": "node scripts/run-typescript-workspaces.mjs build",
- "test:ts": "node scripts/run-typescript-workspaces.mjs test",
- "test:pack:ts": "node scripts/test-typescript-package-closure.mjs",
- "test:eve-browser-network": "node scripts/verify-eve-browser-network.mjs"
- },
- "devDependencies": {
- "esbuild": "^0.28.1",
- "fast-uri": "3.1.5",
- "js-yaml": "4.3.1",
- "playwright-core": "^1.62.1"
+ "build:ts": "npm --workspaces run build",
+ "test:ts": "npm --workspaces run test"
}
}
diff --git a/docs/python-runtime-parity.md b/packages/cultcache-py/PARITY.md
similarity index 96%
rename from docs/python-runtime-parity.md
rename to packages/cultcache-py/PARITY.md
index 1cc222b7..18607d87 100644
--- a/docs/python-runtime-parity.md
+++ b/packages/cultcache-py/PARITY.md
@@ -1,7 +1,7 @@
# Python Runtime Parity Map
-This file is the current audit map for the Python runtime packages
-`cultcache-py`, `cultnet-py`, and `cultmesh-py`. It is evidence, not confetti.
+This file is the current audit map for `cultcache-py` as a Python runtime for
+CultCache, CultNet, and CultMesh. It is evidence, not confetti.
## Proven Surfaces
@@ -255,14 +255,14 @@ This file is the current audit map for the Python runtime packages
individual discovery, peer exchange, or simulation surfaces unserved without
advertising disabled capabilities.
- Python package health:
- `cultmesh-py-verify --json` checks the public CultCache/CultNet/CultMesh
+ `cultcache-py-verify --json` checks the public CultCache/CultNet/CultMesh
export surface declared by each package `__all__`, `py.typed` markers, a live
local CultMesh framed-wire smoke over hello/schema/snapshot/shard
catalog/shard log, typed peer-error export availability, peer-advertised
mutation contracts, payload schema wire contracts, simulation capability
truth, and benchmark sanity for Python-owned hot paths.
- Python/C# public cache baseline:
- `cultnet_py.compare_csharp` runs Python and C# `CultCache` upsert/get
+ `cultcache_py.compare_csharp` runs Python and C# `CultCache` upsert/get
benchmarks with the same operation names and reports median Python-to-C# ratios
from three samples by default. It also emits per-metric threshold checks and an
overall parity status so performance claims have an explicit evidence gate.
@@ -275,14 +275,12 @@ This file is the current audit map for the Python runtime packages
## Local Python Gates
```powershell
-$env:PYTHONPATH="$PWD\packages\cultcache-py\src;$PWD\packages\cultnet-py\src;$PWD\packages\cultmesh-py\src"
+$env:PYTHONPATH="$PWD\packages\cultcache-py\src"
python -m unittest discover -s packages\cultcache-py\tests
-python -m unittest discover -s packages\cultnet-py\tests
-python -m unittest discover -s packages\cultmesh-py\tests
-python -m cultmesh_py.verify --json
-python -m cultnet_py.benchmark --records 1000 --json
-python -m cultnet_py.compare_csharp --records 1000 --samples 3 --json
-python -m pip wheel --no-deps -w $env:TEMP packages\cultcache-py packages\cultnet-py packages\cultmesh-py
+python -m cultcache_py.verify --json
+python -m cultcache_py.benchmark --records 1000 --json
+python -m cultcache_py.compare_csharp --records 1000 --samples 3 --json
+python -m pip wheel --no-deps -w $env:TEMP packages\cultcache-py
```
## Cross-Runtime Gate
diff --git a/packages/cultcache-py/README.md b/packages/cultcache-py/README.md
index 23effdbd..48c023ba 100644
--- a/packages/cultcache-py/README.md
+++ b/packages/cultcache-py/README.md
@@ -1,16 +1,12 @@
-# cultcache-py
+# cultcache-py
-`cultcache-py` is the Python CultCache runtime. Callers work with registered
-domain documents while the cache owns schema identity, routing, globals, name
-lookups, indexes, and backing-store envelopes.
+`cultcache-py` is the Python runtime package for CultCache, CultNet, and
+CultMesh. Callers work with registered domain documents while the cache owns
+schema identity, routing, globals, name lookups, indexes, and backing-store
+envelopes.
It is intentionally small. It is not an ORM, a database, or distributed consensus wearing rented authority.
-It is the bottom of the Python CultLib stack and depends only on `msgpack`.
-`cultnet-py` (transport and wire contracts) and `cultmesh-py` (nodes,
-discovery, sessions, local server) build on it, mirroring `cultcache-rs` /
-`cultnet-rs` / `cultmesh-rs` and the npm packages.
-
## Current Shape
- document types are registered explicitly
@@ -21,14 +17,14 @@ discovery, sessions, local server) build on it, mirroring `cultcache-rs` /
- payloads decode only through the registered document definition for that type
- unknown persisted types fail closed
- global documents are singleton-style per type
-- one home store per document type: a store added with `add_backing_store`
- owns its types, otherwise the generic store is the home; a type claimed by a
- second store, or a second generic store, raises `CultCacheError`; a write
- touches exactly one store and nothing is mirrored; once any store is
- attached, a write whose type has no home raises
+- type-specific backing stores beat generic backing stores
- the JSONL store uses only the Python standard library for bootstrap consumers
- `define_database_entry_type(...)` emits Rust/C#-style slot-indexed
MessagePack array payloads for cross-runtime `DatabaseEntry` contracts
+- `cultnet_py` exposes schema-v0 MessagePack helpers and 4-byte big-endian
+ frame helpers for raw document state, subscriptions, shard catch-up,
+ simulation observations, and witness artifact bundles
+- `cultmesh_py` exposes a cache-backed local node surface for Python tools
## Example
@@ -148,28 +144,382 @@ That matches the Rust `#[derive(DatabaseEntry)]` formatter shape and the C#
`[Key(n)]` intent: schema evolution comes from stable field slots, not source
member order.
+## CultNet And CultMesh
+
+`cultnet_py` provides the Python schema-v0 wire helpers and an interop peer:
+
+```python
+from datetime import UTC, datetime
+
+from cultnet_py import (
+ CultNetSecret,
+ CultNetServerSecurityOptions,
+ CultNetRawClient,
+ CultNetSimulationObservation,
+ CultNetWitnessArtifactBundle,
+ apply_raw_snapshot,
+ apply_shard_log_response,
+ compute_simulation_claim_hash,
+ database_subscribe,
+ decode_frame,
+ encode_frame,
+ hello,
+ parse_message,
+ shard_catalog_request,
+ simulation_observation,
+ witness_artifact_bundle,
+)
+from cultmesh_py import CultMesh
+
+payload = hello(runtime_id="python-runtime").to_bytes()
+message = parse_message(decode_frame(encode_frame(payload)))
+
+client = CultNetRawClient("127.0.0.1", 3075)
+catalog = client.fetch_schema_catalog(kinds=["wire_message"])
+snapshot = client.fetch_snapshot_response(schema_ids=["cultnet.interop-note"])
+shard_catalog = client.fetch_shard_catalog(schema_ids=["cultnet.interop-note"])
+# With a local CultCache and the matching registered document definitions:
+# applied = apply_raw_snapshot(cache, [note_doc], snapshot)
+
+subscription = database_subscribe(subscription_id="ui", schema_ids=["cultnet.interop-note"])
+shard_request = shard_catalog_request(message_id="shards", schema_ids=["cultnet.interop-note"])
+shard_log = client.fetch_shard_log_response(shard_id="interop", shard_epoch=1, after_sequence=0)
+# log_changes = apply_shard_log_response(cache, [note_doc], shard_log)
+with client.subscribe_database(subscription_id="ui", schema_ids=["cultnet.interop-note"]) as live:
+ initial_snapshot = live.read_next_snapshot_response()
+
+# The same raw-client request surface can ride RUDP when the peer advertises a
+# rudp:// endpoint and shares the connection id for that lane.
+rudp_client = CultMesh.create_client(
+ endpoint="rudp://127.0.0.1:3076",
+ connection_id=0x43554C54,
+)
+rudp_catalog = rudp_client.fetch_schema_catalog(kinds=["wire_message"])
+
+claim_hash = compute_simulation_claim_hash("frame:42", "subject:player-1", "hit")
+observation = simulation_observation(
+ message_id="obs-1",
+ witness_runtime_id="python-runtime",
+ shard_id="interop",
+ shard_epoch=1,
+ frame=42,
+ subject_id="player-1",
+ claim_kind="hit",
+ claim_hash=claim_hash,
+)
+typed_observation = CultNetSimulationObservation.from_wire(observation.to_wire())
+witness = witness_artifact_bundle(
+ bundle_id="bundle-1",
+ witness_kind="interop-proof",
+ captured_at="2026-06-13T00:00:00Z",
+ subject={"documentType": "cultnet.interop-note", "subjectId": "note:python"},
+ contracts=[{"role": "payload", "schemaId": "cultnet.interop-note"}],
+ artifacts=[{"role": "log", "uri": "cultcache://bundle-1/log", "mediaType": "text/plain"}],
+ provenance={"pipelineId": "interop", "runId": "run-1", "runtimeId": "python-runtime"},
+)
+typed_witness = CultNetWitnessArtifactBundle.from_wire(witness)
+witness_payload = typed_witness.to_payload()
+
+security = CultNetServerSecurityOptions.development()
+token = CultNetSecret.create_session_token(
+ "318fb4b6-ff5e-4c4f-b911-d81807de53a8",
+ datetime(2035, 1, 1, tzinfo=UTC),
+ security,
+ session_version=1,
+)
+session = CultNetSecret.try_validate_session_token(token, security)
+```
+
+AES-GCM string/byte encryption helpers are available through the same
+`CultNetSecret` surface when the optional `crypto` extra is installed.
+
+The peer can serve, dial, and probe the same raw-state interop lane used by the
+TypeScript, Rust, and C# test peers:
+
+```powershell
+python -m cultnet_py.interop_peer serve --runtime-id python-peer --runtime-kind python --display-name "Python Peer" --agent-id python-agent --advertise-host 127.0.0.1 --tcp-port 3075 --discovery-port 4075 --discovery-group 239.77.44.11 --schema-path ..\cultnet-ts\integration\contracts\cultnet.interop-note.schema.json
+python -m cultnet_py.interop_peer dial --runtime-id python-client --runtime-kind python --display-name "Python Client" --agent-id python-client --target-host 127.0.0.1 --target-port 3075 --schema-path ..\cultnet-ts\integration\contracts\cultnet.interop-note.schema.json
+python -m cultnet_py.interop_peer probe --runtime-id python-prober --discovery-port 4075 --discovery-group 239.77.44.11
+```
+
+`cultmesh_py` includes a local cache-backed node, schema-v0 helpers for the
+CultMesh Verse catalog and peer exchange wire messages, local and verified
+authority lease checks, stream transport negotiation, committed simulation fact
+documents, and local prediction reconciliation:
+
+```python
+from datetime import UTC, datetime, timedelta
+
+from cultcache_py import define_database_entry_type
+from cultnet_py import (
+ CultNetClientAuthorityScope,
+ CultNetFileShardReplicaCursorStore,
+ CultNetRawClient,
+ CultNetSchemaShardLogFetcher,
+ CultNetSchemaShardSnapshotFetcher,
+ CultNetShardDescriptor,
+ CultNetShardReplicator,
+ CultNetShardReplicatorOptions,
+)
+from cultmesh_py import (
+ CultMesh,
+ CultMeshAuthorityLease,
+ CultMeshGameSessionOptions,
+ CultMeshHmacAuthorityLeaseVerifier,
+ CultMeshNodeOptions,
+ CultMeshSnapshotFanout,
+ peer_exchange_request,
+)
+
+note_doc = define_database_entry_type(
+ "mesh.note",
+ [("body", 0), ("name", 1), ("kind", 2)],
+ name="name",
+ indexes={"kind": "kind"},
+)
+node = CultMesh.start_node(
+ "mesh.cc",
+ runtime_id="python-runtime",
+ options=CultMeshNodeOptions(enable_durable_shard_logs=True),
+)
+node.database.register_document(note_doc)
+unsubscribe = node.database.watch_record(note_doc, "note:1", lambda change: print(change.change_kind))
+unsubscribe_named = node.database.watch_by_name(note_doc, "intro", lambda change: print(change.record_key))
+unsubscribe_kind = node.database.watch_by_index(note_doc, "kind", "guide", lambda change: print(change.record_key))
+node.database.put(note_doc, "note:1", {"body": "hello", "name": "intro", "kind": "guide"})
+live_note = node.database.get_required(note_doc, "note:1")
+put_message = node.database.put_raw_message(
+ note_doc,
+ "note:2",
+ {"body": "wire me", "name": "wire", "kind": "interop"},
+ shard_id="interop",
+ shard_epoch=1,
+)
+snapshot_response = node.database.build_snapshot_response(schema_ids=[note_doc.catalog_entry().schema_id])
+snapshot_wire = node.database.create_snapshot_response(schema_ids=[note_doc.catalog_entry().schema_id])
+delete_message = node.database.delete_raw_message(note_doc, "note:2", shard_id="interop", shard_epoch=1)
+shard_log_response = node.database.build_shard_log_response(shard_id="interop", shard_epoch=1)
+shard_log_wire = node.database.create_shard_log_response(shard_id="interop", shard_epoch=1)
+
+station_stock_doc = define_database_entry_type(
+ "aetheria.station_stock",
+ [("missiles", 0), ("coolant", 1)],
+ schema_id="gamecult.aetheria.station_stock.v1",
+)
+station_stock_ui_doc = define_database_entry_type(
+ "aetheria.station_stock.ui",
+ [("missiles", 0), ("coolant", 1)],
+ schema_id="gamecult.aetheria.station_stock.v1",
+)
+node.database.register_document(station_stock_doc)
+node.database.put(station_stock_ui_doc, "station:starbridge:stock", {"missiles": 24, "coolant": 80})
+stock = node.database.get_required(station_stock_ui_doc, "station:starbridge:stock")
+
+peers = CultMesh.create_peer_catalog()
+response = peers.create_response(peer_exchange_request("pex-1", verse_id="local"))
+lease_verifier = CultMeshHmacAuthorityLeaseVerifier({"odin": b"shared-lease-key"})
+lease_catalog = CultMesh.create_authority_lease_catalog(
+ signature_verifier=lease_verifier.verify,
+ require_verified_signatures=True,
+)
+lease_valid_from = datetime.now(UTC)
+lease = lease_verifier.issue(CultMeshAuthorityLease(
+ lease_id="lease:python-runtime",
+ verse_id="local",
+ peer_id="python-runtime",
+ roles=("shard-primary",),
+ valid_from=lease_valid_from,
+ expires_at=lease_valid_from + timedelta(minutes=5),
+ shard_ids=("interop",),
+ issuer_runtime_id="odin",
+))
+lease_catalog.upsert(lease)
+session = CultMesh.create_game_session(
+ node,
+ CultMeshGameSessionOptions(
+ client_authority_scopes=(
+ CultNetClientAuthorityScope("python-runtime", schema_ids=(note_doc.catalog_entry().schema_id,), key_prefix="input:python"),
+ ),
+ ),
+)
+server = CultMesh.serve_node(
+ node,
+ peer_catalog=peers,
+ observation_hub=session.observation_hub,
+ port=3075,
+ max_snapshot_documents=1000,
+ max_snapshot_bytes=4 * 1024 * 1024,
+)
+```
+
+The same local server can be launched as a package daemon when another runtime
+or operator process needs a CultNet endpoint without embedding Python glue:
+
+```powershell
+python -m cultmesh_py.daemon --runtime-id python-runtime --display-name "Python Runtime" --host 127.0.0.1 --port 3075 --cache-file mesh.cc --enable-durable-shard-logs --seed-interop-note --seed-shard-id interop --verse-id local --role shard-primary --ready-file mesh.ready.json
+```
+
+The ready file and stdout line are process readiness hints. The served state and
+capability truth still live on the framed CultNet/CultMesh endpoint. Use
+`--register-interop-note` to expose the package interop-note schema without
+seeding a note, or `--seed-interop-note` to register it and publish one local
+record for wire probes. Seeded records are committed through the raw mutation
+path, so peers can read them through both snapshot and shard-log catch-up. Add
+`--verse-id` and one or more `--role` values to publish the launched endpoint in
+its own Verse and peer catalogs for discovery clients. When the daemon is
+launched with `--cache-file`, `--enable-durable-shard-logs`, and
+`--shard-log-file`, a later daemon process can rehydrate and serve the persisted
+snapshot and shard log without reseeding.
+
+`CultMeshPeerHealthMonitor` probes peer-card endpoints with `cultnet.hello.v0`
+and preserves the runtime id, display name, document types, message versions,
+and mutation contracts reported by the peer.
+
+```python
+
+client = CultMesh.create_verse_discovery_client("127.0.0.1", 3075)
+verses = client.fetch_verses(transport_version="cultmesh.v0")
+mesh_peers = client.fetch_peers(verse_id="python-interop", roles=["read-replica"])
+client.sync_peer_catalog(peers, verse_id="python-interop", roles=["read-replica"])
+client.fanout_peer_catalog(peers, verse_id="python-interop", roles=["read-replica"])
+replica_notes = client.sync_documents(
+ node.database,
+ peers,
+ verse_id="python-interop",
+ roles=["read-replica"],
+ documents=[(note_doc, "note:remote")],
+)
+snapshot_fanout = CultMeshSnapshotFanout(
+ client,
+ node.database,
+ peers,
+ verse_id="python-interop",
+ roles=["read-replica"],
+ documents=[(note_doc, "note:remote")],
+ on_document=lambda note: print(note),
+)
+snapshot_fanout.sync_once()
+
+raw_client = CultNetRawClient("127.0.0.1", 3075)
+synced_note = node.database.sync_document(raw_client, note_doc, "note:remote")
+synced_stock = CultMesh.sync_document(
+ node,
+ raw_client,
+ station_stock_ui_doc,
+ "station:starbridge:stock",
+)
+with CultMesh.subscribe_document(
+ node,
+ raw_client,
+ station_stock_ui_doc,
+ "station:starbridge:stock",
+) as stock_subscription:
+ stock = stock_subscription.sync_initial()
+ next_change = stock_subscription.read_next_change()
+node.database.sync_shard_log(raw_client, shard_id="interop", shard_epoch=1)
+replicator = CultNetShardReplicator(
+ node.database,
+ CultNetShardReplicatorOptions(
+ fetcher=CultNetSchemaShardLogFetcher(),
+ snapshot_fetcher=CultNetSchemaShardSnapshotFetcher(),
+ cursor_store=CultNetFileShardReplicaCursorStore("mesh.replica-cursors.msgpack"),
+ poll_interval_seconds=1.0,
+ on_error=lambda error: print(error),
+ ),
+)
+replica_shard = CultNetShardDescriptor(
+ shard_id="interop",
+ owner_runtime_id="primary-runtime",
+ epoch=1,
+ schema_ids=(note_doc.catalog_entry().schema_id,),
+ primary_endpoints=("cultnet://127.0.0.1:3075",),
+)
+replicator.pull_once(replica_shard)
+replicator.start([replica_shard])
+replicator.stop()
+server.stop()
+
+streams = CultMesh.create_stream_catalog()
+
+claim_hash = "accepted-claim-hash"
+facts = CultMesh.create_simulation_fact_committer(node)
+committed = facts.commit({
+ "shardId": "arena",
+ "shardEpoch": 4,
+ "frame": 100,
+ "subjectId": "bob",
+ "claimKind": "hit",
+ "claimHash": claim_hash,
+ "witnessCount": 2,
+ "supportWeight": 2.0,
+ "totalWeight": 2.0,
+ "confidence": 1.0,
+ "hasQuorum": True,
+})
+session_commits = session.submit_and_commit(typed_observation)
+prediction = session.predict(note_doc, "input:python:move", {"body": "predicted input"})
+unsubscribe()
+```
+
## Wire Parity
-See [docs/python-runtime-parity.md](../../docs/python-runtime-parity.md) for
-the evidence map across the three Python packages.
+See [PARITY.md](PARITY.md) for the current evidence map, gates, and things this
+package still does not claim.
-The store interop peer is `cultcache_py.interop`:
+The Python interop peer is `cultcache_py.interop`:
```powershell
python -m cultcache_py.interop write --file cache.cc --runtime-id python
python -m cultcache_py.interop read --file cache.cc
```
-`packages/cultcache-ts/test/cult-cache.test.ts` includes Python in the shared
-CultCache v1 parity matrix with TypeScript, Rust, and C#. `cultcache_py` ships
-a `py.typed` marker so downstream type checkers can inspect the package surface.
+Current receipts:
-## Tests
+- `packages/cultcache-ts/test/cult-cache.test.ts` includes Python in the shared
+ CultCache v1 parity matrix with TypeScript, Rust, and C#.
+- `packages/cultnet-ts/test/interop/cultnet-interop.test.ts` includes Python in
+ the live TS/Rust/C#/Python schema-v0 peer ring: discovery, hello, schema
+ catalog, wire-message catalog discovery, raw snapshot, raw document
+ put/delete, mutation receipt, fire-command receipt, database subscription
+ changes, shard catalog, shard log catch-up, simulation consensus candidates,
+ committed simulation fact snapshots, Python session prediction reconciliation,
+ and witness artifact bundle round-trips. The same test asks the Python peer
+ for CultMesh Verse catalog and peer exchange responses over the CultNet pipe,
+ then verifies the public `CultMeshDiscoveryClient` can fetch typed Python
+ Verse and peer descriptors from that live endpoint. It also verifies
+ `CultNetRawClient` can fetch the live Python peer's schema catalog, raw
+ snapshot, and shard catalog.
+- `packages/cultcache-py/tests/test_cultcache.py` covers Python CultMesh
+ Verse catalog, peer exchange, authority lease, stream negotiation, CultNet
+ helper shapes, raw snapshot/shard-log application, simulation claim hashing,
+ consensus aggregation, game-session fact commits, local prediction
+ reconciliation, committed simulation fact payload slots, the local CultMesh
+ server's TCP and RUDP schema request paths, and witness artifact bundle
+ payload slots.
+- `cultcache_py`, `cultnet_py`, and `cultmesh_py` ship `py.typed` markers so
+ downstream type checkers can inspect the package surface instead of treating
+ the runtime as an untyped xenos swamp.
+
+## Performance Baseline
+
+The package ships a lightweight benchmark for Python-owned hot paths. In a
+CultLib source checkout, it can also run the C# reference benchmark beside it:
```powershell
-$env:PYTHONPATH="$PWD\packages\cultcache-py\src"
-python -m unittest discover -s packages\cultcache-py\tests
+python -m cultcache_py.benchmark --records 1000 --json
+python -m cultcache_py.compare_csharp --records 1000 --json
+python -m cultcache_py.verify --json
```
-The suite runs with only this package on the path. If a test here needs
-`cultnet_py` or `cultmesh_py`, it belongs in that package.
+It reports slot-indexed `DatabaseEntry` encode/decode throughput, framed
+CultNet MessagePack parse throughput, raw snapshot application into a
+registered cache, and public `CultCache` upsert/get throughput. The C# compare
+command also runs `packages/cultcache-py/tools/GameCult.Caching.Benchmark` and
+reports median Python-to-C# ratios for the shared public cache operations. It
+uses three samples by default; pass `--samples` when you need a different
+evidence shape. Both runtimes precompute record keys before measured cache
+upsert/get loops so the comparison is about public cache access, not string
+construction noise. The public in-memory cache owner stores records in
+type-partitioned dictionaries so hot `get(...)` calls avoid per-call
+composite-key allocation. Treat the benchmark numbers as a local baseline, not
+a claim that Python matches the C# reference in every workload.
diff --git a/packages/cultcache-py/pyproject.toml b/packages/cultcache-py/pyproject.toml
index 6c46d102..93ebf849 100644
--- a/packages/cultcache-py/pyproject.toml
+++ b/packages/cultcache-py/pyproject.toml
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
[project]
name = "cultcache-py"
-version = "0.3.0"
-description = "Python CultCache runtime for GameCult typed state."
+version = "0.1.0"
+description = "Python CultCache, CultNet, and CultMesh runtime package for GameCult typed state."
readme = "README.md"
requires-python = ">=3.10"
-license = "MIT"
+license = { text = "Private GameCult infrastructure" }
authors = [{ name = "GameCult" }]
classifiers = [
"Programming Language :: Python :: 3",
@@ -17,11 +17,23 @@ classifiers = [
"Programming Language :: Python :: 3.12",
]
dependencies = ["msgpack>=1.0"]
+
+[project.optional-dependencies]
+msgpack = ["msgpack>=1.0"]
+crypto = ["cryptography>=42"]
+
[project.scripts]
cultcache-py-interop = "cultcache_py.interop:main"
+cultnet-py-interop = "cultnet_py.interop_peer:main"
+cultcache-py-benchmark = "cultcache_py.benchmark:main"
+cultcache-py-compare-csharp = "cultcache_py.compare_csharp:main"
+cultcache-py-verify = "cultcache_py.verify:main"
+cultmesh-py-daemon = "cultmesh_py.daemon:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
cultcache_py = ["py.typed"]
+cultnet_py = ["py.typed"]
+cultmesh_py = ["py.typed"]
diff --git a/packages/cultnet-py/src/cultnet_py/benchmark.py b/packages/cultcache-py/src/cultcache_py/benchmark.py
similarity index 98%
rename from packages/cultnet-py/src/cultnet_py/benchmark.py
rename to packages/cultcache-py/src/cultcache_py/benchmark.py
index 83834635..a444acd9 100644
--- a/packages/cultnet-py/src/cultnet_py/benchmark.py
+++ b/packages/cultcache-py/src/cultcache_py/benchmark.py
@@ -19,7 +19,7 @@ class BenchItem:
def main(argv: list[str] | None = None) -> int:
- parser = argparse.ArgumentParser(prog="cultnet-py-benchmark")
+ parser = argparse.ArgumentParser(prog="cultcache-py-benchmark")
parser.add_argument("--records", type=int, default=5000)
parser.add_argument("--json", action="store_true", dest="as_json")
args = parser.parse_args(argv)
diff --git a/packages/cultcache-py/src/cultcache_py/cache.py b/packages/cultcache-py/src/cultcache_py/cache.py
index 0da2c04a..f7ad1c12 100644
--- a/packages/cultcache-py/src/cultcache_py/cache.py
+++ b/packages/cultcache-py/src/cultcache_py/cache.py
@@ -1,52 +1,15 @@
from __future__ import annotations
-import functools
-import threading
-from dataclasses import dataclass, field, replace
-from typing import Any, Callable, Generic, TypeVar
+from dataclasses import dataclass, field
+from typing import Any, Generic, TypeVar
from .backing_store import BackingStore, CultCacheEnvelope
from .documents import DocumentDefinition, extract_value
T = TypeVar("T")
-F = TypeVar("F", bound=Callable[..., Any])
GLOBAL_KEY = "__global__"
-def _locked(method: F) -> F:
- """Runs a read holding the cache's lock. Reads are reentrant and may run inside a mutation."""
-
- @functools.wraps(method)
- def wrapper(self: "CultCache", *args: Any, **kwargs: Any) -> Any:
- with self._lock:
- return method(self, *args, **kwargs)
-
- return wrapper # type: ignore[return-value]
-
-
-def _mutating(method: F) -> F:
- """Runs an attach, pull, registration, write or delete holding the cache's lock, so its validation,
- store I/O and apply cannot interleave with another thread's. One started on a thread that is already
- inside a mutation of this cache (from a store call, decoder, encoder, extractor or updater) raises
- before touching anything."""
-
- @functools.wraps(method)
- def wrapper(self: "CultCache", *args: Any, **kwargs: Any) -> Any:
- if self._mutating_thread == threading.get_ident():
- raise CultCacheError(
- f"CultCache refuses a re-entrant {method.__name__}: a mutation cannot start from inside a store call, "
- "decoder, encoder, extractor or updater of another mutation on the same cache"
- )
- with self._lock:
- self._mutating_thread = threading.get_ident()
- try:
- return method(self, *args, **kwargs)
- finally:
- self._mutating_thread = None
-
- return wrapper # type: ignore[return-value]
-
-
class CultCacheError(RuntimeError):
pass
@@ -57,31 +20,12 @@ class _State:
documents_by_schema_name: dict[str, DocumentDefinition[Any]] = field(default_factory=dict)
values: dict[str, dict[str, Any]] = field(default_factory=dict)
envelopes: dict[str, dict[str, CultCacheEnvelope]] = field(default_factory=dict)
- stores_by_type: dict[str, BackingStore] = field(default_factory=dict)
- generic_store: BackingStore | None = None
+ stores_by_type: dict[str, list[BackingStore]] = field(default_factory=dict)
+ generic_stores: list[BackingStore] = field(default_factory=list)
name_extractors: dict[str, str | Any] = field(default_factory=dict)
index_extractors: dict[str, dict[str, str | Any]] = field(default_factory=dict)
names: dict[tuple[str, str], str] = field(default_factory=dict)
indexes: dict[tuple[str, str, str], str] = field(default_factory=dict)
- # The name and index values each held record produced when it was admitted. Removing a
- # record reads these and runs no extractor, so removal has no fallible step.
- lookup_keys: dict[str, dict[str, "_LookupKeys"]] = field(default_factory=dict)
- # Load compatibility shim: a global persisted under a key other than GLOBAL_KEY is held as that
- # type's global under GLOBAL_KEY, and its persisted key is kept here so the first write or delete
- # of that global removes it from the home store. Remove once no store holds a global under a key
- # other than GLOBAL_KEY.
- legacy_global_keys: dict[str, str] = field(default_factory=dict)
-
-
-@dataclass(frozen=True)
-class _LookupKeys:
- name: str | None
- indexes: dict[str, str]
-
-
-_Names = dict[tuple[str, str], str]
-_Indexes = dict[tuple[str, str, str], str]
-_LookupKeysByType = dict[str, dict[str, _LookupKeys]]
class CultCacheBuilder:
@@ -125,135 +69,46 @@ class CultCache:
def __init__(self) -> None:
self._state = _State()
- # Mutations hold this lock and refuse to start on the thread already inside one (see _mutating).
- # It is reentrant so a mutation's extractors, decoders and updaters may still read the cache.
- # Single-structure reads (get, get_all, get_envelope, key lookups) stay unlocked. The guarantee is
- # per call: each reads one dict under the GIL and sees it wholly before or after a write's install.
- # Two unlocked calls can straddle a write or pull, and see different states.
- self._lock = threading.RLock()
- self._mutating_thread: int | None = None
- @_mutating
def register_document_type(self, document: DocumentDefinition[Any]) -> None:
- self._register_document_type(document)
-
- def _register_document_type(self, document: DocumentDefinition[Any]) -> None:
if document.type in self._state.documents:
raise CultCacheError(f"Document type already registered: {document.type}")
schema_name = document.catalog_entry().schema_name
if schema_name in self._state.documents_by_schema_name:
raise CultCacheError(f"Document schema name already registered: {schema_name}")
- name_extractors = dict(self._state.name_extractors)
- index_extractors = {type: dict(indexes) for type, indexes in self._state.index_extractors.items()}
- if document.name is not None:
- name_extractors[document.type] = document.name
- if document.indexes:
- index_extractors.setdefault(document.type, {}).update(document.indexes)
- self._install_lookups(name_extractors, index_extractors)
self._state.documents[document.type] = document
self._state.documents_by_schema_name[schema_name] = document
+ if document.name is not None:
+ self.register_name_lookup(document, document.name)
+ for index, extractor in document.indexes.items():
+ self.register_index(document, index, extractor)
- @_mutating
def register_registry(self, documents: list[DocumentDefinition[Any]] | tuple[DocumentDefinition[Any], ...]) -> None:
for document in documents:
- self._register_document_type(document)
+ self.register_document_type(document)
- @_mutating
def register_name_lookup(self, document: DocumentDefinition[Any], extractor: str | Any) -> None:
self._assert_registered(document)
- name_extractors = dict(self._state.name_extractors)
- name_extractors[document.type] = extractor
- self._install_lookups(name_extractors, self._state.index_extractors)
+ self._state.name_extractors[document.type] = extractor
+ self._rebuild_indexes()
- @_mutating
def register_index(self, document: DocumentDefinition[Any], index: str, extractor: str | Any) -> None:
self._assert_registered(document)
- index_extractors = {type: dict(indexes) for type, indexes in self._state.index_extractors.items()}
- index_extractors.setdefault(document.type, {})[index] = extractor
- self._install_lookups(self._state.name_extractors, index_extractors)
+ self._state.index_extractors.setdefault(document.type, {})[index] = extractor
+ self._rebuild_indexes()
- def _install_lookups(
- self, name_extractors: dict[str, str | Any], index_extractors: dict[str, dict[str, str | Any]]
- ) -> None:
- """Derives every lookup under the candidate extractors, then installs extractors and lookups
- together. An extractor that raises on a held value installs nothing."""
- names, indexes, lookup_keys = self._derive_lookups(self._state.values, name_extractors, index_extractors)
- (
- self._state.name_extractors,
- self._state.index_extractors,
- self._state.names,
- self._state.indexes,
- self._state.lookup_keys,
- ) = (name_extractors, index_extractors, names, indexes, lookup_keys)
-
- @_mutating
def add_backing_store(self, store: BackingStore, types: list[str] | tuple[str, ...] | set[str]) -> None:
- """Makes the store home to the given types, or the generic store when there are none.
-
- A type has exactly one home store, and attaching cannot move the home of a record the cache holds.
- """
- types = list(types)
- if not types:
- self._add_generic_store(store)
- return
for type in types:
- if type in self._state.stores_by_type:
- raise CultCacheError(
- f"Document type {type} is already routed to another backing store; it cannot also route to this one"
- )
- routes = dict(self._state.stores_by_type)
- routes.update({type: store for type in types})
- self._refuse_home_moves(routes, self._state.generic_store)
- self._state.stores_by_type = routes
+ self._state.stores_by_type.setdefault(type, []).append(store)
- @_mutating
def add_generic_store(self, store: BackingStore) -> None:
- """Makes the store home to every type no other store claims. A cache has at most one."""
- self._add_generic_store(store)
+ self._state.generic_stores.append(store)
- def _add_generic_store(self, store: BackingStore) -> None:
- if self._state.generic_store is not None:
- raise CultCacheError(
- "Backing store would be a second generic store; name the types it is home to"
- )
- self._refuse_home_moves(self._state.stores_by_type, store)
- self._state.generic_store = store
-
- def _refuse_home_moves(self, routes: dict[str, BackingStore], generic: BackingStore | None) -> None:
- for type, values in self._state.values.items():
- if not values:
- continue
- before = self._state.stores_by_type.get(type, self._state.generic_store)
- after = routes.get(type, generic)
- if before is not after:
- raise CultCacheError(
- f"Attaching this backing store would move {type} from {self._describe(before, routes, generic)} "
- f"to {self._describe(after, routes, generic)}; attach routed stores before the generic store"
- )
-
- @staticmethod
- def _describe(store: BackingStore | None, routes: dict[str, BackingStore], generic: BackingStore | None) -> str:
- if store is None:
- return "memory"
- if store is generic:
- return "the generic store"
- return "the store routed to " + ", ".join(sorted(type for type, routed in routes.items() if routed is store))
-
- @_mutating
def pull_all_backing_stores(self) -> None:
- """Builds the complete next state (values, envelopes, name and index lookups) in local
- structures, running every check and every user extractor; the cache's state is replaced
- only once that build finishes, so a refused load admits nothing."""
- loaded_values: dict[str, dict[str, Any]] = {}
- loaded_envelopes: dict[str, dict[str, CultCacheEnvelope]] = {}
- loaded_legacy_global_keys: dict[str, str] = {}
- routes, generic = self._state.stores_by_type, self._state.generic_store
+ self._state.values.clear()
+ self._state.envelopes.clear()
seen_globals: set[str] = set()
- stores: list[BackingStore] = []
- for candidate in [*self._state.stores_by_type.values(), self._state.generic_store]:
- if candidate is not None and all(candidate is not store for store in stores):
- stores.append(candidate)
- for store in stores:
+ for store in [*self._all_specific_stores(), *self._state.generic_stores]:
for envelope in store.pull_all():
document = self._resolve_document_for_envelope(envelope)
if document is None:
@@ -267,34 +122,14 @@ class CultCache:
schema_id=envelope.schema_id,
catalog_entry=envelope.catalog_entry,
)
- home = routes.get(envelope.type, generic)
- if home is not store:
- raise CultCacheError(
- f"{envelope.type} record {envelope.key} was loaded from {self._describe(store, routes, generic)}, "
- f"but its home is {self._describe(home, routes, generic)}"
- )
if document.global_document:
- if envelope.type in seen_globals:
+ if envelope.type in seen_globals and envelope.key == GLOBAL_KEY:
raise CultCacheError(f"Duplicate global document for type: {envelope.type}")
seen_globals.add(envelope.type)
- if envelope.key != GLOBAL_KEY:
- loaded_legacy_global_keys[envelope.type] = envelope.key
- envelope = replace(envelope, key=GLOBAL_KEY)
value = document.decode_payload(envelope.payload)
- loaded_values.setdefault(envelope.type, {})[envelope.key] = value
- loaded_envelopes.setdefault(envelope.type, {})[envelope.key] = envelope
- loaded_names, loaded_indexes, loaded_lookup_keys = self._derive_lookups(
- loaded_values, self._state.name_extractors, self._state.index_extractors
- )
- # Plain attribute assignments: nothing fallible runs once the swap begins.
- (
- self._state.values,
- self._state.envelopes,
- self._state.names,
- self._state.indexes,
- self._state.lookup_keys,
- self._state.legacy_global_keys,
- ) = (loaded_values, loaded_envelopes, loaded_names, loaded_indexes, loaded_lookup_keys, loaded_legacy_global_keys)
+ self._state.values.setdefault(envelope.type, {})[envelope.key] = value
+ self._state.envelopes.setdefault(envelope.type, {})[envelope.key] = envelope
+ self._rebuild_indexes()
def get(self, document: DocumentDefinition[T], key: str) -> T | None:
self._assert_registered(document)
@@ -334,9 +169,6 @@ class CultCache:
self._assert_registered(document)
return self._state.names.get((document.type, name))
- # Name and index resolution read the lookup and then the values, two structures a pull swaps and a
- # write updates in sequence, so they hold the lock to resolve a key and its value from one state.
- @_locked
def get_by_name(self, document: DocumentDefinition[T], name: str) -> T | None:
key = self.get_key_by_name(document, name)
return None if key is None else self.get(document, key)
@@ -345,19 +177,17 @@ class CultCache:
self._assert_registered(document)
return self._state.indexes.get((document.type, index, value))
- @_locked
def get_by_index(self, document: DocumentDefinition[T], index: str, value: str) -> T | None:
key = self.get_key_by_index(document, index, value)
return None if key is None else self.get(document, key)
- @_mutating
def put(self, document: DocumentDefinition[T], key: str, value: T) -> None:
- self._put(document, key, value)
-
- def _put(self, document: DocumentDefinition[T], key: str, value: T) -> None:
self._assert_registered(document)
if document.global_document and key != GLOBAL_KEY:
raise CultCacheError(f"Global document {document.type} must use key {GLOBAL_KEY}")
+ values = self._state.values.setdefault(document.type, {})
+ envelopes = self._state.envelopes.setdefault(document.type, {})
+ old_value = values.get(key)
catalog_entry = document.catalog_entry()
envelope = CultCacheEnvelope.create(
key=key,
@@ -366,139 +196,100 @@ class CultCache:
schema_id=catalog_entry.schema_id,
catalog_entry=catalog_entry,
)
- self._write(document, [(envelope, value)], batch=False)
+ stores = self._stores_for_type(document.type)
+ for store in stores:
+ store.push(envelope)
+ if old_value is not None:
+ self._remove_value_indexes(document.type, key, old_value)
+ values[key] = value
+ envelopes[key] = envelope
+ self._add_value_indexes(document.type, key, value)
- @_mutating
def put_envelope(self, document: DocumentDefinition[T], envelope: CultCacheEnvelope) -> T:
self._assert_registered(document)
- self._check_envelope(document, envelope)
- value = document.decode_payload(envelope.payload)
- self._write(document, [(envelope, value)], batch=False)
- return value
-
- @_mutating
- def put_envelopes(self, document: DocumentDefinition[T], envelopes: list[CultCacheEnvelope]) -> list[T]:
- self._assert_registered(document)
- values: list[T] = []
- for envelope in envelopes:
- self._check_envelope(document, envelope)
- values.append(document.decode_payload(envelope.payload))
- self._write(document, list(zip(envelopes, values)), batch=True)
- return values
-
- @staticmethod
- def _check_envelope(document: DocumentDefinition[Any], envelope: CultCacheEnvelope) -> None:
if envelope.type != document.type:
raise CultCacheError(
f"Envelope type {envelope.type} does not match document type {document.type}"
)
- if not envelope.key or not envelope.key.strip():
- raise CultCacheError(f"Envelope key for document type {document.type} must be non-empty")
- if document.global_document and envelope.key != GLOBAL_KEY:
- raise CultCacheError(f"Global document {document.type} must use key {GLOBAL_KEY}")
+ values = self._state.values.setdefault(document.type, {})
+ envelopes = self._state.envelopes.setdefault(document.type, {})
+ old_value = values.get(envelope.key)
+ value = document.decode_payload(envelope.payload)
+ for store in self._stores_for_type(document.type):
+ store.push(envelope)
+ if old_value is not None:
+ self._remove_value_indexes(document.type, envelope.key, old_value)
+ values[envelope.key] = value
+ envelopes[envelope.key] = envelope
+ self._add_value_indexes(document.type, envelope.key, value)
+ return value
- def _write(
- self, document: DocumentDefinition[Any], records: list[tuple[CultCacheEnvelope, Any]], *, batch: bool
- ) -> None:
- """Validates every record (home and name and index extractors included) before the store is
- touched; once the store accepts, applying to the cache has no fallible step."""
- type = document.type
- name_extractor = self._state.name_extractors.get(type)
- index_extractors = self._state.index_extractors.get(type, {})
- admitted = [
- (envelope, value, self._lookup_keys_for(value, name_extractor, index_extractors))
- for envelope, value in records
- ]
- store = self._store_for_type(type)
- legacy_key = self._state.legacy_global_keys.get(type)
- if store is not None:
- if batch:
- store.push_all([envelope for envelope, _ in records])
- else:
- store.push(records[0][0])
- if legacy_key is not None:
- self._retire_legacy_global(store, type, legacy_key)
- self._state.legacy_global_keys.pop(type, None)
- values = self._state.values.setdefault(type, {})
- envelopes = self._state.envelopes.setdefault(type, {})
- lookup_keys = self._state.lookup_keys.setdefault(type, {})
- for envelope, value, keys in admitted:
- self._remove_lookups(type, envelope.key)
- values[envelope.key] = value
- envelopes[envelope.key] = envelope
- lookup_keys[envelope.key] = keys
- if keys.name is not None:
- self._state.names[(type, keys.name)] = envelope.key
- for index, index_value in keys.indexes.items():
- self._state.indexes[(type, index, index_value)] = envelope.key
+ def put_envelopes(self, document: DocumentDefinition[T], envelopes: list[CultCacheEnvelope]) -> list[T]:
+ self._assert_registered(document)
+ values: list[T] = []
+ for envelope in envelopes:
+ if envelope.type != document.type:
+ raise CultCacheError(
+ f"Envelope type {envelope.type} does not match document type {document.type}"
+ )
+ if document.global_document and envelope.key != GLOBAL_KEY:
+ raise CultCacheError(f"Global document {document.type} must use key {GLOBAL_KEY}")
+ values.append(document.decode_payload(envelope.payload))
- @staticmethod
- def _retire_legacy_global(store: BackingStore, type: str, legacy_key: str) -> None:
- """The first write of a global loaded from a legacy key removes that record in the same write. If
- the removal fails, the new record is taken back out, so the store keeps only the legacy record."""
- try:
- store.delete(type, legacy_key)
- except BaseException:
- try:
- store.delete(type, GLOBAL_KEY)
- except Exception: # noqa: BLE001 - the removal failure is the error reported
- pass
- raise
+ stores = self._stores_for_type(document.type)
+ for store in stores:
+ store.push_all(envelopes)
+ values_by_key = self._state.values.setdefault(document.type, {})
+ envelopes_by_key = self._state.envelopes.setdefault(document.type, {})
+ for envelope, value in zip(envelopes, values):
+ old_value = values_by_key.get(envelope.key)
+ if old_value is not None:
+ self._remove_value_indexes(document.type, envelope.key, old_value)
+ values_by_key[envelope.key] = value
+ envelopes_by_key[envelope.key] = envelope
+ self._add_value_indexes(document.type, envelope.key, value)
+ return values
- @_mutating
def put_global(self, document: DocumentDefinition[T], value: T) -> None:
self._assert_global(document)
- self._put(document, GLOBAL_KEY, value)
+ self.put(document, GLOBAL_KEY, value)
- @_mutating
def update(self, document: DocumentDefinition[T], key: str, updater: Any) -> T:
- return self._update(document, key, updater)
-
- def _update(self, document: DocumentDefinition[T], key: str, updater: Any) -> T:
current = self.get_required(document, key)
updated = updater(current)
- self._put(document, key, updated)
+ self.put(document, key, updated)
return updated
- @_mutating
def update_global(self, document: DocumentDefinition[T], updater: Any) -> T:
self._assert_global(document)
- return self._update(document, GLOBAL_KEY, updater)
+ return self.update(document, GLOBAL_KEY, updater)
- @_mutating
def delete(self, document: DocumentDefinition[Any], key: str) -> None:
- self._delete(document, key)
-
- def _delete(self, document: DocumentDefinition[Any], key: str) -> None:
self._assert_registered(document)
- store = self._store_for_type(document.type)
- legacy_key = self._state.legacy_global_keys.get(document.type) if key == GLOBAL_KEY else None
- if store is not None:
- store.delete(document.type, key if legacy_key is None else legacy_key)
- if legacy_key is not None:
- del self._state.legacy_global_keys[document.type]
- self._remove_lookups(document.type, key)
- for by_type in (self._state.values, self._state.envelopes, self._state.lookup_keys):
- held = by_type.get(document.type)
- if held is not None:
- held.pop(key, None)
- if not held:
- by_type.pop(document.type, None)
+ for store in self._stores_for_type(document.type):
+ store.delete(document.type, key)
+ values = self._state.values.get(document.type)
+ old_value = None if values is None else values.pop(key, None)
+ if values == {}:
+ self._state.values.pop(document.type, None)
+ envelopes = self._state.envelopes.get(document.type)
+ if envelopes is not None:
+ envelopes.pop(key, None)
+ if envelopes == {}:
+ self._state.envelopes.pop(document.type, None)
+ if old_value is not None:
+ self._remove_value_indexes(document.type, key, old_value)
- @_mutating
def delete_global(self, document: DocumentDefinition[Any]) -> None:
self._assert_global(document)
- self._delete(document, GLOBAL_KEY)
+ self.delete(document, GLOBAL_KEY)
- # Snapshots iterate dicts a concurrent write would resize mid-iteration, so they hold the lock.
- @_locked
def snapshot(self) -> dict[str, dict[str, Any]]:
out: dict[str, dict[str, Any]] = {}
for type, values in self._state.values.items():
out[type] = dict(values)
return out
- @_locked
def snapshot_envelopes(self) -> list[CultCacheEnvelope]:
return [
envelope
@@ -506,70 +297,75 @@ class CultCache:
for envelope in envelopes.values()
]
- def _store_for_type(self, type: str) -> BackingStore | None:
- """The store that claims the type, else the generic store.
+ def _stores_for_type(self, type: str) -> list[BackingStore]:
+ specific = self._state.stores_by_type.get(type)
+ return specific if specific else self._state.generic_stores
- Returns None only for a cache with no stores, which is an in-memory cache.
- """
- store = self._state.stores_by_type.get(type, self._state.generic_store)
- if store is None and self._state.stores_by_type:
- raise CultCacheError(f"No backing store is home to document type: {type}")
- return store
+ def _all_specific_stores(self) -> list[BackingStore]:
+ stores: list[BackingStore] = []
+ seen: set[int] = set()
+ for routed in self._state.stores_by_type.values():
+ for store in routed:
+ marker = id(store)
+ if marker not in seen:
+ stores.append(store)
+ seen.add(marker)
+ return stores
- @staticmethod
- def _lookup_keys_for(
- value: Any, name_extractor: str | Any | None, index_extractors: dict[str, str | Any]
- ) -> _LookupKeys:
- name = None if name_extractor is None else extract_value(value, name_extractor)
- indexes: dict[str, str] = {}
- for index, extractor in index_extractors.items():
+ def _rebuild_indexes(self) -> None:
+ self._state.names.clear()
+ self._state.indexes.clear()
+ for type, values in self._state.values.items():
+ for key, value in values.items():
+ self._add_value_indexes(type, key, value)
+
+ def _add_value_indexes(self, type: str, key: str, value: Any) -> None:
+ name_extractor = self._state.name_extractors.get(type)
+ if name_extractor is not None:
+ name = extract_value(value, name_extractor)
+ if name is not None:
+ self._state.names[(type, str(name))] = key
+ for index, extractor in self._state.index_extractors.get(type, {}).items():
index_value = extract_value(value, extractor)
if index_value is not None:
- indexes[index] = str(index_value)
- return _LookupKeys(None if name is None else str(name), indexes)
+ self._state.indexes[(type, index, str(index_value))] = key
- def _derive_lookups(
- self,
- values_by_type: dict[str, dict[str, Any]],
- name_extractors: dict[str, str | Any],
- index_extractors: dict[str, dict[str, str | Any]],
- ) -> tuple[_Names, _Indexes, _LookupKeysByType]:
- """Builds name and index lookups for the given values in fresh dicts, touching no cache state."""
- names: _Names = {}
- indexes: _Indexes = {}
- lookup_keys: _LookupKeysByType = {}
- for type, values in values_by_type.items():
- name_extractor = name_extractors.get(type)
- type_index_extractors = index_extractors.get(type, {})
- for key, value in values.items():
- keys = self._lookup_keys_for(value, name_extractor, type_index_extractors)
- lookup_keys.setdefault(type, {})[key] = keys
- if keys.name is not None:
- names[(type, keys.name)] = key
- for index, index_value in keys.indexes.items():
- indexes[(type, index, index_value)] = key
- return names, indexes, lookup_keys
+ def _remove_value_indexes(self, type: str, key: str, value: Any) -> None:
+ name_extractor = self._state.name_extractors.get(type)
+ if name_extractor is not None:
+ name = extract_value(value, name_extractor)
+ if name is not None:
+ self._remove_name_index(type, key, str(name))
+ for index, extractor in self._state.index_extractors.get(type, {}).items():
+ index_value = extract_value(value, extractor)
+ if index_value is not None:
+ self._remove_secondary_index(type, key, index, str(index_value))
- def _remove_lookups(self, type: str, key: str) -> None:
- """Removes the lookups the held record produced at admission, handing a name or index value to
- another held record that produced it too. Reads stored lookup keys only; runs no extractor."""
- held = self._state.lookup_keys.get(type, {})
- keys = held.get(key)
- if keys is None:
+ def _remove_name_index(self, type: str, key: str, name: str) -> None:
+ lookup_key = (type, name)
+ if self._state.names.get(lookup_key) != key:
return
- if keys.name is not None and self._state.names.get((type, keys.name)) == key:
- del self._state.names[(type, keys.name)]
- for candidate_key, candidate in held.items():
- if candidate_key != key and candidate.name == keys.name:
- self._state.names[(type, keys.name)] = candidate_key
- for index, index_value in keys.indexes.items():
- lookup_key = (type, index, index_value)
- if self._state.indexes.get(lookup_key) != key:
- continue
- del self._state.indexes[lookup_key]
- for candidate_key, candidate in held.items():
- if candidate_key != key and candidate.indexes.get(index) == index_value:
- self._state.indexes[lookup_key] = candidate_key
+ self._state.names.pop(lookup_key, None)
+ name_extractor = self._state.name_extractors.get(type)
+ if name_extractor is None:
+ return
+ for candidate_key, candidate in self._state.values.get(type, {}).items():
+ candidate_name = extract_value(candidate, name_extractor)
+ if candidate_key != key and candidate_name is not None and str(candidate_name) == name:
+ self._state.names[lookup_key] = candidate_key
+
+ def _remove_secondary_index(self, type: str, key: str, index: str, value: str) -> None:
+ lookup_key = (type, index, value)
+ if self._state.indexes.get(lookup_key) != key:
+ return
+ self._state.indexes.pop(lookup_key, None)
+ extractor = self._state.index_extractors.get(type, {}).get(index)
+ if extractor is None:
+ return
+ for candidate_key, candidate in self._state.values.get(type, {}).items():
+ candidate_value = extract_value(candidate, extractor)
+ if candidate_key != key and candidate_value is not None and str(candidate_value) == value:
+ self._state.indexes[lookup_key] = candidate_key
def _assert_registered(self, document: DocumentDefinition[Any]) -> None:
if self._state.documents.get(document.type) is not document:
diff --git a/packages/cultnet-py/src/cultnet_py/compare_csharp.py b/packages/cultcache-py/src/cultcache_py/compare_csharp.py
similarity index 99%
rename from packages/cultnet-py/src/cultnet_py/compare_csharp.py
rename to packages/cultcache-py/src/cultcache_py/compare_csharp.py
index a2654a33..8a9e4c5b 100644
--- a/packages/cultnet-py/src/cultnet_py/compare_csharp.py
+++ b/packages/cultcache-py/src/cultcache_py/compare_csharp.py
@@ -16,7 +16,7 @@ DEFAULT_PARITY_THRESHOLD = 0.90
def main(argv: list[str] | None = None) -> int:
- parser = argparse.ArgumentParser(prog="cultnet-py-compare-csharp")
+ parser = argparse.ArgumentParser(prog="cultcache-py-compare-csharp")
parser.add_argument("--records", type=int, default=5000)
parser.add_argument("--samples", type=int, default=DEFAULT_SAMPLE_COUNT)
parser.add_argument("--parity-threshold", type=float, default=DEFAULT_PARITY_THRESHOLD)
diff --git a/packages/cultmesh-py/src/cultmesh_py/verify.py b/packages/cultcache-py/src/cultcache_py/verify.py
similarity index 98%
rename from packages/cultmesh-py/src/cultmesh_py/verify.py
rename to packages/cultcache-py/src/cultcache_py/verify.py
index 068f5dc2..b6a85430 100644
--- a/packages/cultmesh-py/src/cultmesh_py/verify.py
+++ b/packages/cultcache-py/src/cultcache_py/verify.py
@@ -6,7 +6,7 @@ import json
from importlib import resources
from typing import Any
-from cultnet_py.benchmark import run_benchmark
+from .benchmark import run_benchmark
EXPECTED_EXPORTS: dict[str, tuple[str, ...]] = {
@@ -150,7 +150,7 @@ EXPECTED_EXPORTS: dict[str, tuple[str, ...]] = {
def main(argv: list[str] | None = None) -> int:
- parser = argparse.ArgumentParser(prog="cultmesh-py-verify")
+ parser = argparse.ArgumentParser(prog="cultcache-py-verify")
parser.add_argument("--records", type=int, default=64)
parser.add_argument("--json", action="store_true", dest="emit_json")
args = parser.parse_args(argv)
@@ -159,7 +159,7 @@ def main(argv: list[str] | None = None) -> int:
if args.emit_json:
print(json.dumps(result, indent=2, sort_keys=True))
else:
- print(f"cultmesh-py verify: {result['status']}")
+ print(f"cultcache-py verify: {result['status']}")
for check in result["checks"]:
print(f"- {check['name']}: {check['status']}")
return 0 if result["status"] == "ok" else 1
diff --git a/packages/cultmesh-py/src/cultmesh_py/__init__.py b/packages/cultcache-py/src/cultmesh_py/__init__.py
similarity index 95%
rename from packages/cultmesh-py/src/cultmesh_py/__init__.py
rename to packages/cultcache-py/src/cultmesh_py/__init__.py
index af4da272..95aee189 100644
--- a/packages/cultmesh-py/src/cultmesh_py/__init__.py
+++ b/packages/cultcache-py/src/cultmesh_py/__init__.py
@@ -14,10 +14,8 @@ from .node import (
CultMeshDatabase,
CultMeshDatabaseChange,
CultMeshDocumentPublicationSource,
- CultMeshDocumentWriter,
CultMeshNode,
CultMeshNodeOptions,
- CultMeshObservedDocument,
CultMeshPublicationDocumentBinding,
CultMeshReactiveDocument,
CultMeshReactiveDocumentOptions,
@@ -39,7 +37,7 @@ from .simulation import (
CultMeshSimulationFactCommitter,
simulation_fact_document,
)
-from cultnet_py.cultmesh_contracts import (
+from .wire import (
PEER_EXCHANGE_REQUEST,
PEER_EXCHANGE_RESPONSE,
VERSE_CATALOG_REQUEST,
@@ -70,10 +68,8 @@ __all__ = [
"CultMeshDatabase",
"CultMeshDatabaseChange",
"CultMeshDocumentPublicationSource",
- "CultMeshDocumentWriter",
"CultMesh",
"CultMeshPublicationDocumentBinding",
- "CultMeshObservedDocument",
"CultMeshReactiveDocument",
"CultMeshReactiveDocumentOptions",
"CultMeshReactiveDocumentReconciliation",
diff --git a/packages/cultmesh-py/src/cultmesh_py/client.py b/packages/cultcache-py/src/cultmesh_py/client.py
similarity index 99%
rename from packages/cultmesh-py/src/cultmesh_py/client.py
rename to packages/cultcache-py/src/cultmesh_py/client.py
index 2e1c3ab1..e69e0403 100644
--- a/packages/cultmesh-py/src/cultmesh_py/client.py
+++ b/packages/cultcache-py/src/cultmesh_py/client.py
@@ -9,7 +9,7 @@ from urllib.parse import urlparse
from cultcache_py.documents import DocumentDefinition
from cultnet_py import CultNetDatabaseSubscription, CultNetRawClient, CultNetRawSnapshotResponse, hello
-from cultnet_py.cultmesh_contracts import (
+from .wire import (
PEER_EXCHANGE_RESPONSE,
VERSE_CATALOG_RESPONSE,
CultMeshPeerCard,
diff --git a/packages/cultmesh-py/src/cultmesh_py/daemon.py b/packages/cultcache-py/src/cultmesh_py/daemon.py
similarity index 99%
rename from packages/cultmesh-py/src/cultmesh_py/daemon.py
rename to packages/cultcache-py/src/cultmesh_py/daemon.py
index d21d42d8..087dc218 100644
--- a/packages/cultmesh-py/src/cultmesh_py/daemon.py
+++ b/packages/cultcache-py/src/cultmesh_py/daemon.py
@@ -13,7 +13,7 @@ from cultnet_py import CultNetSimulationConsensusOptions, CultNetSimulationObser
from .facade import CultMesh
from .server import CultMeshLocalServer
-from cultnet_py.cultmesh_contracts import (
+from .wire import (
CultMeshPeerCard,
CultMeshPeerCatalog,
CultMeshVerseCatalog,
diff --git a/packages/cultmesh-py/src/cultmesh_py/facade.py b/packages/cultcache-py/src/cultmesh_py/facade.py
similarity index 97%
rename from packages/cultmesh-py/src/cultmesh_py/facade.py
rename to packages/cultcache-py/src/cultmesh_py/facade.py
index 53328aab..115259f3 100644
--- a/packages/cultmesh-py/src/cultmesh_py/facade.py
+++ b/packages/cultcache-py/src/cultmesh_py/facade.py
@@ -24,17 +24,17 @@ from cultnet_py import (
from .client import CultMeshDocumentSubscription, CultMeshPeerExchangeClient, CultMeshVerseDiscoveryClient
from .node import (
CultMeshDocumentPublicationSource,
- CultMeshDocumentWriter,
CultMeshNode,
CultMeshNodeOptions,
- CultMeshObservedDocument,
CultMeshPublicationDocumentBinding,
+ CultMeshReactiveDocument,
+ CultMeshReactiveDocumentOptions,
create_node,
)
from .server import CultMeshLocalServer
from .session import CultMeshGameSession, CultMeshGameSessionOptions
from .simulation import CultMeshSimulationFactCommitter
-from cultnet_py.cultmesh_contracts import (
+from .wire import (
AuthorityLeaseVerifier,
CultMeshAuthorityLeaseCatalog,
CultMeshPeerCard,
@@ -301,20 +301,13 @@ class CultMesh:
)
@staticmethod
- def authoritative_writer(
+ def reactive_document(
node: CultMeshNode,
document: DocumentDefinition[Any],
key: str,
- ) -> CultMeshDocumentWriter:
- return node.authoritative_writer(document, key)
-
- @staticmethod
- def observe_document(
- node: CultMeshNode,
- document: DocumentDefinition[Any],
- key: str,
- ) -> CultMeshObservedDocument:
- return node.observe_document(document, key)
+ options: CultMeshReactiveDocumentOptions | None = None,
+ ) -> CultMeshReactiveDocument:
+ return node.reactive_document(document, key, options)
@staticmethod
def publication_source_from_peer_snapshot(
diff --git a/packages/cultmesh-py/src/cultmesh_py/node.py b/packages/cultcache-py/src/cultmesh_py/node.py
similarity index 87%
rename from packages/cultmesh-py/src/cultmesh_py/node.py
rename to packages/cultcache-py/src/cultmesh_py/node.py
index 09ac2012..142fe226 100644
--- a/packages/cultmesh-py/src/cultmesh_py/node.py
+++ b/packages/cultcache-py/src/cultmesh_py/node.py
@@ -1,9 +1,6 @@
from __future__ import annotations
import threading
-import heapq
-import time
-import traceback
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
@@ -87,90 +84,10 @@ class CultMeshReactiveDocumentReconciliation:
@dataclass(frozen=True)
class CultMeshReactiveDocumentOptions:
flush_delay_seconds: float = 0.016
+ detect_local_changes: bool = True
replace_dirty_current_on_canonical_snapshot: bool = False
-class _CultMeshScheduledCall:
- def __init__(self, callback: Callable[[], None], wake: Callable[[], None]) -> None:
- self._callback: Callable[[], None] | None = callback
- self._wake = wake
- self._lock = threading.Lock()
-
- def cancel(self) -> None:
- with self._lock:
- self._callback = None
- self._wake()
-
- def take(self) -> Callable[[], None] | None:
- with self._lock:
- callback = self._callback
- self._callback = None
- return callback
-
- @property
- def cancelled(self) -> bool:
- with self._lock:
- return self._callback is None
-
-
-class _CultMeshReactiveScheduler:
- """One lazy daemon worker owns delayed reactive flushes for the process."""
-
- def __init__(self) -> None:
- self._condition = threading.Condition()
- self._queue: list[tuple[float, int, _CultMeshScheduledCall]] = []
- self._sequence = 0
- self._worker: threading.Thread | None = None
-
- def schedule(self, delay_seconds: float, callback: Callable[[], None]) -> _CultMeshScheduledCall:
- call = _CultMeshScheduledCall(callback, self._wake)
- with self._condition:
- self._sequence += 1
- heapq.heappush(
- self._queue,
- (time.monotonic() + max(0.0, delay_seconds), self._sequence, call),
- )
- if self._worker is None:
- self._worker = threading.Thread(
- target=self._run,
- name="cultmesh-reactive-scheduler",
- daemon=True,
- )
- self._worker.start()
- self._condition.notify()
- return call
-
- def _wake(self) -> None:
- with self._condition:
- self._condition.notify()
-
- def _run(self) -> None:
- while True:
- callback: Callable[[], None] | None = None
- with self._condition:
- while callback is None:
- while self._queue and self._queue[0][2].cancelled:
- heapq.heappop(self._queue)
- if not self._queue:
- self._condition.wait()
- continue
- deadline, _, call = self._queue[0]
- remaining = deadline - time.monotonic()
- if remaining > 0:
- self._condition.wait(remaining)
- continue
- heapq.heappop(self._queue)
- callback = call.take()
- if callback is not None:
- try:
- callback()
- except Exception:
- traceback.print_exc()
-
-
-_REACTIVE_SCHEDULER = _CultMeshReactiveScheduler()
-
-
@dataclass
class CultMeshNode:
cache: CultCache = field(default_factory=CultCache)
@@ -331,19 +248,13 @@ class CultMeshNode:
shard_epoch=shard_epoch,
)
- def authoritative_writer(
+ def reactive_document(
self,
document: DocumentDefinition[Any],
key: str,
- ) -> "CultMeshDocumentWriter":
- return self.database.authoritative_writer(document, key)
-
- def observe_document(
- self,
- document: DocumentDefinition[Any],
- key: str,
- ) -> "CultMeshObservedDocument":
- return self.database.observe_document(document, key)
+ options: CultMeshReactiveDocumentOptions | None = None,
+ ) -> "CultMeshReactiveDocument":
+ return self.database.reactive_document(document, key, options)
def sync_document_from_publication(
self,
@@ -833,27 +744,17 @@ class CultMeshDatabase:
)
return self.get_required(requested, key)
- def authoritative_writer(
+ def reactive_document(
self,
document: DocumentDefinition[Any],
key: str,
- ) -> "CultMeshDocumentWriter":
+ options: CultMeshReactiveDocumentOptions | None = None,
+ ) -> "CultMeshReactiveDocument":
if not key:
raise ValueError("key must be non-empty")
if not any(self._documents_alias(document, registered) for registered in self.documents):
self.register_document(document)
- return CultMeshDocumentWriter(self, document, key)
-
- def observe_document(
- self,
- document: DocumentDefinition[Any],
- key: str,
- ) -> "CultMeshObservedDocument":
- if not key:
- raise ValueError("key must be non-empty")
- if not any(self._documents_alias(document, registered) for registered in self.documents):
- self.register_document(document)
- return CultMeshObservedDocument(self, document, key)
+ return CultMeshReactiveDocument(self, document, key, options)
def sync_document_from_publication(
self,
@@ -1353,119 +1254,32 @@ def _document_matches_schema_id(document: DocumentDefinition[Any], schema_id: st
return _infer_schema_name(schema_id) == entry.schema_name
-class CultMeshDocumentWriter:
+class CultMeshReactiveDocument:
def __init__(
self,
database: CultMeshDatabase,
document: DocumentDefinition[Any],
key: str,
- ) -> None:
- self.database = database
- self.document = document
- self.key = key
-
- def write(self, value: Any) -> None:
- self.database.put(self.document, self.key, self._clone(value))
-
- def update(self, callback: Callable[[Any], Any | None]) -> Any:
- draft = self._clone(self.database.get_required(self.document, self.key))
- replacement = callback(draft)
- next_value = draft if replacement is None else replacement
- self.write(next_value)
- return self._clone(next_value)
-
- def reactive(
- self,
options: CultMeshReactiveDocumentOptions | None = None,
- ) -> "CultMeshReactiveDocument":
- return CultMeshReactiveDocument(self, options)
-
- def _clone(self, value: Any) -> Any:
- return self.document.decode_payload(self.document.encode_payload(value))
-
-
-class CultMeshObservedDocument:
- def __init__(
- self,
- database: CultMeshDatabase,
- document: DocumentDefinition[Any],
- key: str,
) -> None:
self._database = database
self._document = document
self._key = key
- self._lock = threading.RLock()
- self._disposed = False
- self._current = self._clone(database.get_required(document, key))
- self._unsubscribe = database.watch_record(document, key, self._apply_canonical_change)
-
- @property
- def current(self) -> Any:
- with self._lock:
- self._raise_if_disposed()
- return self._current
-
- def refresh(self) -> Any:
- with self._lock:
- self._raise_if_disposed()
- self._current = self._clone(
- self._database.get_required(self._document, self._key)
- )
- return self._current
-
- def dispose(self) -> None:
- with self._lock:
- if self._disposed:
- return
- self._disposed = True
- self._unsubscribe()
-
- def __enter__(self) -> "CultMeshObservedDocument":
- return self
-
- def __exit__(self, _exc_type: Any, _exc: Any, _tb: Any) -> None:
- self.dispose()
-
- def _apply_canonical_change(self, change: CultMeshDatabaseChange) -> None:
- if change.value is None:
- return
- with self._lock:
- if not self._disposed:
- self._current = self._clone(change.value)
-
- def _clone(self, value: Any) -> Any:
- return self._document.decode_payload(self._document.encode_payload(value))
-
- def _raise_if_disposed(self) -> None:
- if self._disposed:
- raise RuntimeError("CultMeshObservedDocument has been disposed")
-
-
-class CultMeshReactiveDocument:
- def __init__(
- self,
- writer: CultMeshDocumentWriter,
- options: CultMeshReactiveDocumentOptions | None = None,
- ) -> None:
- self.writer = writer
- self._database = writer.database
- self._document = writer.document
- self._key = writer.key
self._options = options or CultMeshReactiveDocumentOptions()
self._lock = threading.RLock()
self._disposed = False
self._dirty = False
self._flushing = False
self._flush_queued = False
- self._flush_timer: _CultMeshScheduledCall | None = None
+ self._flush_timer: threading.Timer | None = None
+ self._detect_timer: threading.Timer | None = None
self._reconciliation_version = 0
self.reconciliation: CultMeshReactiveDocumentReconciliation | None = None
- self._current = self._clone(self._database.get_required(self._document, self._key))
- self._unsubscribe = self._database.watch_record(
- self._document,
- self._key,
- self._apply_canonical_change,
- )
+ self.current = self._clone(self._database.get_required(document, key))
+ self._last_clean_payload = self._serialize(self.current)
+ self._unsubscribe = self._database.watch_record(document, key, self._apply_canonical_change)
+ if self._options.detect_local_changes:
+ self._schedule_detection()
@property
def document(self) -> DocumentDefinition[Any]:
@@ -1480,43 +1294,42 @@ class CultMeshReactiveDocument:
with self._lock:
return self._dirty
- @property
- def snapshot(self) -> Any:
+ def update(self, callback: Callable[[Any], None]) -> Any:
with self._lock:
self._raise_if_disposed()
- return self._clone(self._current)
-
- def update(self, callback: Callable[[Any], Any | None]) -> Any:
- with self._lock:
- self._raise_if_disposed()
- draft = self._clone(self._current)
- replacement = callback(draft)
- self._current = self._clone(draft if replacement is None else replacement)
+ callback(self.current)
self._dirty = True
self._schedule_flush_locked()
- return self._clone(self._current)
+ return self.current
- def replace_local(self, value: Any) -> Any:
+ def set_current(self, value: Any) -> Any:
+ with self._lock:
+ self._raise_if_disposed()
+ self.current = self._clone(value)
+ self._dirty = True
+ self._schedule_flush_locked()
+ return self.current
+
+ def mark_dirty(self) -> None:
with self._lock:
self._raise_if_disposed()
- self._current = self._clone(value)
self._dirty = True
self._schedule_flush_locked()
- return self._clone(self._current)
def refresh(self) -> Any:
with self._lock:
self._raise_if_disposed()
- self._current = self._clone(
- self._database.get_required(self._document, self._key)
- )
+ self.current = self._clone(self._database.get_required(self._document, self._key))
+ self._last_clean_payload = self._serialize(self.current)
self._dirty = False
self.reconciliation = None
- return self._clone(self._current)
+ return self.current
def flush(self) -> None:
with self._lock:
self._raise_if_disposed()
+ if not self._dirty:
+ self._detect_local_changes_locked()
if not self._dirty:
return
if self._flushing:
@@ -1527,14 +1340,16 @@ class CultMeshReactiveDocument:
self._flush_timer = None
self._flushing = True
self._dirty = False
- predicted = self._clone(self._current)
+ predicted = self._clone(self.current)
+ self._last_clean_payload = self._serialize(predicted)
try:
- self.writer.write(predicted)
+ self._database.put(self._document, self._key, predicted)
finally:
should_flush_again = False
with self._lock:
self._flushing = False
+ self._detect_local_changes_locked()
should_flush_again = self._flush_queued or self._dirty
self._flush_queued = False
if should_flush_again:
@@ -1552,6 +1367,8 @@ class CultMeshReactiveDocument:
self._disposed = True
if self._flush_timer is not None:
self._flush_timer.cancel()
+ if self._detect_timer is not None:
+ self._detect_timer.cancel()
self._unsubscribe()
def __enter__(self) -> "CultMeshReactiveDocument":
@@ -1566,13 +1383,11 @@ class CultMeshReactiveDocument:
with self._lock:
if self._disposed:
return
- replacement = None
+ canonical = self._clone(change.value)
if self._dirty or self._flushing:
- delta = self._create_delta(self._current, change.value)
+ predicted = self._clone(self.current)
+ delta = self._create_delta(predicted, canonical)
if delta:
- canonical = self._clone(change.value)
- predicted = self._clone(self._current)
- replacement = canonical
self._reconciliation_version += 1
self.reconciliation = CultMeshReactiveDocumentReconciliation(
canonical=canonical,
@@ -1585,20 +1400,45 @@ class CultMeshReactiveDocument:
self.reconciliation = None
if not self._options.replace_dirty_current_on_canonical_snapshot:
return
- self._current = (
- replacement
- if replacement is not None
- else self._clone(change.value)
- )
+ self.current = canonical
+ self._last_clean_payload = self._serialize(self.current)
self.reconciliation = None
+ def _schedule_detection(self) -> None:
+ delay = max(self._options.flush_delay_seconds, 0.001)
+ timer = threading.Timer(delay, self._detect_timer_elapsed)
+ timer.daemon = True
+ self._detect_timer = timer
+ timer.start()
+
+ def _detect_timer_elapsed(self) -> None:
+ with self._lock:
+ if self._disposed:
+ return
+ if not self._dirty and not self._flushing:
+ self._detect_local_changes_locked()
+ if self._dirty:
+ self._schedule_flush_locked()
+ if not self._disposed and self._options.detect_local_changes:
+ self._schedule_detection()
+
+ def _detect_local_changes_locked(self) -> None:
+ if self._serialize(self.current) != self._last_clean_payload:
+ self._dirty = True
+
def _schedule_flush_locked(self) -> None:
+ if self._options.flush_delay_seconds <= 0:
+ threading.Thread(target=self.flush, daemon=True).start()
+ return
if self._flush_timer is not None:
self._flush_timer.cancel()
- self._flush_timer = _REACTIVE_SCHEDULER.schedule(
- self._options.flush_delay_seconds,
- self.flush,
- )
+ timer = threading.Timer(self._options.flush_delay_seconds, self.flush)
+ timer.daemon = True
+ self._flush_timer = timer
+ timer.start()
+
+ def _serialize(self, value: Any) -> bytes:
+ return self._document.encode_payload(value)
def _clone(self, value: Any) -> Any:
return self._document.decode_payload(self._document.encode_payload(value))
diff --git a/packages/cultmesh-py/src/cultmesh_py/py.typed b/packages/cultcache-py/src/cultmesh_py/py.typed
similarity index 100%
rename from packages/cultmesh-py/src/cultmesh_py/py.typed
rename to packages/cultcache-py/src/cultmesh_py/py.typed
diff --git a/packages/cultmesh-py/src/cultmesh_py/server.py b/packages/cultcache-py/src/cultmesh_py/server.py
similarity index 98%
rename from packages/cultmesh-py/src/cultmesh_py/server.py
rename to packages/cultcache-py/src/cultmesh_py/server.py
index e0c7fdd0..d560cc83 100644
--- a/packages/cultmesh-py/src/cultmesh_py/server.py
+++ b/packages/cultcache-py/src/cultmesh_py/server.py
@@ -24,7 +24,7 @@ from cultnet_py import (
)
from .node import CultMeshNode
-from cultnet_py.cultmesh_contracts import (
+from .wire import (
PEER_EXCHANGE_REQUEST,
VERSE_CATALOG_REQUEST,
CultMeshPeerCatalog,
@@ -245,8 +245,6 @@ class CultMeshLocalServer:
result = peer.session.receive(packet, now_ms)
if result.reply is not None:
self._send_rudp_packet(rudp_socket, remote_addr, result.reply)
- for ready in result.ready_to_send:
- self._send_rudp_packet(rudp_socket, remote_addr, ready)
if result.disconnected:
peers.pop(remote_addr, None)
continue
@@ -267,12 +265,8 @@ class CultMeshLocalServer:
peer.session,
msgpack.packb(response, use_bin_type=True),
)
- if packet.reliable or packet.packet_type == CultNetRudpPacketType.DATA or result.delivered:
- self._send_rudp_packet(
- rudp_socket,
- remote_addr,
- peer.session.create_ack_for_received(packet.sequence),
- )
+ if packet.packet_type == CultNetRudpPacketType.DATA or result.delivered:
+ self._send_rudp_packet(rudp_socket, remote_addr, peer.session.create_ack())
def _poll_rudp_resends(
self,
diff --git a/packages/cultmesh-py/src/cultmesh_py/session.py b/packages/cultcache-py/src/cultmesh_py/session.py
similarity index 98%
rename from packages/cultmesh-py/src/cultmesh_py/session.py
rename to packages/cultcache-py/src/cultmesh_py/session.py
index c02696f0..7e4b1bc1 100644
--- a/packages/cultmesh-py/src/cultmesh_py/session.py
+++ b/packages/cultcache-py/src/cultmesh_py/session.py
@@ -23,7 +23,7 @@ from .simulation import (
CultMeshSimulationFactCommitter,
simulation_fact_document,
)
-from cultnet_py.cultmesh_contracts import CultMeshAuthorityLeaseCatalog, CultMeshPeerCatalog, CultMeshVerseCatalog
+from .wire import CultMeshAuthorityLeaseCatalog, CultMeshPeerCatalog, CultMeshVerseCatalog
if TYPE_CHECKING:
from .server import CultMeshLocalServer
diff --git a/packages/cultcache-py/src/cultmesh_py/simulation.py b/packages/cultcache-py/src/cultmesh_py/simulation.py
new file mode 100644
index 00000000..4b727fd5
--- /dev/null
+++ b/packages/cultcache-py/src/cultmesh_py/simulation.py
@@ -0,0 +1,135 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import UTC, datetime
+import hashlib
+from typing import Any
+
+from cultcache_py import define_database_entry_type
+from cultcache_py.documents import DocumentDefinition
+
+from .node import CultMeshNode
+
+SIMULATION_FACT_DOCUMENT_TYPE = "gamecult.mesh.simulation_fact"
+SIMULATION_FACT_SCHEMA_VERSION = "gamecult.mesh.simulation_fact.v1"
+
+
+@dataclass(frozen=True)
+class CultMeshSimulationFact:
+ fact_id: str
+ shard_id: str
+ shard_epoch: int
+ frame: int
+ subject_id: str
+ claim_kind: str
+ claim_hash: str
+ claim_summary: str | None
+ witness_count: int
+ support_weight: float
+ total_weight: float
+ confidence: float
+ committed_at: str
+
+ @staticmethod
+ def compute_fact_id(candidate: dict[str, Any]) -> str:
+ canonical = "\x1f".join([
+ _candidate_string(candidate, "shardId", "shard_id"),
+ str(_candidate_int(candidate, "shardEpoch", "shard_epoch")),
+ str(_candidate_int(candidate, "frame", "frame")),
+ _candidate_string(candidate, "subjectId", "subject_id"),
+ _candidate_string(candidate, "claimKind", "claim_kind"),
+ ])
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
+
+ @staticmethod
+ def create_record_key(candidate: dict[str, Any]) -> str:
+ return f"simulation:{CultMeshSimulationFact.compute_fact_id(candidate)}"
+
+ @staticmethod
+ def from_candidate(candidate: dict[str, Any], *, committed_at: str | None = None) -> "CultMeshSimulationFact":
+ return CultMeshSimulationFact(
+ fact_id=CultMeshSimulationFact.compute_fact_id(candidate),
+ shard_id=_candidate_string(candidate, "shardId", "shard_id"),
+ shard_epoch=_candidate_int(candidate, "shardEpoch", "shard_epoch"),
+ frame=_candidate_int(candidate, "frame", "frame"),
+ subject_id=_candidate_string(candidate, "subjectId", "subject_id"),
+ claim_kind=_candidate_string(candidate, "claimKind", "claim_kind"),
+ claim_hash=_candidate_string(candidate, "claimHash", "claim_hash"),
+ claim_summary=_candidate_optional_string(candidate, "claimSummary", "claim_summary"),
+ witness_count=_candidate_int(candidate, "witnessCount", "witness_count"),
+ support_weight=_candidate_float(candidate, "supportWeight", "support_weight"),
+ total_weight=_candidate_float(candidate, "totalWeight", "total_weight"),
+ confidence=_candidate_float(candidate, "confidence", "confidence"),
+ committed_at=committed_at or datetime.now(UTC).isoformat(),
+ )
+
+
+simulation_fact_document: DocumentDefinition[CultMeshSimulationFact] = define_database_entry_type(
+ SIMULATION_FACT_DOCUMENT_TYPE,
+ [
+ ("fact_id", 0),
+ ("shard_id", 1),
+ ("shard_epoch", 2),
+ ("frame", 3),
+ ("subject_id", 4),
+ ("claim_kind", 5),
+ ("claim_hash", 6),
+ ("claim_summary", 7, None),
+ ("witness_count", 8),
+ ("support_weight", 9),
+ ("total_weight", 10),
+ ("confidence", 11),
+ ("committed_at", 12),
+ ],
+ cls=CultMeshSimulationFact,
+ name="fact_id",
+ indexes={"shard": "shard_id", "frame": "frame", "subject": "subject_id", "claim_kind": "claim_kind"},
+ schema_name=SIMULATION_FACT_DOCUMENT_TYPE,
+ schema_version=SIMULATION_FACT_SCHEMA_VERSION,
+)
+
+
+@dataclass(frozen=True)
+class CultMeshSimulationFactCommit:
+ key: str
+ fact: CultMeshSimulationFact
+
+
+@dataclass
+class CultMeshSimulationFactCommitter:
+ node: CultMeshNode
+ document: DocumentDefinition[CultMeshSimulationFact] = simulation_fact_document
+
+ def __post_init__(self) -> None:
+ if all(document.type != self.document.type for document in self.node.documents):
+ self.node.register_document(self.document)
+
+ def commit(self, candidate: dict[str, Any], *, committed_at: str | None = None) -> CultMeshSimulationFactCommit:
+ if not bool(candidate.get("hasQuorum") if "hasQuorum" in candidate else candidate.get("has_quorum")):
+ raise ValueError("Simulation candidate cannot be committed before quorum")
+ key = CultMeshSimulationFact.create_record_key(candidate)
+ fact = CultMeshSimulationFact.from_candidate(candidate, committed_at=committed_at)
+ self.node.put(self.document, key, fact)
+ return CultMeshSimulationFactCommit(key=key, fact=fact)
+
+
+def _candidate_string(candidate: dict[str, Any], camel: str, snake: str) -> str:
+ value = candidate.get(camel, candidate.get(snake, ""))
+ if value is None:
+ return ""
+ return str(value)
+
+
+def _candidate_optional_string(candidate: dict[str, Any], camel: str, snake: str) -> str | None:
+ value = candidate.get(camel, candidate.get(snake))
+ if value is None:
+ return None
+ return str(value)
+
+
+def _candidate_int(candidate: dict[str, Any], camel: str, snake: str) -> int:
+ return int(candidate.get(camel, candidate.get(snake, 0)))
+
+
+def _candidate_float(candidate: dict[str, Any], camel: str, snake: str) -> float:
+ return float(candidate.get(camel, candidate.get(snake, 0.0)))
diff --git a/packages/cultnet-py/src/cultnet_py/cultmesh_contracts.py b/packages/cultcache-py/src/cultmesh_py/wire.py
similarity index 84%
rename from packages/cultnet-py/src/cultnet_py/cultmesh_contracts.py
rename to packages/cultcache-py/src/cultmesh_py/wire.py
index 91d0805b..9a666a1e 100644
--- a/packages/cultnet-py/src/cultnet_py/cultmesh_contracts.py
+++ b/packages/cultcache-py/src/cultmesh_py/wire.py
@@ -1,18 +1,14 @@
from __future__ import annotations
import base64
-import hashlib
import hmac
from dataclasses import dataclass, field, replace
-from datetime import UTC, datetime
+from datetime import datetime
from hashlib import sha256
from typing import Any, Callable, Mapping
import msgpack
-from cultcache_py import define_database_entry_type
-from cultcache_py.documents import DocumentDefinition
-
VERSE_CATALOG_REQUEST = "cultmesh.verse_catalog_request.v0"
VERSE_CATALOG_RESPONSE = "cultmesh.verse_catalog_response.v0"
@@ -617,104 +613,3 @@ def copy_budget_for(transport: str) -> str:
if transport == "inline-bytes":
return "opaque-runtime"
return "opaque-runtime"
-
-
-SIMULATION_FACT_DOCUMENT_TYPE = "gamecult.mesh.simulation_fact"
-SIMULATION_FACT_SCHEMA_VERSION = "gamecult.mesh.simulation_fact.v1"
-
-
-@dataclass(frozen=True)
-class CultMeshSimulationFact:
- fact_id: str
- shard_id: str
- shard_epoch: int
- frame: int
- subject_id: str
- claim_kind: str
- claim_hash: str
- claim_summary: str | None
- witness_count: int
- support_weight: float
- total_weight: float
- confidence: float
- committed_at: str
-
- @staticmethod
- def compute_fact_id(candidate: dict[str, Any]) -> str:
- canonical = "\x1f".join([
- _candidate_string(candidate, "shardId", "shard_id"),
- str(_candidate_int(candidate, "shardEpoch", "shard_epoch")),
- str(_candidate_int(candidate, "frame", "frame")),
- _candidate_string(candidate, "subjectId", "subject_id"),
- _candidate_string(candidate, "claimKind", "claim_kind"),
- ])
- return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
-
- @staticmethod
- def create_record_key(candidate: dict[str, Any]) -> str:
- return f"simulation:{CultMeshSimulationFact.compute_fact_id(candidate)}"
-
- @staticmethod
- def from_candidate(candidate: dict[str, Any], *, committed_at: str | None = None) -> "CultMeshSimulationFact":
- return CultMeshSimulationFact(
- fact_id=CultMeshSimulationFact.compute_fact_id(candidate),
- shard_id=_candidate_string(candidate, "shardId", "shard_id"),
- shard_epoch=_candidate_int(candidate, "shardEpoch", "shard_epoch"),
- frame=_candidate_int(candidate, "frame", "frame"),
- subject_id=_candidate_string(candidate, "subjectId", "subject_id"),
- claim_kind=_candidate_string(candidate, "claimKind", "claim_kind"),
- claim_hash=_candidate_string(candidate, "claimHash", "claim_hash"),
- claim_summary=_candidate_optional_string(candidate, "claimSummary", "claim_summary"),
- witness_count=_candidate_int(candidate, "witnessCount", "witness_count"),
- support_weight=_candidate_float(candidate, "supportWeight", "support_weight"),
- total_weight=_candidate_float(candidate, "totalWeight", "total_weight"),
- confidence=_candidate_float(candidate, "confidence", "confidence"),
- committed_at=committed_at or datetime.now(UTC).isoformat(),
- )
-
-
-simulation_fact_document: DocumentDefinition[CultMeshSimulationFact] = define_database_entry_type(
- SIMULATION_FACT_DOCUMENT_TYPE,
- [
- ("fact_id", 0),
- ("shard_id", 1),
- ("shard_epoch", 2),
- ("frame", 3),
- ("subject_id", 4),
- ("claim_kind", 5),
- ("claim_hash", 6),
- ("claim_summary", 7, None),
- ("witness_count", 8),
- ("support_weight", 9),
- ("total_weight", 10),
- ("confidence", 11),
- ("committed_at", 12),
- ],
- cls=CultMeshSimulationFact,
- name="fact_id",
- indexes={"shard": "shard_id", "frame": "frame", "subject": "subject_id", "claim_kind": "claim_kind"},
- schema_name=SIMULATION_FACT_DOCUMENT_TYPE,
- schema_version=SIMULATION_FACT_SCHEMA_VERSION,
-)
-
-
-def _candidate_string(candidate: dict[str, Any], camel: str, snake: str) -> str:
- value = candidate.get(camel, candidate.get(snake, ""))
- if value is None:
- return ""
- return str(value)
-
-
-def _candidate_optional_string(candidate: dict[str, Any], camel: str, snake: str) -> str | None:
- value = candidate.get(camel, candidate.get(snake))
- if value is None:
- return None
- return str(value)
-
-
-def _candidate_int(candidate: dict[str, Any], camel: str, snake: str) -> int:
- return int(candidate.get(camel, candidate.get(snake, 0)))
-
-
-def _candidate_float(candidate: dict[str, Any], camel: str, snake: str) -> float:
- return float(candidate.get(camel, candidate.get(snake, 0.0)))
diff --git a/packages/cultnet-py/src/cultnet_py/__init__.py b/packages/cultcache-py/src/cultnet_py/__init__.py
similarity index 79%
rename from packages/cultnet-py/src/cultnet_py/__init__.py
rename to packages/cultcache-py/src/cultnet_py/__init__.py
index b1734f25..ec54059d 100644
--- a/packages/cultnet-py/src/cultnet_py/__init__.py
+++ b/packages/cultcache-py/src/cultnet_py/__init__.py
@@ -7,34 +7,6 @@ from .client import (
create_rudp_schema_transport,
create_tcp_framed_schema_transport,
)
-from .cultmesh_contracts import (
- PEER_EXCHANGE_REQUEST,
- PEER_EXCHANGE_RESPONSE,
- SIMULATION_FACT_DOCUMENT_TYPE,
- SIMULATION_FACT_SCHEMA_VERSION,
- VERSE_CATALOG_REQUEST,
- VERSE_CATALOG_RESPONSE,
- AuthorityLeaseVerifier,
- CultMeshAuthorityLease,
- CultMeshAuthorityLeaseCatalog,
- CultMeshHmacAuthorityLeaseVerifier,
- CultMeshPeerCard,
- CultMeshPeerCatalog,
- CultMeshSimulationFact,
- CultMeshStreamCatalog,
- CultMeshStreamConsumerProfile,
- CultMeshStreamDescriptor,
- CultMeshStreamFrameHandle,
- CultMeshStreamNegotiation,
- CultMeshVerseCatalog,
- CultMeshVerseCompatibility,
- CultMeshVerseDescriptor,
- peer_exchange_request,
- peer_from_wire,
- simulation_fact_document,
- verse_catalog_request,
- verse_from_wire,
-)
from .framing import decode_frame, encode_frame, read_frame, write_frame
from .messages import (
CultNetMessage,
@@ -136,32 +108,6 @@ from .simulation import (
)
__all__ = [
- "PEER_EXCHANGE_REQUEST",
- "PEER_EXCHANGE_RESPONSE",
- "SIMULATION_FACT_DOCUMENT_TYPE",
- "SIMULATION_FACT_SCHEMA_VERSION",
- "VERSE_CATALOG_REQUEST",
- "VERSE_CATALOG_RESPONSE",
- "AuthorityLeaseVerifier",
- "CultMeshAuthorityLease",
- "CultMeshAuthorityLeaseCatalog",
- "CultMeshHmacAuthorityLeaseVerifier",
- "CultMeshPeerCard",
- "CultMeshPeerCatalog",
- "CultMeshSimulationFact",
- "CultMeshStreamCatalog",
- "CultMeshStreamConsumerProfile",
- "CultMeshStreamDescriptor",
- "CultMeshStreamFrameHandle",
- "CultMeshStreamNegotiation",
- "CultMeshVerseCatalog",
- "CultMeshVerseCompatibility",
- "CultMeshVerseDescriptor",
- "peer_exchange_request",
- "peer_from_wire",
- "simulation_fact_document",
- "verse_catalog_request",
- "verse_from_wire",
"CultNetMessage",
"CultNetWitnessArtifactBundle",
"CultNetDatabaseSubscription",
diff --git a/packages/cultnet-py/src/cultnet_py/client.py b/packages/cultcache-py/src/cultnet_py/client.py
similarity index 100%
rename from packages/cultnet-py/src/cultnet_py/client.py
rename to packages/cultcache-py/src/cultnet_py/client.py
diff --git a/packages/cultnet-py/src/cultnet_py/framing.py b/packages/cultcache-py/src/cultnet_py/framing.py
similarity index 100%
rename from packages/cultnet-py/src/cultnet_py/framing.py
rename to packages/cultcache-py/src/cultnet_py/framing.py
diff --git a/packages/cultnet-py/src/cultnet_py/interop_peer.py b/packages/cultcache-py/src/cultnet_py/interop_peer.py
similarity index 99%
rename from packages/cultnet-py/src/cultnet_py/interop_peer.py
rename to packages/cultcache-py/src/cultnet_py/interop_peer.py
index ca3c692d..d26f1b70 100644
--- a/packages/cultnet-py/src/cultnet_py/interop_peer.py
+++ b/packages/cultcache-py/src/cultnet_py/interop_peer.py
@@ -24,7 +24,7 @@ from cultcache_py import (
define_document_type,
)
from cultcache_py.documents import DocumentDefinition
-from .cultmesh_contracts import (
+from cultmesh_py import (
PEER_EXCHANGE_REQUEST,
SIMULATION_FACT_DOCUMENT_TYPE,
VERSE_CATALOG_REQUEST,
@@ -306,8 +306,6 @@ def rudp_loop(
result = peer.session.receive(packet, now_ms())
if result.reply is not None:
send_rudp_packet(sock, remote, result.reply)
- for ready in result.ready_to_send:
- send_rudp_packet(sock, remote, ready)
if result.disconnected:
peers.pop(remote, None)
continue
@@ -319,8 +317,8 @@ def rudp_loop(
continue
for response in handle_server_message(state, message, peer.subscriptions):
send_rudp_schema_frame(sock, remote, peer.session, response)
- if packet.reliable or packet.packet_type == CultNetRudpPacketType.DATA or result.delivered:
- send_rudp_packet(sock, remote, peer.session.create_ack_for_received(packet.sequence))
+ if packet.packet_type == CultNetRudpPacketType.DATA or result.delivered:
+ send_rudp_packet(sock, remote, peer.session.create_ack())
except Exception as error:
sys.stderr.write(json.dumps({
"event": "rudpMessageError",
diff --git a/packages/cultnet-py/src/cultnet_py/messages.py b/packages/cultcache-py/src/cultnet_py/messages.py
similarity index 100%
rename from packages/cultnet-py/src/cultnet_py/messages.py
rename to packages/cultcache-py/src/cultnet_py/messages.py
diff --git a/packages/cultnet-py/src/cultnet_py/prediction.py b/packages/cultcache-py/src/cultnet_py/prediction.py
similarity index 100%
rename from packages/cultnet-py/src/cultnet_py/prediction.py
rename to packages/cultcache-py/src/cultnet_py/prediction.py
diff --git a/packages/cultnet-py/src/cultnet_py/py.typed b/packages/cultcache-py/src/cultnet_py/py.typed
similarity index 100%
rename from packages/cultnet-py/src/cultnet_py/py.typed
rename to packages/cultcache-py/src/cultnet_py/py.typed
diff --git a/packages/cultnet-py/src/cultnet_py/replication.py b/packages/cultcache-py/src/cultnet_py/replication.py
similarity index 100%
rename from packages/cultnet-py/src/cultnet_py/replication.py
rename to packages/cultcache-py/src/cultnet_py/replication.py
diff --git a/packages/cultnet-py/src/cultnet_py/schema_catalog.py b/packages/cultcache-py/src/cultnet_py/schema_catalog.py
similarity index 100%
rename from packages/cultnet-py/src/cultnet_py/schema_catalog.py
rename to packages/cultcache-py/src/cultnet_py/schema_catalog.py
diff --git a/packages/cultnet-py/src/cultnet_py/security.py b/packages/cultcache-py/src/cultnet_py/security.py
similarity index 100%
rename from packages/cultnet-py/src/cultnet_py/security.py
rename to packages/cultcache-py/src/cultnet_py/security.py
diff --git a/packages/cultnet-py/src/cultnet_py/shard_catalog.py b/packages/cultcache-py/src/cultnet_py/shard_catalog.py
similarity index 100%
rename from packages/cultnet-py/src/cultnet_py/shard_catalog.py
rename to packages/cultcache-py/src/cultnet_py/shard_catalog.py
diff --git a/packages/cultnet-py/src/cultnet_py/shard_log.py b/packages/cultcache-py/src/cultnet_py/shard_log.py
similarity index 100%
rename from packages/cultnet-py/src/cultnet_py/shard_log.py
rename to packages/cultcache-py/src/cultnet_py/shard_log.py
diff --git a/packages/cultnet-py/src/cultnet_py/shard_replication.py b/packages/cultcache-py/src/cultnet_py/shard_replication.py
similarity index 100%
rename from packages/cultnet-py/src/cultnet_py/shard_replication.py
rename to packages/cultcache-py/src/cultnet_py/shard_replication.py
diff --git a/packages/cultnet-py/src/cultnet_py/simulation.py b/packages/cultcache-py/src/cultnet_py/simulation.py
similarity index 100%
rename from packages/cultnet-py/src/cultnet_py/simulation.py
rename to packages/cultcache-py/src/cultnet_py/simulation.py
diff --git a/packages/cultnet-py/src/cultnet_py/snapshot.py b/packages/cultcache-py/src/cultnet_py/snapshot.py
similarity index 100%
rename from packages/cultnet-py/src/cultnet_py/snapshot.py
rename to packages/cultcache-py/src/cultnet_py/snapshot.py
diff --git a/packages/cultnet-py/src/cultnet_py/subscription.py b/packages/cultcache-py/src/cultnet_py/subscription.py
similarity index 100%
rename from packages/cultnet-py/src/cultnet_py/subscription.py
rename to packages/cultcache-py/src/cultnet_py/subscription.py
diff --git a/packages/cultnet-py/src/cultnet_py/transport.py b/packages/cultcache-py/src/cultnet_py/transport.py
similarity index 82%
rename from packages/cultnet-py/src/cultnet_py/transport.py
rename to packages/cultcache-py/src/cultnet_py/transport.py
index 73bd1e62..25a4c891 100644
--- a/packages/cultnet-py/src/cultnet_py/transport.py
+++ b/packages/cultcache-py/src/cultnet_py/transport.py
@@ -280,7 +280,6 @@ class CultNetRudpDeliveredFrame:
@dataclass(frozen=True)
class CultNetRudpReceiveResult:
delivered: tuple[CultNetRudpDeliveredFrame, ...] = field(default_factory=tuple)
- ready_to_send: tuple[CultNetRudpPacket, ...] = field(default_factory=tuple)
reply: CultNetRudpPacket | None = None
pong: bool = False
pong_payload: bytes = b""
@@ -396,9 +395,6 @@ def create_rudp_transport_profile(
class CultNetRudpSession:
- RELIABLE_SEND_WINDOW_PACKETS = 32
- RECEIVED_SEQUENCE_WINDOW = 4_096
-
def __init__(self, options: CultNetRudpSessionOptions) -> None:
self.connection_id = _uint32(options.connection_id, "connection_id")
self.resend_delay_ms = options.resend_delay_ms
@@ -406,33 +402,20 @@ class CultNetRudpSession:
raise ValueError("RUDP max_pending_reliable_packets must be greater than zero")
self.max_pending_reliable_packets = options.max_pending_reliable_packets
self._next_sequence = _uint32(options.initial_sequence, "initial_sequence")
- self._next_sequenced_by_channel: dict[str, int] = {}
self._next_fragment_id = 1
self._connected = False
self._last_received_at_ms: int | None = None
self._highest_received_sequence: int | None = None
self._received_sequences: set[int] = set()
- self._latest_sequenced_by_channel: dict[str, int] = {}
self._pending_reliable: dict[int, _PendingReliablePacket] = {}
- self._queued_reliable: deque[CultNetRudpPacket] = deque()
self._ordered_next_sequence_by_channel: dict[str, int] = {}
self._ordered_buffers: dict[str, dict[int, tuple[CultNetRudpDeliveredFrame, int]]] = {}
self._fragment_buffers: dict[tuple[str, int], dict[str, Any]] = {}
- # Matches cultnet-rs max_pending_fragment_sets. A set that loses one
- # fragment is never completed; without a bound it is kept for the life
- # of the session and the map grows under any loss on a fragmenting channel.
- self._max_pending_fragment_sets = 64
- self._fragment_sets_evicted = 0
@property
def connected(self) -> bool:
return self._connected
- @property
- def fragment_sets_evicted(self) -> int:
- """Incomplete fragment sets dropped to admit newer ones; one lost payload each."""
- return self._fragment_sets_evicted
-
@property
def last_received_at_ms(self) -> int | None:
return self._last_received_at_ms
@@ -441,14 +424,6 @@ class CultNetRudpSession:
def pending_reliable_sequences(self) -> tuple[int, ...]:
return tuple(sorted(self._pending_reliable))
- @property
- def queued_reliable_packet_count(self) -> int:
- return len(self._queued_reliable)
-
- @property
- def outstanding_reliable_packet_count(self) -> int:
- return len(self._pending_reliable) + len(self._queued_reliable)
-
def create_connect(self, now_ms: int = 0, payload: bytes = b"") -> CultNetRudpPacket:
self._ensure_reliable_capacity(1)
packet = self._create_packet(
@@ -490,10 +465,7 @@ class CultNetRudpSession:
payload: bytes,
options: CultNetRudpSendOptions | None = None,
) -> CultNetRudpPacket:
- resolved = options or CultNetRudpSendOptions()
- if resolved.reliable and len(self._pending_reliable) >= self.RELIABLE_SEND_WINDOW_PACKETS:
- raise ValueError("RUDP reliable send window is full; receive acknowledgements before sending")
- return self.send_many(channel_id, payload, resolved)[0]
+ return self.send_many(channel_id, payload, options)[0]
def send_many(
self,
@@ -507,8 +479,6 @@ class CultNetRudpSession:
raise ValueError("Cannot send RUDP data before the session is connected")
options = options or CultNetRudpSendOptions()
- if options.ordered and not options.reliable:
- raise ValueError("RUDP ordered delivery requires reliability")
if max_fragment_bytes is not None and max_fragment_bytes <= 0:
raise ValueError("RUDP max_fragment_bytes must be greater than zero")
@@ -522,7 +492,9 @@ class CultNetRudpSession:
ordered=options.ordered,
sequenced=options.sequenced,
)
- return self._admit_reliable_packets((packet,), options.now_ms) if packet.reliable else (packet,)
+ if packet.reliable:
+ self._track_reliable(packet, options.now_ms)
+ return (packet,)
fragment_count = (len(payload) + max_fragment_bytes - 1) // max_fragment_bytes
if fragment_count > 0xFFFF:
@@ -544,13 +516,14 @@ class CultNetRudpSession:
fragment_index=index,
fragment_count=fragment_count,
)
+ if packet.reliable:
+ self._track_reliable(packet, options.now_ms)
packets.append(packet)
- return self._admit_reliable_packets(tuple(packets), options.now_ms) if options.reliable else tuple(packets)
+ return tuple(packets)
def receive(self, packet: CultNetRudpPacket, now_ms: int = 0) -> CultNetRudpReceiveResult:
self._require_connection(packet)
self._apply_acknowledgements(packet)
- ready_to_send = self._promote_queued_reliable(now_ms)
self._last_received_at_ms = now_ms
expected_sequence_if_uninitialized = (
packet.sequence
@@ -561,93 +534,51 @@ class CultNetRudpSession:
if packet.packet_type == CultNetRudpPacketType.ACCEPT:
self._remember_received(packet.sequence)
self._connected = True
- return CultNetRudpReceiveResult(ready_to_send=ready_to_send)
+ return CultNetRudpReceiveResult()
if packet.packet_type == CultNetRudpPacketType.PING:
+ self._remember_received(packet.sequence)
return CultNetRudpReceiveResult(
- ready_to_send=ready_to_send,
reply=self._create_packet(CultNetRudpPacketType.PONG, "control", packet.payload)
)
if packet.packet_type in (CultNetRudpPacketType.ACK, CultNetRudpPacketType.PONG):
+ self._remember_received(packet.sequence)
return CultNetRudpReceiveResult(
- ready_to_send=ready_to_send,
pong=packet.packet_type == CultNetRudpPacketType.PONG,
pong_payload=bytes(packet.payload) if packet.packet_type == CultNetRudpPacketType.PONG else b"",
)
if packet.packet_type == CultNetRudpPacketType.DISCONNECT:
+ self._remember_received(packet.sequence)
self._connected = False
return CultNetRudpReceiveResult(
- ready_to_send=ready_to_send,
disconnected=True,
disconnect_reason=bytes(packet.payload),
)
if packet.packet_type != CultNetRudpPacketType.DATA:
- return CultNetRudpReceiveResult(ready_to_send=ready_to_send)
+ return CultNetRudpReceiveResult()
- duplicate = packet.reliable and (
- packet.sequence in self._received_sequences or (
- self._highest_received_sequence is not None
- and packet.sequence < self._highest_received_sequence
- and self._highest_received_sequence - packet.sequence >= self.RECEIVED_SEQUENCE_WINDOW
- )
- )
- if packet.reliable:
- self._remember_received(packet.sequence)
+ duplicate = packet.sequence in self._received_sequences
+ self._remember_received(packet.sequence)
if duplicate:
- return CultNetRudpReceiveResult(ready_to_send=ready_to_send)
+ return CultNetRudpReceiveResult()
reassembled = self._reassemble(packet)
if reassembled is None:
- return CultNetRudpReceiveResult(ready_to_send=ready_to_send)
+ return CultNetRudpReceiveResult()
frame, ordered, next_sequence = reassembled
- if not ordered and packet.sequenced:
- newest_sequence = next_sequence - 1
- latest_sequence = self._latest_sequenced_by_channel.get(frame.channel_id)
- if latest_sequence is not None and newest_sequence <= latest_sequence:
- return CultNetRudpReceiveResult(ready_to_send=ready_to_send)
- self._latest_sequenced_by_channel[frame.channel_id] = newest_sequence
- return CultNetRudpReceiveResult(delivered=(frame,), ready_to_send=ready_to_send)
if not ordered:
- return CultNetRudpReceiveResult(delivered=(frame,), ready_to_send=ready_to_send)
+ return CultNetRudpReceiveResult(delivered=(frame,))
return CultNetRudpReceiveResult(
delivered=tuple(
self._deliver_ordered(frame, next_sequence, expected_sequence_if_uninitialized)
- ),
- ready_to_send=ready_to_send,
+ )
)
def create_ack(self) -> CultNetRudpPacket:
- ack, ack_mask = self._ack_state()
- return CultNetRudpPacket(
- packet_type=CultNetRudpPacketType.ACK,
- connection_id=self.connection_id,
- sequence=0,
- ack=ack,
- ack_mask=ack_mask,
- channel_id="control",
- payload=b"",
- )
-
- def create_ack_for(self, sequence: int) -> CultNetRudpPacket:
- return CultNetRudpPacket(
- packet_type=CultNetRudpPacketType.ACK,
- connection_id=self.connection_id,
- sequence=0,
- ack=_uint32(sequence, "ack sequence"),
- ack_mask=0,
- channel_id="control",
- payload=b"",
- )
-
- def create_ack_for_received(self, sequence: int) -> CultNetRudpPacket:
- received_sequence = _uint32(sequence, "received sequence")
- ack, _ = self._ack_state()
- if ack >= received_sequence and ack - received_sequence <= 32:
- return self.create_ack()
- return self.create_ack_for(received_sequence)
+ return self._create_packet(CultNetRudpPacketType.ACK, "control", b"")
def create_ping(self, payload: bytes = b"") -> CultNetRudpPacket:
return self._create_packet(CultNetRudpPacketType.PING, "control", payload)
@@ -685,17 +616,8 @@ class CultNetRudpSession:
fragment_index: int = 0,
fragment_count: int = 0,
) -> CultNetRudpPacket:
- if reliable:
- sequence = self._next_sequence
- self._next_sequence = _uint32(self._next_sequence + 1, "reliable sequence")
- elif sequenced:
- sequence = self._next_sequenced_by_channel.get(channel_id, 1)
- self._next_sequenced_by_channel[channel_id] = _uint32(
- sequence + 1,
- "sequenced channel sequence",
- )
- else:
- sequence = 0
+ sequence = self._next_sequence
+ self._next_sequence = _uint32(self._next_sequence + 1, "sequence")
ack, ack_mask = self._ack_state()
return CultNetRudpPacket(
packet_type=packet_type,
@@ -733,31 +655,10 @@ class CultNetRudpSession:
last_sent_at_ms=now_ms,
)
- def _admit_reliable_packets(
- self,
- packets: tuple[CultNetRudpPacket, ...],
- now_ms: int,
- ) -> tuple[CultNetRudpPacket, ...]:
- available = max(0, self.RELIABLE_SEND_WINDOW_PACKETS - len(self._pending_reliable))
- ready = packets[:available]
- for packet in ready:
- self._track_reliable(packet, now_ms)
- self._queued_reliable.extend(packets[available:])
- return ready
-
- def _promote_queued_reliable(self, now_ms: int) -> tuple[CultNetRudpPacket, ...]:
- available = max(0, self.RELIABLE_SEND_WINDOW_PACKETS - len(self._pending_reliable))
- ready: list[CultNetRudpPacket] = []
- while len(ready) < available and self._queued_reliable:
- packet = self._queued_reliable.popleft()
- self._track_reliable(packet, now_ms)
- ready.append(packet)
- return tuple(ready)
-
def _ensure_reliable_capacity(self, packet_count: int) -> None:
if packet_count == 0 or self.max_pending_reliable_packets is None:
return
- if self.outstanding_reliable_packet_count + packet_count > self.max_pending_reliable_packets:
+ if len(self._pending_reliable) + packet_count > self.max_pending_reliable_packets:
raise ValueError("RUDP reliable send queue is full")
def _apply_acknowledgements(self, packet: CultNetRudpPacket) -> None:
@@ -770,9 +671,6 @@ class CultNetRudpSession:
self._received_sequences.add(sequence)
if self._highest_received_sequence is None or sequence > self._highest_received_sequence:
self._highest_received_sequence = sequence
- if len(self._received_sequences) > self.RECEIVED_SEQUENCE_WINDOW:
- keep_from = max(0, (self._highest_received_sequence or sequence) - self.RECEIVED_SEQUENCE_WINDOW + 1)
- self._received_sequences = {received for received in self._received_sequences if received >= keep_from}
def _ack_state(self) -> tuple[int, int]:
ack = self._highest_received_sequence or 0
@@ -795,11 +693,6 @@ class CultNetRudpSession:
raise ValueError("RUDP fragment index must be lower than fragment count")
key = (packet.channel_id, packet.fragment_id)
- if key not in self._fragment_buffers and len(self._fragment_buffers) >= self._max_pending_fragment_sets:
- # Evict the oldest stranded set rather than refusing the payload; dicts
- # iterate in insertion order, so the first key is the oldest.
- del self._fragment_buffers[next(iter(self._fragment_buffers))]
- self._fragment_sets_evicted += 1
buffer = self._fragment_buffers.setdefault(
key,
{
@@ -945,10 +838,6 @@ class CultNetRudpSocketTransportConnection:
frames_sent=self._frames_sent,
)
- @property
- def outstanding_reliable_packet_count(self) -> int:
- return self.session.outstanding_reliable_packet_count
-
def connect(self, payload: bytes = b"") -> None:
if self.mode != CultNetRudpSocketMode.CLIENT:
raise ValueError("Only a client RUDP socket transport can initiate connect")
@@ -990,8 +879,6 @@ class CultNetRudpSocketTransportConnection:
result = self.session.receive(packet, _now_ms())
if result.reply is not None:
self._send_packet(result.reply)
- for ready in result.ready_to_send:
- self._send_packet(ready)
if result.pong:
self.pong_payloads.append(result.pong_payload)
if result.disconnected:
@@ -1005,8 +892,8 @@ class CultNetRudpSocketTransportConnection:
self._frames_received += 1
frame = self._delivered_frames.popleft() if self._delivered_frames else None
- if packet.reliable or packet.packet_type == CultNetRudpPacketType.ACCEPT or frame is not None:
- self._send_packet(self.session.create_ack_for_received(packet.sequence))
+ if packet.packet_type == CultNetRudpPacketType.ACCEPT or frame is not None:
+ self._send_packet(self.session.create_ack())
return frame
def receive(self, timeout_seconds: float | None = None) -> CultNetTransportFrame:
@@ -1019,29 +906,6 @@ class CultNetRudpSocketTransportConnection:
if deadline is not None and time.monotonic() >= deadline:
raise TimeoutError("Timed out waiting for RUDP schema frame")
- def flush_reliable(self, timeout_seconds: float = 30.0) -> None:
- deadline = time.monotonic() + max(0.0, timeout_seconds)
- original_timeout = self.socket.gettimeout()
- poll_timeout = min(original_timeout, 0.01) if original_timeout is not None else 0.01
- preserved = deque(self._delivered_frames)
- self._delivered_frames.clear()
- self.socket.settimeout(poll_timeout)
- try:
- while self.session.outstanding_reliable_packet_count > 0:
- if time.monotonic() >= deadline:
- raise TimeoutError(
- "RUDP reliable flush timed out with "
- f"{self.session.outstanding_reliable_packet_count} packets outstanding"
- )
- frame = self.receive_once()
- if frame is not None:
- preserved.append(frame)
- self.poll_resends()
- finally:
- preserved.extend(self._delivered_frames)
- self._delivered_frames = preserved
- self.socket.settimeout(original_timeout)
-
def disconnect(self, reason: bytes = b"") -> None:
self._send_packet(self.session.create_disconnect(reason))
diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py
index ac81cc6d..3ecb763f 100644
--- a/packages/cultcache-py/tests/test_cultcache.py
+++ b/packages/cultcache-py/tests/test_cultcache.py
@@ -1,12 +1,23 @@
-from __future__ import annotations
+from __future__ import annotations
+import json
+import os
+import socket
+import subprocess
+import sys
import tempfile
import threading
+import time
import unittest
-from dataclasses import asdict, dataclass, replace
+import hashlib
+import hmac
+import io
+from dataclasses import asdict, dataclass
+from datetime import UTC, datetime, timedelta
from pathlib import Path
-from cultcache_py.backing_store import CultCacheEnvelope
-from cultcache_py.cache import CultCacheError
+from typing import Callable
+from uuid import uuid4
+
from cultcache_py import (
CultCache,
JsonLinesBackingStore,
@@ -14,7 +25,155 @@ from cultcache_py import (
define_database_entry_type,
define_document_type,
)
-from cultcache_py.interop import read_note, write_note
+from cultcache_py.benchmark import run_benchmark
+from cultcache_py.compare_csharp import (
+ DEFAULT_PARITY_THRESHOLD,
+ DEFAULT_SAMPLE_COUNT,
+ _compare_common_metrics,
+ _median_result,
+ _parity_status,
+)
+from cultcache_py.interop import INTEROP_SCHEMA_VERSION, interop_note_document, read_note, write_note
+from cultcache_py.verify import verify
+from cultnet_py.interop_peer import append_shard_log_put, build_state, raw_snapshot_response
+from cultnet_py import (
+ compute_simulation_claim_hash,
+ CultNetDatabaseChange,
+ CultNetClientAuthorityScope,
+ CultNetRawClient,
+ CultNetRawDocumentRecord,
+ CultNetRawSnapshotResponse,
+ CultNetSchemaCatalog,
+ CultNetSchemaDescriptor,
+ CultNetShardCatalog,
+ CultNetShardDescriptor,
+ CultNetShardLogEntry,
+ CultNetShardLogResponse,
+ CultNetFileShardReplicaCursorStore,
+ CultNetFileShardMutationLogStore,
+ CultNetInMemoryShardReplicaCursorStore,
+ CultNetSchemaWriteForwarder,
+ CultNetSchemaShardLogFetcher,
+ CultNetSchemaShardSnapshotFetcher,
+ CultNetPeerError,
+ CultNetClientSecurityOptions,
+ CultNetSecret,
+ CultNetServerSecurityOptions,
+ CultNetShardReplicator,
+ CultNetShardReplicatorOptions,
+ CultNetShardMutationLogStore,
+ CultNetShardWriteForwarder,
+ CultNetSimulationConsensusOptions,
+ CultNetSimulationConsensusCandidate,
+ CultNetSimulationObservation,
+ CultNetSimulationObservationHub,
+ CultNetRudpPacket,
+ CultNetRudpPacketType,
+ CultNetRudpSendOptions,
+ CultNetRudpSession,
+ CultNetRudpSessionOptions,
+ CultNetRudpSocketMode,
+ CultNetRudpReconnectLoop,
+ CultNetRudpSocketTransportConnection,
+ CultNetRudpSocketTransportOptions,
+ TcpFramedTransportConnection,
+ CultNetWitnessArtifactBundle,
+ apply_raw_document_record,
+ apply_raw_snapshot,
+ apply_shard_log_response,
+ create_rudp_transport_profile,
+ create_tcp_framed_transport_profile,
+ create_reconnect_policy,
+ CultNetReconnectController,
+ database_subscribe,
+ database_unsubscribe,
+ decode_frame,
+ decode_rudp_packet,
+ decode_witness_artifact_bundle_payload,
+ document_delete,
+ document_put_raw,
+ encode_frame,
+ encode_rudp_packet,
+ encode_witness_artifact_bundle_payload,
+ hello,
+ login,
+ login_success,
+ parse_message,
+ register,
+ schema_catalog_request,
+ schema_document_map,
+ shard_catalog_request,
+ shard_log_request,
+ simulation_observation,
+ snapshot_request,
+ verify_session,
+ wire_message_schema_catalog,
+ wire_message_schema_descriptors,
+ witness_artifact_bundle,
+ compute_reconnect_delay_ms,
+)
+from cultmesh_py import create_node
+from cultmesh_py import (
+ CultMesh,
+ CultMeshDatabase,
+ CultMeshDatabaseChange,
+ CultMeshGameSessionOptions,
+ CultMeshNodeOptions,
+ CultMeshReactiveDocumentOptions,
+ CultMeshPeerCard,
+ CultMeshPeerCatalog,
+ CultMeshSimulationFact,
+ CultMeshAuthorityLease,
+ CultMeshAuthorityLeaseCatalog,
+ CultMeshHmacAuthorityLeaseVerifier,
+ CultMeshDiscoveryClient,
+ CultMeshPeerExchangeClient,
+ CultMeshPeerHealthMonitor,
+ CultMeshSimulationObservationFanout,
+ CultMeshSnapshotFanout,
+ CultMeshStreamCatalog,
+ CultMeshStreamConsumerProfile,
+ CultMeshStreamDescriptor,
+ CultMeshStreamFrameHandle,
+ CultMeshVerseCatalog,
+ CultMeshVerseCompatibility,
+ CultMeshVerseDescriptor,
+ CultMeshVerseDiscoveryClient,
+ READY_SCHEMA_VERSION,
+ peer_exchange_request,
+ simulation_fact_document,
+ verse_catalog_request,
+)
+
+
+def bind_udp_socket() -> socket.socket:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.bind(("127.0.0.1", 0))
+ sock.settimeout(0.02)
+ return sock
+
+
+def pump_rudp_handshake(
+ client: CultNetRudpSocketTransportConnection,
+ server: CultNetRudpSocketTransportConnection,
+) -> None:
+ for _ in range(20):
+ server.receive_once()
+ client.receive_once()
+ server.receive_once()
+ if client.connected and server.connected:
+ return
+ time.sleep(0.005)
+ raise AssertionError("RUDP socket handshake did not complete")
+
+
+def receive_rudp_frame(transport: CultNetRudpSocketTransportConnection):
+ for _ in range(20):
+ frame = transport.receive_once()
+ if frame is not None:
+ return frame
+ time.sleep(0.005)
+ raise AssertionError("RUDP socket frame was not delivered")
@dataclass
@@ -35,6 +194,14 @@ def item_doc():
class CultCacheTests(unittest.TestCase):
+ def _wait_until(self, predicate: Callable[[], bool], *, timeout_seconds: float = 2.0) -> None:
+ deadline = time.monotonic() + timeout_seconds
+ while time.monotonic() < deadline:
+ if predicate():
+ return
+ time.sleep(0.01)
+ self.fail("Timed out waiting for condition")
+
def test_round_trips_registered_documents(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
document = item_doc()
@@ -241,549 +408,100 @@ class CultCacheTests(unittest.TestCase):
self.assertEqual(loaded["authorRuntimeId"], "python-test")
self.assertIn("interop", loaded["tags"])
- def test_second_generic_store_rejected(self) -> None:
- with tempfile.TemporaryDirectory() as tmp:
- cache = CultCache()
- cache.add_generic_store(SingleFileMessagePackBackingStore(Path(tmp) / "a.cc"))
- with self.assertRaisesRegex(CultCacheError, "second generic store"):
- cache.add_generic_store(SingleFileMessagePackBackingStore(Path(tmp) / "b.cc"))
+ def test_benchmark_reports_core_hot_path_metrics(self) -> None:
+ result = run_benchmark(8)
+ metric_names = {metric["name"] for metric in result["metrics"]}
+ self.assertEqual(result["records"], 8)
+ self.assertEqual(result["wireMessageCount"], 8)
+ self.assertEqual(metric_names, {
+ "database_entry_encode",
+ "database_entry_decode",
+ "cultnet_frame_parse",
+ "raw_snapshot_apply",
+ "cache_upsert",
+ "cache_get",
+ })
+ self.assertTrue(all(metric["opsPerSecond"] > 0 for metric in result["metrics"]))
- def test_type_claimed_twice_rejected(self) -> None:
- with tempfile.TemporaryDirectory() as tmp:
- cache = CultCache()
- cache.add_backing_store(SingleFileMessagePackBackingStore(Path(tmp) / "a.cc"), ["item"])
- with self.assertRaisesRegex(CultCacheError, "already routed"):
- cache.add_backing_store(SingleFileMessagePackBackingStore(Path(tmp) / "b.cc"), ["settings", "item"])
- # A refused registration claims nothing, so "settings" is still free.
- cache.add_backing_store(SingleFileMessagePackBackingStore(Path(tmp) / "c.cc"), ["settings"])
+ def test_compare_csharp_median_result_summarizes_samples(self) -> None:
+ self.assertEqual(DEFAULT_SAMPLE_COUNT, 3)
+ self.assertEqual(DEFAULT_PARITY_THRESHOLD, 0.90)
+ samples = [
+ {
+ "runtime": "python",
+ "records": 3,
+ "metrics": [
+ {"name": "cache_get", "operations": 3, "elapsedMs": 30.0, "opsPerSecond": 100.0},
+ {"name": "cache_upsert", "operations": 3, "elapsedMs": 60.0, "opsPerSecond": 50.0},
+ ],
+ },
+ {
+ "runtime": "python",
+ "records": 3,
+ "metrics": [
+ {"name": "cache_get", "operations": 3, "elapsedMs": 10.0, "opsPerSecond": 300.0},
+ {"name": "cache_upsert", "operations": 3, "elapsedMs": 20.0, "opsPerSecond": 150.0},
+ ],
+ },
+ {
+ "runtime": "python",
+ "records": 3,
+ "metrics": [
+ {"name": "cache_get", "operations": 3, "elapsedMs": 20.0, "opsPerSecond": 200.0},
+ {"name": "cache_upsert", "operations": 3, "elapsedMs": 40.0, "opsPerSecond": 75.0},
+ ],
+ },
+ ]
- def test_types_route_to_home_store(self) -> None:
- with tempfile.TemporaryDirectory() as tmp:
- item = item_doc()
- settings = define_database_entry_type("settings", [("theme", 0)])
- generic_path = Path(tmp) / "generic.cc"
- settings_path = Path(tmp) / "settings.cc"
+ summarized = _median_result(samples)
- def build() -> CultCache:
- return (
- CultCache.builder()
- .register_document_type(item)
- .register_document_type(settings)
- .add_backing_store(SingleFileMessagePackBackingStore(settings_path), ["settings"])
- .add_generic_store(SingleFileMessagePackBackingStore(generic_path))
- .build()
- )
+ self.assertEqual(summarized["sampleCount"], 3)
+ self.assertEqual(summarized["metrics"][0]["name"], "cache_get")
+ self.assertEqual(summarized["metrics"][0]["elapsedMs"], 20.0)
+ self.assertEqual(summarized["metrics"][0]["opsPerSecond"], 200.0)
+ self.assertEqual(summarized["metrics"][1]["opsPerSecond"], 75.0)
- cache = build()
- cache.put(item, "item:potion", Item(name="Potion", category="Consumable", value=50))
- cache.put(settings, "app", {"theme": "ash"})
+ def test_compare_csharp_marks_threshold_status_per_metric(self) -> None:
+ python_result = {
+ "metrics": [
+ {"name": "cache_get", "operations": 3, "elapsedMs": 2.0, "opsPerSecond": 50.0},
+ {"name": "cache_upsert", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 95.0},
+ ],
+ }
+ csharp_result = {
+ "metrics": [
+ {"name": "cache_get", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 100.0},
+ {"name": "cache_upsert", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 100.0},
+ ],
+ }
- generic = SingleFileMessagePackBackingStore(generic_path).pull_all()
- routed = SingleFileMessagePackBackingStore(settings_path).pull_all()
- self.assertEqual([(e.key, e.type) for e in generic], [("item:potion", "item")])
- self.assertEqual([(e.key, e.type) for e in routed], [("app", "settings")])
+ comparison = _compare_common_metrics(python_result, csharp_result, parity_threshold=0.90)
+ by_name = {item["name"]: item for item in comparison}
- cache.delete(settings, "app")
- self.assertEqual(SingleFileMessagePackBackingStore(settings_path).pull_all(), [])
- self.assertEqual(len(SingleFileMessagePackBackingStore(generic_path).pull_all()), 1)
+ self.assertFalse(by_name["cache_get"]["meetsParityThreshold"])
+ self.assertTrue(by_name["cache_upsert"]["meetsParityThreshold"])
+ self.assertEqual(_parity_status(comparison, "ok"), "below-threshold")
+ self.assertEqual(_parity_status([by_name["cache_upsert"]], "ok"), "meets-threshold")
+ self.assertEqual(_parity_status(comparison, "failed"), "unknown")
- reloaded = build()
- reloaded.pull_all_backing_stores()
- self.assertEqual(reloaded.get_required(item, "item:potion").value, 50)
- self.assertIsNone(reloaded.get(settings, "app"))
-
- def test_attach_that_would_move_a_held_record_home_is_refused(self) -> None:
- with tempfile.TemporaryDirectory() as tmp:
- settings = define_database_entry_type("settings", [("theme", 0)])
- generic_path = Path(tmp) / "generic.cc"
- settings_path = Path(tmp) / "settings.cc"
- cache = (
- CultCache.builder()
- .register_document_type(settings)
- .add_generic_store(SingleFileMessagePackBackingStore(generic_path))
- .build()
- )
- cache.put(settings, "app", {"theme": "v1-generic"})
-
- with self.assertRaisesRegex(
- CultCacheError, "would move settings from the generic store to the store routed to settings"
- ):
- cache.add_backing_store(SingleFileMessagePackBackingStore(settings_path), ["settings"])
- # Nothing attached: the next write still lands in the generic store.
- cache.put(settings, "app", {"theme": "v2-generic"})
- generic = SingleFileMessagePackBackingStore(generic_path).pull_all()
- self.assertEqual([(e.key, e.type) for e in generic], [("app", "settings")])
- self.assertFalse(settings_path.exists())
-
- def test_load_from_a_store_that_is_not_the_record_home_is_refused(self) -> None:
- with tempfile.TemporaryDirectory() as tmp:
- settings = define_database_entry_type("settings", [("theme", 0)])
- generic_path = Path(tmp) / "generic.cc"
- settings_path = Path(tmp) / "settings.cc"
- writer = CultCache.builder().register_document_type(settings).add_generic_store(
- SingleFileMessagePackBackingStore(generic_path)
- ).build()
- writer.put(settings, "app", {"theme": "stray"})
- generic_bytes = generic_path.read_bytes()
-
- cache = (
- CultCache.builder()
- .register_document_type(settings)
- .add_backing_store(SingleFileMessagePackBackingStore(settings_path), ["settings"])
- .add_generic_store(SingleFileMessagePackBackingStore(generic_path))
- .build()
- )
- # The cache already holds a record, in its home store, before the refused load.
- cache.put(settings, "held", {"theme": "home"})
- before = cache.snapshot_envelopes()
- settings_bytes = settings_path.read_bytes()
- with self.assertRaisesRegex(
- CultCacheError,
- "settings record app was loaded from the generic store, but its home is the store routed to settings",
- ):
- cache.pull_all_backing_stores()
- self.assertIsNone(cache.get(settings, "app"))
- self.assertEqual(cache.snapshot_envelopes(), before)
- self.assertEqual(cache.get_required(settings, "held"), {"theme": "home"})
- self.assertEqual(generic_path.read_bytes(), generic_bytes)
- self.assertEqual(settings_path.read_bytes(), settings_bytes)
-
- def test_pull_with_duplicate_global_leaves_cache_unchanged(self) -> None:
- with tempfile.TemporaryDirectory() as tmp:
- item = item_doc()
- settings = define_document_type("settings", global_document=True)
- store_path = Path(tmp) / "generic.cc"
- cache = (
- CultCache.builder()
- .register_document_type(item)
- .register_document_type(settings)
- .add_generic_store(SingleFileMessagePackBackingStore(store_path))
- .build()
- )
- cache.put(item, "item:potion", Item(name="Potion", category="Consumable", value=50))
- cache.put_global(settings, {"theme": "ash"})
- before = cache.snapshot_envelopes()
-
- # A second record of the global type lands in the store file behind the cache's back.
- stray = replace(cache.get_required_envelope(settings, CultCache.GLOBAL_KEY), key="stray")
- SingleFileMessagePackBackingStore(store_path).push(stray)
-
- with self.assertRaisesRegex(CultCacheError, "Duplicate global document for type: settings"):
- cache.pull_all_backing_stores()
- self.assertEqual(cache.snapshot_envelopes(), before)
- self.assertEqual(cache.get_required(item, "item:potion").value, 50)
- self.assertEqual(cache.get_key_by_name(item, "Potion"), "item:potion")
- self.assertEqual(cache.get_required_global(settings)["theme"], "ash")
-
- def test_pull_with_throwing_index_accessor_leaves_cache_unchanged(self) -> None:
- def category(item: Item) -> str:
- if item.name == "Bomb":
- raise ValueError("category accessor refuses Bomb")
- return item.category
-
- with tempfile.TemporaryDirectory() as tmp:
- item = define_document_type(
- "item",
- encode=lambda value: asdict(value),
- decode=lambda raw: Item(**raw),
- name="name",
- indexes={"category": category},
- )
- settings = define_document_type("settings", global_document=True)
- store_path = Path(tmp) / "generic.cc"
- cache = (
- CultCache.builder()
- .register_document_type(item)
- .register_document_type(settings)
- .add_generic_store(SingleFileMessagePackBackingStore(store_path))
- .build()
- )
- cache.put(item, "item:potion", Item(name="Potion", category="Consumable", value=50))
- cache.put_global(settings, {"theme": "ash"})
- before_envelopes = cache.snapshot_envelopes()
- before_values = cache.snapshot()
-
- # A record whose index accessor throws lands in the store file behind the cache's back.
- bomb = replace(
- cache.get_required_envelope(item, "item:potion"),
- key="item:bomb",
- payload=item.encode_payload(Item(name="Bomb", category="Explosive", value=1)),
- )
- SingleFileMessagePackBackingStore(store_path).push(bomb)
-
- with self.assertRaisesRegex(ValueError, "category accessor refuses Bomb"):
- cache.pull_all_backing_stores()
- self.assertEqual(cache.snapshot_envelopes(), before_envelopes)
- self.assertEqual(cache.snapshot(), before_values)
- self.assertEqual(cache.get_required(item, "item:potion").value, 50)
- self.assertIsNone(cache.get(item, "item:bomb"))
- self.assertEqual(cache.get_key_by_name(item, "Potion"), "item:potion")
- self.assertIsNone(cache.get_key_by_name(item, "Bomb"))
- self.assertEqual(cache.get_key_by_index(item, "category", "Consumable"), "item:potion")
- self.assertEqual(cache.get_required_global(settings)["theme"], "ash")
-
- def _store_state(self, path: Path) -> list[tuple[str, str, bytes]]:
- return sorted((e.type, e.key, e.payload) for e in SingleFileMessagePackBackingStore(path).pull_all())
-
- def test_put_with_throwing_name_extractor_changes_neither_store_nor_cache(self) -> None:
- def name(value: dict) -> str:
- if value["n"] == "bad":
- raise ValueError("extractor refuses bad")
- return value["n"]
-
- with tempfile.TemporaryDirectory() as tmp:
- item = define_document_type("item", name=name)
- path = Path(tmp) / "generic.cc"
- cache = CultCache.builder().register_document_type(item).add_generic_store(
- SingleFileMessagePackBackingStore(path)
- ).build()
- cache.put(item, "k", {"n": "good"})
- store_before, cache_before = self._store_state(path), cache.snapshot_envelopes()
-
- with self.assertRaisesRegex(ValueError, "extractor refuses bad"):
- cache.put(item, "k", {"n": "bad"})
- self.assertEqual(self._store_state(path), store_before)
- self.assertEqual(cache.snapshot_envelopes(), cache_before)
- self.assertEqual(cache.get_required(item, "k"), {"n": "good"})
- self.assertEqual(cache.get_key_by_name(item, "good"), "k")
-
- def test_overwrite_under_a_registered_extractor_that_now_raises_changes_neither_store_nor_cache(self) -> None:
- refusing = {"on": False}
-
- def category(value: dict) -> str:
- if refusing["on"]:
- raise ValueError("extractor refuses now")
- return value["c"]
-
- with tempfile.TemporaryDirectory() as tmp:
- item = define_document_type("item")
- path = Path(tmp) / "generic.cc"
- cache = CultCache.builder().register_document_type(item).add_generic_store(
- SingleFileMessagePackBackingStore(path)
- ).build()
- cache.put(item, "k", {"c": "old"})
- cache.register_index(item, "category", category)
- refusing["on"] = True
- store_before, cache_before = self._store_state(path), cache.snapshot_envelopes()
-
- with self.assertRaisesRegex(ValueError, "extractor refuses now"):
- cache.put(item, "k", {"c": "new"})
- self.assertEqual(self._store_state(path), store_before)
- self.assertEqual(cache.snapshot_envelopes(), cache_before)
- self.assertEqual(cache.get_key_by_index(item, "category", "old"), "k")
- # Deleting runs no extractor: it uses the lookup keys stored when the record was admitted.
- cache.delete(item, "k")
- self.assertIsNone(cache.get_key_by_index(item, "category", "old"))
-
- def test_registering_an_extractor_that_raises_on_a_held_value_installs_nothing(self) -> None:
- def name(value: dict) -> str:
- if value["n"] == "bad":
- raise ValueError("extractor refuses bad")
- return value["n"]
-
- with tempfile.TemporaryDirectory() as tmp:
- item = define_document_type("item")
- path = Path(tmp) / "generic.cc"
- cache = CultCache.builder().register_document_type(item).add_generic_store(
- SingleFileMessagePackBackingStore(path)
- ).build()
- cache.put(item, "old", {"n": "bad"})
- with self.assertRaisesRegex(ValueError, "extractor refuses bad"):
- cache.register_name_lookup(item, name)
- cache.put(item, "old", {"n": "fine"})
- self.assertEqual(cache.get_required(item, "old"), {"n": "fine"})
- self.assertIsNone(cache.get_key_by_name(item, "fine"))
- self.assertEqual([item.decode_payload(e.payload) for e in SingleFileMessagePackBackingStore(path).pull_all()], [{"n": "fine"}])
-
- def test_put_envelopes_with_throwing_extractor_changes_neither_store_nor_cache(self) -> None:
- def name(value: dict) -> str:
- if value["n"] == "bad":
- raise ValueError("extractor refuses bad")
- return value["n"]
-
- with tempfile.TemporaryDirectory() as tmp:
- item = define_document_type("item", name=name)
- path = Path(tmp) / "generic.cc"
- cache = CultCache.builder().register_document_type(item).add_generic_store(
- SingleFileMessagePackBackingStore(path)
- ).build()
- cache.put(item, "seed", {"n": "seed"})
- seed = cache.get_required_envelope(item, "seed")
- first = replace(seed, key="a", payload=item.encode_payload({"n": "a"}))
- second = replace(seed, key="b", payload=item.encode_payload({"n": "bad"}))
- store_before, cache_before = self._store_state(path), cache.snapshot_envelopes()
-
- with self.assertRaisesRegex(ValueError, "extractor refuses bad"):
- cache.put_envelopes(item, [first, second])
- self.assertEqual(self._store_state(path), store_before)
- self.assertEqual(cache.snapshot_envelopes(), cache_before)
- self.assertIsNone(cache.get_key_by_name(item, "a"))
-
- def test_global_replace_with_failing_store_push_keeps_old_global(self) -> None:
- class FailingPushStore(SingleFileMessagePackBackingStore):
- fail = False
-
- def push(self, envelope):
- if self.fail:
- raise OSError("disk full")
- super().push(envelope)
-
- with tempfile.TemporaryDirectory() as tmp:
- settings = define_document_type("settings", global_document=True)
- path = Path(tmp) / "generic.cc"
- store = FailingPushStore(path)
- cache = CultCache.builder().register_document_type(settings).add_generic_store(store).build()
- cache.put_global(settings, {"theme": "base"})
- store_before, cache_before = self._store_state(path), cache.snapshot_envelopes()
- store.fail = True
-
- with self.assertRaisesRegex(OSError, "disk full"):
- cache.put_global(settings, {"theme": "new"})
- with self.assertRaisesRegex(CultCacheError, "must use key __global__"):
- cache.put(settings, "other", {"theme": "new"})
- self.assertEqual(self._store_state(path), store_before)
- self.assertEqual(cache.snapshot_envelopes(), cache_before)
- self.assertEqual(cache.get_required_global(settings), {"theme": "base"})
-
- @staticmethod
- def _pausing_store(path: Path, barrier: threading.Barrier, resume: threading.Event):
- """A store whose first push writes, meets the other thread at the barrier, then waits until that
- thread's call has returned (or a timeout: a locked cache makes the other thread wait on us)."""
-
- class PausingStore(SingleFileMessagePackBackingStore):
- pushes = 0
- paused = False
- other_reached_store_while_paused = False
-
- def push(self, envelope):
- self.other_reached_store_while_paused |= self.paused
- self.pushes += 1
- super().push(envelope)
- if self.pushes == 1:
- self.paused = True
- barrier.wait(timeout=5)
- resume.wait(timeout=0.5)
- self.paused = False
-
- return PausingStore(path)
-
- @staticmethod
- def _run_racing(first, second, resume: threading.Event) -> list[BaseException]:
- errors: list[BaseException] = []
-
- def run(action, signal):
- try:
- action()
- except BaseException as error: # noqa: BLE001 - reported to the test
- errors.append(error)
- finally:
- if signal:
- resume.set()
-
- threads = [threading.Thread(target=run, args=(first, False)), threading.Thread(target=run, args=(second, True))]
- for thread in threads:
- thread.start()
- for thread in threads:
- thread.join(timeout=10)
- return errors
-
- def test_concurrent_put_and_attach_cannot_land_record_in_non_home_store(self) -> None:
- with tempfile.TemporaryDirectory() as tmp:
- settings = define_database_entry_type("settings", [("theme", 0)])
- generic_path, settings_path = Path(tmp) / "generic.cc", Path(tmp) / "settings.cc"
- barrier, resume = threading.Barrier(2), threading.Event()
- generic = self._pausing_store(generic_path, barrier, resume)
- cache = CultCache.builder().register_document_type(settings).add_generic_store(generic).build()
-
- def attach() -> None:
- barrier.wait(timeout=5)
- cache.add_backing_store(SingleFileMessagePackBackingStore(settings_path), ["settings"])
-
- errors = self._run_racing(lambda: cache.put(settings, "app", {"theme": "t"}), attach, resume)
-
- # Either the attach was refused (the record is held, so its home cannot move) or it won;
- # in every case each held record must be in the store that is its home.
- home = SingleFileMessagePackBackingStore(generic_path if errors else settings_path)
- self.assertEqual([(e.key, e.type) for e in home.pull_all()], [("app", "settings")])
- self.assertTrue(all(isinstance(error, CultCacheError) for error in errors), errors)
-
- def test_concurrent_global_puts_cannot_both_reach_disk(self) -> None:
- with tempfile.TemporaryDirectory() as tmp:
- settings = define_document_type("settings", global_document=True)
- path = Path(tmp) / "generic.cc"
- barrier, resume = threading.Barrier(2), threading.Event()
- store = self._pausing_store(path, barrier, resume)
- cache = CultCache.builder().register_document_type(settings).add_generic_store(store).build()
-
- def second() -> None:
- barrier.wait(timeout=5)
- cache.put_global(settings, {"theme": "second"})
-
- errors = self._run_racing(lambda: cache.put_global(settings, {"theme": "first"}), second, resume)
-
- self.assertEqual(errors, [])
- # While the first write was between its store push and its apply, the second never reached disk.
- self.assertFalse(store.other_reached_store_while_paused)
- on_disk = SingleFileMessagePackBackingStore(path).pull_all()
- self.assertEqual(len(on_disk), 1)
- self.assertEqual(on_disk[0].payload, cache.get_required_envelope(settings, CultCache.GLOBAL_KEY).payload)
- self.assertEqual(cache.get_required_global(settings), {"theme": "second"})
-
- @staticmethod
- def _envelope(document, key: str, value) -> CultCacheEnvelope:
- catalog_entry = document.catalog_entry()
- return CultCacheEnvelope.create(
- key=key,
- type=document.type,
- payload=document.encode_payload(value),
- schema_id=catalog_entry.schema_id,
- catalog_entry=catalog_entry,
+ def test_compare_csharp_marks_meets_threshold_when_all_common_metrics_pass(self) -> None:
+ comparison = _compare_common_metrics(
+ {
+ "metrics": [
+ {"name": "cache_get", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 95.0},
+ {"name": "cache_upsert", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 120.0},
+ ],
+ },
+ {
+ "metrics": [
+ {"name": "cache_get", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 100.0},
+ {"name": "cache_upsert", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 100.0},
+ ],
+ },
+ parity_threshold=0.90,
)
- def test_legacy_key_global_loads_without_writing_and_first_write_leaves_one_global(self) -> None:
- with tempfile.TemporaryDirectory() as tmp:
- settings = define_document_type("settings", global_document=True)
- path = Path(tmp) / "generic.cc"
-
- def build() -> CultCache:
- return CultCache.builder().register_document_type(settings).add_generic_store(
- SingleFileMessagePackBackingStore(path)
- ).build()
-
- SingleFileMessagePackBackingStore(path).push(self._envelope(settings, "legacy", {"theme": "old"}))
- stored_bytes = path.read_bytes()
-
- cache = build()
- cache.pull_all_backing_stores()
- self.assertEqual(cache.get_global(settings), {"theme": "old"})
- self.assertEqual(cache.get(settings, CultCache.GLOBAL_KEY), {"theme": "old"})
- self.assertEqual(path.read_bytes(), stored_bytes)
-
- cache.put_global(settings, {"theme": "new"})
- self.assertEqual([e.key for e in SingleFileMessagePackBackingStore(path).pull_all()], [CultCache.GLOBAL_KEY])
- reloaded = build()
- reloaded.pull_all_backing_stores()
- self.assertEqual(reloaded.get_required_global(settings), {"theme": "new"})
-
- def test_deleting_a_legacy_key_global_removes_the_legacy_record(self) -> None:
- with tempfile.TemporaryDirectory() as tmp:
- settings = define_document_type("settings", global_document=True)
- path = Path(tmp) / "generic.cc"
- SingleFileMessagePackBackingStore(path).push(self._envelope(settings, "legacy", {"theme": "old"}))
- cache = CultCache.builder().register_document_type(settings).add_generic_store(
- SingleFileMessagePackBackingStore(path)
- ).build()
- cache.pull_all_backing_stores()
-
- cache.delete_global(settings)
- self.assertEqual(SingleFileMessagePackBackingStore(path).pull_all(), [])
- self.assertIsNone(cache.get_global(settings))
-
- def test_two_globals_of_one_type_on_disk_are_refused_under_any_keys(self) -> None:
- with tempfile.TemporaryDirectory() as tmp:
- settings = define_document_type("settings", global_document=True)
- for keys in (["one", "two"], ["legacy", CultCache.GLOBAL_KEY]):
- path = Path(tmp) / f"{keys[0]}.cc"
- store = SingleFileMessagePackBackingStore(path)
- store.push_all([self._envelope(settings, key, {"theme": key}) for key in keys])
- cache = CultCache.builder().register_document_type(settings).add_generic_store(store).build()
- with self.assertRaisesRegex(CultCacheError, "Duplicate global document for type: settings"):
- cache.pull_all_backing_stores()
- self.assertIsNone(cache.get_global(settings))
-
- def test_decoder_writing_during_pull_raises_and_changes_neither_store_nor_cache(self) -> None:
- with tempfile.TemporaryDirectory() as tmp:
- path = Path(tmp) / "generic.cc"
- holder: dict[str, CultCache] = {}
-
- def decode(raw):
- holder["cache"].put(item, "side", {"name": "side"})
- return raw
-
- item = define_document_type("item", decode=decode, encode=lambda value: value)
- store = SingleFileMessagePackBackingStore(path)
- store.push(self._envelope(item, "a", {"name": "a"}))
- stored_bytes = path.read_bytes()
- cache = holder["cache"] = CultCache.builder().register_document_type(item).add_generic_store(store).build()
-
- with self.assertRaisesRegex(CultCacheError, "re-entrant put"):
- cache.pull_all_backing_stores()
- self.assertEqual(path.read_bytes(), stored_bytes)
- self.assertEqual(cache.snapshot_envelopes(), [])
- self.assertIsNone(cache.get(item, "side"))
- # The refusal released the cache: an ordinary write still works.
- cache.put(item, "b", {"name": "b"})
- self.assertEqual(cache.get(item, "b"), {"name": "b"})
-
- def test_index_extractor_writing_during_register_index_raises_and_installs_nothing(self) -> None:
- thing = define_document_type("thing")
- cache = CultCache.builder().register_document_type(thing).build()
- cache.put(thing, "k1", {"n": "one", "cat": "x"})
-
- def category(value: dict) -> str:
- cache.put(thing, "k2", {"n": "two", "cat": "y"})
- return value["cat"]
-
- with self.assertRaisesRegex(CultCacheError, "re-entrant put"):
- cache.register_index(thing, "cat", category)
- self.assertIsNone(cache.get(thing, "k2"))
- self.assertNotIn("thing", cache._state.index_extractors)
- self.assertIsNone(cache.get_key_by_index(thing, "cat", "x"))
-
- def test_put_envelope_refuses_a_global_under_another_key(self) -> None:
- with tempfile.TemporaryDirectory() as tmp:
- settings = define_document_type("settings", global_document=True)
- path = Path(tmp) / "generic.cc"
- cache = CultCache.builder().register_document_type(settings).add_generic_store(
- SingleFileMessagePackBackingStore(path)
- ).build()
- cache.put_global(settings, {"theme": "base"})
- store_before, cache_before = self._store_state(path), cache.snapshot_envelopes()
- stray = replace(cache.get_required_envelope(settings, CultCache.GLOBAL_KEY), key="stray")
-
- with self.assertRaisesRegex(CultCacheError, "must use key __global__"):
- cache.put_envelope(settings, stray)
- with self.assertRaisesRegex(CultCacheError, "must be non-empty"):
- cache.put_envelope(settings, replace(stray, key=""))
- self.assertEqual(self._store_state(path), store_before)
- self.assertEqual(cache.snapshot_envelopes(), cache_before)
-
- def test_refused_put_leaves_no_empty_type_entry(self) -> None:
- def refuse(value: dict) -> dict:
- raise ValueError("encode refuses")
-
- with tempfile.TemporaryDirectory() as tmp:
- other = define_document_type("other", encode=refuse)
- path = Path(tmp) / "generic.cc"
- cache = CultCache.builder().register_document_type(other).add_generic_store(
- SingleFileMessagePackBackingStore(path)
- ).build()
- with self.assertRaisesRegex(ValueError, "encode refuses"):
- cache.put(other, "x", {"a": 1})
- self.assertEqual(cache.snapshot(), {})
- self.assertFalse(path.exists())
-
- def test_attach_with_empty_type_list_is_the_generic_store(self) -> None:
- with tempfile.TemporaryDirectory() as tmp:
- settings = define_database_entry_type("settings", [("theme", 0)])
- store_path = Path(tmp) / "generic.cc"
- cache = CultCache.builder().register_document_type(settings).build()
- cache.add_backing_store(SingleFileMessagePackBackingStore(store_path), [])
- cache.put(settings, "y", {"theme": "t"})
- self.assertEqual(
- [(e.key, e.type) for e in SingleFileMessagePackBackingStore(store_path).pull_all()],
- [("y", "settings")],
- )
- with self.assertRaisesRegex(CultCacheError, "second generic store"):
- cache.add_backing_store(SingleFileMessagePackBackingStore(Path(tmp) / "b.cc"), [])
-
- def test_zero_store_cache_writes_and_deletes_in_memory(self) -> None:
- settings = define_database_entry_type("settings", [("theme", 0)])
- cache = CultCache.builder().register_document_type(settings).build()
- cache.put(settings, "x", {"theme": "mem"})
- self.assertEqual(cache.get_required(settings, "x"), {"theme": "mem"})
- cache.delete(settings, "x")
- self.assertIsNone(cache.get(settings, "x"))
+ self.assertEqual(_parity_status(comparison, "ok"), "meets-threshold")
def test_cache_put_without_backing_store_keeps_in_memory_value(self) -> None:
document = define_database_entry_type(
@@ -801,6 +519,5893 @@ class CultCacheTests(unittest.TestCase):
self.assertEqual(cache.get(document, "item:one"), {"name": "one", "value": 1})
self.assertEqual(cache.get_required_envelope(document, "item:one").key, "item:one")
+ def test_verify_reports_python_runtime_surface_health(self) -> None:
+ result = verify(records=4)
+
+ self.assertEqual(result["status"], "ok")
+ self.assertEqual(
+ {check["name"] for check in result["checks"]},
+ {
+ "public_exports",
+ "typed_markers",
+ "local_cultmesh_wire_smoke",
+ "cultmesh_capability_truth",
+ "benchmark_sanity",
+ },
+ )
+ by_name = {check["name"]: check for check in result["checks"]}
+ self.assertEqual(by_name["public_exports"]["missing"], {})
+ self.assertEqual(by_name["cultmesh_capability_truth"]["failures"], [])
+
+ def test_cultnet_security_options_match_cultlib_defaults_and_environment_rules(self) -> None:
+ client = CultNetClientSecurityOptions.development()
+ server = CultNetServerSecurityOptions.development()
+
+ self.assertEqual(client.connection_key, "gamecult-dev-connection-key")
+ self.assertEqual(server.connection_key, "gamecult-dev-connection-key")
+ self.assertTrue(server.is_development)
+ self.assertEqual(server.to_client_options().connection_key, client.connection_key)
+ self.assertEqual(client.encryption_key(), hashlib.sha256(b"gamecult-dev-connection-key").digest())
+ self.assertEqual(server.session_signing_key(), hashlib.sha256(b"gamecult-dev-session-signing-secret").digest())
+ self.assertEqual(
+ CultNetServerSecurityOptions.from_environment({}, allow_development_defaults=True),
+ server,
+ )
+ with self.assertRaisesRegex(ValueError, "not configured"):
+ CultNetServerSecurityOptions.from_environment({})
+ with self.assertRaisesRegex(ValueError, "GAMECULT_SESSION_SIGNING_SECRET"):
+ CultNetServerSecurityOptions.from_environment({"GAMECULT_CONNECTION_KEY": "key"})
+ configured = CultNetServerSecurityOptions.from_environment({
+ "GAMECULT_CONNECTION_KEY": "prod-key",
+ "GAMECULT_SESSION_SIGNING_SECRET": "prod-secret",
+ })
+ self.assertEqual(configured.connection_key, "prod-key")
+ self.assertFalse(configured.is_development)
+
+ def test_cultnet_secret_helpers_validate_csharp_versioned_session_tokens(self) -> None:
+ security = CultNetServerSecurityOptions("connection-key", "session-secret")
+ user_id = uuid4()
+ expires = datetime(2035, 1, 2, 3, 4, 5, tzinfo=UTC)
+
+ token = CultNetSecret.create_session_token(user_id, expires, security, session_version=7)
+ payload_b64, signature_b64 = token.split(".")
+ self.assertEqual(
+ CultNetSecret.from_base64url(payload_b64).decode("utf-8"),
+ f"{user_id.hex}|{int(expires.timestamp())}|7",
+ )
+ self.assertEqual(
+ CultNetSecret.from_base64url(signature_b64),
+ hmac.new(
+ hashlib.sha256(b"session-secret").digest(),
+ CultNetSecret.from_base64url(payload_b64),
+ hashlib.sha256,
+ ).digest(),
+ )
+
+ validated = CultNetSecret.try_validate_session_token(
+ token,
+ security,
+ now_utc=datetime(2035, 1, 2, 3, 4, 4, tzinfo=UTC),
+ )
+ self.assertIsNotNone(validated)
+ assert validated is not None
+ self.assertEqual(validated.user_id, user_id)
+ self.assertEqual(validated.expires_at_utc, expires)
+ self.assertEqual(validated.session_version, 7)
+
+ tampered_payload = CultNetSecret.to_base64url(CultNetSecret.from_base64url(payload_b64) + b"tamper")
+ tampered = f"{tampered_payload}.{signature_b64}"
+ self.assertIsNone(CultNetSecret.try_validate_session_token(tampered, security))
+ self.assertIsNone(CultNetSecret.try_validate_session_token(token, security, now_utc=expires))
+
+ def test_cultnet_secret_helpers_validate_legacy_two_field_session_tokens(self) -> None:
+ security = CultNetServerSecurityOptions.development()
+ user_id = uuid4()
+ expires_at_seconds = int(datetime(2036, 1, 1, tzinfo=UTC).timestamp())
+ payload = f"{user_id.hex}|{expires_at_seconds}".encode("utf-8")
+ signature = hmac.new(security.session_signing_key(), payload, hashlib.sha256).digest()
+ token = f"{CultNetSecret.to_base64url(payload)}.{CultNetSecret.to_base64url(signature)}"
+
+ validated = CultNetSecret.try_validate_session_token(
+ token,
+ security,
+ now_utc=datetime(2035, 12, 31, tzinfo=UTC),
+ )
+
+ self.assertIsNotNone(validated)
+ assert validated is not None
+ self.assertEqual(validated.user_id, user_id)
+ self.assertEqual(validated.session_version, 0)
+
+ def test_cultnet_secret_encryption_helpers_are_optional_without_crypto_dependency(self) -> None:
+ nonce = b"123456789012"
+ security = CultNetServerSecurityOptions.development()
+ try:
+ encrypted = CultNetSecret.encrypt_string("hello", nonce, security.to_client_options())
+ except ImportError as exc:
+ self.assertIn("cryptography", str(exc))
+ return
+ self.assertEqual(CultNetSecret.decrypt_string(encrypted, nonce, security), "hello")
+
+ def test_cultnet_schema_message_frame_round_trip(self) -> None:
+ message = hello(
+ runtime_id="python-test",
+ supported_schema_versions=["cultnet.hello.v0"],
+ transport_profiles=[
+ {
+ "schemaVersion": "cultnet.transport_profile.v0",
+ "runtimeId": "python-test",
+ "transports": [
+ {
+ "transportId": "test",
+ "protocol": "tcp_framed",
+ "channels": [{"channelId": "schema", "delivery": "reliable", "ordering": "ordered"}],
+ }
+ ],
+ }
+ ],
+ )
+ parsed = parse_message(decode_frame(encode_frame(message.to_bytes())))
+ self.assertEqual(parsed.schema_version, "cultnet.hello.v0")
+ self.assertEqual(parsed.body["runtimeId"], "python-test")
+ self.assertEqual(parsed.body["transportProfiles"][0]["transports"][0]["protocol"], "tcp_framed")
+
+ def test_cultnet_tcp_framed_transport_carries_schema_payloads_with_stats(self) -> None:
+ payload = b"cultnet-payload"
+ output = io.BytesIO()
+ sender = TcpFramedTransportConnection(
+ output,
+ profile=create_tcp_framed_transport_profile("sender", transport_id="test-tcp"),
+ )
+ sender.send("schema", payload)
+ self.assertEqual(sender.stats.frames_sent, 1)
+ self.assertEqual(sender.stats.bytes_sent, len(payload) + 4)
+ with self.assertRaisesRegex(ValueError, "only supports the schema channel"):
+ sender.send("unreliable", b"")
+
+ receiver = TcpFramedTransportConnection(
+ io.BytesIO(output.getvalue()),
+ profile=create_tcp_framed_transport_profile("receiver", transport_id="test-tcp"),
+ )
+ frame = receiver.receive()
+ self.assertEqual(frame.channel_id, "schema")
+ self.assertEqual(frame.payload, payload)
+ self.assertEqual(receiver.stats.frames_received, 1)
+ self.assertEqual(receiver.stats.bytes_received, len(payload) + 4)
+ self.assertEqual(receiver.profile["transports"][0]["protocol"], "tcp_framed")
+
+ def test_cultnet_raw_client_uses_schema_transport_factory(self) -> None:
+ import msgpack # type: ignore
+
+ sent_messages: list[dict[str, object]] = []
+ test_case = self
+
+ class FakeSchemaTransport:
+ profile = create_tcp_framed_transport_profile("fake-client")
+
+ def __enter__(self) -> "FakeSchemaTransport":
+ return self
+
+ def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
+ self.close()
+
+ def send(self, channel_id: str, payload: bytes) -> None:
+ test_case.assertEqual(channel_id, "schema")
+ sent_messages.append(msgpack.unpackb(payload, raw=False))
+
+ def receive(self) -> object:
+ response = {
+ "schemaVersion": "cultnet.schema_catalog_response.v0",
+ "messageId": "transport-factory",
+ "schemas": [],
+ }
+ return type("Frame", (), {"channel_id": "schema", "payload": msgpack.packb(response, use_bin_type=True)})()
+
+ def close(self) -> None:
+ pass
+
+ client = CultNetRawClient(
+ "unused.example.test",
+ 1,
+ create_transport=FakeSchemaTransport,
+ )
+
+ response = client.fetch_schema_catalog(message_id="transport-factory")
+
+ self.assertEqual(response["schemaVersion"], "cultnet.schema_catalog_response.v0")
+ self.assertEqual(sent_messages[0]["schemaVersion"], "cultnet.schema_catalog_request.v0")
+ self.assertEqual(sent_messages[0]["messageId"], "transport-factory")
+
+ def test_cultmesh_client_uses_rudp_schema_transport_endpoint(self) -> None:
+ import msgpack # type: ignore
+
+ server_socket = bind_udp_socket()
+ server_host, server_port = server_socket.getsockname()[:2]
+ connection_id = 0x43554C54
+ server = CultNetRudpSocketTransportConnection(
+ CultNetRudpSocketTransportOptions(
+ runtime_id="python-rudp-schema-server",
+ socket=server_socket,
+ mode=CultNetRudpSocketMode.SERVER,
+ connection_id=connection_id,
+ initial_sequence=100,
+ resend_delay_ms=25,
+ )
+ )
+ received_messages: list[dict[str, object]] = []
+
+ def serve_once() -> None:
+ deadline = time.monotonic() + 2.0
+ try:
+ while time.monotonic() < deadline:
+ frame = server.receive_once()
+ server.poll_resends()
+ if frame is None:
+ time.sleep(0.005)
+ continue
+ received = msgpack.unpackb(frame.payload, raw=False)
+ received_messages.append(received)
+ response = {
+ "schemaVersion": "cultnet.schema_catalog_response.v0",
+ "messageId": received["messageId"],
+ "schemas": [],
+ }
+ server.send("schema", msgpack.packb(response, use_bin_type=True))
+ return
+ raise AssertionError("RUDP schema server did not receive a request")
+ finally:
+ server.close()
+
+ server_thread = threading.Thread(target=serve_once, daemon=True)
+ server_thread.start()
+ client = CultMesh.create_client(
+ endpoint=f"rudp://{server_host}:{server_port}",
+ timeout_seconds=2.0,
+ connection_id=connection_id,
+ runtime_id="python-rudp-schema-client",
+ )
+
+ response = client.fetch_schema_catalog(message_id="rudp-transport-factory")
+ server_thread.join(timeout=2.0)
+
+ self.assertFalse(server_thread.is_alive())
+ self.assertEqual(response["schemaVersion"], "cultnet.schema_catalog_response.v0")
+ self.assertEqual(response["messageId"], "rudp-transport-factory")
+ self.assertEqual(received_messages[0]["schemaVersion"], "cultnet.schema_catalog_request.v0")
+ self.assertEqual(received_messages[0]["messageId"], "rudp-transport-factory")
+
+ def test_cultnet_rudp_packet_codec_uses_deterministic_reliable_ordered_fixture(self) -> None:
+ encoded = encode_rudp_packet(
+ CultNetRudpPacket(
+ packet_type=CultNetRudpPacketType.DATA,
+ connection_id=0x01020304,
+ sequence=0x0000002A,
+ ack=0x00000029,
+ ack_mask=0x80000001,
+ channel_id="schema",
+ reliable=True,
+ ordered=True,
+ fragment_id=7,
+ fragment_index=1,
+ fragment_count=3,
+ payload=b"hello",
+ )
+ )
+
+ self.assertEqual(
+ encoded.hex(),
+ "434e523000030b2a010203040000002a0000002980000001000700010003000000050600736368656d6168656c6c6f",
+ )
+
+ decoded = decode_rudp_packet(encoded)
+ self.assertEqual(decoded.packet_type, CultNetRudpPacketType.DATA)
+ self.assertEqual(decoded.connection_id, 0x01020304)
+ self.assertEqual(decoded.sequence, 0x0000002A)
+ self.assertEqual(decoded.ack, 0x00000029)
+ self.assertEqual(decoded.ack_mask, 0x80000001)
+ self.assertEqual(decoded.channel_id, "schema")
+ self.assertTrue(decoded.reliable)
+ self.assertTrue(decoded.ordered)
+ self.assertFalse(decoded.sequenced)
+ self.assertEqual(decoded.fragment_id, 7)
+ self.assertEqual(decoded.fragment_index, 1)
+ self.assertEqual(decoded.fragment_count, 3)
+ self.assertEqual(decoded.payload, b"hello")
+
+ def test_cultnet_rudp_transport_profile_advertises_state_and_realtime_channels(self) -> None:
+ profile = create_rudp_transport_profile(
+ "python-rudp",
+ transport_id="public-rudp",
+ host="127.0.0.1",
+ port=7777,
+ max_payload_bytes=1200,
+ max_fragment_bytes=1000,
+ )
+
+ self.assertEqual(profile["transports"][0]["protocol"], "rudp")
+ self.assertEqual(profile["transports"][0]["reconnectPolicy"]["schemaVersion"], "cultnet.reconnect_policy.v0")
+ self.assertEqual(profile["transports"][0]["reconnectPolicy"]["baseDelayMs"], 1_000)
+ self.assertEqual(
+ [
+ (channel["channelId"], channel["delivery"], channel["ordering"])
+ for channel in profile["transports"][0]["channels"]
+ ],
+ [
+ ("schema", "reliable", "ordered"),
+ ("latest", "unreliable", "sequenced"),
+ ("realtime", "unreliable", "unordered"),
+ ],
+ )
+
+ def test_cultnet_reconnect_policy_exposes_portable_delay_contract(self) -> None:
+ policy = create_reconnect_policy(policy_id="rudp-default", max_attempts=8)
+
+ self.assertEqual(policy.schema_version, "cultnet.reconnect_policy.v0")
+ self.assertEqual(policy.policy_id, "rudp-default")
+ self.assertEqual(policy.max_attempts, 8)
+ self.assertEqual(policy.to_wire()["policyId"], "rudp-default")
+ self.assertEqual(policy.to_wire()["maxAttempts"], 8)
+ self.assertEqual(compute_reconnect_delay_ms(policy, 1), 1_000)
+ self.assertEqual(compute_reconnect_delay_ms(policy, 3, 17), 4_017)
+ self.assertEqual(compute_reconnect_delay_ms(policy, 9, 999), 30_250)
+ self.assertEqual(compute_reconnect_delay_ms(policy, 0, -5), 1_000)
+
+ def test_cultnet_reconnect_controller_schedules_attempts_and_reset(self) -> None:
+ controller = CultNetReconnectController(create_reconnect_policy(max_attempts=2))
+
+ first = controller.record_failure(10_000)
+ self.assertEqual(first.attempt, 1)
+ self.assertTrue(first.should_retry)
+ self.assertEqual(first.delay_ms, 1_000)
+ self.assertEqual(first.next_attempt_at_ms, 11_000)
+ self.assertFalse(first.exhausted)
+ self.assertFalse(controller.can_attempt(10_999))
+ self.assertTrue(controller.can_attempt(11_000))
+
+ second = controller.record_failure(11_000, 17)
+ self.assertEqual(second.attempt, 2)
+ self.assertEqual(second.delay_ms, 2_017)
+ self.assertEqual(second.next_attempt_at_ms, 13_017)
+ self.assertTrue(second.should_retry)
+
+ exhausted = controller.record_failure(13_017)
+ self.assertEqual(exhausted.attempt, 2)
+ self.assertFalse(exhausted.should_retry)
+ self.assertEqual(exhausted.delay_ms, 0)
+ self.assertIsNone(exhausted.next_attempt_at_ms)
+ self.assertTrue(exhausted.exhausted)
+ self.assertFalse(controller.can_attempt(99_000))
+
+ controller.reset()
+ self.assertEqual(controller.attempt, 0)
+ self.assertIsNone(controller.next_attempt_at_ms)
+ self.assertFalse(controller.exhausted)
+ self.assertTrue(controller.can_attempt(99_000))
+
+ def test_cultnet_rudp_reconnect_loop_consumes_shared_controller(self) -> None:
+ class FakeRudpReconnectTransport:
+ def __init__(self) -> None:
+ self.connect_calls = 0
+ self.close_calls = 0
+ self.last_payload: bytes | None = None
+
+ def connect(self, payload: bytes = b"") -> None:
+ self.connect_calls += 1
+ self.last_payload = bytes(payload)
+
+ def close(self) -> None:
+ self.close_calls += 1
+
+ now_ms = 10_000
+ captured_callback: list[Callable[[], None]] = []
+ transports: list[FakeRudpReconnectTransport] = []
+
+ def create_transport() -> FakeRudpReconnectTransport:
+ transport = FakeRudpReconnectTransport()
+ transports.append(transport)
+ return transport
+
+ def scheduler(delay_ms: int, callback: Callable[[], None]) -> Callable[[], None]:
+ self.assertEqual(delay_ms, 1_017)
+ captured_callback[:] = [callback]
+
+ def cancel() -> None:
+ captured_callback.clear()
+
+ return cancel
+
+ loop = CultNetRudpReconnectLoop(
+ create_transport,
+ reconnect_policy=create_reconnect_policy(max_attempts=2),
+ connect_payload=b"join",
+ now_ms=lambda: now_ms,
+ jitter_ms=lambda: 17,
+ scheduler=scheduler,
+ )
+
+ first = loop.start()
+ self.assertEqual(first.connect_calls, 1)
+ self.assertEqual(first.last_payload, b"join")
+ self.assertIs(loop.transport, first)
+
+ decision = loop.handle_closed()
+ self.assertIsNotNone(decision)
+ self.assertEqual(decision.attempt, 1)
+ self.assertTrue(decision.should_retry)
+ self.assertEqual(decision.delay_ms, 1_017)
+ self.assertEqual(decision.next_attempt_at_ms, 11_017)
+ self.assertEqual(loop.reconnect_controller.attempt, 1)
+ self.assertEqual(loop.reconnect_controller.next_attempt_at_ms, 11_017)
+ self.assertEqual(len(captured_callback), 1)
+
+ now_ms = 11_017
+ captured_callback[0]()
+ self.assertEqual(len(transports), 2)
+ self.assertEqual(transports[1].connect_calls, 1)
+ self.assertIs(loop.transport, transports[1])
+
+ loop.mark_connected()
+ self.assertEqual(loop.reconnect_controller.attempt, 0)
+
+ loop.stop()
+ self.assertEqual(transports[1].close_calls, 1)
+ self.assertIsNone(loop.transport)
+
+ def test_cultnet_rudp_session_handshake_acks_reliable_connect_and_accept_packets(self) -> None:
+ client = CultNetRudpSession(
+ CultNetRudpSessionOptions(connection_id=0x0A0B0C0D, initial_sequence=1, resend_delay_ms=50)
+ )
+ server = CultNetRudpSession(
+ CultNetRudpSessionOptions(connection_id=0x0A0B0C0D, initial_sequence=100, resend_delay_ms=50)
+ )
+
+ connect = client.create_connect(0, b"join")
+ self.assertEqual(connect.packet_type, CultNetRudpPacketType.CONNECT)
+ self.assertEqual(connect.sequence, 1)
+ self.assertEqual(client.pending_reliable_sequences, (1,))
+
+ accept = server.accept_connect(connect, 10, b"ok")
+ self.assertEqual(accept.packet_type, CultNetRudpPacketType.ACCEPT)
+ self.assertEqual(accept.ack, 1)
+ self.assertTrue(server.connected)
+ self.assertEqual(server.pending_reliable_sequences, (100,))
+
+ client.receive(accept, 20)
+ self.assertTrue(client.connected)
+ self.assertEqual(client.pending_reliable_sequences, ())
+
+ ack = client.create_ack()
+ self.assertEqual(ack.ack, 100)
+ server.receive(ack, 30)
+ self.assertEqual(server.pending_reliable_sequences, ())
+
+ def test_cultnet_rudp_session_computes_ack_masks_and_clears_pending_reliable_packets(self) -> None:
+ sender = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=7, initial_sequence=10, resend_delay_ms=100))
+ receiver = CultNetRudpSession(
+ CultNetRudpSessionOptions(connection_id=7, initial_sequence=200, resend_delay_ms=100)
+ )
+ sender.receive(
+ CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 7, 1, 0, 0, "control")
+ )
+ receiver.receive(
+ CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 7, 2, 0, 0, "control")
+ )
+
+ first = sender.send(
+ "schema",
+ b"first",
+ CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=0),
+ )
+ second = sender.send(
+ "schema",
+ b"second",
+ CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=0),
+ )
+ third = sender.send(
+ "schema",
+ b"third",
+ CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=0),
+ )
+ self.assertEqual(sender.pending_reliable_sequences, (10, 11, 12))
+
+ receiver.receive(first)
+ receiver.receive(third)
+ ack_with_gap = receiver.create_ack()
+ self.assertEqual(ack_with_gap.ack, 12)
+ self.assertEqual(ack_with_gap.ack_mask, 0b10 | (1 << 9))
+ sender.receive(ack_with_gap)
+ self.assertEqual(sender.pending_reliable_sequences, (11,))
+
+ receiver.receive(second)
+ full_ack = receiver.create_ack()
+ self.assertEqual(full_ack.ack, 12)
+ self.assertEqual(full_ack.ack_mask, 0b11 | (1 << 9))
+ sender.receive(full_ack)
+ self.assertEqual(sender.pending_reliable_sequences, ())
+
+ def test_cultnet_rudp_session_schedules_reliable_resends_until_acked(self) -> None:
+ session = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=99, initial_sequence=1, resend_delay_ms=100))
+ session.receive(
+ CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 99, 50, 0, 0, "control")
+ )
+ sent = session.send(
+ "schema",
+ b"payload",
+ CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=10),
+ )
+
+ self.assertEqual(session.due_resends(90), ())
+ self.assertEqual(tuple(packet.sequence for packet in session.due_resends(110)), (sent.sequence,))
+ self.assertEqual(session.due_resends(150), ())
+
+ session.receive(
+ CultNetRudpPacket(CultNetRudpPacketType.ACK, 99, 51, sent.sequence, 0, "control")
+ )
+ self.assertEqual(session.due_resends(250), ())
+
+ def test_cultnet_rudp_session_pings_and_detects_receive_timeout(self) -> None:
+ client = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=101, initial_sequence=1))
+ server = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=101, initial_sequence=100))
+ connect = client.create_connect(0, b"join")
+ accept = server.accept_connect(connect, 10)
+ client.receive(accept, 20)
+
+ ping = client.create_ping(b"pulse")
+ ping_result = server.receive(ping, 30)
+ self.assertIsNotNone(ping_result.reply)
+ self.assertEqual(ping_result.reply.packet_type, CultNetRudpPacketType.PONG)
+ self.assertEqual(ping_result.reply.payload, b"pulse")
+
+ pong_result = client.receive(ping_result.reply, 40)
+ self.assertTrue(pong_result.pong)
+ self.assertEqual(pong_result.pong_payload, b"pulse")
+ self.assertFalse(client.check_timeout(90, 50))
+ self.assertTrue(client.check_timeout(91, 50))
+ self.assertFalse(client.connected)
+
+ def test_cultnet_rudp_session_bounds_pending_reliable_packets_before_enqueue(self) -> None:
+ session = CultNetRudpSession(
+ CultNetRudpSessionOptions(connection_id=102, initial_sequence=1, max_pending_reliable_packets=2)
+ )
+ session.receive(CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 102, 50, 0, 0, "control"))
+ session.send("schema", b"first", CultNetRudpSendOptions(reliable=True, ordered=True))
+ session.send("schema", b"second", CultNetRudpSendOptions(reliable=True, ordered=True))
+ with self.assertRaisesRegex(ValueError, "reliable send queue is full"):
+ session.send("schema", b"third", CultNetRudpSendOptions(reliable=True, ordered=True))
+ self.assertEqual(session.pending_reliable_sequences, (1, 2))
+
+ fragmented = CultNetRudpSession(
+ CultNetRudpSessionOptions(connection_id=103, initial_sequence=1, max_pending_reliable_packets=3)
+ )
+ fragmented.receive(CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 103, 50, 0, 0, "control"))
+ with self.assertRaisesRegex(ValueError, "reliable send queue is full"):
+ fragmented.send_many(
+ "schema",
+ b"fragment-me",
+ CultNetRudpSendOptions(reliable=True, ordered=True),
+ max_fragment_bytes=3,
+ )
+ self.assertEqual(fragmented.pending_reliable_sequences, ())
+
+ def test_cultnet_rudp_session_suppresses_duplicates_and_delivers_reliable_ordered_payloads(self) -> None:
+ sender = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=123, initial_sequence=1))
+ receiver = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=123, initial_sequence=100))
+ sender.receive(
+ CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 123, 90, 0, 0, "control")
+ )
+ receiver.receive(
+ CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 123, 91, 0, 0, "control")
+ )
+
+ first = sender.send("schema", b"first", CultNetRudpSendOptions(reliable=True, ordered=True))
+ second = sender.send("schema", b"second", CultNetRudpSendOptions(reliable=True, ordered=True))
+ third = sender.send("schema", b"third", CultNetRudpSendOptions(reliable=True, ordered=True))
+
+ self.assertEqual([frame.payload.decode("utf-8") for frame in receiver.receive(first).delivered], ["first"])
+ self.assertEqual(receiver.receive(third).delivered, ())
+ self.assertEqual(receiver.receive(first).delivered, ())
+ self.assertEqual(
+ [frame.payload.decode("utf-8") for frame in receiver.receive(second).delivered],
+ ["second", "third"],
+ )
+
+ def test_cultnet_rudp_session_skips_control_packets_while_ordering_schema_payloads(self) -> None:
+ sender = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=124, initial_sequence=1))
+ receiver = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=124, initial_sequence=100))
+ sender.receive(
+ CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 124, 90, 0, 0, "control")
+ )
+ receiver.receive(
+ CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 124, 91, 0, 0, "control")
+ )
+
+ first = sender.send("schema", b"first", CultNetRudpSendOptions(reliable=True, ordered=True))
+ control = sender.create_ack()
+ second = sender.send("schema", b"second", CultNetRudpSendOptions(reliable=True, ordered=True))
+
+ self.assertEqual([frame.payload.decode("utf-8") for frame in receiver.receive(first).delivered], ["first"])
+ self.assertEqual(receiver.receive(control).delivered, ())
+ self.assertEqual([frame.payload.decode("utf-8") for frame in receiver.receive(second).delivered], ["second"])
+
+ def test_cultnet_rudp_session_fragments_and_reassembles_reliable_ordered_payloads(self) -> None:
+ sender = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=456, initial_sequence=1))
+ receiver = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=456, initial_sequence=100))
+ sender.receive(
+ CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 456, 90, 0, 0, "control")
+ )
+ receiver.receive(
+ CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 456, 91, 0, 0, "control")
+ )
+
+ packets = sender.send_many(
+ "schema",
+ b"fragment-me-please",
+ CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=10),
+ max_fragment_bytes=5,
+ )
+ self.assertEqual(len(packets), 4)
+ self.assertEqual(tuple(packet.fragment_count for packet in packets), (4, 4, 4, 4))
+ self.assertEqual(tuple(packet.fragment_index for packet in packets), (0, 1, 2, 3))
+ self.assertTrue(all(packet.fragment_id == packets[0].fragment_id for packet in packets))
+
+ self.assertEqual(receiver.receive(packets[0]).delivered, ())
+ self.assertEqual(receiver.receive(packets[1]).delivered, ())
+ self.assertEqual(receiver.receive(packets[2]).delivered, ())
+ delivered = receiver.receive(packets[3]).delivered
+ self.assertEqual(len(delivered), 1)
+ self.assertEqual(delivered[0].payload, b"fragment-me-please")
+ self.assertEqual(delivered[0].sequence, packets[0].sequence)
+
+ def test_cultnet_rudp_socket_transport_handshakes_and_carries_reliable_ordered_schema_frames(self) -> None:
+ server_socket = bind_udp_socket()
+ client_socket = bind_udp_socket()
+ connection_id = 0x10203040
+ server = CultNetRudpSocketTransportConnection(
+ CultNetRudpSocketTransportOptions(
+ runtime_id="python-rudp-server",
+ socket=server_socket,
+ mode=CultNetRudpSocketMode.SERVER,
+ connection_id=connection_id,
+ initial_sequence=100,
+ resend_delay_ms=25,
+ )
+ )
+ client = CultNetRudpSocketTransportConnection(
+ CultNetRudpSocketTransportOptions(
+ runtime_id="python-rudp-client",
+ socket=client_socket,
+ mode=CultNetRudpSocketMode.CLIENT,
+ remote_addr=server_socket.getsockname(),
+ connection_id=connection_id,
+ initial_sequence=1,
+ resend_delay_ms=25,
+ )
+ )
+
+ try:
+ client.connect(b"join")
+ pump_rudp_handshake(client, server)
+ self.assertTrue(client.connected)
+ self.assertTrue(server.connected)
+
+ client.send("schema", b"client-state")
+ server_frame = receive_rudp_frame(server)
+ self.assertEqual(server_frame.channel_id, "schema")
+ self.assertEqual(server_frame.payload, b"client-state")
+
+ server.send("schema", b"server-state")
+ client_frame = receive_rudp_frame(client)
+ self.assertEqual(client_frame.channel_id, "schema")
+ self.assertEqual(client_frame.payload, b"server-state")
+ self.assertEqual(server.profile["transports"][0]["protocol"], "rudp")
+ self.assertEqual(client.stats.frames_sent, 1)
+ self.assertEqual(server.stats.frames_received, 1)
+ finally:
+ client.close()
+ server.close()
+
+ def test_cultnet_rudp_socket_transport_carries_fragmented_reliable_ordered_schema_frames(self) -> None:
+ server_socket = bind_udp_socket()
+ client_socket = bind_udp_socket()
+ connection_id = 0x10203041
+ server = CultNetRudpSocketTransportConnection(
+ CultNetRudpSocketTransportOptions(
+ runtime_id="python-rudp-fragment-server",
+ socket=server_socket,
+ mode=CultNetRudpSocketMode.SERVER,
+ connection_id=connection_id,
+ initial_sequence=100,
+ resend_delay_ms=25,
+ max_fragment_bytes=8,
+ )
+ )
+ client = CultNetRudpSocketTransportConnection(
+ CultNetRudpSocketTransportOptions(
+ runtime_id="python-rudp-fragment-client",
+ socket=client_socket,
+ mode=CultNetRudpSocketMode.CLIENT,
+ remote_addr=server_socket.getsockname(),
+ connection_id=connection_id,
+ initial_sequence=1,
+ resend_delay_ms=25,
+ max_fragment_bytes=8,
+ )
+ )
+
+ try:
+ payload = b"this-schema-frame-is-larger-than-one-rudp-fragment"
+ client.connect(b"join")
+ pump_rudp_handshake(client, server)
+ client.send("schema", payload)
+ server_frame = receive_rudp_frame(server)
+ self.assertEqual(server_frame.channel_id, "schema")
+ self.assertEqual(server_frame.payload, payload)
+ self.assertEqual(client.stats.frames_sent, 1)
+ self.assertEqual(server.stats.frames_received, 1)
+ finally:
+ client.close()
+ server.close()
+
+ def test_cultmesh_facade_creates_rudp_client_from_peer_endpoint(self) -> None:
+ connection_id = 0x10203042
+ server = CultMesh.create_rudp_server(
+ "python-cultmesh-rudp-server",
+ connection_id,
+ initial_sequence=100,
+ resend_delay_ms=25,
+ max_fragment_bytes=1024,
+ max_pending_reliable_packets=16,
+ )
+ server_host, server_port = server.socket.getsockname()[:2]
+ endpoint = CultMesh.parse_rudp_endpoint(f"rudp://{server_host}:{server_port}")
+ self.assertEqual(endpoint.host, server_host)
+ self.assertEqual(endpoint.port, server_port)
+ peer = CultMeshPeerCard(
+ peer_id="python-cultmesh-rudp-server",
+ verse_id="local",
+ endpoints=(endpoint.uri,),
+ roles=("schema",),
+ authority_lease_id="lease:python-cultmesh-rudp-server",
+ )
+ peers = CultMesh.create_peer_catalog()
+ leases = CultMesh.create_authority_lease_catalog()
+ peers.upsert(peer)
+ with self.assertRaisesRegex(ValueError, "No authorized RUDP peer"):
+ CultMesh.create_rudp_client_for_authorized_peer(
+ "python-cultmesh-rudp-client",
+ connection_id,
+ peers,
+ leases,
+ "local",
+ "schema",
+ resend_delay_ms=25,
+ max_fragment_bytes=1024,
+ max_pending_reliable_packets=16,
+ )
+ leases.upsert(
+ CultMeshAuthorityLease(
+ lease_id="lease:python-cultmesh-rudp-server",
+ verse_id="local",
+ peer_id="python-cultmesh-rudp-server",
+ roles=("schema",),
+ valid_from=datetime.now(UTC) - timedelta(seconds=1),
+ expires_at=datetime.now(UTC) + timedelta(seconds=30),
+ issuer_runtime_id="python-authority",
+ )
+ )
+ client = CultMesh.create_rudp_client_for_authorized_peer(
+ "python-cultmesh-rudp-client",
+ connection_id,
+ peers,
+ leases,
+ "local",
+ "schema",
+ resend_delay_ms=25,
+ max_fragment_bytes=1024,
+ max_pending_reliable_packets=16,
+ )
+
+ try:
+ client.connect(b"join")
+ pump_rudp_handshake(client, server)
+ self.assertTrue(client.connected)
+ self.assertTrue(server.connected)
+
+ client.send("schema", b"client-state")
+ server_frame = receive_rudp_frame(server)
+ self.assertEqual(server_frame.channel_id, "schema")
+ self.assertEqual(server_frame.payload, b"client-state")
+ self.assertEqual(server.profile["transports"][0]["protocol"], "rudp")
+ self.assertEqual(client.profile["transports"][0]["protocol"], "rudp")
+ finally:
+ client.close()
+ server.close()
+
+ def test_cultnet_database_subscription_helpers_match_schema_v0_shape(self) -> None:
+ subscribe = database_subscribe(
+ message_id="sub-message",
+ subscription_id="sub-1",
+ schema_ids=["schema-a"],
+ record_keys=["record-a"],
+ include_snapshot=False,
+ ).to_wire()
+ self.assertEqual(subscribe["schemaVersion"], "cultnet.database_subscribe.v0")
+ self.assertEqual(subscribe["messageId"], "sub-message")
+ self.assertEqual(subscribe["subscriptionId"], "sub-1")
+ self.assertEqual(subscribe["schemaIds"], ["schema-a"])
+ self.assertEqual(subscribe["recordKeys"], ["record-a"])
+ self.assertFalse(subscribe["includeSnapshot"])
+
+ unsubscribe = database_unsubscribe(message_id="unsub-message", subscription_id="sub-1").to_wire()
+ self.assertEqual(unsubscribe["schemaVersion"], "cultnet.database_unsubscribe.v0")
+ self.assertEqual(unsubscribe["messageId"], "unsub-message")
+ self.assertEqual(unsubscribe["subscriptionId"], "sub-1")
+
+ def test_cultnet_database_change_parses_put_and_delete_shapes(self) -> None:
+ put_change = CultNetDatabaseChange.from_wire({
+ "schemaVersion": "cultnet.database_change_raw.v0",
+ "messageId": "change-put",
+ "subscriptionId": "sub-1",
+ "changeKind": "added",
+ "document": {
+ "schemaId": "schema-note",
+ "recordKey": "note:1",
+ "payload": b"payload",
+ },
+ })
+ self.assertEqual(put_change.schema_id, "schema-note")
+ self.assertEqual(put_change.record_key, "note:1")
+ self.assertIsInstance(put_change.raw_document, CultNetRawDocumentRecord)
+ self.assertEqual(put_change.raw_document.payload, b"payload")
+ self.assertEqual(put_change.to_wire()["document"]["recordKey"], "note:1")
+
+ delete_change = CultNetDatabaseChange.from_wire({
+ "schemaVersion": "cultnet.database_change_raw.v0",
+ "messageId": "change-delete",
+ "subscriptionId": "sub-1",
+ "changeKind": "removed",
+ "schemaId": "schema-note",
+ "recordKey": "note:1",
+ })
+ self.assertIsNone(delete_change.document)
+ self.assertIsNone(delete_change.raw_document)
+ self.assertEqual(delete_change.schema_id, "schema-note")
+ self.assertEqual(delete_change.to_wire()["recordKey"], "note:1")
+
+ def test_cultnet_raw_put_helper_carries_message_id(self) -> None:
+ put = document_put_raw(
+ message_id="put-1",
+ key="record-a",
+ schema_id="schema-a",
+ stored_at="2026-06-13T00:00:00Z",
+ payload=b"payload",
+ source_runtime_id="python-test",
+ shard_id="interop",
+ shard_epoch=1,
+ ).to_wire()
+ self.assertEqual(put["schemaVersion"], "cultnet.document_put_raw.v0")
+ self.assertEqual(put["messageId"], "put-1")
+ self.assertEqual(put["document"]["recordKey"], "record-a")
+ self.assertEqual(put["document"]["sourceRuntimeId"], "python-test")
+ self.assertEqual(put["shardId"], "interop")
+ self.assertEqual(put["shardEpoch"], 1)
+
+ def test_cultnet_catalog_and_snapshot_helpers_accept_filters(self) -> None:
+ catalog = schema_catalog_request(
+ message_id="catalog-1",
+ include_schema_json=True,
+ schema_ids=["schema-a"],
+ kinds=["wire_message"],
+ ).to_wire()
+ self.assertEqual(catalog["schemaVersion"], "cultnet.schema_catalog_request.v0")
+ self.assertEqual(catalog["messageId"], "catalog-1")
+ self.assertTrue(catalog["includeSchemaJson"])
+ self.assertEqual(catalog["schemaIds"], ["schema-a"])
+ self.assertEqual(catalog["kinds"], ["wire_message"])
+
+ snapshot = snapshot_request(
+ message_id="snapshot-1",
+ schema_ids=["schema-a"],
+ record_keys=["record-a"],
+ shard_id="interop",
+ shard_epoch=1,
+ ).to_wire()
+ self.assertEqual(snapshot["schemaVersion"], "cultnet.snapshot_request.v0")
+ self.assertEqual(snapshot["messageId"], "snapshot-1")
+ self.assertEqual(snapshot["schemaIds"], ["schema-a"])
+ self.assertEqual(snapshot["recordKeys"], ["record-a"])
+ self.assertEqual(snapshot["shardId"], "interop")
+ self.assertEqual(snapshot["shardEpoch"], 1)
+
+ def test_cultnet_raw_snapshot_response_filters_and_roundtrips_records(self) -> None:
+ response = CultNetRawSnapshotResponse.from_wire({
+ "schemaVersion": "cultnet.snapshot_response_raw.v0",
+ "messageId": "snapshot-1",
+ "shardId": "notes",
+ "shardEpoch": 3,
+ "shardLogSequence": 8,
+ "documents": [
+ CultNetRawDocumentRecord(
+ schema_id="schema-note",
+ record_key="note:1",
+ stored_at="2026-06-14T00:00:00Z",
+ payload=b"note",
+ source_runtime_id="python-test",
+ tags=("snapshot",),
+ ).to_wire(),
+ {
+ "schemaId": "schema-fact",
+ "recordKey": "fact:1",
+ "payloadEncoding": "messagepack",
+ "payload": b"fact",
+ },
+ ],
+ })
+ self.assertEqual(response.shard_id, "notes")
+ self.assertEqual(response.shard_epoch, 3)
+ self.assertEqual(response.shard_log_sequence, 8)
+ self.assertEqual(response.documents[0].source_runtime_id, "python-test")
+ self.assertEqual(response.filter(schema_ids=["schema-note"])[0].record_key, "note:1")
+ self.assertEqual(response.filter(record_keys=["fact:1"])[0].schema_id, "schema-fact")
+ self.assertEqual(response.to_wire()["documents"][0]["tags"], ["snapshot"])
+
+ def test_cultnet_raw_client_fetches_schema_snapshot_and_shard_reads(self) -> None:
+ import msgpack # type: ignore
+ from cultnet_py import read_frame, write_frame
+
+ received_versions: list[str] = []
+ ready = threading.Event()
+ server_error: list[BaseException] = []
+ port_holder: list[int] = []
+
+ def serve_requests() -> None:
+ try:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
+ server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ server.bind(("127.0.0.1", 0))
+ port_holder.append(server.getsockname()[1])
+ server.listen(4)
+ ready.set()
+ for _ in range(4):
+ connection, _ = server.accept()
+ with connection:
+ stream = connection.makefile("rwb")
+ request = msgpack.unpackb(read_frame(stream), raw=False)
+ received_versions.append(request["schemaVersion"])
+ if request["schemaVersion"] == "cultnet.schema_catalog_request.v0":
+ self.assertEqual(request["kinds"], ["wire_message"])
+ response = {"schemaVersion": "cultnet.schema_catalog_response.v0", "messageId": request["messageId"], "schemas": []}
+ elif request["schemaVersion"] == "cultnet.snapshot_request.v0":
+ self.assertEqual(request["shardId"], "interop")
+ response = {"schemaVersion": "cultnet.snapshot_response_raw.v0", "messageId": request["messageId"], "documents": []}
+ elif request["schemaVersion"] == "cultnet.shard_catalog_request.v0":
+ response = {
+ "schemaVersion": "cultnet.shard_catalog_response.v0",
+ "messageId": request["messageId"],
+ "shards": [{
+ "shardId": "interop",
+ "ownerRuntimeId": "socket-test",
+ "epoch": 1,
+ "schemaIds": request["schemaIds"],
+ "keyPrefix": "record-",
+ "primaryEndpoints": ["cultnet://127.0.0.1:1"],
+ }],
+ }
+ elif request["schemaVersion"] == "cultnet.shard_log_request.v0":
+ self.assertEqual(request["afterSequence"], 7)
+ response = {
+ "schemaVersion": "cultnet.shard_log_response.v0",
+ "messageId": request["messageId"],
+ "shardId": request["shardId"],
+ "shardEpoch": request["shardEpoch"],
+ "entries": [],
+ "resyncRequired": False,
+ }
+ else:
+ raise AssertionError(f"unexpected request {request['schemaVersion']}")
+ write_frame(stream, msgpack.packb(response, use_bin_type=True))
+ stream.flush()
+ except BaseException as error:
+ server_error.append(error)
+ ready.set()
+
+ thread = threading.Thread(target=serve_requests, daemon=True)
+ thread.start()
+ self.assertTrue(ready.wait(2.0))
+ self.assertFalse(server_error)
+
+ client = CultNetRawClient("127.0.0.1", port_holder[0], timeout_seconds=2.0)
+ self.assertEqual(client.fetch_schema_catalog(kinds=["wire_message"])["schemaVersion"], "cultnet.schema_catalog_response.v0")
+ typed_snapshot = client.fetch_snapshot_response(shard_id="interop", shard_epoch=1)
+ self.assertEqual(typed_snapshot.shard_id, None)
+ self.assertEqual(typed_snapshot.documents, ())
+ shard_catalog = CultNetShardCatalog()
+ synced_shards = client.sync_shard_catalog(shard_catalog, schema_ids=["schema-a"])
+ self.assertEqual(synced_shards[0].shard_id, "interop")
+ self.assertEqual(shard_catalog.get("interop"), synced_shards[0])
+ shard_log = client.fetch_shard_log_response(shard_id="interop", shard_epoch=1, after_sequence=7)
+ self.assertEqual(shard_log.shard_id, "interop")
+ self.assertFalse(shard_log.resync_required)
+ self.assertEqual(shard_log.last_sequence, 0)
+
+ thread.join(2.0)
+ self.assertFalse(server_error)
+ self.assertEqual(received_versions, [
+ "cultnet.schema_catalog_request.v0",
+ "cultnet.snapshot_request.v0",
+ "cultnet.shard_catalog_request.v0",
+ "cultnet.shard_log_request.v0",
+ ])
+
+ def test_cultnet_raw_client_raises_typed_peer_error_response(self) -> None:
+ import msgpack # type: ignore
+ from cultnet_py import read_frame, write_frame
+
+ ready = threading.Event()
+ port_holder: list[int] = []
+
+ def serve_error() -> None:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
+ server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ server.bind(("127.0.0.1", 0))
+ port_holder.append(server.getsockname()[1])
+ server.listen(1)
+ ready.set()
+ connection, _ = server.accept()
+ with connection:
+ stream = connection.makefile("rwb")
+ read_frame(stream)
+ write_frame(stream, msgpack.packb({
+ "schemaVersion": "cultnet.error.v0",
+ "messageId": "error-1",
+ "error": "Snapshot document limit exceeded",
+ }, use_bin_type=True))
+ stream.flush()
+
+ thread = threading.Thread(target=serve_error, daemon=True)
+ thread.start()
+ self.assertTrue(ready.wait(2.0))
+
+ client = CultNetRawClient("127.0.0.1", port_holder[0], timeout_seconds=2.0)
+ with self.assertRaisesRegex(CultNetPeerError, "Snapshot document limit exceeded") as raised:
+ client.fetch_snapshot()
+
+ self.assertEqual(raised.exception.peer_error, "Snapshot document limit exceeded")
+ self.assertEqual(raised.exception.response["schemaVersion"], "cultnet.error.v0")
+ thread.join(2.0)
+
+ def test_cultnet_database_subscription_reads_snapshot_and_change(self) -> None:
+ import msgpack # type: ignore
+ from cultnet_py import read_frame, write_frame
+
+ document = define_database_entry_type(
+ "sub.item",
+ [
+ ("name", 0),
+ ("category", 1),
+ ("value", 2, 0),
+ ],
+ cls=Item,
+ )
+ schema_id = document.catalog_entry().schema_id
+ ready = threading.Event()
+ server_error: list[BaseException] = []
+ port_holder: list[int] = []
+ received_versions: list[str] = []
+
+ def serve_subscription() -> None:
+ try:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
+ server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ server.bind(("127.0.0.1", 0))
+ port_holder.append(server.getsockname()[1])
+ server.listen(1)
+ ready.set()
+ connection, _ = server.accept()
+ with connection:
+ stream = connection.makefile("rwb")
+ subscribe = msgpack.unpackb(read_frame(stream), raw=False)
+ received_versions.append(subscribe["schemaVersion"])
+ self.assertEqual(subscribe["subscriptionId"], "sub-1")
+ snapshot = {
+ "schemaVersion": "cultnet.snapshot_response_raw.v0",
+ "messageId": subscribe["messageId"],
+ "documents": [],
+ }
+ write_frame(stream, msgpack.packb(snapshot, use_bin_type=True))
+ stream.flush()
+
+ put = msgpack.unpackb(read_frame(stream), raw=False)
+ received_versions.append(put["schemaVersion"])
+ change = {
+ "schemaVersion": "cultnet.database_change_raw.v0",
+ "messageId": "change-1",
+ "subscriptionId": "sub-1",
+ "changeKind": "added",
+ "document": put["document"],
+ }
+ write_frame(stream, msgpack.packb(change, use_bin_type=True))
+ stream.flush()
+
+ unsubscribe = msgpack.unpackb(read_frame(stream), raw=False)
+ received_versions.append(unsubscribe["schemaVersion"])
+ except BaseException as error:
+ server_error.append(error)
+ ready.set()
+
+ thread = threading.Thread(target=serve_subscription, daemon=True)
+ thread.start()
+ self.assertTrue(ready.wait(2.0))
+ self.assertFalse(server_error)
+
+ client = CultNetRawClient("127.0.0.1", port_holder[0], timeout_seconds=2.0)
+ put = document_put_raw(
+ message_id="put-sub",
+ key="item:sub",
+ schema_id=schema_id,
+ stored_at="2026-06-14T00:00:00Z",
+ payload=document.encode_payload(Item("orb", "gear", 8)),
+ )
+ with client.subscribe_database(subscription_id="sub-1", schema_ids=[schema_id]) as subscription:
+ snapshot = subscription.read_next_snapshot_response()
+ subscription.send(put)
+ change = subscription.read_next_change()
+
+ thread.join(2.0)
+ self.assertFalse(server_error)
+ self.assertIsInstance(snapshot, CultNetRawSnapshotResponse)
+ self.assertEqual(snapshot.message_id, "cultnet-python-subscribe")
+ self.assertEqual(snapshot.to_wire()["schemaVersion"], "cultnet.snapshot_response_raw.v0")
+ self.assertEqual(change.change_kind, "added")
+ self.assertEqual(change.record_key, "item:sub")
+ self.assertIsInstance(change.raw_document, CultNetRawDocumentRecord)
+ self.assertEqual(change.raw_document.record_key, "item:sub")
+ self.assertEqual(received_versions, [
+ "cultnet.database_subscribe.v0",
+ "cultnet.document_put_raw.v0",
+ "cultnet.database_unsubscribe.v0",
+ ])
+
+ def test_cultnet_database_subscription_raises_typed_peer_error(self) -> None:
+ import msgpack # type: ignore
+ from cultnet_py import read_frame, write_frame
+
+ ready = threading.Event()
+ port_holder: list[int] = []
+
+ def serve_subscription_error() -> None:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
+ server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ server.bind(("127.0.0.1", 0))
+ port_holder.append(server.getsockname()[1])
+ server.listen(1)
+ ready.set()
+ connection, _ = server.accept()
+ with connection:
+ stream = connection.makefile("rwb")
+ read_frame(stream)
+ write_frame(stream, msgpack.packb({
+ "schemaVersion": "cultnet.error.v0",
+ "messageId": "subscription-error",
+ "error": "subscription rejected",
+ }, use_bin_type=True))
+ stream.flush()
+
+ thread = threading.Thread(target=serve_subscription_error, daemon=True)
+ thread.start()
+ self.assertTrue(ready.wait(2.0))
+
+ client = CultNetRawClient("127.0.0.1", port_holder[0], timeout_seconds=2.0)
+ with client.subscribe_database(subscription_id="sub-error") as subscription:
+ with self.assertRaisesRegex(CultNetPeerError, "subscription rejected") as raised:
+ subscription.read_next()
+ self.assertEqual(raised.exception.response["messageId"], "subscription-error")
+ thread.join(2.0)
+
+ def test_cultnet_replication_helpers_apply_raw_snapshot_and_shard_log(self) -> None:
+ document = define_database_entry_type(
+ "replica.item",
+ [
+ ("name", 0),
+ ("category", 1),
+ ("value", 2, 0),
+ ],
+ cls=Item,
+ )
+ cache = CultCache()
+ cache.register_document_type(document)
+ schema_id = document.catalog_entry().schema_id
+
+ snapshot = {
+ "schemaVersion": "cultnet.snapshot_response_raw.v0",
+ "messageId": "snapshot-1",
+ "documents": [
+ {
+ "schemaId": schema_id,
+ "recordKey": "item:1",
+ "storedAt": "2026-06-13T00:00:00Z",
+ "payloadEncoding": "messagepack",
+ "payload": document.encode_payload(Item("sword", "gear", 3)),
+ }
+ ],
+ }
+ applied_snapshot = apply_raw_snapshot(cache, [document], snapshot)
+ self.assertEqual(applied_snapshot[0].record_key, "item:1")
+ self.assertEqual(cache.get_required(document, "item:1").value, 3)
+ typed_snapshot = CultNetRawSnapshotResponse.from_wire(snapshot)
+ direct_applied = apply_raw_document_record(cache, schema_document_map([document]), typed_snapshot.documents[0])
+ self.assertEqual(direct_applied.schema_id, schema_id)
+ self.assertEqual(direct_applied.record_key, "item:1")
+ typed_snapshot_applied = apply_raw_snapshot(cache, [document], typed_snapshot)
+ self.assertEqual(typed_snapshot_applied[0].record_key, "item:1")
+
+ shard_log = {
+ "schemaVersion": "cultnet.shard_log_response.v0",
+ "messageId": "log-1",
+ "shardId": "interop",
+ "shardEpoch": 1,
+ "resyncRequired": False,
+ "entries": [
+ {
+ "sequence": 0,
+ "changeKind": "updated",
+ "put": {
+ "schemaVersion": "cultnet.document_put_raw.v0",
+ "messageId": "put-unknown",
+ "document": {
+ "schemaId": "replica.unknown.v1",
+ "recordKey": "unknown:1",
+ "storedAt": "2026-06-13T00:00:01Z",
+ "payloadEncoding": "messagepack",
+ "payload": document.encode_payload(Item("ignored", "unknown", 0)),
+ },
+ "shardId": "interop",
+ "shardEpoch": 1,
+ },
+ },
+ {
+ "sequence": 1,
+ "changeKind": "updated",
+ "put": {
+ "schemaVersion": "cultnet.document_put_raw.v0",
+ "messageId": "put-1",
+ "document": {
+ "schemaId": schema_id,
+ "recordKey": "item:1",
+ "storedAt": "2026-06-13T00:00:01Z",
+ "payloadEncoding": "messagepack",
+ "payload": document.encode_payload(Item("sword", "gear", 12)),
+ },
+ "shardId": "interop",
+ "shardEpoch": 1,
+ },
+ },
+ {
+ "sequence": 2,
+ "changeKind": "removed",
+ "delete": {
+ "schemaVersion": "cultnet.document_delete.v0",
+ "messageId": "delete-1",
+ "schemaId": schema_id,
+ "recordKey": "item:1",
+ "shardId": "interop",
+ "shardEpoch": 1,
+ },
+ },
+ ],
+ }
+ applied_log = apply_shard_log_response(cache, [document], shard_log)
+ self.assertEqual([change.change_kind for change in applied_log], ["updated", "removed"])
+ self.assertIsNone(cache.get(document, "item:1"))
+
+ cache.put(document, "item:1", Item("sword", "gear", 3))
+ typed_shard_log = CultNetShardLogResponse.from_wire({
+ **shard_log,
+ "entries": shard_log["entries"][1:],
+ })
+ typed_applied_log = apply_shard_log_response(cache, [document], typed_shard_log)
+ self.assertEqual([change.change_kind for change in typed_applied_log], ["updated", "removed"])
+ self.assertIsNone(cache.get(document, "item:1"))
+
+ def test_cultnet_replication_helpers_recover_schema_stamped_raw_records(self) -> None:
+ document = define_database_entry_type(
+ "replica.runtime-policy",
+ [
+ ("schema_version", 0),
+ ("name", 1),
+ ("value", 2),
+ ],
+ schema_id="replica.runtime-policy.current",
+ schema_name="replica.runtime_policy",
+ schema_version="replica.runtime_policy.v1",
+ )
+ cache = CultCache()
+ cache.register_document_type(document)
+ stale_schema_id = "sha256:stale-replica-runtime-policy"
+ payload = document.encode_payload({
+ "schema_version": "replica.runtime_policy.v1",
+ "name": "policy",
+ "value": "still synced",
+ })
+ record = {
+ "schemaId": stale_schema_id,
+ "recordKey": "policy:1",
+ "storedAt": "2026-06-25T00:00:00Z",
+ "payloadEncoding": "messagepack",
+ "payload": payload,
+ }
+ snapshot = {
+ "schemaVersion": "cultnet.snapshot_response_raw.v0",
+ "messageId": "snapshot-stale",
+ "documents": [record],
+ }
+
+ applied_snapshot = apply_raw_snapshot(cache, [document], snapshot)
+ local_schema_id = document.catalog_entry().schema_id
+ self.assertEqual(applied_snapshot[0].schema_id, local_schema_id)
+ self.assertEqual(cache.get_required(document, "policy:1")["value"], "still synced")
+ self.assertEqual(cache.get_required_envelope(document, "policy:1").schema_id, local_schema_id)
+ direct_applied = apply_raw_document_record(cache, schema_document_map([document]), record)
+ self.assertEqual(direct_applied.schema_id, local_schema_id)
+ self.assertEqual(cache.get_required_envelope(document, "policy:1").schema_id, local_schema_id)
+
+ shard_log = {
+ "schemaVersion": "cultnet.shard_log_response.v0",
+ "messageId": "log-stale",
+ "shardId": "interop",
+ "shardEpoch": 1,
+ "resyncRequired": False,
+ "entries": [
+ {
+ "sequence": 0,
+ "changeKind": "updated",
+ "put": {
+ "schemaVersion": "cultnet.document_put_raw.v0",
+ "messageId": "put-stale",
+ "document": {
+ **record,
+ "payload": document.encode_payload({
+ "schema_version": "replica.runtime_policy.v1",
+ "name": "policy",
+ "value": "updated through log",
+ }),
+ },
+ "shardId": "interop",
+ "shardEpoch": 1,
+ },
+ }
+ ],
+ }
+
+ applied_log = apply_shard_log_response(cache, [document], shard_log)
+ self.assertEqual(applied_log[0].schema_id, local_schema_id)
+ self.assertEqual(cache.get_required(document, "policy:1")["value"], "updated through log")
+ self.assertEqual(cache.get_required_envelope(document, "policy:1").schema_id, local_schema_id)
+
+ delete_log = {
+ "schemaVersion": "cultnet.shard_log_response.v0",
+ "messageId": "log-delete-alias",
+ "shardId": "interop",
+ "shardEpoch": 1,
+ "resyncRequired": False,
+ "entries": [
+ {
+ "sequence": 1,
+ "changeKind": "removed",
+ "delete": {
+ "schemaVersion": "cultnet.document_delete.v0",
+ "messageId": "delete-version-alias",
+ "schemaId": "replica.runtime_policy.v1",
+ "recordKey": "policy:1",
+ "shardId": "interop",
+ "shardEpoch": 1,
+ },
+ }
+ ],
+ }
+ applied_delete = apply_shard_log_response(cache, [document], delete_log)
+ self.assertEqual(applied_delete[0].schema_id, local_schema_id)
+ self.assertEqual(applied_delete[0].change_kind, "removed")
+ self.assertIsNone(cache.get(document, "policy:1"))
+
+ def test_cultmesh_node_applies_foreign_schema_snapshot_as_local_update(self) -> None:
+ document = define_database_entry_type(
+ "mesh.runtime-policy",
+ [
+ ("schema_version", 0),
+ ("name", 1),
+ ("value", 2),
+ ],
+ schema_id="mesh.runtime-policy.current",
+ schema_name="mesh.runtime_policy",
+ schema_version="mesh.runtime_policy.v1",
+ )
+ node = create_node(runtime_id="python-node")
+ node.register_document(document)
+ node.put(document, "policy:1", {
+ "schema_version": "mesh.runtime_policy.v1",
+ "name": "policy",
+ "value": "old",
+ })
+ observed: list[CultMeshDatabaseChange] = []
+ unsubscribe = node.database.watch_record(document, "policy:1", observed.append)
+ try:
+ applied = node.database.apply_snapshot_response({
+ "schemaVersion": "cultnet.snapshot_response_raw.v0",
+ "messageId": "foreign-schema-snapshot",
+ "documents": [
+ {
+ "schemaId": "sha256:foreign-mesh-runtime-policy",
+ "recordKey": "policy:1",
+ "storedAt": "2026-06-25T00:00:00Z",
+ "payloadEncoding": "messagepack",
+ "payload": document.encode_payload({
+ "schema_version": "mesh.runtime_policy.v1",
+ "name": "policy",
+ "value": "new",
+ }),
+ },
+ ],
+ })
+ finally:
+ unsubscribe()
+
+ local_schema_id = document.catalog_entry().schema_id
+ self.assertEqual(applied[0].schema_id, local_schema_id)
+ self.assertEqual(observed[0].schema_id, local_schema_id)
+ self.assertEqual(observed[0].change_kind, "updated")
+ self.assertEqual(observed[0].previous_value["value"], "old")
+ self.assertEqual(observed[0].value["value"], "new")
+ self.assertEqual(node.cache.get_required_envelope(document, "policy:1").schema_id, local_schema_id)
+
+ def test_cultmesh_node_syncs_snapshot_and_shard_log_through_cultnet_client(self) -> None:
+ import msgpack # type: ignore
+ from cultnet_py import read_frame, write_frame
+
+ document = define_database_entry_type(
+ "mesh.sync_item",
+ [
+ ("name", 0),
+ ("category", 1),
+ ("value", 2, 0),
+ ],
+ cls=Item,
+ )
+ node = create_node(runtime_id="python-node")
+ node.register_document(document)
+ schema_id = document.catalog_entry().schema_id
+ ready = threading.Event()
+ server_error: list[BaseException] = []
+ port_holder: list[int] = []
+
+ def serve_requests() -> None:
+ try:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
+ server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ server.bind(("127.0.0.1", 0))
+ port_holder.append(server.getsockname()[1])
+ server.listen(2)
+ ready.set()
+ for _ in range(2):
+ connection, _ = server.accept()
+ with connection:
+ stream = connection.makefile("rwb")
+ request = msgpack.unpackb(read_frame(stream), raw=False)
+ if request["schemaVersion"] == "cultnet.snapshot_request.v0":
+ response = {
+ "schemaVersion": "cultnet.snapshot_response_raw.v0",
+ "messageId": request["messageId"],
+ "documents": [
+ {
+ "schemaId": schema_id,
+ "recordKey": "item:node",
+ "storedAt": "2026-06-13T00:00:00Z",
+ "payloadEncoding": "messagepack",
+ "payload": document.encode_payload(Item("node", "mesh", 1)),
+ }
+ ],
+ }
+ elif request["schemaVersion"] == "cultnet.shard_log_request.v0":
+ response = {
+ "schemaVersion": "cultnet.shard_log_response.v0",
+ "messageId": request["messageId"],
+ "shardId": request["shardId"],
+ "shardEpoch": request["shardEpoch"],
+ "resyncRequired": False,
+ "entries": [
+ {
+ "sequence": 1,
+ "changeKind": "updated",
+ "put": {
+ "schemaVersion": "cultnet.document_put_raw.v0",
+ "messageId": "put-node",
+ "document": {
+ "schemaId": schema_id,
+ "recordKey": "item:node",
+ "storedAt": "2026-06-13T00:00:01Z",
+ "payloadEncoding": "messagepack",
+ "payload": document.encode_payload(Item("node", "mesh", 9)),
+ },
+ "shardId": request["shardId"],
+ "shardEpoch": request["shardEpoch"],
+ },
+ }
+ ],
+ }
+ else:
+ raise AssertionError(f"unexpected request {request['schemaVersion']}")
+ write_frame(stream, msgpack.packb(response, use_bin_type=True))
+ stream.flush()
+ except BaseException as error:
+ server_error.append(error)
+ ready.set()
+
+ thread = threading.Thread(target=serve_requests, daemon=True)
+ thread.start()
+ self.assertTrue(ready.wait(2.0))
+ self.assertFalse(server_error)
+
+ client = CultNetRawClient("127.0.0.1", port_holder[0], timeout_seconds=2.0)
+ snapshot_changes = node.sync_snapshot(client, schema_ids=[schema_id])
+ log_changes = node.sync_shard_log(client, shard_id="mesh", shard_epoch=1)
+
+ thread.join(2.0)
+ self.assertFalse(server_error)
+ self.assertEqual(snapshot_changes[0].record_key, "item:node")
+ self.assertEqual(log_changes[0].change_kind, "updated")
+ self.assertEqual(node.get_required(document, "item:node").value, 9)
+
+ def test_cultmesh_node_sync_document_returns_requested_alias_type(self) -> None:
+ @dataclass
+ class CanonicalNote:
+ body: str
+
+ @dataclass
+ class UiNote:
+ body: str
+
+ document = define_database_entry_type(
+ "mesh.sync_alias_note",
+ [("body", 0)],
+ cls=CanonicalNote,
+ schema_id="mesh.sync_alias_note.v1",
+ schema_name="mesh.sync_alias_note",
+ schema_version="mesh.sync_alias_note.v1",
+ )
+ alias = define_database_entry_type(
+ "mesh.sync_alias_note.ui",
+ [("body", 0)],
+ cls=UiNote,
+ schema_id="mesh.sync_alias_note.v1",
+ schema_name="mesh.sync_alias_note",
+ schema_version="mesh.sync_alias_note.v1",
+ )
+ node = create_node(runtime_id="python-node")
+ node.register_document(document)
+ requests: list[dict[str, Any]] = []
+
+ class SnapshotClient:
+ def fetch_snapshot_response(
+ self,
+ *,
+ schema_ids: list[str] | None = None,
+ record_keys: list[str] | None = None,
+ shard_id: str | None = None,
+ shard_epoch: int | None = None,
+ ) -> dict[str, Any]:
+ requests.append({
+ "schema_ids": schema_ids,
+ "record_keys": record_keys,
+ "shard_id": shard_id,
+ "shard_epoch": shard_epoch,
+ })
+ return {
+ "schemaVersion": "cultnet.snapshot_response_raw.v0",
+ "messageId": "sync-document",
+ "documents": [
+ {
+ "schemaId": document.catalog_entry().schema_id,
+ "recordKey": "note:remote",
+ "storedAt": "2026-06-27T00:00:00Z",
+ "payloadEncoding": "messagepack",
+ "payload": document.encode_payload(CanonicalNote("synced-alias")),
+ }
+ ],
+ }
+
+ synced = node.sync_document(SnapshotClient(), alias, "note:remote")
+ facade_synced = CultMesh.sync_document(node, SnapshotClient(), alias, "note:remote")
+
+ self.assertIsInstance(synced, UiNote)
+ self.assertEqual(synced.body, "synced-alias")
+ self.assertIsInstance(facade_synced, UiNote)
+ self.assertEqual(facade_synced.body, "synced-alias")
+ self.assertEqual(node.get_required(document, "note:remote").body, "synced-alias")
+ self.assertIsInstance(node.get_required(alias, "note:remote"), UiNote)
+ self.assertEqual(requests, [
+ {
+ "schema_ids": ["mesh.sync_alias_note.v1"],
+ "record_keys": ["note:remote"],
+ "shard_id": None,
+ "shard_epoch": None,
+ },
+ {
+ "schema_ids": ["mesh.sync_alias_note.v1"],
+ "record_keys": ["note:remote"],
+ "shard_id": None,
+ "shard_epoch": None,
+ },
+ ])
+
+ def test_cultmesh_node_sync_document_from_publication_returns_requested_alias_type(self) -> None:
+ @dataclass
+ class CanonicalNote:
+ body: str
+
+ @dataclass
+ class UiNote:
+ body: str
+
+ document = define_database_entry_type(
+ "mesh.publication_alias_note",
+ [("body", 0)],
+ cls=CanonicalNote,
+ schema_id="mesh.publication_alias_note.v1",
+ schema_name="mesh.publication_alias_note",
+ schema_version="mesh.publication_alias_note.v1",
+ )
+ alias = define_database_entry_type(
+ "mesh.publication_alias_note.ui",
+ [("body", 0)],
+ cls=UiNote,
+ schema_id="mesh.publication_alias_note.v1",
+ schema_name="mesh.publication_alias_note",
+ schema_version="mesh.publication_alias_note.v1",
+ )
+ node = create_node(runtime_id="python-publication-node")
+ node.register_document(document)
+ requests: list[dict[str, Any]] = []
+
+ class SnapshotClient:
+ def fetch_snapshot_response(
+ self,
+ *,
+ schema_ids: list[str] | None = None,
+ record_keys: list[str] | None = None,
+ shard_id: str | None = None,
+ shard_epoch: int | None = None,
+ ) -> dict[str, Any]:
+ requests.append({
+ "schema_ids": schema_ids,
+ "record_keys": record_keys,
+ "shard_id": shard_id,
+ "shard_epoch": shard_epoch,
+ })
+ return {
+ "schemaVersion": "cultnet.snapshot_response_raw.v0",
+ "messageId": "publication-sync-document",
+ "documents": [
+ {
+ "schemaId": document.catalog_entry().schema_id,
+ "recordKey": "note:published-peer",
+ "storedAt": "2026-06-27T00:00:00Z",
+ "payloadEncoding": "messagepack",
+ "payload": document.encode_payload(CanonicalNote("peer-publication-alias")),
+ }
+ ],
+ }
+
+ source = CultMesh.publication_source_from_peer_snapshot(
+ SnapshotClient(),
+ shard_id="notes",
+ shard_epoch=3,
+ )
+
+ synced = node.sync_document_from_publication(source, alias, "note:published-peer")
+ facade_synced = CultMesh.sync_document_from_publication(node, source, alias, "note:published-peer")
+
+ self.assertIsInstance(synced, UiNote)
+ self.assertEqual(synced.body, "peer-publication-alias")
+ self.assertIsInstance(facade_synced, UiNote)
+ self.assertEqual(facade_synced.body, "peer-publication-alias")
+ self.assertEqual(node.get_required(document, "note:published-peer").body, "peer-publication-alias")
+ self.assertEqual(requests, [
+ {
+ "schema_ids": ["mesh.publication_alias_note.v1"],
+ "record_keys": ["note:published-peer"],
+ "shard_id": "notes",
+ "shard_epoch": 3,
+ },
+ {
+ "schema_ids": ["mesh.publication_alias_note.v1"],
+ "record_keys": ["note:published-peer"],
+ "shard_id": "notes",
+ "shard_epoch": 3,
+ },
+ ])
+
+ def test_cultmesh_node_sync_document_from_single_file_publication_uses_node_aliases(self) -> None:
+ @dataclass
+ class CanonicalNote:
+ body: str
+
+ @dataclass
+ class UiNote:
+ body: str
+
+ document = define_database_entry_type(
+ "mesh.file_publication_alias_note",
+ [("body", 0)],
+ cls=CanonicalNote,
+ schema_id="mesh.file_publication_alias_note.v1",
+ schema_name="mesh.file_publication_alias_note",
+ schema_version="mesh.file_publication_alias_note.v1",
+ )
+ alias = define_database_entry_type(
+ "mesh.file_publication_alias_note.ui",
+ [("body", 0)],
+ cls=UiNote,
+ schema_id="mesh.file_publication_alias_note.v1",
+ schema_name="mesh.file_publication_alias_note",
+ schema_version="mesh.file_publication_alias_note.v1",
+ )
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "publication.ccmp"
+ writer = CultCache()
+ writer.register_document_type(document)
+ writer.add_generic_store(SingleFileMessagePackBackingStore(path))
+ writer.put(document, "note:published-file", CanonicalNote("file-publication-alias"))
+
+ node = create_node(runtime_id="python-file-publication-node")
+ node.register_document(document)
+ source = CultMesh.publication_source_from_single_file(path)
+
+ synced = CultMesh.sync_document_from_publication(node, source, alias, "note:published-file")
+
+ self.assertIsInstance(synced, UiNote)
+ self.assertEqual(synced.body, "file-publication-alias")
+ self.assertEqual(node.get_required(document, "note:published-file").body, "file-publication-alias")
+ self.assertIsInstance(node.get_required(alias, "note:published-file"), UiNote)
+
+ def test_cultmesh_node_sync_documents_from_publication_uses_binding_sources_and_aliases(self) -> None:
+ @dataclass
+ class CanonicalNote:
+ body: str
+
+ @dataclass
+ class UiNote:
+ body: str
+
+ document = define_database_entry_type(
+ "mesh.catalog_publication_alias_note",
+ [("body", 0)],
+ cls=CanonicalNote,
+ schema_id="mesh.catalog_publication_alias_note.v1",
+ schema_name="mesh.catalog_publication_alias_note",
+ schema_version="mesh.catalog_publication_alias_note.v1",
+ )
+ alias = define_database_entry_type(
+ "mesh.catalog_publication_alias_note.ui",
+ [("body", 0)],
+ cls=UiNote,
+ schema_id="mesh.catalog_publication_alias_note.v1",
+ schema_name="mesh.catalog_publication_alias_note",
+ schema_version="mesh.catalog_publication_alias_note.v1",
+ )
+ with tempfile.TemporaryDirectory() as tmp:
+ first_path = Path(tmp) / "first.ccmp"
+ second_path = Path(tmp) / "second.ccmp"
+ first = CultCache()
+ first.register_document_type(document)
+ first.add_generic_store(SingleFileMessagePackBackingStore(first_path))
+ first.put(document, "note:first", CanonicalNote("first catalog publication"))
+ second = CultCache()
+ second.register_document_type(document)
+ second.add_generic_store(SingleFileMessagePackBackingStore(second_path))
+ second.put(document, "note:second", CanonicalNote("second catalog publication"))
+
+ node = create_node(runtime_id="python-catalog-publication-node")
+ node.register_document(document)
+ source = CultMesh.publication_source_from_single_file(first_path)
+ bindings = [
+ CultMesh.publication_document(alias, "note:first"),
+ CultMesh.publication_document(
+ alias,
+ "note:second",
+ source=CultMesh.publication_source_from_single_file(second_path),
+ ),
+ ]
+
+ synced = node.sync_documents_from_publication(source, bindings)
+ facade_synced = CultMesh.sync_documents_from_publication(node, source, bindings)
+
+ self.assertEqual([value.body for value in synced], [
+ "first catalog publication",
+ "second catalog publication",
+ ])
+ self.assertTrue(all(isinstance(value, UiNote) for value in synced))
+ self.assertEqual([value.body for value in facade_synced], [
+ "first catalog publication",
+ "second catalog publication",
+ ])
+ self.assertEqual(node.get_required(document, "note:first").body, "first catalog publication")
+ self.assertEqual(node.get_required(document, "note:second").body, "second catalog publication")
+ self.assertIsInstance(node.get_required(alias, "note:second"), UiNote)
+
+ def test_cultmesh_node_emits_raw_put_and_delete_messages_for_local_writes(self) -> None:
+ document = define_database_entry_type(
+ "mesh.emit_item",
+ [
+ ("name", 0),
+ ("category", 1),
+ ("value", 2, 0),
+ ],
+ cls=Item,
+ )
+ node = create_node(runtime_id="python-node")
+ node.register_document(document)
+
+ put = node.put_raw_message(
+ document,
+ "item:emit",
+ Item("wand", "gear", 4),
+ message_id="put-emit",
+ shard_id="mesh",
+ shard_epoch=1,
+ ).to_wire()
+ self.assertEqual(node.get_required(document, "item:emit").name, "wand")
+ self.assertEqual(put["schemaVersion"], "cultnet.document_put_raw.v0")
+ self.assertEqual(put["messageId"], "put-emit")
+ self.assertEqual(put["document"]["schemaId"], document.catalog_entry().schema_id)
+ self.assertEqual(put["document"]["recordKey"], "item:emit")
+ self.assertEqual(put["document"]["sourceRuntimeId"], "python-node")
+ self.assertEqual(put["shardId"], "mesh")
+ self.assertEqual(put["shardEpoch"], 1)
+
+ delete = node.delete_raw_message(
+ document,
+ "item:emit",
+ message_id="delete-emit",
+ shard_id="mesh",
+ shard_epoch=1,
+ ).to_wire()
+ self.assertIsNone(node.get(document, "item:emit"))
+ self.assertEqual(delete["schemaVersion"], "cultnet.document_delete.v0")
+ self.assertEqual(delete["messageId"], "delete-emit")
+ self.assertEqual(delete["schemaId"], document.catalog_entry().schema_id)
+ self.assertEqual(delete["recordKey"], "item:emit")
+ self.assertEqual(delete["shardId"], "mesh")
+ self.assertEqual(delete["shardEpoch"], 1)
+
+ def test_cultmesh_node_resolves_same_schema_aliases_for_local_state(self) -> None:
+ @dataclass
+ class CanonicalNote:
+ body: str
+
+ @dataclass
+ class UiNote:
+ body: str
+
+ document = define_database_entry_type(
+ "mesh.alias_note",
+ [("body", 0)],
+ cls=CanonicalNote,
+ schema_id="mesh.alias_note.v1",
+ schema_name="mesh.alias_note",
+ schema_version="mesh.alias_note.v1",
+ )
+ alias = define_database_entry_type(
+ "mesh.alias_note.ui",
+ [("body", 0)],
+ cls=UiNote,
+ schema_id="mesh.alias_note.v1",
+ schema_name="mesh.alias_note",
+ schema_version="mesh.alias_note.v1",
+ )
+ node = create_node(runtime_id="python-node")
+ node.register_document(document)
+ seen: list[CultMeshDatabaseChange] = []
+ all_seen: list[CultMeshDatabaseChange] = []
+ node.database.watch(all_seen.append, document=alias)
+ node.database.watch_record(alias, "note:alias", seen.append)
+
+ node.put(alias, "note:alias", UiNote("from-alias-put"))
+ self.assertEqual(node.get_required(document, "note:alias").body, "from-alias-put")
+ alias_value = node.get_required(alias, "note:alias")
+ self.assertIsInstance(alias_value, UiNote)
+ self.assertEqual(alias_value.body, "from-alias-put")
+ alias_collection = node.get_all(alias)
+ self.assertEqual(len(alias_collection), 1)
+ self.assertIsInstance(alias_collection[0], UiNote)
+ self.assertEqual(alias_collection[0].body, "from-alias-put")
+ self.assertEqual(seen[0].document.type, alias.type)
+ self.assertEqual(seen[0].schema_id, "mesh.alias_note.v1")
+ self.assertIsInstance(seen[0].value, UiNote)
+ self.assertEqual(seen[0].value.body, "from-alias-put")
+ self.assertEqual(all_seen[0].document.type, alias.type)
+ self.assertIsInstance(all_seen[0].value, UiNote)
+ self.assertEqual(all_seen[0].value.body, "from-alias-put")
+
+ node.put(document, "note:alias", CanonicalNote("from-canonical-put"))
+ self.assertEqual(seen[1].document.type, alias.type)
+ self.assertIsInstance(seen[1].value, UiNote)
+ self.assertIsInstance(seen[1].previous_value, UiNote)
+ self.assertEqual(seen[1].value.body, "from-canonical-put")
+ self.assertEqual(seen[1].previous_value.body, "from-alias-put")
+
+ put = node.put_raw_message(
+ alias,
+ "note:alias",
+ UiNote("from-alias-raw-put"),
+ message_id="alias-put",
+ ).to_wire()
+ self.assertEqual(put["document"]["schemaId"], "mesh.alias_note.v1")
+ self.assertEqual(put["document"]["recordKey"], "note:alias")
+ self.assertEqual(node.get_required(alias, "note:alias").body, "from-alias-raw-put")
+
+ delete = node.delete_raw_message(alias, "note:alias", message_id="alias-delete").to_wire()
+ self.assertEqual(delete["schemaId"], "mesh.alias_note.v1")
+ self.assertIsNone(node.get(alias, "note:alias"))
+ self.assertEqual(node.get_all(alias), [])
+
+ def test_cultmesh_reactive_document_coalesces_direct_alias_member_writes(self) -> None:
+ @dataclass
+ class CanonicalNote:
+ body: str
+ revision: int
+
+ @dataclass
+ class UiNote:
+ body: str
+ revision: int
+
+ document = define_database_entry_type(
+ "mesh.reactive_alias_note",
+ [("body", 0), ("revision", 1)],
+ cls=CanonicalNote,
+ schema_id="mesh.reactive_alias_note.v1",
+ schema_name="mesh.reactive_alias_note",
+ schema_version="mesh.reactive_alias_note.v1",
+ )
+ alias = define_database_entry_type(
+ "mesh.reactive_alias_note.ui",
+ [("body", 0), ("revision", 1)],
+ cls=UiNote,
+ schema_id="mesh.reactive_alias_note.v1",
+ schema_name="mesh.reactive_alias_note",
+ schema_version="mesh.reactive_alias_note.v1",
+ )
+ node = create_node(runtime_id="python-reactive")
+ node.register_document(document)
+ node.put(document, "note:reactive", CanonicalNote("initial", 1))
+ seen: list[CultMeshDatabaseChange] = []
+ node.database.watch_record(alias, "note:reactive", seen.append)
+
+ reactive = node.reactive_document(
+ alias,
+ "note:reactive",
+ CultMeshReactiveDocumentOptions(flush_delay_seconds=0.02),
+ )
+ try:
+ self.assertIsInstance(reactive.current, UiNote)
+ reactive.current.body = "first-local-edit"
+ reactive.current.body = "second-local-edit"
+ reactive.current.revision = 2
+
+ self._wait_until(lambda: node.get_required(alias, "note:reactive").body == "second-local-edit")
+
+ self.assertFalse(reactive.is_dirty)
+ self.assertEqual(node.get_required(document, "note:reactive").revision, 2)
+ self.assertEqual(seen[-1].value.body, "second-local-edit")
+ finally:
+ reactive.dispose()
+
+ def test_cultmesh_reactive_document_tracks_canonical_reconciliation_delta(self) -> None:
+ @dataclass
+ class Note:
+ body: str
+ revision: int
+
+ document = define_database_entry_type(
+ "mesh.reactive_reconcile_note",
+ [("body", 0), ("revision", 1)],
+ cls=Note,
+ schema_id="mesh.reactive_reconcile_note.v1",
+ schema_name="mesh.reactive_reconcile_note",
+ schema_version="mesh.reactive_reconcile_note.v1",
+ )
+ node = create_node(runtime_id="python-reactive-reconcile")
+ node.register_document(document)
+ node.put(document, "note:reconcile", Note("initial", 1))
+
+ reactive = CultMesh.reactive_document(
+ node,
+ document,
+ "note:reconcile",
+ CultMeshReactiveDocumentOptions(flush_delay_seconds=60),
+ )
+ try:
+ reactive.update(lambda note: setattr(note, "body", "local-prediction"))
+ reactive.current.revision = 2
+
+ node.put(document, "note:reconcile", Note("authoritative", 7))
+
+ self.assertEqual(reactive.current.body, "local-prediction")
+ self.assertIsNotNone(reactive.reconciliation)
+ self.assertEqual(reactive.reconciliation.canonical.body, "authoritative")
+ self.assertEqual(reactive.reconciliation.predicted.body, "local-prediction")
+ self.assertEqual(reactive.reconciliation.delta["body"], "local-prediction")
+ self.assertEqual(reactive.reconciliation.delta["revision"], -5)
+
+ reactive.flush()
+ self.assertEqual(node.get_required(document, "note:reconcile").body, "local-prediction")
+ self.assertIsNone(reactive.reconciliation)
+ finally:
+ reactive.dispose()
+
+ def test_cultnet_document_delete_helper_matches_schema_v0_shape(self) -> None:
+ message = document_delete(
+ message_id="delete-1",
+ schema_id="schema-a",
+ record_key="record-a",
+ shard_id="interop",
+ shard_epoch=1,
+ ).to_wire()
+ self.assertEqual(message["schemaVersion"], "cultnet.document_delete.v0")
+ self.assertEqual(message["messageId"], "delete-1")
+ self.assertEqual(message["schemaId"], "schema-a")
+ self.assertEqual(message["recordKey"], "record-a")
+ self.assertEqual(message["shardId"], "interop")
+ self.assertEqual(message["shardEpoch"], 1)
+
+ def test_cultnet_auth_session_helpers_match_schema_v0_shape(self) -> None:
+ self.assertEqual(
+ login(nonce="nonce", auth="encrypted-auth", password="encrypted-password").to_wire(),
+ {
+ "schemaVersion": "cultnet.login.v0",
+ "nonce": "nonce",
+ "auth": "encrypted-auth",
+ "password": "encrypted-password",
+ },
+ )
+ self.assertEqual(
+ register(
+ nonce="nonce",
+ email="encrypted-email",
+ password="encrypted-password",
+ name="encrypted-name",
+ ).to_wire(),
+ {
+ "schemaVersion": "cultnet.register.v0",
+ "nonce": "nonce",
+ "email": "encrypted-email",
+ "password": "encrypted-password",
+ "name": "encrypted-name",
+ },
+ )
+ self.assertEqual(
+ verify_session(nonce="nonce", session="encrypted-session").to_wire(),
+ {
+ "schemaVersion": "cultnet.verify.v0",
+ "nonce": "nonce",
+ "session": "encrypted-session",
+ },
+ )
+ self.assertEqual(
+ login_success(nonce="nonce", session="encrypted-session").to_wire(),
+ {
+ "schemaVersion": "cultnet.login_success.v0",
+ "nonce": "nonce",
+ "session": "encrypted-session",
+ },
+ )
+
+ def test_cultnet_wire_catalog_describes_python_handled_messages(self) -> None:
+ descriptors = wire_message_schema_descriptors(include_schema_json=True)
+ by_version = {descriptor["schemaVersion"]: descriptor for descriptor in descriptors}
+ for schema_version in [
+ "cultnet.error.v0",
+ "cultnet.login.v0",
+ "cultnet.register.v0",
+ "cultnet.verify.v0",
+ "cultnet.login_success.v0",
+ "cultnet.transport_profile.v0",
+ "cultnet.document_delete.v0",
+ "cultnet.database_change_raw.v0",
+ "cultnet.shard_log_response.v0",
+ "cultnet.simulation_consensus_candidate.v0",
+ "cultmesh.peer_exchange_response.v0",
+ ]:
+ self.assertIn(schema_version, by_version)
+ expected_kind = "shared_contract" if schema_version == "cultnet.transport_profile.v0" else "wire_message"
+ self.assertEqual(by_version[schema_version]["kind"], expected_kind)
+ self.assertIn("cultnet.schema.v0", by_version[schema_version]["wireContracts"])
+ self.assertIn(schema_version, by_version[schema_version]["schemaJson"])
+ self.assertEqual(len(by_version[schema_version]["contentHash"]), 64)
+
+ delete_schema = json.loads(by_version["cultnet.document_delete.v0"]["schemaJson"])
+ self.assertEqual(
+ delete_schema["required"],
+ ["schemaVersion", "messageId", "schemaId", "recordKey"],
+ )
+ self.assertEqual(delete_schema["properties"]["recordKey"]["type"], "string")
+
+ error_schema = json.loads(by_version["cultnet.error.v0"]["schemaJson"])
+ self.assertEqual(error_schema["required"], ["schemaVersion", "error"])
+ self.assertEqual(error_schema["properties"]["error"]["type"], "string")
+ self.assertIn("details", error_schema["properties"])
+
+ login_schema = json.loads(by_version["cultnet.login.v0"]["schemaJson"])
+ self.assertEqual(login_schema["required"], ["schemaVersion", "nonce", "auth", "password"])
+ self.assertEqual(login_schema["properties"]["auth"]["type"], "string")
+
+ register_schema = json.loads(by_version["cultnet.register.v0"]["schemaJson"])
+ self.assertEqual(register_schema["required"], ["schemaVersion", "nonce", "email", "password", "name"])
+ self.assertEqual(register_schema["properties"]["email"]["type"], "string")
+
+ verify_schema = json.loads(by_version["cultnet.verify.v0"]["schemaJson"])
+ self.assertEqual(verify_schema["required"], ["schemaVersion", "nonce", "session"])
+ self.assertEqual(verify_schema["properties"]["session"]["type"], "string")
+
+ success_schema = json.loads(by_version["cultnet.login_success.v0"]["schemaJson"])
+ self.assertEqual(success_schema["required"], ["schemaVersion", "nonce", "session"])
+
+ transport_profile_schema = json.loads(by_version["cultnet.transport_profile.v0"]["schemaJson"])
+ self.assertEqual(transport_profile_schema["required"], ["schemaVersion", "runtimeId", "transports"])
+ transport = transport_profile_schema["properties"]["transports"]["items"]
+ self.assertEqual(transport["properties"]["protocol"]["enum"], ["tcp_framed", "litenetlib", "websocket", "rudp"])
+ self.assertEqual(
+ transport["properties"]["reconnectPolicy"]["properties"]["schemaVersion"]["const"],
+ "cultnet.reconnect_policy.v0",
+ )
+ channel = transport["properties"]["channels"]["items"]
+ self.assertEqual(channel["properties"]["ordering"]["enum"], ["ordered", "unordered", "sequenced"])
+
+ change_schema = json.loads(by_version["cultnet.database_change_raw.v0"]["schemaJson"])
+ self.assertEqual(change_schema["properties"]["changeKind"]["enum"], ["added", "updated", "removed"])
+ self.assertIn("document", change_schema["properties"])
+ self.assertIn("schemaId", change_schema["properties"])
+ self.assertIn("recordKey", change_schema["properties"])
+
+ log_schema = json.loads(by_version["cultnet.shard_log_response.v0"]["schemaJson"])
+ self.assertIn("entries", log_schema["required"])
+ self.assertIn("resyncRequired", log_schema["required"])
+ self.assertIn("compactedThrough", log_schema["properties"])
+
+ hello_schema = json.loads(by_version["cultnet.hello.v0"]["schemaJson"])
+ mutation_contract = hello_schema["properties"]["supportedMutationContracts"]["items"]
+ self.assertEqual(mutation_contract["type"], "object")
+ self.assertIn("operations", mutation_contract["properties"])
+ self.assertEqual(
+ hello_schema["properties"]["transportProfiles"]["items"]["$ref"],
+ "https://github.com/GameCult/cultnet-ts/contracts/cultnet.transport-profile.schema.json",
+ )
+
+ consensus_schema = json.loads(by_version["cultnet.simulation_consensus_candidate.v0"]["schemaJson"])
+ for required_field in ["witnessCount", "supportWeight", "totalWeight", "hasQuorum", "confidence"]:
+ self.assertIn(required_field, consensus_schema["required"])
+
+ def test_cultnet_schema_catalog_applies_filters_and_responses(self) -> None:
+ catalog = wire_message_schema_catalog(include_schema_json=True)
+ descriptor = catalog.get("https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-put-raw.schema.json")
+ self.assertIsNotNone(descriptor)
+ assert descriptor is not None
+ self.assertEqual(descriptor.kind, "wire_message")
+ self.assertEqual(descriptor.schema_version, "cultnet.document_put_raw.v0")
+ self.assertIsNotNone(descriptor.schema_json)
+
+ filtered = catalog.list(kinds=["wire_message"], schema_ids=[descriptor.schema_id])
+ self.assertEqual(filtered, [descriptor])
+
+ response = catalog.create_response(
+ message_id="catalog-response",
+ include_schema_json=False,
+ schema_ids=[descriptor.schema_id],
+ kinds=["wire_message"],
+ )
+ self.assertEqual(response["schemaVersion"], "cultnet.schema_catalog_response.v0")
+ self.assertNotIn("schemaJson", response["schemas"][0])
+
+ remote = CultNetSchemaCatalog()
+ applied = remote.apply_response({
+ "schemaVersion": "cultnet.schema_catalog_response.v0",
+ "messageId": "remote",
+ "schemas": [
+ CultNetSchemaDescriptor(
+ schema_id="schema:custom",
+ kind="shared_contract",
+ schema_version="custom.v0",
+ wire_contracts=("cultnet.schema.v0",),
+ content_hash="hash",
+ schema_json="{}",
+ ).to_wire()
+ ],
+ })
+ self.assertEqual(applied[0].schema_id, "schema:custom")
+ self.assertEqual(remote.get("schema:custom"), applied[0])
+
+ def test_cultnet_shard_helpers_match_schema_v0_shape(self) -> None:
+ catalog = shard_catalog_request(
+ message_id="catalog-1",
+ schema_ids=["schema-a"],
+ record_keys=["record-a"],
+ ).to_wire()
+ self.assertEqual(catalog["schemaVersion"], "cultnet.shard_catalog_request.v0")
+ self.assertEqual(catalog["messageId"], "catalog-1")
+ self.assertEqual(catalog["schemaIds"], ["schema-a"])
+ self.assertEqual(catalog["recordKeys"], ["record-a"])
+
+ log = shard_log_request(
+ message_id="log-1",
+ shard_id="interop",
+ shard_epoch=7,
+ after_sequence=3,
+ limit=2,
+ ).to_wire()
+ self.assertEqual(log["schemaVersion"], "cultnet.shard_log_request.v0")
+ self.assertEqual(log["messageId"], "log-1")
+ self.assertEqual(log["shardId"], "interop")
+ self.assertEqual(log["shardEpoch"], 7)
+ self.assertEqual(log["afterSequence"], 3)
+ self.assertEqual(log["limit"], 2)
+
+ def test_cultnet_shard_catalog_applies_filters_and_responses(self) -> None:
+ catalog = CultNetShardCatalog()
+ primary = catalog.upsert(CultNetShardDescriptor(
+ shard_id="notes",
+ owner_runtime_id="python-runtime",
+ epoch=2,
+ is_primary=True,
+ schema_ids=("schema-note.v1",),
+ key_prefix="note:",
+ primary_endpoints=("cultnet://127.0.0.1:3075",),
+ read_replica_endpoints=("cultnet://127.0.0.1:3075",),
+ region="local",
+ ))
+ catalog.upsert({
+ "shardId": "facts",
+ "ownerRuntimeId": "python-runtime",
+ "epoch": 1,
+ "schemaIds": ["schema-fact"],
+ "keyPrefix": "fact:",
+ "primaryEndpoints": ["cultnet://127.0.0.1:3076"],
+ })
+
+ self.assertTrue(primary.serves(schema_id="schema-note.v1", record_key="note:1"))
+ self.assertTrue(primary.serves(schema_id="schema-note", record_key="note:1"))
+ self.assertFalse(primary.serves(schema_id="schema-note", record_key="fact:1"))
+ self.assertEqual(catalog.list(schema_ids=["schema-note.v1"]), [primary])
+ self.assertEqual(catalog.list(schema_ids=["schema-note"]), [primary])
+ self.assertEqual(catalog.list(record_keys=["note:1"]), [primary])
+
+ response = catalog.create_response(
+ message_id="shards",
+ schema_ids=["schema-note"],
+ record_keys=["note:1"],
+ )
+ self.assertEqual(response["schemaVersion"], "cultnet.shard_catalog_response.v0")
+ self.assertEqual(response["shards"][0]["shardId"], "notes")
+ self.assertEqual(response["shards"][0]["ownerRuntimeId"], "python-runtime")
+
+ remote = CultNetShardCatalog()
+ applied = remote.apply_response(response)
+ self.assertEqual(applied[0].shard_id, "notes")
+ self.assertEqual(remote.get("notes"), applied[0])
+
+ def test_cultnet_shard_log_response_tracks_cursor_and_resync_state(self) -> None:
+ response = CultNetShardLogResponse.from_wire({
+ "schemaVersion": "cultnet.shard_log_response.v0",
+ "messageId": "log",
+ "shardId": "notes",
+ "shardEpoch": 2,
+ "entries": [
+ CultNetShardLogEntry(
+ sequence=2,
+ change_kind="updated",
+ put={"schemaVersion": "cultnet.document_put_raw.v0", "messageId": "put", "document": {"schemaId": "schema-note", "recordKey": "note:1", "payload": b"p"}},
+ committed_at="2026-06-14T00:00:00Z",
+ ).to_wire(),
+ {"sequence": 3, "changeKind": "removed", "delete": {"schemaId": "schema-note", "recordKey": "note:1"}},
+ ],
+ "resyncRequired": False,
+ })
+ self.assertEqual(response.last_sequence, 3)
+ self.assertFalse(response.resync_required)
+ self.assertEqual(response.entries[0].put["document"]["recordKey"], "note:1")
+ self.assertIsInstance(response.entries[0].raw_document, CultNetRawDocumentRecord)
+ self.assertEqual(response.entries[0].raw_document.record_key, "note:1")
+ self.assertEqual(response.entries[1].delete_schema_id, "schema-note")
+ self.assertEqual(response.entries[1].delete_record_key, "note:1")
+ self.assertEqual(response.to_wire()["entries"][1]["delete"]["recordKey"], "note:1")
+ self.assertIs(response.require_usable(), response)
+
+ resync = CultNetShardLogResponse.from_wire({
+ "schemaVersion": "cultnet.shard_log_response.v0",
+ "messageId": "log-resync",
+ "shardId": "notes",
+ "shardEpoch": 2,
+ "entries": [],
+ "resyncRequired": True,
+ "reason": "compacted",
+ "compactedThrough": 12,
+ })
+ self.assertEqual(resync.last_sequence, 12)
+ with self.assertRaisesRegex(ValueError, "compacted"):
+ resync.require_usable()
+
+ def test_cultnet_simulation_observation_helper_matches_schema_v0_shape(self) -> None:
+ claim_hash = compute_simulation_claim_hash("frame:42", "subject:player-1", "hit")
+ self.assertEqual(len(claim_hash), 64)
+ message = simulation_observation(
+ message_id="obs-1",
+ witness_runtime_id="python-test",
+ shard_id="interop",
+ shard_epoch=1,
+ frame=42,
+ subject_id="player-1",
+ claim_kind="hit",
+ claim_hash=claim_hash,
+ claim_summary="player-1 hit target-a",
+ observed_at="2026-06-13T00:00:02Z",
+ ).to_wire()
+ self.assertEqual(message["schemaVersion"], "cultnet.simulation_observation.v0")
+ self.assertEqual(message["messageId"], "obs-1")
+ self.assertEqual(message["observation"]["witnessRuntimeId"], "python-test")
+ self.assertEqual(message["observation"]["claimHash"], claim_hash)
+ self.assertEqual(message["observation"]["weight"], 1.0)
+ observation = CultNetSimulationObservation.from_wire(message)
+ self.assertEqual(observation.witness_runtime_id, "python-test")
+ self.assertEqual(observation.claim_summary, "player-1 hit target-a")
+ self.assertEqual(observation.to_message_wire(message_id="obs-2")["observation"], message["observation"])
+
+ def test_cultnet_witness_artifact_bundle_uses_csharp_slot_order(self) -> None:
+ import msgpack # type: ignore
+
+ bundle = witness_artifact_bundle(
+ bundle_id="bundle-1",
+ witness_kind="interop-proof",
+ captured_at="2026-06-13T00:00:03Z",
+ subject={"documentType": "cultnet.interop-note", "subjectId": "note:python"},
+ contracts=[{"role": "payload", "schemaId": "schema-a"}],
+ artifacts=[{"role": "log", "uri": "cultcache://bundle-1/log", "mediaType": "text/plain"}],
+ timing_witnesses=[{"stage": "roundtrip", "startedAt": "2026-06-13T00:00:03Z", "completedAt": "2026-06-13T00:00:04Z", "latencyMs": 1.0}],
+ provenance={"pipelineId": "interop", "runId": "run-1", "runtimeId": "python-test"},
+ )
+ payload = encode_witness_artifact_bundle_payload(bundle)
+ decoded = decode_witness_artifact_bundle_payload(payload)
+ typed = CultNetWitnessArtifactBundle.from_wire(bundle)
+ typed_payload = typed.to_payload()
+ typed_decoded = CultNetWitnessArtifactBundle.from_payload(typed_payload)
+ slots = msgpack.unpackb(payload, raw=False)
+
+ self.assertEqual(payload, typed_payload)
+ self.assertEqual(slots[0], "bundle-1")
+ self.assertEqual(slots[1], "interop-proof")
+ self.assertEqual(slots[2], "2026-06-13T00:00:03Z")
+ self.assertEqual(slots[3]["subjectId"], "note:python")
+ self.assertEqual(slots[4][0]["schemaId"], "schema-a")
+ self.assertEqual(slots[5][0]["uri"], "cultcache://bundle-1/log")
+ self.assertEqual(slots[6][0]["latencyMs"], 1.0)
+ self.assertEqual(slots[7]["runtimeId"], "python-test")
+ self.assertEqual(decoded["bundleId"], "bundle-1")
+ self.assertEqual(decoded["witnessKind"], "interop-proof")
+ self.assertEqual(decoded["subject"]["subjectId"], "note:python")
+ self.assertEqual(decoded["contracts"][0]["schemaId"], "schema-a")
+ self.assertEqual(decoded["artifacts"][0]["mediaType"], "text/plain")
+ self.assertEqual(decoded["provenance"]["runtimeId"], "python-test")
+ self.assertEqual(typed.bundle_id, "bundle-1")
+ self.assertEqual(typed_decoded.to_wire(), bundle)
+
+ def test_cultmesh_node_uses_cultcache_store(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ document = define_database_entry_type("mesh.note", [("body", 0)])
+ store_path = Path(tmp) / "mesh.cc"
+ node = create_node(store_path, runtime_id="mesh-test")
+ self.assertIsInstance(node.database, CultMeshDatabase)
+ node.database.register_document(document)
+ node.database.put(document, "note:1", {"body": "hello"})
+ self.assertEqual(node.get_required(document, "note:1")["body"], "hello")
+
+ reopened = create_node(store_path, runtime_id="mesh-test")
+ reopened.database.register_document(document)
+ reopened.database.pull()
+ self.assertEqual(reopened.database.get(document, "note:1")["body"], "hello")
+
+ def test_cultmesh_node_options_attach_default_durable_shard_log_store(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ document = define_database_entry_type(
+ "mesh.durable_option_note",
+ [("body", 0)],
+ schema_id="mesh.durable_option_note.v1",
+ )
+ cache_path = Path(tmp) / "world.cc"
+ node = CultMesh.create_node(
+ cache_path,
+ runtime_id="durable-option-primary",
+ options=CultMeshNodeOptions(enable_durable_shard_logs=True),
+ )
+ node.database.register_document(document)
+ node.database.put_raw_message(document, "note:1", {"body": "stored"}, shard_id="notes", shard_epoch=5)
+
+ log_path = Path(tmp) / "world.cultmesh" / "shard-logs"
+ self.assertTrue(log_path.exists())
+
+ reopened = CultMesh.start_node(
+ cache_path,
+ runtime_id="durable-option-primary",
+ enable_durable_shard_logs=True,
+ )
+ reopened.database.register_document(document)
+ response = reopened.database.build_shard_log_response(shard_id="notes", shard_epoch=5)
+
+ self.assertEqual(reopened.database.shard_ids(), ["notes"])
+ self.assertEqual([entry.sequence for entry in response.entries], [1])
+ self.assertEqual(response.entries[0].raw_document.record_key, "note:1")
+
+ def test_cultmesh_database_watchers_observe_local_changes_and_unsubscribe(self) -> None:
+ document = define_database_entry_type("mesh.watch", [("body", 0)])
+ node = CultMesh.create_node(runtime_id="mesh-watch")
+ node.database.register_document(document)
+ changes: list[CultMeshDatabaseChange] = []
+ all_changes: list[CultMeshDatabaseChange] = []
+
+ unsubscribe_record = node.database.watch_record(document, "note:1", changes.append)
+ unsubscribe_all = node.database.watch(all_changes.append, document=document)
+
+ node.database.put(document, "note:1", {"body": "hello"})
+ node.database.put(document, "note:2", {"body": "skip"})
+ node.database.put(document, "note:1", {"body": "updated"})
+ node.database.delete(document, "note:1")
+ unsubscribe_record()
+ node.database.put(document, "note:1", {"body": "after-unsubscribe"})
+ unsubscribe_all()
+
+ self.assertEqual([change.change_kind for change in changes], ["added", "updated", "removed"])
+ self.assertEqual([change.record_key for change in changes], ["note:1", "note:1", "note:1"])
+ self.assertEqual(changes[1].previous_value, {"body": "hello"})
+ self.assertIsNone(changes[2].value)
+ self.assertEqual([change.record_key for change in all_changes], ["note:1", "note:2", "note:1", "note:1", "note:1"])
+
+ def test_cultmesh_database_register_document_is_idempotent(self) -> None:
+ document = define_database_entry_type("mesh.idempotent", [("body", 0)])
+ conflicting = define_database_entry_type("mesh.idempotent", [("title", 0)])
+ node = CultMesh.create_node(runtime_id="mesh-idempotent")
+
+ node.database.register_document(document)
+ node.database.register_document(document)
+ node.database.put(document, "note:1", {"body": "still registered once"})
+
+ self.assertEqual(node.database.get_required(document, "note:1")["body"], "still registered once")
+ self.assertEqual([registered.type for registered in node.documents], ["mesh.idempotent"])
+ with self.assertRaises(ValueError):
+ node.database.register_document(conflicting)
+
+ def test_cultmesh_database_global_document_facade_uses_singleton_key_and_watchers(self) -> None:
+ document = define_database_entry_type(
+ "mesh.global_settings",
+ [("theme", 0)],
+ global_document=True,
+ )
+ node = CultMesh.create_node(runtime_id="mesh-global")
+ node.database.register_document(document)
+ changes: list[CultMeshDatabaseChange] = []
+
+ node.database.watch_global(document, changes.append)
+ node.database.put_global(document, {"theme": "ash"})
+ node.put_global(document, {"theme": "ember"})
+ node.delete_global(document)
+
+ self.assertIsNone(node.database.get_global(document))
+ self.assertEqual([change.record_key for change in changes], [node.cache.GLOBAL_KEY] * 3)
+ self.assertEqual([change.change_kind for change in changes], ["added", "updated", "removed"])
+ self.assertEqual(changes[1].previous_value, {"theme": "ash"})
+ self.assertEqual(changes[2].previous_value, {"theme": "ember"})
+ with self.assertRaises(Exception):
+ node.database.put(document, "settings:wrong", {"theme": "bad"})
+
+ def test_cultmesh_database_creates_raw_snapshot_response_from_envelopes(self) -> None:
+ document = define_database_entry_type(
+ "mesh.snapshot_note",
+ [("body", 0)],
+ schema_id="mesh.snapshot_note.v1",
+ )
+ source = CultMesh.create_node(runtime_id="mesh-snapshot-source")
+ source.database.register_document(document)
+ source.database.put(document, "note:1", {"body": "include"})
+ source.database.put(document, "note:2", {"body": "skip"})
+
+ typed_response = source.database.build_snapshot_response(
+ message_id="snapshot-1",
+ schema_ids=["mesh.snapshot_note.v1"],
+ record_keys=["note:1"],
+ shard_id="notes",
+ shard_epoch=4,
+ shard_log_sequence=7,
+ )
+ response = source.database.create_snapshot_response(
+ message_id="snapshot-1",
+ schema_ids=["mesh.snapshot_note.v1"],
+ record_keys=["note:1"],
+ shard_id="notes",
+ shard_epoch=4,
+ shard_log_sequence=7,
+ )
+
+ self.assertIsInstance(typed_response, CultNetRawSnapshotResponse)
+ self.assertEqual(typed_response.message_id, "snapshot-1")
+ self.assertEqual(typed_response.documents[0].record_key, "note:1")
+ self.assertEqual(typed_response.to_wire(), response)
+ self.assertEqual(source.build_snapshot_response(schema_ids=["mesh.snapshot_note.v1"]).documents[0].record_key, "note:1")
+ self.assertEqual(response["schemaVersion"], "cultnet.snapshot_response_raw.v0")
+ self.assertEqual(response["messageId"], "snapshot-1")
+ self.assertEqual(response["shardId"], "notes")
+ self.assertEqual(response["shardEpoch"], 4)
+ self.assertEqual(response["shardLogSequence"], 7)
+ self.assertEqual([record["recordKey"] for record in response["documents"]], ["note:1"])
+ self.assertEqual(response["documents"][0]["schemaId"], "mesh.snapshot_note.v1")
+ self.assertEqual(response["documents"][0]["payloadEncoding"], "messagepack")
+
+ target = CultMesh.create_node(runtime_id="mesh-snapshot-target")
+ target.database.register_document(document)
+ applied = target.database.apply_snapshot_response(response)
+ self.assertEqual([(record.schema_id, record.record_key) for record in applied], [("mesh.snapshot_note.v1", "note:1")])
+ self.assertEqual(target.database.get_required(document, "note:1"), {"body": "include"})
+ self.assertIsNone(target.database.get(document, "note:2"))
+
+ typed_target = CultMesh.create_node(runtime_id="mesh-snapshot-typed-target")
+ typed_target.database.register_document(document)
+ typed_applied = typed_target.database.apply_snapshot_response(typed_response)
+ self.assertEqual([(record.schema_id, record.record_key) for record in typed_applied], [("mesh.snapshot_note.v1", "note:1")])
+ self.assertEqual(typed_target.database.get_required(document, "note:1"), {"body": "include"})
+
+ def test_cultmesh_database_filters_snapshot_by_logged_shard_membership(self) -> None:
+ document = define_database_entry_type(
+ "mesh.sharded_snapshot_note",
+ [("body", 0)],
+ schema_id="mesh.sharded_snapshot_note.v1",
+ )
+ source = CultMesh.create_node(runtime_id="mesh-sharded-snapshot-source")
+ source.database.register_document(document)
+ source.database.put_raw_message(document, "note:notes", {"body": "include"}, shard_id="notes", shard_epoch=2)
+ source.database.put_raw_message(document, "note:other", {"body": "skip"}, shard_id="other", shard_epoch=1)
+
+ response = source.database.build_snapshot_response(
+ schema_ids=["mesh.sharded_snapshot_note.v1"],
+ shard_id="notes",
+ shard_epoch=2,
+ )
+
+ self.assertEqual(response.shard_id, "notes")
+ self.assertEqual(response.shard_epoch, 2)
+ self.assertEqual([record.record_key for record in response.documents], ["note:notes"])
+
+ def test_python_interop_peer_filters_shard_snapshot_by_logged_membership(self) -> None:
+ runtime_id = f"python-interop-test-{uuid4().hex}"
+ state = build_state(
+ runtime_id=runtime_id,
+ runtime_kind="python",
+ display_name="Python Interop Test",
+ agent_id="python-interop-test-agent",
+ schema_path=str(Path("packages/cultnet-ts/integration/contracts/cultnet.interop-note.schema.json")),
+ )
+ binding = state.bindings["note"]
+ logged_value = {
+ "schemaVersion": "cultnet.interop_note.v0",
+ "documentId": "note:logged",
+ "authorRuntimeId": runtime_id,
+ "title": "Logged",
+ "body": "This record belongs to the shard log.",
+ "tags": ["interop", "logged"],
+ }
+ unlogged_value = {
+ **logged_value,
+ "documentId": "note:unlogged",
+ "title": "Unlogged",
+ "body": "This record is in cache but not in the shard log.",
+ }
+ state.cache.put(binding.document, "note:logged", logged_value)
+ state.cache.put(binding.document, "note:unlogged", unlogged_value)
+ logged_record = {
+ "schemaId": state.note_schema_id,
+ "recordKey": "note:logged",
+ "storedAt": "2026-06-14T00:00:00Z",
+ "payloadEncoding": "messagepack",
+ "payload": binding.document.encode_payload(logged_value),
+ }
+ append_shard_log_put(
+ state,
+ {"messageId": "logged-put"},
+ logged_record,
+ )
+
+ response = raw_snapshot_response(
+ state,
+ {
+ "schemaVersion": "cultnet.snapshot_request.v0",
+ "messageId": "interop-shard-snapshot",
+ "schemaIds": [state.note_schema_id],
+ "shardId": state.shard_id,
+ },
+ )
+
+ self.assertEqual(response["shardId"], state.shard_id)
+ self.assertEqual(response["shardLogSequence"], 1)
+ self.assertEqual([record["recordKey"] for record in response["documents"]], ["note:logged"])
+
+ def test_cultmesh_database_creates_shard_log_response_from_raw_mutations(self) -> None:
+ document = define_database_entry_type(
+ "mesh.log_note",
+ [("body", 0)],
+ schema_id="mesh.log_note.v1",
+ )
+ source = CultMesh.create_node(runtime_id="mesh-log-source")
+ source.database.register_document(document)
+ source.database.put_raw_message(document, "note:1", {"body": "first"}, shard_id="notes", shard_epoch=3)
+ source.database.put_raw_message(document, "note:1", {"body": "second"}, shard_id="notes", shard_epoch=3)
+ source.database.put_raw_message(document, "note:2", {"body": "other"}, shard_id="other", shard_epoch=1)
+ source.database.delete_raw_message(document, "note:1", shard_id="notes", shard_epoch=3)
+
+ self.assertEqual(source.database.shard_ids(), ["notes", "other"])
+ self.assertEqual(source.database.shard_schema_ids("notes"), ["mesh.log_note.v1"])
+
+ typed_response = source.database.build_shard_log_response(
+ message_id="log-1",
+ shard_id="notes",
+ shard_epoch=3,
+ after_sequence=1,
+ limit=2,
+ )
+ response = source.database.create_shard_log_response(
+ message_id="log-1",
+ shard_id="notes",
+ shard_epoch=3,
+ after_sequence=1,
+ limit=2,
+ )
+
+ self.assertIsInstance(typed_response, CultNetShardLogResponse)
+ self.assertEqual(typed_response.message_id, "log-1")
+ self.assertEqual([entry.sequence for entry in typed_response.entries], [2, 3])
+ self.assertEqual(typed_response.to_wire(), response)
+ self.assertEqual([entry.sequence for entry in source.build_shard_log_response(shard_id="notes").entries], [1, 2, 3])
+ self.assertEqual(response["schemaVersion"], "cultnet.shard_log_response.v0")
+ self.assertEqual(response["messageId"], "log-1")
+ self.assertEqual(response["shardId"], "notes")
+ self.assertEqual(response["shardEpoch"], 3)
+ self.assertFalse(response["resyncRequired"])
+ self.assertEqual([entry["sequence"] for entry in response["entries"]], [2, 3])
+ self.assertEqual([entry["changeKind"] for entry in response["entries"]], ["updated", "removed"])
+ self.assertEqual(response["entries"][0]["put"]["document"]["recordKey"], "note:1")
+ self.assertEqual(response["entries"][1]["delete"]["recordKey"], "note:1")
+ empty = source.database.create_shard_log_response(shard_id="missing")
+ self.assertEqual(empty["shardEpoch"], 0)
+ self.assertEqual(empty["entries"], [])
+ self.assertTrue(empty["resyncRequired"])
+ self.assertEqual(empty["reason"], "unknown_shard")
+ stale = source.database.create_shard_log_response(shard_id="notes", shard_epoch=2)
+ self.assertEqual(stale["shardEpoch"], 3)
+ self.assertEqual(stale["entries"], [])
+ self.assertTrue(stale["resyncRequired"])
+ self.assertEqual(stale["reason"], "stale_epoch")
+ with self.assertRaises(ValueError):
+ source.database.create_shard_log_response(shard_id="notes", after_sequence=-1)
+ with self.assertRaises(ValueError):
+ source.database.create_shard_log_response(shard_id="notes", limit=-1)
+
+ target = CultMesh.create_node(runtime_id="mesh-log-target")
+ target.database.register_document(document)
+ target.database.put(document, "note:1", {"body": "first"})
+ applied = target.database.apply_shard_log_response(response)
+ self.assertEqual([record.change_kind for record in applied], ["updated", "removed"])
+ self.assertIsNone(target.database.get(document, "note:1"))
+ self.assertIsNone(target.database.get(document, "note:2"))
+
+ typed_target = CultMesh.create_node(runtime_id="mesh-log-typed-target")
+ typed_target.database.register_document(document)
+ typed_target.database.put(document, "note:1", {"body": "first"})
+ typed_applied = typed_target.database.apply_shard_log_response(typed_response)
+ self.assertEqual([record.change_kind for record in typed_applied], ["updated", "removed"])
+ self.assertIsNone(typed_target.database.get(document, "note:1"))
+
+ def test_cultnet_file_shard_mutation_log_store_persists_authoritative_entries(self) -> None:
+ document = define_database_entry_type(
+ "mesh.durable_log_note",
+ [("body", 0)],
+ schema_id="mesh.durable_log_note.v1",
+ )
+ with tempfile.TemporaryDirectory() as tmp:
+ store: CultNetShardMutationLogStore = CultNetFileShardMutationLogStore(Path(tmp) / "logs")
+ source = CultMesh.create_node(runtime_id="durable-primary")
+ source.database.register_document(document)
+ source.database.use_shard_mutation_log_store(store)
+ source.database.put_raw_message(document, "note:1", {"body": "first"}, shard_id="durable", shard_epoch=4)
+ source.database.put_raw_message(document, "note:2", {"body": "second"}, shard_id="durable", shard_epoch=4)
+
+ reopened = CultMesh.create_node(runtime_id="durable-primary")
+ reopened.database.register_document(document)
+ reopened.database.use_shard_mutation_log_store(CultNetFileShardMutationLogStore(Path(tmp) / "logs"))
+ response = reopened.database.build_shard_log_response(shard_id="durable", shard_epoch=4)
+ self.assertEqual(reopened.database.shard_ids(), ["durable"])
+ self.assertEqual(reopened.database.shard_schema_ids("durable"), ["mesh.durable_log_note.v1"])
+ self.assertEqual([entry.sequence for entry in response.entries], [1, 2])
+ self.assertEqual(response.entries[0].raw_document.record_key, "note:1")
+
+ store.compact_through("durable", 1)
+ compacted = reopened.database.build_shard_log_response(shard_id="durable", shard_epoch=4, after_sequence=0)
+ retained = reopened.database.build_shard_log_response(shard_id="durable", shard_epoch=4, after_sequence=1)
+ self.assertTrue(compacted.resync_required)
+ self.assertEqual(compacted.reason, "compacted_history")
+ self.assertEqual(compacted.compacted_through, 1)
+ self.assertEqual([entry.sequence for entry in retained.entries], [2])
+
+ def test_cultnet_shard_replicator_pulls_logs_and_resyncs_from_snapshot(self) -> None:
+ document = define_database_entry_type(
+ "mesh.replica_note",
+ [("body", 0)],
+ schema_id="mesh.replica_note.v1",
+ )
+ source = CultMesh.create_node(runtime_id="primary")
+ source.database.register_document(document)
+ source.database.put_raw_message(document, "note:1", {"body": "one"}, shard_id="notes", shard_epoch=3)
+ source.database.put_raw_message(document, "note:2", {"body": "two"}, shard_id="notes", shard_epoch=3)
+ with tempfile.TemporaryDirectory() as tmp:
+ cursor_path = Path(tmp) / "replica-cursors.msgpack"
+ server = CultMesh.serve_node(source)
+ try:
+ shard = CultNetShardDescriptor(
+ shard_id="notes",
+ owner_runtime_id="primary",
+ epoch=3,
+ is_primary=False,
+ schema_ids=("mesh.replica_note.v1",),
+ primary_endpoints=(f"cultnet://127.0.0.1:{server.port}",),
+ )
+ target = CultMesh.create_node(runtime_id="replica")
+ target.database.register_document(document)
+ cursor_store = CultNetFileShardReplicaCursorStore(cursor_path)
+ replicator = CultNetShardReplicator(
+ target.database,
+ CultNetShardReplicatorOptions(
+ fetcher=CultNetSchemaShardLogFetcher(timeout_seconds=2.0),
+ snapshot_fetcher=CultNetSchemaShardSnapshotFetcher(timeout_seconds=2.0),
+ cursor_store=cursor_store,
+ batch_size=16,
+ ),
+ )
+
+ first_sequence = replicator.pull_once(shard)
+ restarted_replicator = CultNetShardReplicator(
+ target.database,
+ CultNetShardReplicatorOptions(
+ fetcher=CultNetSchemaShardLogFetcher(timeout_seconds=2.0),
+ snapshot_fetcher=CultNetSchemaShardSnapshotFetcher(timeout_seconds=2.0),
+ cursor_store=CultNetFileShardReplicaCursorStore(cursor_path),
+ batch_size=16,
+ ),
+ )
+ second_sequence = restarted_replicator.pull_once(shard)
+
+ self.assertEqual(first_sequence, 2)
+ self.assertEqual(second_sequence, 2)
+ self.assertEqual(target.get_required(document, "note:1")["body"], "one")
+ self.assertEqual(target.get_required(document, "note:2")["body"], "two")
+ cursor = CultNetFileShardReplicaCursorStore(cursor_path).read("notes")
+ self.assertIsNotNone(cursor)
+ assert cursor is not None
+ self.assertEqual(cursor.last_applied_sequence, 2)
+ self.assertEqual(cursor.shard_epoch, 3)
+ finally:
+ server.stop()
+
+ resync_target = CultMesh.create_node(runtime_id="resync-replica")
+ resync_target.database.register_document(document)
+ snapshot = source.database.build_snapshot_response(shard_id="notes", shard_epoch=3, shard_log_sequence=2)
+
+ class CompactingFetcher:
+ def fetch(self, shard: CultNetShardDescriptor, *, after_sequence: int, limit: int | None = None) -> CultNetShardLogResponse:
+ return CultNetShardLogResponse(
+ message_id="compact",
+ shard_id=shard.shard_id,
+ shard_epoch=shard.epoch,
+ entries=(),
+ resync_required=True,
+ reason="compacted_history",
+ compacted_through=after_sequence,
+ )
+
+ class SnapshotFetcher:
+ def fetch(self, shard: CultNetShardDescriptor) -> CultNetRawSnapshotResponse:
+ return snapshot
+
+ resync_replicator = CultNetShardReplicator(
+ resync_target.database,
+ CultNetShardReplicatorOptions(
+ fetcher=CompactingFetcher(),
+ snapshot_fetcher=SnapshotFetcher(),
+ cursor_store=CultNetInMemoryShardReplicaCursorStore(),
+ ),
+ )
+ resync_sequence = resync_replicator.pull_once(CultNetShardDescriptor(
+ shard_id="notes",
+ owner_runtime_id="primary",
+ epoch=3,
+ is_primary=False,
+ schema_ids=("mesh.replica_note.v1",),
+ primary_endpoints=("cultnet://primary:3075",),
+ ))
+
+ self.assertEqual(resync_sequence, 2)
+ self.assertEqual(resync_target.get_required(document, "note:1")["body"], "one")
+
+ def test_cultnet_shard_replicator_background_loop_polls_non_primary_shards(self) -> None:
+ document = define_database_entry_type(
+ "mesh.background_replica_note",
+ [("body", 0)],
+ schema_id="mesh.background_replica_note.v1",
+ )
+ source = CultMesh.create_node(runtime_id="background-primary")
+ source.database.register_document(document)
+ target = CultMesh.create_node(runtime_id="background-replica")
+ target.database.register_document(document)
+ cursor_store = CultNetInMemoryShardReplicaCursorStore()
+ errors: list[Exception] = []
+
+ class FlakySourceFetcher:
+ def __init__(self) -> None:
+ self.calls = 0
+
+ def fetch(self, shard: CultNetShardDescriptor, *, after_sequence: int, limit: int | None = None) -> CultNetShardLogResponse:
+ self.calls += 1
+ if self.calls == 1:
+ raise RuntimeError("transient fetch failure")
+ return source.database.build_shard_log_response(
+ shard_id=shard.shard_id,
+ shard_epoch=shard.epoch,
+ after_sequence=after_sequence,
+ limit=limit,
+ )
+
+ fetcher = FlakySourceFetcher()
+ shard = CultNetShardDescriptor(
+ shard_id="background",
+ owner_runtime_id="background-primary",
+ epoch=7,
+ is_primary=False,
+ schema_ids=("mesh.background_replica_note.v1",),
+ primary_endpoints=("cultnet://127.0.0.1:3075",),
+ )
+ replicator = CultNetShardReplicator(
+ target.database,
+ CultNetShardReplicatorOptions(
+ fetcher=fetcher,
+ cursor_store=cursor_store,
+ batch_size=1,
+ poll_interval_seconds=0.01,
+ on_error=errors.append,
+ ),
+ )
+ try:
+ source.database.put_raw_message(document, "note:1", {"body": "one"}, shard_id="background", shard_epoch=7)
+ replicator.start([shard])
+ self._wait_until(lambda: target.database.get(document, "note:1") is not None)
+ source.database.put_raw_message(document, "note:2", {"body": "two"}, shard_id="background", shard_epoch=7)
+ self._wait_until(lambda: target.database.get(document, "note:2") is not None)
+ finally:
+ replicator.stop()
+
+ cursor = cursor_store.read("background")
+ self.assertEqual(target.get_required(document, "note:1")["body"], "one")
+ self.assertEqual(target.get_required(document, "note:2")["body"], "two")
+ self.assertIsNotNone(cursor)
+ assert cursor is not None
+ self.assertEqual(cursor.last_applied_sequence, 2)
+ self.assertEqual(cursor.shard_epoch, 7)
+ self.assertEqual(len(errors), 1)
+ self.assertEqual(str(errors[0]), "transient fetch failure")
+
+ def test_cultnet_schema_write_forwarder_sends_raw_writes_to_primary(self) -> None:
+ document = define_database_entry_type(
+ "mesh.forward_note",
+ [("body", 0)],
+ schema_id="mesh.forward_note.v1",
+ )
+ primary = CultMesh.create_node(runtime_id="forward-primary")
+ primary.database.register_document(document)
+ server = CultMesh.serve_node(primary)
+ try:
+ shard = CultNetShardDescriptor(
+ shard_id="forward",
+ owner_runtime_id="forward-primary",
+ epoch=9,
+ is_primary=False,
+ schema_ids=("mesh.forward_note.v1",),
+ primary_endpoints=(f"cultnet://127.0.0.1:{server.port}",),
+ )
+ forwarder: CultNetShardWriteForwarder = CultNetSchemaWriteForwarder(timeout_seconds=2.0)
+ put = document_put_raw(
+ message_id="forward-put",
+ key="note:forward",
+ schema_id=document.catalog_entry().schema_id,
+ stored_at="2026-06-14T00:00:00Z",
+ payload=document.encode_payload({"body": "forwarded"}),
+ ).to_wire()
+ delete = document_delete(
+ message_id="forward-delete",
+ schema_id=document.catalog_entry().schema_id,
+ record_key="note:forward",
+ ).to_wire()
+
+ forwarder.forward_put(shard, put)
+ self._eventually(lambda: primary.database.get(document, "note:forward") is not None)
+ self.assertEqual(primary.database.get_required(document, "note:forward")["body"], "forwarded")
+ forwarder.forward_delete(shard, delete)
+ self._eventually(lambda: primary.database.get(document, "note:forward") is None)
+
+ log = primary.database.build_shard_log_response(shard_id="forward", shard_epoch=9)
+ self.assertIsNone(primary.database.get(document, "note:forward"))
+ self.assertEqual([entry.change_kind for entry in log.entries], ["added", "removed"])
+ self.assertEqual(log.entries[0].put["shardId"], "forward")
+ self.assertEqual(log.entries[1].delete["shardEpoch"], 9)
+ finally:
+ server.stop()
+
+ def _eventually(self, predicate: Callable[[], bool], *, timeout_seconds: float = 2.0) -> None:
+ deadline = time.monotonic() + timeout_seconds
+ while time.monotonic() < deadline:
+ if predicate():
+ return
+ time.sleep(0.01)
+ self.assertTrue(predicate())
+
+ def test_cultmesh_database_watchers_observe_name_and_index_changes(self) -> None:
+ document = define_database_entry_type(
+ "mesh.named_watch",
+ [("name", 0), ("kind", 1)],
+ name="name",
+ indexes={"kind": "kind"},
+ )
+ node = CultMesh.create_node(runtime_id="mesh-named-watch")
+ node.database.register_document(document)
+ name_changes: list[CultMeshDatabaseChange] = []
+ index_changes: list[CultMeshDatabaseChange] = []
+
+ node.database.watch_by_name(document, "Potion", name_changes.append)
+ node.database.watch_by_index(document, "kind", "consumable", index_changes.append)
+
+ node.database.put(document, "item:1", {"name": "Potion", "kind": "consumable"})
+ node.database.put(document, "item:2", {"name": "Sword", "kind": "weapon"})
+ node.database.put(document, "item:1", {"name": "Elixir", "kind": "rare"})
+ node.database.delete(document, "item:1")
+
+ self.assertEqual([change.change_kind for change in name_changes], ["added", "updated"])
+ self.assertEqual([change.record_key for change in name_changes], ["item:1", "item:1"])
+ self.assertEqual(name_changes[1].previous_value, {"name": "Potion", "kind": "consumable"})
+ self.assertEqual([change.change_kind for change in index_changes], ["added", "updated"])
+ self.assertEqual([change.record_key for change in index_changes], ["item:1", "item:1"])
+ self.assertEqual(index_changes[1].value, {"name": "Elixir", "kind": "rare"})
+
+ def test_cultmesh_database_collection_reads_resolve_same_schema_aliases(self) -> None:
+ @dataclass
+ class CanonicalItem:
+ name: str
+ kind: str
+
+ @dataclass
+ class UiItem:
+ name: str
+ kind: str
+
+ document = define_database_entry_type(
+ "mesh.alias_collection_item",
+ [("name", 0), ("kind", 1)],
+ cls=CanonicalItem,
+ name="name",
+ indexes={"kind": "kind"},
+ schema_id="mesh.alias_collection_item.v1",
+ schema_name="mesh.alias_collection_item",
+ schema_version="mesh.alias_collection_item.v1",
+ )
+ alias = define_database_entry_type(
+ "mesh.alias_collection_item.ui",
+ [("name", 0), ("kind", 1)],
+ cls=UiItem,
+ name="name",
+ indexes={"kind": "kind"},
+ schema_id="mesh.alias_collection_item.v1",
+ schema_name="mesh.alias_collection_item",
+ schema_version="mesh.alias_collection_item.v1",
+ )
+ node = CultMesh.create_node(runtime_id="mesh-alias-collection")
+ node.database.register_document(document)
+
+ node.database.put(alias, "item:potion", UiItem("Potion", "consumable"))
+ node.database.put(document, "item:sword", CanonicalItem("Sword", "weapon"))
+
+ all_items = node.database.get_all(alias)
+ potion = node.database.get_by_name(alias, "Potion")
+ sword = node.get_by_index(alias, "kind", "weapon")
+
+ self.assertEqual([item.name for item in all_items], ["Potion", "Sword"])
+ self.assertTrue(all(isinstance(item, UiItem) for item in all_items))
+ self.assertIsInstance(potion, UiItem)
+ self.assertEqual(potion.kind, "consumable")
+ self.assertIsInstance(sword, UiItem)
+ self.assertEqual(sword.name, "Sword")
+ self.assertIsNone(node.database.get_by_name(alias, "Missing"))
+
+ def test_cultmesh_database_alias_collection_watches_emit_alias_values(self) -> None:
+ @dataclass
+ class CanonicalItem:
+ name: str
+ kind: str
+
+ @dataclass
+ class UiItem:
+ name: str
+ kind: str
+
+ document = define_database_entry_type(
+ "mesh.alias_collection_watch_item",
+ [("name", 0), ("kind", 1)],
+ cls=CanonicalItem,
+ name="name",
+ indexes={"kind": "kind"},
+ schema_id="mesh.alias_collection_watch_item.v1",
+ schema_name="mesh.alias_collection_watch_item",
+ schema_version="mesh.alias_collection_watch_item.v1",
+ )
+ alias = define_database_entry_type(
+ "mesh.alias_collection_watch_item.ui",
+ [("name", 0), ("kind", 1)],
+ cls=UiItem,
+ name="name",
+ indexes={"kind": "kind"},
+ schema_id="mesh.alias_collection_watch_item.v1",
+ schema_name="mesh.alias_collection_watch_item",
+ schema_version="mesh.alias_collection_watch_item.v1",
+ )
+ node = CultMesh.create_node(runtime_id="mesh-alias-collection-watch")
+ node.database.register_document(document)
+ name_changes: list[CultMeshDatabaseChange] = []
+ index_changes: list[CultMeshDatabaseChange] = []
+
+ node.database.watch_by_name(alias, "Potion", name_changes.append)
+ node.database.watch_by_index(alias, "kind", "consumable", index_changes.append)
+
+ node.database.put(document, "item:potion", CanonicalItem("Potion", "consumable"))
+ node.database.put(document, "item:potion", CanonicalItem("Elixir", "rare"))
+
+ self.assertEqual([change.change_kind for change in name_changes], ["added", "updated"])
+ self.assertTrue(all(change.document.type == alias.type for change in name_changes))
+ self.assertIsInstance(name_changes[0].value, UiItem)
+ self.assertIsInstance(name_changes[1].value, UiItem)
+ self.assertIsInstance(name_changes[1].previous_value, UiItem)
+ self.assertEqual(name_changes[1].previous_value.name, "Potion")
+ self.assertEqual(name_changes[1].value.name, "Elixir")
+ self.assertEqual([change.change_kind for change in index_changes], ["added", "updated"])
+ self.assertTrue(all(change.document.type == alias.type for change in index_changes))
+ self.assertIsInstance(index_changes[0].value, UiItem)
+ self.assertIsInstance(index_changes[1].previous_value, UiItem)
+
+ def test_cultmesh_database_watch_by_name_and_index_validate_lookup_shape(self) -> None:
+ document = define_database_entry_type("mesh.unnamed_watch", [("body", 0)])
+ node = CultMesh.create_node(runtime_id="mesh-watch-validation")
+ node.database.register_document(document)
+
+ with self.assertRaises(ValueError):
+ node.database.watch_by_name(document, "missing", lambda _: None)
+ with self.assertRaises(ValueError):
+ node.database.watch_by_index(document, "missing", "value", lambda _: None)
+ with self.assertRaises(ValueError):
+ node.database.watch_by_index(document, "", "value", lambda _: None)
+ with self.assertRaises(ValueError):
+ node.database.watch_by_index(document, "missing", "", lambda _: None)
+
+ def test_cultmesh_facade_matches_peer_runtime_entrypoints(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ store_path = Path(tmp) / "mesh.cc"
+ node = CultMesh.start_node(store_path, runtime_id="mesh-facade")
+
+ self.assertEqual(node.runtime_id, "mesh-facade")
+ self.assertIsInstance(CultMesh.create_node(), type(node))
+ self.assertIsInstance(CultMesh.create_verse_catalog(), CultMeshVerseCatalog)
+ self.assertIsInstance(CultMesh.create_peer_catalog(), CultMeshPeerCatalog)
+ self.assertIsInstance(CultMesh.create_authority_lease_catalog(), CultMeshAuthorityLeaseCatalog)
+ self.assertIsInstance(CultMesh.create_stream_catalog(), CultMeshStreamCatalog)
+ self.assertIsInstance(CultMesh.create_schema_catalog(), CultNetSchemaCatalog)
+ self.assertIsInstance(CultMesh.create_builtin_schema_catalog(), CultNetSchemaCatalog)
+ self.assertIsInstance(CultMesh.create_shard_catalog(), CultNetShardCatalog)
+ builtins = CultMesh.create_builtin_schema_catalog(
+ include_schema_json=False,
+ kinds=["shared_contract"],
+ )
+ self.assertEqual(
+ builtins.list()[0].schema_id,
+ "https://github.com/GameCult/cultnet-ts/contracts/cultnet.transport-profile.schema.json",
+ )
+ self.assertIsNone(builtins.list()[0].schema_json)
+ peer_exchange_schema_id = (
+ "https://github.com/GameCult/cultnet-ts/contracts/cultmesh.peer-exchange-request.schema.json"
+ )
+ filtered_builtin = CultMesh.create_builtin_schema_catalog(
+ include_schema_json=True,
+ schema_ids=[peer_exchange_schema_id],
+ )
+ self.assertEqual(
+ filtered_builtin.list()[0].schema_version,
+ "cultmesh.peer_exchange_request.v0",
+ )
+ self.assertIn(
+ "cultmesh.peer_exchange_request.v0",
+ filtered_builtin.list()[0].schema_json or "",
+ )
+ shard_catalog = CultMesh.create_shard_catalog()
+ shard_catalog.upsert(
+ CultNetShardDescriptor(
+ shard_id="python-notes-a",
+ owner_runtime_id="mesh-facade",
+ epoch=7,
+ is_primary=True,
+ schema_ids=("python.note.v1",),
+ key_prefix="note:",
+ )
+ )
+ self.assertEqual(
+ [
+ shard.shard_id
+ for shard in shard_catalog.list(schema_ids=["python.note.v1"], record_keys=["note:1"])
+ ],
+ ["python-notes-a"],
+ )
+ verse_client = CultMesh.create_verse_discovery_client("127.0.0.1", 4010, timeout_seconds=1.5)
+ peer_client = CultMesh.create_peer_exchange_client("127.0.0.1", 4011, timeout_seconds=1.5)
+ raw_client = CultMesh.create_client("127.0.0.1", 4012, timeout_seconds=1.5)
+ connected_client = CultMesh.connect_client("127.0.0.1", 4013, timeout_seconds=1.5)
+ self.assertIsInstance(verse_client, CultMeshVerseDiscoveryClient)
+ self.assertIsInstance(peer_client, CultMeshPeerExchangeClient)
+ self.assertIsInstance(verse_client, CultMeshDiscoveryClient)
+ self.assertIsInstance(peer_client, CultMeshDiscoveryClient)
+ self.assertIsInstance(raw_client, CultNetRawClient)
+ self.assertIsInstance(connected_client, CultNetRawClient)
+ self.assertEqual((verse_client.host, verse_client.port, verse_client.timeout_seconds), ("127.0.0.1", 4010, 1.5))
+ self.assertEqual((peer_client.host, peer_client.port, peer_client.timeout_seconds), ("127.0.0.1", 4011, 1.5))
+ self.assertEqual((raw_client.host, raw_client.port, raw_client.timeout_seconds), ("127.0.0.1", 4012, 1.5))
+ self.assertEqual((connected_client.host, connected_client.port, connected_client.timeout_seconds), ("127.0.0.1", 4013, 1.5))
+
+ def test_cultmesh_simulation_fact_uses_csharp_slot_contract(self) -> None:
+ import msgpack # type: ignore
+
+ candidate = {
+ "shardId": "arena",
+ "shardEpoch": 4,
+ "frame": 100,
+ "subjectId": "bob",
+ "claimKind": "hit",
+ "claimHash": compute_simulation_claim_hash("hit", "alice", "bob", "frame:100"),
+ "claimSummary": "alice shot bob first",
+ "witnessCount": 2,
+ "supportWeight": 2.0,
+ "totalWeight": 2.0,
+ "confidence": 1.0,
+ "hasQuorum": True,
+ }
+
+ fact = CultMeshSimulationFact.from_candidate(candidate, committed_at="2026-06-13T00:00:00Z")
+ payload = simulation_fact_document.encode_payload(fact)
+ slots = msgpack.unpackb(payload, raw=False)
+
+ self.assertEqual(CultMeshSimulationFact.create_record_key(candidate), f"simulation:{fact.fact_id}")
+ self.assertEqual(slots[0], fact.fact_id)
+ self.assertEqual(slots[1], "arena")
+ self.assertEqual(slots[2], 4)
+ self.assertEqual(slots[3], 100)
+ self.assertEqual(slots[4], "bob")
+ self.assertEqual(slots[5], "hit")
+ self.assertEqual(slots[6], candidate["claimHash"])
+ self.assertEqual(slots[7], "alice shot bob first")
+ self.assertEqual(slots[8], 2)
+ self.assertEqual(slots[9], 2.0)
+ self.assertEqual(slots[10], 2.0)
+ self.assertEqual(slots[11], 1.0)
+ self.assertEqual(slots[12], "2026-06-13T00:00:00Z")
+
+ def test_cultmesh_simulation_fact_committer_rejects_without_quorum_and_stores_fact(self) -> None:
+ candidate = {
+ "shardId": "arena",
+ "shardEpoch": 4,
+ "frame": 100,
+ "subjectId": "bob",
+ "claimKind": "hit",
+ "claimHash": compute_simulation_claim_hash("hit", "alice", "bob", "frame:100"),
+ "claimSummary": "alice shot bob first",
+ "witnessCount": 2,
+ "supportWeight": 2.0,
+ "totalWeight": 2.0,
+ "confidence": 1.0,
+ "hasQuorum": True,
+ }
+
+ with tempfile.TemporaryDirectory() as tmp:
+ node = CultMesh.create_node(Path(tmp) / "facts.cc", runtime_id="mesh-facts")
+ committer = CultMesh.create_simulation_fact_committer(node)
+
+ rejected = dict(candidate)
+ rejected["hasQuorum"] = False
+ with self.assertRaisesRegex(ValueError, "before quorum"):
+ committer.commit(rejected)
+
+ committed = committer.commit(candidate, committed_at="2026-06-13T00:00:00Z")
+ stored = node.get_required(simulation_fact_document, committed.key)
+
+ self.assertEqual(stored.claim_hash, candidate["claimHash"])
+ self.assertEqual(stored.committed_at, "2026-06-13T00:00:00Z")
+ self.assertEqual(committed.fact.fact_id, stored.fact_id)
+
+ def test_cultnet_simulation_consensus_dedupes_witnesses_and_requires_quorum(self) -> None:
+ from cultnet_py import CultNetSimulationConsensus
+
+ consensus = CultNetSimulationConsensus(
+ CultNetSimulationConsensusOptions(minimum_witnesses=2, quorum_ratio=1.0)
+ )
+ claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:100")
+ observations = [
+ {
+ "witnessRuntimeId": "watcher-1",
+ "shardId": "arena",
+ "shardEpoch": 4,
+ "frame": 100,
+ "subjectId": "bob",
+ "claimKind": "hit",
+ "claimHash": claim_hash,
+ "claimSummary": "alice shot bob first",
+ "weight": 1.0,
+ },
+ {
+ "witnessRuntimeId": "watcher-1",
+ "shardId": "arena",
+ "shardEpoch": 4,
+ "frame": 100,
+ "subjectId": "bob",
+ "claimKind": "hit",
+ "claimHash": "stale-duplicate",
+ "weight": 0.5,
+ },
+ {
+ "witnessRuntimeId": "watcher-2",
+ "shardId": "arena",
+ "shardEpoch": 4,
+ "frame": 100,
+ "subjectId": "bob",
+ "claimKind": "hit",
+ "claimHash": claim_hash,
+ "weight": 1.0,
+ },
+ ]
+
+ candidates = consensus.build_candidates(observations)
+ typed_candidates = consensus.build_candidate_objects([
+ CultNetSimulationObservation.from_wire(observation)
+ for observation in observations
+ ])
+
+ self.assertEqual(len(candidates), 1)
+ self.assertEqual(candidates[0]["claimHash"], claim_hash)
+ self.assertEqual(candidates[0]["witnessCount"], 2)
+ self.assertEqual(candidates[0]["supportWeight"], 2.0)
+ self.assertTrue(candidates[0]["hasQuorum"])
+ self.assertEqual(len(typed_candidates), 1)
+ self.assertIsInstance(typed_candidates[0], CultNetSimulationConsensusCandidate)
+ self.assertEqual(typed_candidates[0].claim_hash, claim_hash)
+ self.assertEqual(typed_candidates[0].to_wire(), candidates[0])
+ self.assertEqual(CultNetSimulationConsensusCandidate.from_wire(candidates[0]), typed_candidates[0])
+
+ def test_cultmesh_game_session_submits_observations_and_commits_quorum_once(self) -> None:
+ claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:100")
+ first = simulation_observation(
+ message_id="obs-1",
+ witness_runtime_id="watcher-1",
+ shard_id="arena",
+ shard_epoch=4,
+ frame=100,
+ subject_id="bob",
+ claim_kind="hit",
+ claim_hash=claim_hash,
+ claim_summary="alice shot bob first",
+ ).to_wire()
+ second = simulation_observation(
+ message_id="obs-2",
+ witness_runtime_id="watcher-2",
+ shard_id="arena",
+ shard_epoch=4,
+ frame=100,
+ subject_id="bob",
+ claim_kind="hit",
+ claim_hash=claim_hash,
+ claim_summary="alice shot bob first",
+ ).to_wire()
+
+ with tempfile.TemporaryDirectory() as tmp:
+ node = CultMesh.create_node(Path(tmp) / "session.cc", runtime_id="mesh-session")
+ session = CultMesh.create_game_session(
+ node,
+ CultMeshGameSessionOptions(
+ consensus_options=CultNetSimulationConsensusOptions(minimum_witnesses=2, quorum_ratio=1.0)
+ ),
+ )
+ seen_candidates: list[CultNetSimulationConsensusCandidate] = []
+ seen_facts: list[CultMeshDatabaseChange] = []
+ unsubscribe_candidates = session.watch_candidates(seen_candidates.append)
+ unsubscribe_facts = session.watch_simulation_facts(seen_facts.append)
+
+ self.assertEqual(session.submit_and_commit(CultNetSimulationObservation.from_wire(first)), [])
+ typed_candidates = session.submit_observation_candidates(CultNetSimulationObservation.from_wire(second))
+ commits = session.commit_quorum_candidate_objects(typed_candidates)
+ replay = session.commit_quorum_candidates(session.submit_observation(second))
+ unsubscribe_candidates()
+ unsubscribe_facts()
+
+ self.assertEqual(len(typed_candidates), 1)
+ self.assertTrue(typed_candidates[0].has_quorum)
+ self.assertEqual(len(commits), 1)
+ self.assertEqual(replay, [])
+ self.assertEqual([candidate.claim_hash for candidate in seen_candidates], [claim_hash, claim_hash, claim_hash])
+ self.assertEqual(len(seen_facts), 1)
+ self.assertEqual(seen_facts[0].change_kind, "added")
+ self.assertEqual(seen_facts[0].value.claim_hash, claim_hash)
+ stored = node.get_required(simulation_fact_document, commits[0].key)
+ self.assertEqual(stored.claim_hash, claim_hash)
+ self.assertEqual(stored.witness_count, 2)
+
+ def test_cultmesh_game_session_serves_catalogs_and_observations_over_wire(self) -> None:
+ verse_catalog = CultMeshVerseCatalog()
+ peer_catalog = CultMeshPeerCatalog()
+ verse_catalog.upsert(CultMeshVerseDescriptor(
+ verse_id="session-verse",
+ display_name="Session Verse",
+ authority_model="local",
+ compatibility=CultMeshVerseCompatibility("cultmesh.v0", "session-rules"),
+ ))
+ peer_catalog.upsert(CultMeshPeerCard(
+ peer_id="session-peer",
+ verse_id="session-verse",
+ endpoints=("cultnet://127.0.0.1:0",),
+ roles=("simulation-observer", "read-replica"),
+ shard_ids=("arena",),
+ ))
+ session = CultMesh.create_game_session(
+ CultMesh.create_node(runtime_id="session-peer"),
+ CultMeshGameSessionOptions(
+ verse_catalog=verse_catalog,
+ peer_catalog=peer_catalog,
+ consensus_options=CultNetSimulationConsensusOptions(minimum_witnesses=2, quorum_ratio=1.0),
+ ),
+ )
+ server = session.serve(display_name="Session Peer")
+ try:
+ raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
+ hello_response = raw_client.request(
+ hello(runtime_id="session-prober"),
+ expected_schema_version="cultnet.hello.v0",
+ )
+ discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", server.port, timeout_seconds=2.0)
+ verses = discovery_client.fetch_verses(transport_version="cultmesh.v0")
+ peers = discovery_client.fetch_peers(verse_id="session-verse", roles=["simulation-observer"])
+ claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:100")
+ first = raw_client.request(
+ simulation_observation(
+ message_id="session-wire-1",
+ witness_runtime_id="watcher-1",
+ shard_id="arena",
+ shard_epoch=4,
+ frame=100,
+ subject_id="bob",
+ claim_kind="hit",
+ claim_hash=claim_hash,
+ claim_summary="alice shot bob first",
+ ),
+ expected_schema_version="cultnet.simulation_consensus_candidate.v0",
+ )
+ second = raw_client.request(
+ simulation_observation(
+ message_id="session-wire-2",
+ witness_runtime_id="watcher-2",
+ shard_id="arena",
+ shard_epoch=4,
+ frame=100,
+ subject_id="bob",
+ claim_kind="hit",
+ claim_hash=claim_hash,
+ claim_summary="alice shot bob first",
+ ),
+ expected_schema_version="cultnet.simulation_consensus_candidate.v0",
+ )
+ finally:
+ server.stop()
+
+ self.assertIn("cultnet.simulation_observation.v0", hello_response["supportedMessageVersions"])
+ self.assertEqual(verses[0].verse_id, "session-verse")
+ self.assertEqual(peers[0].peer_id, "session-peer")
+ self.assertIn("simulation-observer", peers[0].roles)
+ self.assertFalse(first["hasQuorum"])
+ self.assertEqual(first["witnessCount"], 1)
+ self.assertTrue(second["hasQuorum"])
+ self.assertEqual(second["witnessCount"], 2)
+
+ def test_cultmesh_game_session_can_disable_served_simulation_observations(self) -> None:
+ verse_catalog = CultMeshVerseCatalog()
+ verse_catalog.upsert(CultMeshVerseDescriptor(
+ verse_id="session-quiet",
+ display_name="Quiet Session",
+ authority_model="local",
+ compatibility=CultMeshVerseCompatibility("cultmesh.v0", "quiet-rules"),
+ ))
+ session = CultMesh.create_game_session(
+ CultMesh.create_node(runtime_id="quiet-session-peer"),
+ CultMeshGameSessionOptions(
+ verse_catalog=verse_catalog,
+ serve_simulation_observations=False,
+ ),
+ )
+ server = session.serve(display_name="Quiet Session Peer")
+ try:
+ raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
+ hello_response = raw_client.request(
+ hello(runtime_id="quiet-session-prober"),
+ expected_schema_version="cultnet.hello.v0",
+ )
+ discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", server.port, timeout_seconds=2.0)
+ verses = discovery_client.fetch_verses(transport_version="cultmesh.v0")
+ with self.assertRaisesRegex(CultNetPeerError, "Simulation observations are not enabled") as raised:
+ raw_client.request(
+ simulation_observation(
+ message_id="quiet-session-sim",
+ witness_runtime_id="watcher-1",
+ shard_id="arena",
+ shard_epoch=4,
+ frame=100,
+ subject_id="bob",
+ claim_kind="hit",
+ claim_hash=compute_simulation_claim_hash("hit", "alice", "bob", "frame:100"),
+ ),
+ expected_schema_version="cultnet.simulation_consensus_candidate.v0",
+ )
+ finally:
+ server.stop()
+
+ self.assertNotIn("cultnet.simulation_observation.v0", hello_response["supportedMessageVersions"])
+ self.assertEqual(verses[0].verse_id, "session-quiet")
+ self.assertEqual(raised.exception.response["code"], "simulation_observations_disabled")
+
+ def test_cultmesh_game_session_prediction_requires_scope_and_reconciles_shard_log(self) -> None:
+ note_doc = define_database_entry_type(
+ "mesh.input",
+ [("body", 0)],
+ schema_id="mesh.input.v1",
+ )
+ with tempfile.TemporaryDirectory() as tmp:
+ node = CultMesh.create_node(Path(tmp) / "prediction.cc", runtime_id="client-a")
+ node.register_document(note_doc)
+ session = CultMesh.create_game_session(
+ node,
+ CultMeshGameSessionOptions(
+ client_authority_scopes=(
+ CultNetClientAuthorityScope(
+ "client-a",
+ schema_ids=("mesh.input.v1",),
+ key_prefix="input:client-a",
+ ),
+ )
+ ),
+ )
+
+ with self.assertRaisesRegex(ValueError, "does not have client prediction authority"):
+ session.predict(note_doc, "input:other", {"body": "nope"})
+
+ prediction = session.predict(note_doc, "input:client-a:move", {"body": "predicted"})
+ put = document_put_raw(
+ message_id="authoritative",
+ key=prediction.key,
+ schema_id=prediction.schema_id,
+ stored_at="2026-06-13T00:00:00Z",
+ payload=note_doc.encode_payload({"body": "authoritative"}),
+ shard_id="inputs",
+ shard_epoch=1,
+ )
+ authoritative_response = {
+ "schemaVersion": "cultnet.shard_log_response.v0",
+ "messageId": "inputs-log",
+ "shardId": "inputs",
+ "shardEpoch": 1,
+ "entries": [
+ {
+ "sequence": 1,
+ "committedAt": "2026-06-13T00:00:00Z",
+ "changeKind": "updated",
+ "put": put.to_wire(),
+ }
+ ],
+ "resyncRequired": False,
+ }
+ changes = session.apply_shard_log_response(CultNetShardLogResponse.from_wire(authoritative_response))
+
+ self.assertEqual(changes[0].change_kind, "reconciled")
+ self.assertEqual(node.get_required(note_doc, prediction.key)["body"], "authoritative")
+ self.assertEqual(session.pending_predictions(), ())
+ self.assertEqual(session.resimulation_inputs(), ())
+
+ def test_cultmesh_game_session_partial_reconciliation_keeps_unmatched_predictions(self) -> None:
+ note_doc = define_database_entry_type(
+ "mesh.partial_input",
+ [("body", 0)],
+ schema_id="mesh.partial_input.v1",
+ )
+ node = CultMesh.create_node(runtime_id="client-partial")
+ node.register_document(note_doc)
+ session = CultMesh.create_game_session(
+ node,
+ CultMeshGameSessionOptions(
+ client_authority_scopes=(
+ CultNetClientAuthorityScope(
+ "client-partial",
+ schema_ids=("mesh.partial_input.v1",),
+ key_prefix="input:client-partial",
+ ),
+ )
+ ),
+ )
+ pending = session.predict(note_doc, "input:client-partial:pending", {"body": "pending-local"})
+ accepted = session.predict(note_doc, "input:client-partial:accepted", {"body": "accepted-local"})
+ put = document_put_raw(
+ message_id="authoritative-partial",
+ key=accepted.key,
+ schema_id=accepted.schema_id,
+ stored_at="2026-06-13T00:00:00Z",
+ payload=note_doc.encode_payload({"body": "accepted-authority"}),
+ shard_id="inputs",
+ shard_epoch=1,
+ )
+
+ changes = session.apply_shard_log_response({
+ "schemaVersion": "cultnet.shard_log_response.v0",
+ "messageId": "inputs-partial-log",
+ "shardId": "inputs",
+ "shardEpoch": 1,
+ "entries": [
+ {
+ "sequence": 1,
+ "committedAt": "2026-06-13T00:00:00Z",
+ "changeKind": "updated",
+ "put": put.to_wire(),
+ }
+ ],
+ "resyncRequired": False,
+ })
+
+ self.assertEqual([(change.change_kind, change.record_key) for change in changes], [
+ ("reconciled", accepted.key),
+ ])
+ self.assertEqual(node.get_required(note_doc, accepted.key)["body"], "accepted-authority")
+ self.assertEqual(node.get_required(note_doc, pending.key)["body"], "pending-local")
+ self.assertEqual([prediction.key for prediction in session.pending_predictions()], [pending.key])
+ self.assertEqual([prediction.key for prediction in session.resimulation_inputs()], [pending.key])
+
+ def test_cultmesh_game_session_rolls_back_pending_predictions(self) -> None:
+ note_doc = define_database_entry_type(
+ "mesh.rollback_input",
+ [("body", 0)],
+ schema_id="mesh.rollback_input.v1",
+ )
+ node = CultMesh.create_node(runtime_id="client-rollback")
+ node.register_document(note_doc)
+ node.put(note_doc, "input:client-rollback:existing", {"body": "previous"})
+ session = CultMesh.create_game_session(
+ node,
+ CultMeshGameSessionOptions(
+ client_authority_scopes=(
+ CultNetClientAuthorityScope(
+ "client-rollback",
+ schema_ids=("mesh.rollback_input.v1",),
+ key_prefix="input:client-rollback",
+ ),
+ )
+ ),
+ )
+
+ existing = session.predict(note_doc, "input:client-rollback:existing", {"body": "predicted-existing"})
+ created = session.predict(note_doc, "input:client-rollback:new", {"body": "predicted-new"})
+ self.assertEqual(
+ [prediction.key for prediction in session.pending_predictions()],
+ ["input:client-rollback:existing", "input:client-rollback:new"],
+ )
+ self.assertEqual([prediction.key for prediction in session.resimulation_inputs()], [
+ "input:client-rollback:existing",
+ "input:client-rollback:new",
+ ])
+
+ restored = session.rollback_prediction(existing)
+ self.assertIsNotNone(restored)
+ assert restored is not None
+ self.assertEqual(restored.change_kind, "rolled_back")
+ self.assertEqual(restored.value, {"body": "previous"})
+ self.assertEqual(node.get_required(note_doc, "input:client-rollback:existing")["body"], "previous")
+
+ rolled_back = session.rollback_predictions()
+ self.assertEqual([(change.change_kind, change.record_key, change.value) for change in rolled_back], [
+ ("rolled_back", created.key, None),
+ ])
+ self.assertIsNone(node.get(note_doc, created.key))
+ self.assertEqual(session.pending_predictions(), ())
+
+ def test_cultmesh_game_session_authoritative_delete_rolls_back_prediction(self) -> None:
+ note_doc = define_database_entry_type(
+ "mesh.deleted_input",
+ [("body", 0)],
+ schema_id="mesh.deleted_input.v1",
+ )
+ node = CultMesh.create_node(runtime_id="client-delete")
+ node.register_document(note_doc)
+ session = CultMesh.create_game_session(
+ node,
+ CultMeshGameSessionOptions(
+ client_authority_scopes=(
+ CultNetClientAuthorityScope(
+ "client-delete",
+ schema_ids=("mesh.deleted_input.v1",),
+ key_prefix="input:client-delete",
+ ),
+ )
+ ),
+ )
+
+ prediction = session.predict(note_doc, "input:client-delete:move", {"body": "predicted"})
+ delete = document_delete(
+ message_id="authoritative-delete",
+ schema_id=prediction.schema_id,
+ record_key=prediction.key,
+ shard_id="inputs",
+ shard_epoch=1,
+ )
+ changes = session.apply_shard_log_response({
+ "schemaVersion": "cultnet.shard_log_response.v0",
+ "messageId": "inputs-delete-log",
+ "shardId": "inputs",
+ "shardEpoch": 1,
+ "entries": [
+ {
+ "sequence": 1,
+ "committedAt": "2026-06-13T00:00:00Z",
+ "changeKind": "removed",
+ "delete": delete.to_wire(),
+ }
+ ],
+ "resyncRequired": False,
+ })
+
+ self.assertEqual(changes[0].change_kind, "rolled_back")
+ self.assertIsNone(node.get(note_doc, prediction.key))
+ self.assertEqual(session.pending_predictions(), ())
+
+ def test_cultmesh_database_watchers_observe_authoritative_shard_log_reconciliation(self) -> None:
+ note_doc = define_database_entry_type(
+ "mesh.watched_input",
+ [("body", 0)],
+ schema_id="mesh.watched_input.v1",
+ )
+ node = CultMesh.create_node(runtime_id="client-watch")
+ node.database.register_document(note_doc)
+ seen: list[CultMeshDatabaseChange] = []
+ node.database.watch_record(note_doc, "input:client-watch:move", seen.append)
+ session = CultMesh.create_game_session(
+ node,
+ CultMeshGameSessionOptions(
+ client_authority_scopes=(
+ CultNetClientAuthorityScope(
+ "client-watch",
+ schema_ids=("mesh.watched_input.v1",),
+ key_prefix="input:client-watch",
+ ),
+ )
+ ),
+ )
+
+ prediction = session.predict(note_doc, "input:client-watch:move", {"body": "predicted"})
+ put = document_put_raw(
+ message_id="authoritative-watch",
+ key=prediction.key,
+ schema_id=prediction.schema_id,
+ stored_at="2026-06-13T00:00:00Z",
+ payload=note_doc.encode_payload({"body": "authoritative"}),
+ shard_id="inputs",
+ shard_epoch=1,
+ )
+ session_changes = session.apply_shard_log_response({
+ "schemaVersion": "cultnet.shard_log_response.v0",
+ "messageId": "inputs-watch-log",
+ "shardId": "inputs",
+ "shardEpoch": 1,
+ "entries": [
+ {
+ "sequence": 1,
+ "committedAt": "2026-06-13T00:00:00Z",
+ "changeKind": "updated",
+ "put": put.to_wire(),
+ }
+ ],
+ "resyncRequired": False,
+ })
+
+ self.assertEqual([change.change_kind for change in seen], ["added", "updated"])
+ self.assertEqual(seen[1].value, {"body": "authoritative"})
+ self.assertEqual(seen[1].previous_value, {"body": "predicted"})
+ self.assertEqual(session_changes[0].change_kind, "reconciled")
+
+ def test_cultmesh_verse_catalog_response_matches_schema_v0_wire_shape(self) -> None:
+ import msgpack # type: ignore
+
+ catalog = CultMeshVerseCatalog()
+ catalog.upsert(
+ CultMeshVerseDescriptor(
+ verse_id="aetheria-main",
+ display_name="Aetheria",
+ authority_model="OperatorCluster",
+ compatibility=CultMeshVerseCompatibility(
+ transport_version="cultmesh.v0",
+ rules_hash="rules",
+ compatible_verse_ids=("aetheria-modded",),
+ required_plugin_ids=("core",),
+ optional_plugin_ids=("skylands",),
+ ),
+ discovery_endpoints=("cultmesh://aetheria.example.test:3075",),
+ authority_runtime_ids=("runtime-a",),
+ description="main branch",
+ )
+ )
+
+ response = catalog.create_response(verse_catalog_request("verses-1"))
+ decoded = msgpack.unpackb(msgpack.packb(response, use_bin_type=True), raw=False)
+ self.assertEqual(decoded["schemaVersion"], "cultmesh.verse_catalog_response.v0")
+ self.assertEqual(decoded["messageId"], "verses-1")
+ self.assertEqual(decoded["verses"][0]["verseId"], "aetheria-main")
+ self.assertEqual(decoded["verses"][0]["compatibility"]["requiredPluginIds"], ["core"])
+
+ def test_cultmesh_verse_catalog_watches_updates_and_finds_transfer_targets(self) -> None:
+ source = CultMeshVerseDescriptor(
+ verse_id="aetheria-main",
+ display_name="Aetheria",
+ authority_model="federated",
+ compatibility=CultMeshVerseCompatibility(
+ transport_version="cultmesh.v0",
+ rules_hash="rules-main",
+ ),
+ )
+ compatible = CultMeshVerseDescriptor(
+ verse_id="aetheria-modded",
+ display_name="Aetheria Modded",
+ authority_model="federated",
+ compatibility=CultMeshVerseCompatibility(
+ transport_version="cultmesh.v0",
+ rules_hash="rules-modded",
+ compatible_verse_ids=("aetheria-main",),
+ ),
+ )
+ incompatible = CultMeshVerseDescriptor(
+ verse_id="old-world",
+ display_name="Old World",
+ authority_model="solo",
+ compatibility=CultMeshVerseCompatibility(
+ transport_version="cultmesh.v0",
+ rules_hash="rules-old",
+ ),
+ )
+ catalog = CultMeshVerseCatalog()
+ seen: list[str] = []
+ unsubscribe = catalog.watch(lambda verse: seen.append(verse.verse_id))
+
+ catalog.upsert(source)
+ catalog.apply_response({
+ "schemaVersion": "cultmesh.verse_catalog_response.v0",
+ "messageId": "verses-watch",
+ "verses": [compatible.to_wire(), incompatible.to_wire()],
+ })
+ unsubscribe()
+ catalog.upsert(CultMeshVerseDescriptor(
+ verse_id="after-unsubscribe",
+ display_name="After",
+ authority_model="none",
+ compatibility=CultMeshVerseCompatibility("cultmesh.v0", "rules-after"),
+ ))
+
+ self.assertEqual(seen, ["aetheria-main", "aetheria-modded", "old-world"])
+ self.assertEqual([verse.verse_id for verse in catalog.verses], ["aetheria-main", "aetheria-modded", "after-unsubscribe", "old-world"])
+ self.assertEqual(catalog.get("aetheria-modded"), compatible)
+ self.assertEqual([verse.verse_id for verse in catalog.find_transfer_targets(source)], ["aetheria-modded"])
+
+ def test_cultmesh_peer_exchange_response_matches_schema_v0_wire_shape(self) -> None:
+ import msgpack # type: ignore
+
+ catalog = CultMeshPeerCatalog()
+ catalog.upsert(
+ CultMeshPeerCard(
+ peer_id="peer-a",
+ verse_id="aetheria-main",
+ endpoints=("cultnet://peer-a.example.test:3075",),
+ roles=("discovery", "read-replica"),
+ shard_ids=("players",),
+ region="eu-west",
+ authority_lease_id="lease-1",
+ expires_at="2026-05-20T12:00:00.0000000Z",
+ signature="sig",
+ )
+ )
+
+ response = catalog.create_response(
+ peer_exchange_request("pex-1", verse_id="aetheria-main", roles=["read-replica"])
+ )
+ decoded = msgpack.unpackb(msgpack.packb(response, use_bin_type=True), raw=False)
+ self.assertEqual(decoded["schemaVersion"], "cultmesh.peer_exchange_response.v0")
+ self.assertEqual(decoded["messageId"], "pex-1")
+ self.assertEqual(decoded["peers"][0]["peerId"], "peer-a")
+ self.assertIn("read-replica", decoded["peers"][0]["roles"])
+ self.assertEqual(decoded["peers"][0]["authorityLeaseId"], "lease-1")
+
+ def test_cultmesh_peer_catalog_watches_updates_and_gets_peers(self) -> None:
+ first = CultMeshPeerCard(
+ peer_id="peer-a",
+ verse_id="aetheria-main",
+ endpoints=("cultnet://peer-a.example.test:3075",),
+ roles=("discovery", "read-replica"),
+ )
+ second = CultMeshPeerCard(
+ peer_id="peer-b",
+ verse_id="aetheria-main",
+ endpoints=("cultnet://peer-b.example.test:3075",),
+ roles=("shard-primary",),
+ )
+ catalog = CultMeshPeerCatalog()
+ seen: list[str] = []
+ unsubscribe = catalog.watch(lambda peer: seen.append(peer.peer_id))
+
+ catalog.upsert(first)
+ catalog.apply_response({
+ "schemaVersion": "cultmesh.peer_exchange_response.v0",
+ "messageId": "peers-watch",
+ "peers": [second.to_wire()],
+ })
+ unsubscribe()
+ catalog.upsert(CultMeshPeerCard(
+ peer_id="peer-c",
+ verse_id="aetheria-main",
+ endpoints=("cultnet://peer-c.example.test:3075",),
+ roles=("read-replica",),
+ ))
+
+ self.assertEqual(seen, ["peer-a", "peer-b"])
+ self.assertTrue(first.has_role("read-replica"))
+ self.assertEqual([peer.peer_id for peer in catalog.peers], ["peer-a", "peer-b", "peer-c"])
+ self.assertEqual(catalog.get("peer-b"), second)
+ self.assertEqual([peer.peer_id for peer in catalog.find("aetheria-main", role="read-replica")], ["peer-a", "peer-c"])
+
+ def test_cultmesh_discovery_client_fetches_typed_catalogs_over_cultnet_frames(self) -> None:
+ import msgpack # type: ignore
+ from cultnet_py import read_frame, write_frame
+
+ verses = CultMeshVerseCatalog()
+ verses.upsert(
+ CultMeshVerseDescriptor(
+ verse_id="aetheria-main",
+ display_name="Aetheria",
+ authority_model="federated",
+ compatibility=CultMeshVerseCompatibility(
+ transport_version="cultmesh.v0",
+ rules_hash="rules-1",
+ required_plugin_ids=("core",),
+ ),
+ discovery_endpoints=("cultmesh://aetheria.example.test:3075",),
+ authority_runtime_ids=("runtime-a",),
+ )
+ )
+ peers = CultMeshPeerCatalog()
+ peers.upsert(
+ CultMeshPeerCard(
+ peer_id="peer-a",
+ verse_id="aetheria-main",
+ endpoints=("cultnet://peer-a.example.test:3075",),
+ roles=("read-replica",),
+ shard_ids=("players",),
+ )
+ )
+
+ ready = threading.Event()
+ server_error: list[BaseException] = []
+
+ def serve_requests() -> None:
+ try:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
+ server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ server.bind(("127.0.0.1", 0))
+ port_holder.append(server.getsockname()[1])
+ server.listen(8)
+ ready.set()
+ for _ in range(8):
+ connection, _ = server.accept()
+ with connection:
+ stream = connection.makefile("rwb")
+ request = msgpack.unpackb(read_frame(stream), raw=False)
+ if request["schemaVersion"] == "cultmesh.verse_catalog_request.v0":
+ response = verses.create_response(request)
+ elif request["schemaVersion"] == "cultmesh.peer_exchange_request.v0":
+ response = peers.create_response(request)
+ else:
+ raise AssertionError(f"unexpected request {request['schemaVersion']}")
+ write_frame(stream, msgpack.packb(response, use_bin_type=True))
+ stream.flush()
+ except BaseException as error:
+ server_error.append(error)
+ ready.set()
+
+ port_holder: list[int] = []
+ thread = threading.Thread(target=serve_requests, daemon=True)
+ thread.start()
+ self.assertTrue(ready.wait(2.0))
+ self.assertFalse(server_error)
+
+ client = CultMeshDiscoveryClient("127.0.0.1", port_holder[0], timeout_seconds=2.0)
+ fetched_verses = client.fetch_verses(transport_version="cultmesh.v0")
+ fetched_peers = client.fetch_peers(verse_id="aetheria-main", roles=["read-replica"])
+ local_verses = CultMeshVerseCatalog()
+ local_peers = CultMeshPeerCatalog()
+ synced_verses = client.sync_verse_catalog(local_verses, transport_version="cultmesh.v0")
+ synced_peers = client.sync_peer_catalog(local_peers, verse_id="aetheria-main", roles=["read-replica"])
+ discovered_verses = CultMeshVerseCatalog()
+ discovered_peers = CultMeshPeerCatalog()
+ endpoint = f"cultnet://127.0.0.1:{port_holder[0]}"
+ verse_discovery = CultMeshVerseDiscoveryClient.from_endpoint(endpoint, timeout_seconds=2.0)
+ peer_discovery = CultMeshPeerExchangeClient.from_endpoint(endpoint, timeout_seconds=2.0)
+ discovered_verse_count = verse_discovery.discover(
+ discovered_verses,
+ endpoints=[endpoint, endpoint, ""],
+ transport_version="cultmesh.v0",
+ )
+ discovered_peer_count = peer_discovery.discover(
+ discovered_peers,
+ verse_id="aetheria-main",
+ endpoints=[endpoint, endpoint, ""],
+ roles=["read-replica"],
+ )
+ fanout_verses = CultMeshVerseCatalog()
+ fanout_verses.upsert(
+ CultMeshVerseDescriptor(
+ verse_id="aetheria-main",
+ display_name="Known Aetheria",
+ authority_model="federated",
+ compatibility=CultMeshVerseCompatibility("cultmesh.v0", "rules-1"),
+ discovery_endpoints=(endpoint, endpoint, ""),
+ )
+ )
+ fanout_peers = CultMeshPeerCatalog()
+ fanout_peers.upsert(
+ CultMeshPeerCard(
+ peer_id="known-peer",
+ verse_id="aetheria-main",
+ endpoints=(endpoint, endpoint, ""),
+ roles=("read-replica",),
+ )
+ )
+ fanout_verse_count = verse_discovery.fanout(fanout_verses, transport_version="cultmesh.v0")
+ fanout_peer_count = peer_discovery.fanout(
+ fanout_peers,
+ verse_id="aetheria-main",
+ roles=["read-replica"],
+ )
+
+ thread.join(2.0)
+ self.assertFalse(server_error)
+ self.assertEqual(fetched_verses[0].verse_id, "aetheria-main")
+ self.assertEqual(fetched_verses[0].compatibility.required_plugin_ids, ("core",))
+ self.assertEqual(fetched_peers[0].peer_id, "peer-a")
+ self.assertEqual(fetched_peers[0].shard_ids, ("players",))
+ self.assertEqual(synced_verses[0].verse_id, "aetheria-main")
+ self.assertEqual(local_peers.find("aetheria-main", role="read-replica")[0].peer_id, "peer-a")
+ self.assertEqual(synced_peers[0].roles, ("read-replica",))
+ self.assertEqual(discovered_verse_count, 1)
+ self.assertEqual(discovered_peer_count, 1)
+ self.assertEqual(discovered_verses.get("aetheria-main").display_name, "Aetheria")
+ self.assertEqual(discovered_peers.find("aetheria-main", role="read-replica")[0].peer_id, "peer-a")
+ self.assertEqual(fanout_verse_count, 1)
+ self.assertEqual(fanout_peer_count, 1)
+ self.assertEqual(fanout_verses.get("aetheria-main").display_name, "Aetheria")
+ self.assertIn(
+ "peer-a",
+ {peer.peer_id for peer in fanout_peers.find("aetheria-main", role="read-replica")},
+ )
+
+ def test_cultmesh_discovery_client_fans_out_simulation_observations_to_peers(self) -> None:
+ first_hub = CultNetSimulationObservationHub(
+ CultNetSimulationConsensusOptions(minimum_witnesses=1, quorum_ratio=1.0)
+ )
+ second_hub = CultNetSimulationObservationHub(
+ CultNetSimulationConsensusOptions(minimum_witnesses=1, quorum_ratio=1.0)
+ )
+ first_server = CultMesh.serve_node(
+ CultMesh.create_node(runtime_id="sim-peer-a"),
+ observation_hub=first_hub,
+ )
+ second_server = CultMesh.serve_node(
+ CultMesh.create_node(runtime_id="sim-peer-b"),
+ observation_hub=second_hub,
+ )
+ try:
+ peers = CultMeshPeerCatalog()
+ peers.upsert(CultMeshPeerCard(
+ peer_id="sim-peer-a",
+ verse_id="arena",
+ endpoints=(f"cultnet://127.0.0.1:{first_server.port}",),
+ roles=("simulation-witness",),
+ ))
+ peers.upsert(CultMeshPeerCard(
+ peer_id="sim-peer-b",
+ verse_id="arena",
+ endpoints=(f"cultnet://127.0.0.1:{second_server.port}",),
+ roles=("simulation-witness",),
+ ))
+ claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:200")
+ message = simulation_observation(
+ message_id="fanout-observation",
+ witness_runtime_id="python-fanout",
+ shard_id="arena",
+ shard_epoch=1,
+ frame=200,
+ subject_id="bob",
+ claim_kind="hit",
+ claim_hash=claim_hash,
+ claim_summary="alice hit bob",
+ ).to_wire()
+
+ client = CultMeshDiscoveryClient("127.0.0.1", first_server.port, timeout_seconds=2.0)
+ candidates = client.fanout_simulation_observation(
+ peers,
+ message,
+ verse_id="arena",
+ roles=["simulation-witness"],
+ )
+ finally:
+ first_server.stop()
+ second_server.stop()
+
+ self.assertEqual([candidate["schemaVersion"] for candidate in candidates], [
+ "cultnet.simulation_consensus_candidate.v0",
+ "cultnet.simulation_consensus_candidate.v0",
+ ])
+ self.assertEqual({candidate["claimHash"] for candidate in candidates}, {claim_hash})
+ self.assertEqual({candidate["messageId"] for candidate in candidates}, {"fanout-observation"})
+ self.assertEqual({candidate["hasQuorum"] for candidate in candidates}, {True})
+
+ def test_cultmesh_simulation_observation_fanout_flushes_queued_messages(self) -> None:
+ hub = CultNetSimulationObservationHub(
+ CultNetSimulationConsensusOptions(minimum_witnesses=1, quorum_ratio=1.0)
+ )
+ server = CultMesh.serve_node(CultMesh.create_node(runtime_id="sim-flush-peer"), observation_hub=hub)
+ try:
+ peers = CultMeshPeerCatalog()
+ peers.upsert(CultMeshPeerCard(
+ peer_id="sim-flush-peer",
+ verse_id="arena",
+ endpoints=(f"cultnet://127.0.0.1:{server.port}",),
+ roles=("simulation-witness",),
+ ))
+ claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:210")
+ seen_candidates: list[dict[str, object]] = []
+ fanout = CultMeshSimulationObservationFanout(
+ CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0),
+ peers,
+ verse_id="arena",
+ roles=["simulation-witness"],
+ on_candidate=seen_candidates.append,
+ )
+ fanout.enqueue(simulation_observation(
+ message_id="queued-observation",
+ witness_runtime_id="python-fanout",
+ shard_id="arena",
+ shard_epoch=1,
+ frame=210,
+ subject_id="bob",
+ claim_kind="hit",
+ claim_hash=claim_hash,
+ claim_summary="alice hit bob",
+ ).to_wire())
+
+ candidates = fanout.flush()
+ finally:
+ server.stop()
+
+ self.assertEqual(fanout.pending_count(), 0)
+ self.assertEqual([candidate["claimHash"] for candidate in candidates], [claim_hash])
+ self.assertEqual(seen_candidates[0]["messageId"], "queued-observation")
+
+ def test_cultmesh_simulation_observation_fanout_keeps_failed_messages_pending(self) -> None:
+ closed_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ closed_socket.bind(("127.0.0.1", 0))
+ closed_port = closed_socket.getsockname()[1]
+ closed_socket.close()
+ peers = CultMeshPeerCatalog()
+ peers.upsert(CultMeshPeerCard(
+ peer_id="closed-simulation-peer",
+ verse_id="arena",
+ endpoints=(f"cultnet://127.0.0.1:{closed_port}",),
+ roles=("simulation-witness",),
+ ))
+ errors: list[tuple[str, str]] = []
+ fanout = CultMeshSimulationObservationFanout(
+ CultMeshDiscoveryClient("127.0.0.1", closed_port, timeout_seconds=0.2),
+ peers,
+ verse_id="arena",
+ roles=["simulation-witness"],
+ on_error=lambda endpoint, error: errors.append((endpoint, str(error))),
+ )
+ fanout.enqueue(simulation_observation(
+ message_id="retry-observation",
+ witness_runtime_id="python-fanout",
+ shard_id="arena",
+ shard_epoch=1,
+ frame=212,
+ subject_id="bob",
+ claim_kind="miss",
+ claim_hash=compute_simulation_claim_hash("miss", "alice", "bob", "frame:212"),
+ claim_summary="alice missed bob",
+ ).to_wire())
+
+ candidates = fanout.flush()
+
+ self.assertEqual(candidates, [])
+ self.assertEqual(fanout.pending_count(), 1)
+ self.assertEqual(len(errors), 1)
+ self.assertIn(f"127.0.0.1:{closed_port}", errors[0][0])
+
+ def test_cultmesh_simulation_observation_fanout_runs_background_loop(self) -> None:
+ hub = CultNetSimulationObservationHub(
+ CultNetSimulationConsensusOptions(minimum_witnesses=1, quorum_ratio=1.0)
+ )
+ server = CultMesh.serve_node(CultMesh.create_node(runtime_id="sim-loop-peer"), observation_hub=hub)
+ try:
+ peers = CultMeshPeerCatalog()
+ peers.upsert(CultMeshPeerCard(
+ peer_id="sim-loop-peer",
+ verse_id="arena",
+ endpoints=(f"cultnet://127.0.0.1:{server.port}",),
+ roles=("simulation-witness",),
+ ))
+ claim_hash = compute_simulation_claim_hash("block", "dana", "eve", "frame:211")
+ seen_candidates: list[dict[str, object]] = []
+ fanout = CultMeshSimulationObservationFanout(
+ CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0),
+ peers,
+ verse_id="arena",
+ roles=["simulation-witness"],
+ interval_seconds=0.05,
+ on_candidate=seen_candidates.append,
+ )
+ fanout.start()
+ fanout.enqueue(simulation_observation(
+ message_id="loop-observation",
+ witness_runtime_id="python-fanout",
+ shard_id="arena",
+ shard_epoch=1,
+ frame=211,
+ subject_id="eve",
+ claim_kind="block",
+ claim_hash=claim_hash,
+ claim_summary="dana blocked eve",
+ ).to_wire())
+ deadline = time.monotonic() + 2.0
+ while not seen_candidates and time.monotonic() < deadline:
+ time.sleep(0.01)
+ fanout.stop()
+ finally:
+ server.stop()
+
+ self.assertEqual(fanout.pending_count(), 0)
+ self.assertTrue(seen_candidates)
+ self.assertEqual(seen_candidates[0]["claimHash"], claim_hash)
+ self.assertEqual(seen_candidates[0]["messageId"], "loop-observation")
+
+ def test_cultmesh_discovery_client_fans_out_snapshots_to_peers(self) -> None:
+ document = define_database_entry_type(
+ "mesh.snapshot_fanout_note",
+ [("body", 0)],
+ schema_id="mesh.snapshot_fanout_note.v1",
+ )
+ first = CultMesh.create_node(runtime_id="snapshot-peer-a")
+ first.database.register_document(document)
+ first.database.put_raw_message(document, "note:a", {"body": "from-a"}, shard_id="notes", shard_epoch=1)
+ second = CultMesh.create_node(runtime_id="snapshot-peer-b")
+ second.database.register_document(document)
+ second.database.put_raw_message(document, "note:b", {"body": "from-b"}, shard_id="notes", shard_epoch=1)
+ target = CultMesh.create_node(runtime_id="snapshot-target")
+ target.database.register_document(document)
+ first_server = CultMesh.serve_node(first)
+ second_server = CultMesh.serve_node(second)
+ try:
+ peers = CultMeshPeerCatalog()
+ peers.upsert(CultMeshPeerCard(
+ peer_id="snapshot-peer-a",
+ verse_id="mesh",
+ endpoints=(f"cultnet://127.0.0.1:{first_server.port}", f"cultnet://127.0.0.1:{first_server.port}"),
+ roles=("read-replica",),
+ ))
+ peers.upsert(CultMeshPeerCard(
+ peer_id="snapshot-peer-b",
+ verse_id="mesh",
+ endpoints=(f"cultnet://127.0.0.1:{second_server.port}",),
+ roles=("read-replica",),
+ ))
+
+ client = CultMeshDiscoveryClient("127.0.0.1", first_server.port, timeout_seconds=2.0)
+ responses = client.fanout_snapshot_responses(
+ peers,
+ verse_id="mesh",
+ roles=["read-replica"],
+ schema_ids=["mesh.snapshot_fanout_note.v1"],
+ )
+ applied = client.sync_snapshots(
+ target.database,
+ peers,
+ verse_id="mesh",
+ roles=["read-replica"],
+ schema_ids=["mesh.snapshot_fanout_note.v1"],
+ )
+ finally:
+ first_server.stop()
+ second_server.stop()
+
+ self.assertEqual(len(responses), 2)
+ self.assertTrue(all(isinstance(response, CultNetRawSnapshotResponse) for response in responses))
+ self.assertEqual(
+ sorted(record.record_key for response in responses for record in response.documents),
+ ["note:a", "note:b"],
+ )
+ self.assertEqual(sorted(record.record_key for record in applied), ["note:a", "note:b"])
+ self.assertEqual(target.database.get_required(document, "note:a")["body"], "from-a")
+ self.assertEqual(target.database.get_required(document, "note:b")["body"], "from-b")
+
+ def test_cultmesh_discovery_client_sync_documents_returns_requested_aliases(self) -> None:
+ @dataclass
+ class CanonicalNote:
+ body: str
+
+ @dataclass
+ class UiNote:
+ body: str
+
+ document = define_database_entry_type(
+ "mesh.snapshot_fanout_alias_note",
+ [("body", 0)],
+ cls=CanonicalNote,
+ schema_id="mesh.snapshot_fanout_alias_note.v1",
+ schema_name="mesh.snapshot_fanout_alias_note",
+ schema_version="mesh.snapshot_fanout_alias_note.v1",
+ )
+ alias = define_database_entry_type(
+ "mesh.snapshot_fanout_alias_note.ui",
+ [("body", 0)],
+ cls=UiNote,
+ schema_id="mesh.snapshot_fanout_alias_note.v1",
+ schema_name="mesh.snapshot_fanout_alias_note",
+ schema_version="mesh.snapshot_fanout_alias_note.v1",
+ )
+ source = CultMesh.create_node(runtime_id="snapshot-alias-source")
+ source.database.register_document(document)
+ source.database.put_raw_message(document, "note:alias", CanonicalNote("from-source"))
+ target = CultMesh.create_node(runtime_id="snapshot-alias-target")
+ target.database.register_document(document)
+ server = CultMesh.serve_node(source)
+ try:
+ peers = CultMeshPeerCatalog()
+ peers.upsert(CultMeshPeerCard(
+ peer_id="snapshot-alias-source",
+ verse_id="mesh",
+ endpoints=(f"cultnet://127.0.0.1:{server.port}",),
+ roles=("read-replica",),
+ ))
+
+ values = CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0).sync_documents(
+ target.database,
+ peers,
+ verse_id="mesh",
+ roles=["read-replica"],
+ documents=[(alias, "note:alias")],
+ )
+ finally:
+ server.stop()
+
+ self.assertEqual(len(values), 1)
+ self.assertIsInstance(values[0], UiNote)
+ self.assertEqual(values[0].body, "from-source")
+ self.assertEqual(target.database.get_required(document, "note:alias").body, "from-source")
+ self.assertIsInstance(target.database.get_required(alias, "note:alias"), UiNote)
+
+ def test_cultmesh_document_subscription_syncs_alias_snapshot_and_changes(self) -> None:
+ @dataclass
+ class CanonicalNote:
+ body: str
+
+ @dataclass
+ class UiNote:
+ body: str
+
+ document = define_database_entry_type(
+ "mesh.subscription_alias_note",
+ [("body", 0)],
+ cls=CanonicalNote,
+ schema_id="mesh.subscription_alias_note.v1",
+ schema_name="mesh.subscription_alias_note",
+ schema_version="mesh.subscription_alias_note.v1",
+ )
+ alias = define_database_entry_type(
+ "mesh.subscription_alias_note.ui",
+ [("body", 0)],
+ cls=UiNote,
+ schema_id="mesh.subscription_alias_note.v1",
+ schema_name="mesh.subscription_alias_note",
+ schema_version="mesh.subscription_alias_note.v1",
+ )
+ source = CultMesh.create_node(runtime_id="subscription-alias-source")
+ source.database.register_document(document)
+ source.database.put_raw_message(document, "note:live", CanonicalNote("initial"))
+ target = CultMesh.create_node(runtime_id="subscription-alias-target")
+ target.database.register_document(document)
+ server = CultMesh.serve_node(source)
+ try:
+ raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
+ with CultMesh.subscribe_document(target, raw_client, alias, "note:live") as subscription:
+ initial = subscription.sync_initial()
+ subscription.send(document_put_raw(
+ message_id="subscription-alias-put",
+ key="note:live",
+ schema_id="mesh.subscription_alias_note.v1",
+ stored_at="2026-06-14T00:00:00Z",
+ payload=document.encode_payload(CanonicalNote("updated")),
+ ))
+ updated = subscription.read_next_change()
+ subscription.send(document_delete(
+ message_id="subscription-alias-delete",
+ schema_id="mesh.subscription_alias_note.v1",
+ record_key="note:live",
+ ))
+ removed = subscription.read_next_change()
+ finally:
+ server.stop()
+
+ self.assertIsInstance(initial, UiNote)
+ self.assertEqual(initial.body, "initial")
+ self.assertEqual(updated.change_kind, "updated")
+ self.assertIsInstance(updated.value, UiNote)
+ self.assertEqual(updated.value.body, "updated")
+ self.assertIsInstance(updated.previous_value, UiNote)
+ self.assertEqual(updated.previous_value.body, "initial")
+ self.assertEqual(removed.change_kind, "removed")
+ self.assertIsNone(removed.value)
+ self.assertIsInstance(removed.previous_value, UiNote)
+ self.assertEqual(removed.previous_value.body, "updated")
+ self.assertIsNone(target.database.get(alias, "note:live"))
+
+ def test_cultmesh_snapshot_fanout_syncs_once_and_reports_applied_records(self) -> None:
+ document = define_database_entry_type(
+ "mesh.snapshot_loop_note",
+ [("body", 0)],
+ schema_id="mesh.snapshot_loop_note.v1",
+ )
+ source = CultMesh.create_node(runtime_id="snapshot-loop-source")
+ source.database.register_document(document)
+ source.database.put_raw_message(document, "note:sync-once", {"body": "from-source"})
+ target = CultMesh.create_node(runtime_id="snapshot-loop-target")
+ target.database.register_document(document)
+ server = CultMesh.serve_node(source)
+ try:
+ peers = CultMeshPeerCatalog()
+ peers.upsert(CultMeshPeerCard(
+ peer_id="snapshot-loop-source",
+ verse_id="mesh",
+ endpoints=(f"cultnet://127.0.0.1:{server.port}",),
+ roles=("read-replica",),
+ ))
+ seen: list[str] = []
+ fanout = CultMeshSnapshotFanout(
+ CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0),
+ target.database,
+ peers,
+ verse_id="mesh",
+ roles=["read-replica"],
+ schema_ids=["mesh.snapshot_loop_note.v1"],
+ on_applied=lambda record: seen.append(record.record_key),
+ )
+
+ applied = fanout.sync_once()
+ finally:
+ server.stop()
+
+ self.assertEqual([record.record_key for record in applied], ["note:sync-once"])
+ self.assertEqual(seen, ["note:sync-once"])
+ self.assertEqual(target.database.get_required(document, "note:sync-once")["body"], "from-source")
+
+ def test_cultmesh_snapshot_fanout_syncs_typed_documents_and_reports_alias_values(self) -> None:
+ @dataclass
+ class CanonicalNote:
+ body: str
+
+ @dataclass
+ class UiNote:
+ body: str
+
+ document = define_database_entry_type(
+ "mesh.snapshot_loop_alias_note",
+ [("body", 0)],
+ cls=CanonicalNote,
+ schema_id="mesh.snapshot_loop_alias_note.v1",
+ schema_name="mesh.snapshot_loop_alias_note",
+ schema_version="mesh.snapshot_loop_alias_note.v1",
+ )
+ alias = define_database_entry_type(
+ "mesh.snapshot_loop_alias_note.ui",
+ [("body", 0)],
+ cls=UiNote,
+ schema_id="mesh.snapshot_loop_alias_note.v1",
+ schema_name="mesh.snapshot_loop_alias_note",
+ schema_version="mesh.snapshot_loop_alias_note.v1",
+ )
+ source = CultMesh.create_node(runtime_id="snapshot-loop-alias-source")
+ source.database.register_document(document)
+ source.database.put_raw_message(document, "note:typed-loop", CanonicalNote("from-source"))
+ target = CultMesh.create_node(runtime_id="snapshot-loop-alias-target")
+ target.database.register_document(document)
+ server = CultMesh.serve_node(source)
+ try:
+ peers = CultMeshPeerCatalog()
+ peers.upsert(CultMeshPeerCard(
+ peer_id="snapshot-loop-alias-source",
+ verse_id="mesh",
+ endpoints=(f"cultnet://127.0.0.1:{server.port}",),
+ roles=("read-replica",),
+ ))
+ seen: list[UiNote] = []
+ fanout = CultMeshSnapshotFanout(
+ CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0),
+ target.database,
+ peers,
+ verse_id="mesh",
+ roles=["read-replica"],
+ documents=[(alias, "note:typed-loop")],
+ on_document=seen.append,
+ )
+
+ values = fanout.sync_once()
+ finally:
+ server.stop()
+
+ self.assertEqual(len(values), 1)
+ self.assertIsInstance(values[0], UiNote)
+ self.assertEqual(values[0].body, "from-source")
+ self.assertEqual(seen, [values[0]])
+ self.assertEqual(target.database.get_required(document, "note:typed-loop").body, "from-source")
+ self.assertIsInstance(target.database.get_required(alias, "note:typed-loop"), UiNote)
+
+ def test_cultmesh_snapshot_fanout_runs_background_loop(self) -> None:
+ document = define_database_entry_type(
+ "mesh.snapshot_background_note",
+ [("body", 0)],
+ schema_id="mesh.snapshot_background_note.v1",
+ )
+ source = CultMesh.create_node(runtime_id="snapshot-background-source")
+ source.database.register_document(document)
+ source.database.put_raw_message(document, "note:background", {"body": "from-background"})
+ target = CultMesh.create_node(runtime_id="snapshot-background-target")
+ target.database.register_document(document)
+ server = CultMesh.serve_node(source)
+ try:
+ peers = CultMeshPeerCatalog()
+ peers.upsert(CultMeshPeerCard(
+ peer_id="snapshot-background-source",
+ verse_id="mesh",
+ endpoints=(f"cultnet://127.0.0.1:{server.port}",),
+ roles=("read-replica",),
+ ))
+ seen: list[str] = []
+ fanout = CultMeshSnapshotFanout(
+ CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0),
+ target.database,
+ peers,
+ verse_id="mesh",
+ roles=["read-replica"],
+ schema_ids=["mesh.snapshot_background_note.v1"],
+ interval_seconds=0.05,
+ on_applied=lambda record: seen.append(record.record_key),
+ )
+ fanout.start()
+ deadline = time.monotonic() + 2.0
+ while not seen and time.monotonic() < deadline:
+ time.sleep(0.01)
+ fanout.stop()
+ finally:
+ server.stop()
+
+ self.assertTrue(seen)
+ self.assertEqual(target.database.get_required(document, "note:background")["body"], "from-background")
+
+ def test_cultmesh_peer_health_monitor_probes_reachable_and_failed_endpoints(self) -> None:
+ document = define_database_entry_type(
+ "mesh.health_note",
+ [("body", 0)],
+ schema_id="mesh.health_note.v1",
+ )
+ node = CultMesh.create_node(runtime_id="health-peer")
+ node.database.register_document(document)
+ server = CultMesh.serve_node(node)
+ closed_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ closed_socket.bind(("127.0.0.1", 0))
+ closed_port = closed_socket.getsockname()[1]
+ closed_socket.close()
+ try:
+ peers = CultMeshPeerCatalog()
+ peers.upsert(CultMeshPeerCard(
+ peer_id="health-peer",
+ verse_id="mesh",
+ endpoints=(f"cultnet://127.0.0.1:{server.port}",),
+ roles=("read-replica",),
+ ))
+ peers.upsert(CultMeshPeerCard(
+ peer_id="closed-peer",
+ verse_id="mesh",
+ endpoints=(f"cultnet://127.0.0.1:{closed_port}",),
+ roles=("read-replica",),
+ ))
+ seen: list[tuple[str, bool]] = []
+ monitor = CultMeshPeerHealthMonitor(
+ runtime_id="health-prober",
+ timeout_seconds=0.2,
+ on_update=lambda health: seen.append((health.peer_id, health.is_reachable)),
+ )
+
+ results = monitor.probe_catalog(peers, verse_id="mesh", roles=["read-replica"])
+ finally:
+ server.stop()
+
+ by_peer = {result.peer_id: result for result in results}
+ self.assertTrue(by_peer["health-peer"].is_reachable)
+ self.assertEqual(by_peer["health-peer"].runtime_id, "health-peer")
+ self.assertEqual(by_peer["health-peer"].runtime_kind, "python")
+ self.assertEqual(by_peer["health-peer"].display_name, "health-peer")
+ self.assertEqual(by_peer["health-peer"].supported_document_types, ("mesh.health_note",))
+ self.assertIn("cultnet.hello.v0", by_peer["health-peer"].supported_message_versions)
+ self.assertIn("cultnet.error.v0", by_peer["health-peer"].supported_message_versions)
+ self.assertEqual(by_peer["health-peer"].supported_mutation_contracts[0]["documentType"], "mesh.health_note")
+ self.assertIn("shardLog", by_peer["health-peer"].supported_mutation_contracts[0]["operations"])
+ self.assertFalse(by_peer["closed-peer"].is_reachable)
+ self.assertIsNone(by_peer["closed-peer"].runtime_kind)
+ self.assertEqual(by_peer["closed-peer"].supported_document_types, ())
+ self.assertEqual(by_peer["closed-peer"].supported_message_versions, ())
+ self.assertEqual(by_peer["closed-peer"].supported_mutation_contracts, ())
+ self.assertTrue(by_peer["closed-peer"].error)
+ self.assertEqual({peer_id for peer_id, _ in seen}, {"health-peer", "closed-peer"})
+ self.assertEqual(len(monitor.latest()), 2)
+
+ def test_cultmesh_peer_health_monitor_runs_background_probe_loop(self) -> None:
+ server = CultMesh.serve_node(CultMesh.create_node(runtime_id="health-loop-peer"))
+ try:
+ peers = CultMeshPeerCatalog()
+ peers.upsert(CultMeshPeerCard(
+ peer_id="health-loop-peer",
+ verse_id="mesh",
+ endpoints=(f"cultnet://127.0.0.1:{server.port}",),
+ roles=("read-replica",),
+ ))
+ seen: list[str] = []
+ monitor = CultMeshPeerHealthMonitor(
+ runtime_id="health-loop-prober",
+ timeout_seconds=0.2,
+ interval_seconds=0.05,
+ on_update=lambda health: seen.append(health.peer_id),
+ )
+ monitor.start(peers, verse_id="mesh", roles=["read-replica"])
+ deadline = time.monotonic() + 2.0
+ while time.monotonic() < deadline:
+ latest = monitor.latest()
+ if latest and latest[0].is_reachable:
+ break
+ time.sleep(0.01)
+ monitor.stop()
+ finally:
+ server.stop()
+
+ self.assertTrue(seen)
+ latest = monitor.latest()
+ self.assertEqual(latest[0].peer_id, "health-loop-peer")
+ self.assertTrue(latest[0].is_reachable)
+
+ def test_cultmesh_daemon_entrypoint_serves_framed_cultnet_hello(self) -> None:
+ with tempfile.TemporaryDirectory() as temp:
+ ready_path = Path(temp) / "ready.json"
+ package_src = Path(__file__).resolve().parents[1] / "src"
+ env = dict(os.environ)
+ existing_pythonpath = env.get("PYTHONPATH")
+ env["PYTHONPATH"] = (
+ str(package_src)
+ if not existing_pythonpath
+ else f"{package_src}{os.pathsep}{existing_pythonpath}"
+ )
+ process = subprocess.Popen(
+ [
+ sys.executable,
+ "-m",
+ "cultmesh_py.daemon",
+ "--runtime-id",
+ "daemon-test-peer",
+ "--display-name",
+ "Daemon Test Peer",
+ "--port",
+ "0",
+ "--ready-file",
+ str(ready_path),
+ "--seed-interop-note",
+ "--seed-shard-id",
+ "daemon-interop",
+ "--verse-id",
+ "daemon-verse",
+ "--verse-display-name",
+ "Daemon Verse",
+ "--role",
+ "shard-primary",
+ ],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ env=env,
+ )
+ try:
+ deadline = time.monotonic() + 5.0
+ while not ready_path.exists() and time.monotonic() < deadline:
+ if process.poll() is not None:
+ break
+ time.sleep(0.05)
+ if not ready_path.exists():
+ _, stderr = process.communicate(timeout=2.0)
+ self.fail(f"cultmesh daemon did not publish readiness: {stderr}")
+ ready = json.loads(ready_path.read_text(encoding="utf-8"))
+ self.assertEqual(ready["schemaVersion"], READY_SCHEMA_VERSION)
+ self.assertEqual(ready["runtimeId"], "daemon-test-peer")
+ self.assertEqual(ready["runtimeKind"], "python")
+ self.assertEqual(ready["displayName"], "Daemon Test Peer")
+ self.assertEqual(ready["endpoint"], f"cultnet://127.0.0.1:{ready['port']}")
+ self.assertEqual(ready["shardIds"], ["daemon-interop"])
+ self.assertEqual(ready["supportedDocumentTypes"], ["cultcache.interop-note"])
+ self.assertIn("cultnet.hello.v0", ready["supportedMessageVersions"])
+ self.assertIn("cultnet.document_put_raw.v0", ready["supportedMessageVersions"])
+ self.assertNotIn("cultnet.simulation_observation.v0", ready["supportedMessageVersions"])
+ self.assertEqual(ready["supportedMutationContracts"][0]["documentType"], "cultcache.interop-note")
+ self.assertIn("shardLog", ready["supportedMutationContracts"][0]["operations"])
+ self.assertEqual(ready["snapshotLimits"], {"maxSnapshotBytes": None, "maxSnapshotDocuments": None})
+ self.assertEqual(ready["verses"][0]["verseId"], "daemon-verse")
+ self.assertEqual(ready["verses"][0]["displayName"], "Daemon Verse")
+ self.assertEqual(ready["verses"][0]["discoveryEndpoints"], [ready["endpoint"]])
+ self.assertEqual(ready["verses"][0]["authorityRuntimeIds"], ["daemon-test-peer"])
+ self.assertEqual(ready["peers"][0]["peerId"], "daemon-test-peer")
+ self.assertEqual(ready["peers"][0]["endpoints"], [ready["endpoint"]])
+ self.assertEqual(ready["peers"][0]["roles"], ["shard-primary"])
+ self.assertEqual(ready["peers"][0]["shardIds"], ["daemon-interop"])
+
+ client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0)
+ hello_response = client.request(
+ hello(runtime_id="daemon-test-prober"),
+ expected_schema_version="cultnet.hello.v0",
+ )
+
+ self.assertEqual(hello_response["runtimeId"], "daemon-test-peer")
+ self.assertEqual(hello_response["displayName"], "Daemon Test Peer")
+ self.assertIn("cultnet.hello.v0", hello_response["supportedMessageVersions"])
+ self.assertIn("cultnet.schema_catalog_request.v0", hello_response["supportedMessageVersions"])
+ self.assertNotIn("cultnet.simulation_observation.v0", hello_response["supportedMessageVersions"])
+ self.assertEqual(hello_response["supportedDocumentTypes"], ["cultcache.interop-note"])
+ self.assertEqual(hello_response["transportProfiles"][0]["transports"][0]["protocol"], "tcp_framed")
+ self.assertEqual(hello_response["transportProfiles"][0]["transports"][0]["port"], int(ready["port"]))
+ self.assertEqual(ready["supportedMessageVersions"], hello_response["supportedMessageVersions"])
+ self.assertEqual(ready["supportedMutationContracts"], hello_response["supportedMutationContracts"])
+ self.assertEqual(ready["transportProfiles"], hello_response["transportProfiles"])
+
+ schema_response = client.fetch_schema_catalog(schema_ids=["cultcache.interop-note"], include_schema_json=True)
+ self.assertEqual(schema_response["schemas"][0]["schemaVersion"], INTEROP_SCHEMA_VERSION)
+ self.assertEqual(schema_response["schemas"][0]["documentType"], "cultcache.interop-note")
+
+ snapshot_response = client.fetch_snapshot_response(schema_ids=["cultcache.interop-note"])
+ self.assertEqual(snapshot_response.documents[0].record_key, "note:daemon-test-peer")
+ note = interop_note_document.decode_payload(snapshot_response.documents[0].payload)
+ self.assertEqual(note["authorRuntimeId"], "daemon-test-peer")
+ self.assertIn("daemon", note["tags"])
+
+ shard_catalog = client.fetch_shard_catalog(schema_ids=["cultcache.interop-note"])
+ self.assertEqual(shard_catalog["shards"][0]["shardId"], "daemon-interop")
+ self.assertEqual(shard_catalog["shards"][0]["schemaIds"], ["cultcache.interop-note"])
+
+ shard_log = client.fetch_shard_log_response(shard_id="daemon-interop", shard_epoch=1)
+ self.assertFalse(shard_log.resync_required)
+ self.assertEqual(shard_log.entries[0].sequence, 1)
+ self.assertEqual(shard_log.entries[0].change_kind, "added")
+ self.assertIsNotNone(shard_log.entries[0].raw_document)
+ assert shard_log.entries[0].raw_document is not None
+ self.assertEqual(shard_log.entries[0].raw_document.record_key, "note:daemon-test-peer")
+
+ discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0)
+ verses = discovery_client.fetch_verses(transport_version="cultmesh.v0")
+ self.assertEqual(verses[0].verse_id, "daemon-verse")
+ self.assertEqual(verses[0].display_name, "Daemon Verse")
+ self.assertEqual(verses[0].discovery_endpoints, (ready["endpoint"],))
+ self.assertEqual(verses[0].authority_runtime_ids, ("daemon-test-peer",))
+ self.assertEqual([verse.to_wire() for verse in verses], ready["verses"])
+
+ peers = discovery_client.fetch_peers(verse_id="daemon-verse", roles=["shard-primary"])
+ self.assertEqual(peers[0].peer_id, "daemon-test-peer")
+ self.assertEqual(peers[0].endpoints, (ready["endpoint"],))
+ self.assertEqual(peers[0].roles, ("shard-primary",))
+ self.assertEqual(peers[0].shard_ids, ("daemon-interop",))
+ self.assertEqual([peer.to_wire() for peer in peers], ready["peers"])
+
+ monitor = CultMeshPeerHealthMonitor(runtime_id="daemon-health-prober", timeout_seconds=2.0)
+ health = monitor.probe_peer(peers[0])[0]
+ self.assertTrue(health.is_reachable)
+ self.assertEqual(health.runtime_id, "daemon-test-peer")
+ self.assertEqual(health.supported_document_types, ("cultcache.interop-note",))
+ self.assertEqual(health.supported_mutation_contracts[0]["documentType"], "cultcache.interop-note")
+ finally:
+ process.terminate()
+ try:
+ process.communicate(timeout=5.0)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ process.communicate(timeout=5.0)
+
+ def test_cultmesh_daemon_restarts_with_persisted_cache_and_shard_log(self) -> None:
+ with tempfile.TemporaryDirectory() as temp:
+ root = Path(temp)
+ cache_path = root / "mesh.cc"
+ shard_log_path = root / "shard-log"
+ env = dict(os.environ)
+ package_src = Path(__file__).resolve().parents[1] / "src"
+ existing_pythonpath = env.get("PYTHONPATH")
+ env["PYTHONPATH"] = (
+ str(package_src)
+ if not existing_pythonpath
+ else f"{package_src}{os.pathsep}{existing_pythonpath}"
+ )
+
+ first_ready = root / "first-ready.json"
+ first = subprocess.Popen(
+ [
+ sys.executable,
+ "-m",
+ "cultmesh_py.daemon",
+ "--runtime-id",
+ "durable-daemon-peer",
+ "--port",
+ "0",
+ "--cache-file",
+ str(cache_path),
+ "--enable-durable-shard-logs",
+ "--shard-log-file",
+ str(shard_log_path),
+ "--seed-interop-note",
+ "--seed-shard-id",
+ "durable-interop",
+ "--ready-file",
+ str(first_ready),
+ ],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ env=env,
+ )
+ try:
+ self._wait_for_ready_file(first, first_ready)
+ finally:
+ self._terminate_process(first)
+
+ second_ready = root / "second-ready.json"
+ second = subprocess.Popen(
+ [
+ sys.executable,
+ "-m",
+ "cultmesh_py.daemon",
+ "--runtime-id",
+ "durable-daemon-peer",
+ "--port",
+ "0",
+ "--cache-file",
+ str(cache_path),
+ "--enable-durable-shard-logs",
+ "--shard-log-file",
+ str(shard_log_path),
+ "--register-interop-note",
+ "--verse-id",
+ "durable-verse",
+ "--role",
+ "shard-primary",
+ "--ready-file",
+ str(second_ready),
+ ],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ env=env,
+ )
+ try:
+ ready = self._wait_for_ready_file(second, second_ready)
+ client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0)
+
+ snapshot_response = client.fetch_snapshot_response(schema_ids=["cultcache.interop-note"])
+ self.assertEqual(snapshot_response.documents[0].record_key, "note:durable-daemon-peer")
+ note = interop_note_document.decode_payload(snapshot_response.documents[0].payload)
+ self.assertEqual(note["authorRuntimeId"], "durable-daemon-peer")
+
+ shard_log = client.fetch_shard_log_response(shard_id="durable-interop", shard_epoch=1)
+ self.assertFalse(shard_log.resync_required)
+ self.assertEqual(shard_log.entries[0].sequence, 1)
+ self.assertIsNotNone(shard_log.entries[0].raw_document)
+ assert shard_log.entries[0].raw_document is not None
+ self.assertEqual(shard_log.entries[0].raw_document.record_key, "note:durable-daemon-peer")
+
+ discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0)
+ peers = discovery_client.fetch_peers(verse_id="durable-verse", roles=["shard-primary"])
+ self.assertEqual(peers[0].shard_ids, ("durable-interop",))
+ finally:
+ self._terminate_process(second)
+
+ def test_cultmesh_daemon_serves_live_database_subscription_mutations(self) -> None:
+ with tempfile.TemporaryDirectory() as temp:
+ ready_path = Path(temp) / "ready.json"
+ package_src = Path(__file__).resolve().parents[1] / "src"
+ env = dict(os.environ)
+ existing_pythonpath = env.get("PYTHONPATH")
+ env["PYTHONPATH"] = (
+ str(package_src)
+ if not existing_pythonpath
+ else f"{package_src}{os.pathsep}{existing_pythonpath}"
+ )
+ process = subprocess.Popen(
+ [
+ sys.executable,
+ "-m",
+ "cultmesh_py.daemon",
+ "--runtime-id",
+ "live-daemon-peer",
+ "--port",
+ "0",
+ "--seed-interop-note",
+ "--seed-shard-id",
+ "live-interop",
+ "--ready-file",
+ str(ready_path),
+ ],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ env=env,
+ )
+ try:
+ ready = self._wait_for_ready_file(process, ready_path)
+ client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0)
+ live_note = {
+ "schemaVersion": INTEROP_SCHEMA_VERSION,
+ "documentId": "note:daemon-live",
+ "authorRuntimeId": "daemon-live-client",
+ "title": "daemon live subscription note",
+ "body": "live subscription put from Python daemon smoke",
+ "tags": ["daemon", "subscription", "interop"],
+ }
+
+ with client.subscribe_database(
+ subscription_id="daemon-live-sub",
+ schema_ids=["cultcache.interop-note"],
+ ) as subscription:
+ snapshot = subscription.read_next_snapshot_response()
+ subscription.send(document_put_raw(
+ message_id="daemon-live-put",
+ key="note:daemon-live",
+ schema_id="cultcache.interop-note",
+ stored_at="2026-06-14T00:00:00Z",
+ payload=interop_note_document.encode_payload(live_note),
+ source_runtime_id="daemon-live-client",
+ shard_id="live-interop",
+ shard_epoch=1,
+ ))
+ change = subscription.read_next_change()
+
+ self.assertEqual(snapshot.documents[0].record_key, "note:live-daemon-peer")
+ self.assertEqual(change.change_kind, "added")
+ self.assertEqual(change.record_key, "note:daemon-live")
+ self.assertIsNotNone(change.raw_document)
+ assert change.raw_document is not None
+ self.assertEqual(
+ interop_note_document.decode_payload(change.raw_document.payload),
+ live_note,
+ )
+
+ shard_log = client.fetch_shard_log_response(shard_id="live-interop", shard_epoch=1)
+ self.assertFalse(shard_log.resync_required)
+ self.assertEqual([entry.sequence for entry in shard_log.entries], [1, 2])
+ self.assertEqual(shard_log.entries[1].change_kind, "added")
+ self.assertIsNotNone(shard_log.entries[1].raw_document)
+ assert shard_log.entries[1].raw_document is not None
+ self.assertEqual(shard_log.entries[1].raw_document.record_key, "note:daemon-live")
+ finally:
+ self._terminate_process(process)
+
+ def test_cultmesh_daemon_serves_compacted_shard_log_resync_boundary(self) -> None:
+ with tempfile.TemporaryDirectory() as temp:
+ root = Path(temp)
+ cache_path = root / "mesh.cc"
+ shard_log_path = root / "shard-log"
+ store = CultNetFileShardMutationLogStore(shard_log_path)
+ node = CultMesh.create_node(
+ cache_path,
+ runtime_id="compacted-daemon-peer",
+ enable_durable_shard_logs=True,
+ shard_log_path=shard_log_path,
+ )
+ node.database.register_document(interop_note_document)
+ node.database.put_raw_message(
+ interop_note_document,
+ "note:compacted-one",
+ {
+ "schemaVersion": INTEROP_SCHEMA_VERSION,
+ "documentId": "note:compacted-one",
+ "authorRuntimeId": "compacted-daemon-peer",
+ "title": "compacted daemon note one",
+ "body": "first retained cache value",
+ "tags": ["daemon", "compaction"],
+ },
+ message_id="compacted-put-1",
+ shard_id="compacted-interop",
+ shard_epoch=7,
+ )
+ node.database.put_raw_message(
+ interop_note_document,
+ "note:compacted-two",
+ {
+ "schemaVersion": INTEROP_SCHEMA_VERSION,
+ "documentId": "note:compacted-two",
+ "authorRuntimeId": "compacted-daemon-peer",
+ "title": "compacted daemon note two",
+ "body": "second retained cache value",
+ "tags": ["daemon", "compaction"],
+ },
+ message_id="compacted-put-2",
+ shard_id="compacted-interop",
+ shard_epoch=7,
+ )
+ store.compact_through("compacted-interop", 1)
+
+ ready_path = root / "ready.json"
+ package_src = Path(__file__).resolve().parents[1] / "src"
+ env = dict(os.environ)
+ existing_pythonpath = env.get("PYTHONPATH")
+ env["PYTHONPATH"] = (
+ str(package_src)
+ if not existing_pythonpath
+ else f"{package_src}{os.pathsep}{existing_pythonpath}"
+ )
+ process = subprocess.Popen(
+ [
+ sys.executable,
+ "-m",
+ "cultmesh_py.daemon",
+ "--runtime-id",
+ "compacted-daemon-peer",
+ "--port",
+ "0",
+ "--cache-file",
+ str(cache_path),
+ "--enable-durable-shard-logs",
+ "--shard-log-file",
+ str(shard_log_path),
+ "--register-interop-note",
+ "--ready-file",
+ str(ready_path),
+ ],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ env=env,
+ )
+ try:
+ ready = self._wait_for_ready_file(process, ready_path)
+ client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0)
+
+ snapshot_response = client.fetch_snapshot_response(schema_ids=["cultcache.interop-note"])
+ self.assertEqual(
+ {document.record_key for document in snapshot_response.documents},
+ {"note:compacted-one", "note:compacted-two"},
+ )
+
+ compacted = client.fetch_shard_log_response(
+ shard_id="compacted-interop",
+ shard_epoch=7,
+ after_sequence=0,
+ )
+ retained = client.fetch_shard_log_response(
+ shard_id="compacted-interop",
+ shard_epoch=7,
+ after_sequence=1,
+ )
+
+ self.assertTrue(compacted.resync_required)
+ self.assertEqual(compacted.reason, "compacted_history")
+ self.assertEqual(compacted.compacted_through, 1)
+ self.assertEqual(compacted.entries, ())
+ self.assertFalse(retained.resync_required)
+ self.assertEqual([entry.sequence for entry in retained.entries], [2])
+ self.assertIsNotNone(retained.entries[0].raw_document)
+ assert retained.entries[0].raw_document is not None
+ self.assertEqual(retained.entries[0].raw_document.record_key, "note:compacted-two")
+ finally:
+ self._terminate_process(process)
+
+ def test_cultmesh_daemon_accepts_schema_write_forwarder_mutations(self) -> None:
+ with tempfile.TemporaryDirectory() as temp:
+ ready_path = Path(temp) / "ready.json"
+ package_src = Path(__file__).resolve().parents[1] / "src"
+ env = dict(os.environ)
+ existing_pythonpath = env.get("PYTHONPATH")
+ env["PYTHONPATH"] = (
+ str(package_src)
+ if not existing_pythonpath
+ else f"{package_src}{os.pathsep}{existing_pythonpath}"
+ )
+ process = subprocess.Popen(
+ [
+ sys.executable,
+ "-m",
+ "cultmesh_py.daemon",
+ "--runtime-id",
+ "forwarder-daemon-peer",
+ "--port",
+ "0",
+ "--register-interop-note",
+ "--ready-file",
+ str(ready_path),
+ ],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ env=env,
+ )
+ try:
+ ready = self._wait_for_ready_file(process, ready_path)
+ endpoint = f"cultnet://127.0.0.1:{ready['port']}"
+ shard = CultNetShardDescriptor(
+ shard_id="forwarded-interop",
+ owner_runtime_id="forwarder-daemon-peer",
+ epoch=3,
+ is_primary=False,
+ schema_ids=("cultcache.interop-note",),
+ primary_endpoints=(endpoint,),
+ )
+ forwarder: CultNetShardWriteForwarder = CultNetSchemaWriteForwarder(timeout_seconds=2.0)
+ note = {
+ "schemaVersion": INTEROP_SCHEMA_VERSION,
+ "documentId": "note:forwarded-daemon",
+ "authorRuntimeId": "forwarding-client",
+ "title": "forwarded daemon note",
+ "body": "forwarded through the schema write-forwarder",
+ "tags": ["daemon", "forwarded"],
+ }
+
+ forwarder.forward_put(shard, document_put_raw(
+ message_id="forwarded-daemon-put",
+ key="note:forwarded-daemon",
+ schema_id="cultcache.interop-note",
+ stored_at="2026-06-14T00:00:00Z",
+ payload=interop_note_document.encode_payload(note),
+ source_runtime_id="forwarding-client",
+ ).to_wire())
+
+ client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0)
+ self._eventually(lambda: bool(client.fetch_snapshot_response(
+ schema_ids=["cultcache.interop-note"],
+ record_keys=["note:forwarded-daemon"],
+ ).documents))
+ snapshot = client.fetch_snapshot_response(
+ schema_ids=["cultcache.interop-note"],
+ record_keys=["note:forwarded-daemon"],
+ )
+ self.assertEqual(
+ interop_note_document.decode_payload(snapshot.documents[0].payload),
+ note,
+ )
+
+ forwarder.forward_delete(shard, document_delete(
+ message_id="forwarded-daemon-delete",
+ schema_id="cultcache.interop-note",
+ record_key="note:forwarded-daemon",
+ ).to_wire())
+ self._eventually(lambda: not client.fetch_snapshot_response(
+ schema_ids=["cultcache.interop-note"],
+ record_keys=["note:forwarded-daemon"],
+ ).documents)
+
+ shard_log = client.fetch_shard_log_response(shard_id="forwarded-interop", shard_epoch=3)
+ self.assertEqual([entry.change_kind for entry in shard_log.entries], ["added", "removed"])
+ self.assertEqual(shard_log.entries[0].put["shardId"], "forwarded-interop")
+ self.assertEqual(shard_log.entries[0].put["shardEpoch"], 3)
+ self.assertEqual(shard_log.entries[1].delete["shardId"], "forwarded-interop")
+ self.assertEqual(shard_log.entries[1].delete["shardEpoch"], 3)
+ finally:
+ self._terminate_process(process)
+
+ def test_cultmesh_daemon_enforces_snapshot_document_limit_as_peer_error(self) -> None:
+ with tempfile.TemporaryDirectory() as temp:
+ ready_path = Path(temp) / "ready.json"
+ package_src = Path(__file__).resolve().parents[1] / "src"
+ env = dict(os.environ)
+ existing_pythonpath = env.get("PYTHONPATH")
+ env["PYTHONPATH"] = (
+ str(package_src)
+ if not existing_pythonpath
+ else f"{package_src}{os.pathsep}{existing_pythonpath}"
+ )
+ process = subprocess.Popen(
+ [
+ sys.executable,
+ "-m",
+ "cultmesh_py.daemon",
+ "--runtime-id",
+ "limited-daemon-peer",
+ "--port",
+ "0",
+ "--seed-interop-note",
+ "--max-snapshot-documents",
+ "0",
+ "--ready-file",
+ str(ready_path),
+ ],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ env=env,
+ )
+ try:
+ ready = self._wait_for_ready_file(process, ready_path)
+ self.assertEqual(ready["snapshotLimits"], {"maxSnapshotBytes": None, "maxSnapshotDocuments": 0})
+ client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0)
+
+ with self.assertRaisesRegex(CultNetPeerError, "Snapshot document limit exceeded") as raised:
+ client.fetch_snapshot_response(schema_ids=["cultcache.interop-note"])
+
+ self.assertEqual(raised.exception.response["schemaVersion"], "cultnet.error.v0")
+ self.assertEqual(raised.exception.response["code"], "snapshot_document_limit_exceeded")
+ self.assertEqual(raised.exception.response["details"]["documentCount"], 1)
+ self.assertEqual(raised.exception.response["details"]["maxSnapshotDocuments"], 0)
+ finally:
+ self._terminate_process(process)
+
+ def test_cultmesh_daemon_serves_opt_in_simulation_observations(self) -> None:
+ with tempfile.TemporaryDirectory() as temp:
+ ready_path = Path(temp) / "ready.json"
+ package_src = Path(__file__).resolve().parents[1] / "src"
+ env = dict(os.environ)
+ existing_pythonpath = env.get("PYTHONPATH")
+ env["PYTHONPATH"] = (
+ str(package_src)
+ if not existing_pythonpath
+ else f"{package_src}{os.pathsep}{existing_pythonpath}"
+ )
+ process = subprocess.Popen(
+ [
+ sys.executable,
+ "-m",
+ "cultmesh_py.daemon",
+ "--runtime-id",
+ "simulation-daemon-peer",
+ "--port",
+ "0",
+ "--enable-simulation-observations",
+ "--simulation-minimum-witnesses",
+ "2",
+ "--simulation-quorum-ratio",
+ "1.0",
+ "--verse-id",
+ "simulation-verse",
+ "--ready-file",
+ str(ready_path),
+ ],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ env=env,
+ )
+ try:
+ ready = self._wait_for_ready_file(process, ready_path)
+ self.assertIn("cultnet.simulation_observation.v0", ready["supportedMessageVersions"])
+ self.assertIn("cultnet.simulation_consensus_candidate.v0", ready["supportedMessageVersions"])
+ client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0)
+ hello_response = client.request(
+ hello(runtime_id="simulation-daemon-prober"),
+ expected_schema_version="cultnet.hello.v0",
+ )
+ self.assertEqual(ready["supportedMessageVersions"], hello_response["supportedMessageVersions"])
+ discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0)
+ peers = discovery_client.fetch_peers(verse_id="simulation-verse", roles=["simulation-observer"])
+ self.assertEqual(peers[0].peer_id, "simulation-daemon-peer")
+ self.assertIn("simulation-observer", peers[0].roles)
+ self.assertIn("read-replica", peers[0].roles)
+
+ claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:100")
+ first = client.request(
+ simulation_observation(
+ message_id="daemon-sim-1",
+ witness_runtime_id="watcher-1",
+ shard_id="arena",
+ shard_epoch=4,
+ frame=100,
+ subject_id="bob",
+ claim_kind="hit",
+ claim_hash=claim_hash,
+ claim_summary="alice shot bob first",
+ ),
+ expected_schema_version="cultnet.simulation_consensus_candidate.v0",
+ )
+ second = client.request(
+ simulation_observation(
+ message_id="daemon-sim-2",
+ witness_runtime_id="watcher-2",
+ shard_id="arena",
+ shard_epoch=4,
+ frame=100,
+ subject_id="bob",
+ claim_kind="hit",
+ claim_hash=claim_hash,
+ claim_summary="alice shot bob first",
+ ),
+ expected_schema_version="cultnet.simulation_consensus_candidate.v0",
+ )
+
+ self.assertEqual(first["messageId"], "daemon-sim-1")
+ self.assertFalse(first["hasQuorum"])
+ self.assertEqual(first["witnessCount"], 1)
+ self.assertEqual(second["messageId"], "daemon-sim-2")
+ self.assertTrue(second["hasQuorum"])
+ self.assertEqual(second["witnessCount"], 2)
+ self.assertEqual(second["claimHash"], claim_hash)
+ finally:
+ self._terminate_process(process)
+
+ def _wait_for_ready_file(self, process: subprocess.Popen[str], ready_path: Path) -> dict[str, object]:
+ deadline = time.monotonic() + 5.0
+ while not ready_path.exists() and time.monotonic() < deadline:
+ if process.poll() is not None:
+ break
+ time.sleep(0.05)
+ if not ready_path.exists():
+ _, stderr = process.communicate(timeout=2.0)
+ self.fail(f"cultmesh daemon did not publish readiness: {stderr}")
+ return json.loads(ready_path.read_text(encoding="utf-8"))
+
+ def _terminate_process(self, process: subprocess.Popen[str]) -> None:
+ process.terminate()
+ try:
+ process.communicate(timeout=5.0)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ process.communicate(timeout=5.0)
+
+ def test_cultmesh_local_server_serves_node_and_catalogs_over_clients(self) -> None:
+ document = define_database_entry_type(
+ "mesh.server_note",
+ [("body", 0)],
+ schema_id="mesh.server_note.v1",
+ )
+ node = CultMesh.create_node(runtime_id="mesh-server")
+ node.database.register_document(document)
+ node.database.put_raw_message(document, "note:1", {"body": "served"}, shard_id="notes", shard_epoch=1)
+ observation_hub = CultNetSimulationObservationHub(
+ CultNetSimulationConsensusOptions(minimum_witnesses=2, quorum_ratio=1.0)
+ )
+ seen_candidates: list[CultNetSimulationConsensusCandidate] = []
+ unsubscribe_candidates = observation_hub.watch_candidates(seen_candidates.append)
+ claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:100")
+ verses = CultMeshVerseCatalog()
+ verses.upsert(
+ CultMeshVerseDescriptor(
+ verse_id="server-verse",
+ display_name="Server Verse",
+ authority_model="local",
+ compatibility=CultMeshVerseCompatibility("cultmesh.v0", "rules"),
+ )
+ )
+ peers = CultMeshPeerCatalog()
+ peers.upsert(
+ CultMeshPeerCard(
+ peer_id="mesh-server",
+ verse_id="server-verse",
+ endpoints=("cultnet://127.0.0.1:0",),
+ roles=("read-replica",),
+ )
+ )
+
+ server = CultMesh.serve_node(
+ node,
+ verse_catalog=verses,
+ peer_catalog=peers,
+ observation_hub=observation_hub,
+ display_name="Mesh Server",
+ )
+ try:
+ raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
+ hello_response = raw_client.request(hello(runtime_id="probe"), expected_schema_version="cultnet.hello.v0")
+ schema_response = raw_client.fetch_schema_catalog(schema_ids=["mesh.server_note.v1"], include_schema_json=True)
+ rudp_client = CultMesh.create_client(
+ endpoint=f"rudp://127.0.0.1:{server.port}",
+ timeout_seconds=2.0,
+ connection_id=server.rudp_connection_id,
+ runtime_id="rudp-probe",
+ )
+ rudp_hello_response = rudp_client.request(
+ hello(runtime_id="rudp-probe"),
+ expected_schema_version="cultnet.hello.v0",
+ )
+ rudp_schema_response = rudp_client.fetch_schema_catalog(
+ schema_ids=["mesh.server_note.v1"],
+ include_schema_json=True,
+ )
+ wire_schema_response = raw_client.fetch_schema_catalog(kinds=["wire_message"], include_schema_json=True)
+ synced_schema_catalog = CultNetSchemaCatalog()
+ synced_wire_descriptors = raw_client.sync_schema_catalog(
+ synced_schema_catalog,
+ kinds=["wire_message"],
+ include_schema_json=True,
+ )
+ snapshot_response = raw_client.fetch_snapshot(schema_ids=["mesh.server_note.v1"])
+ typed_snapshot_response = raw_client.fetch_snapshot_response(schema_ids=["mesh.server_note.v1"])
+ shard_catalog = raw_client.fetch_shard_catalog(schema_ids=["mesh.server_note.v1"])
+ synced_shard_catalog = CultNetShardCatalog()
+ synced_shards = raw_client.sync_shard_catalog(synced_shard_catalog, schema_ids=["mesh.server_note.v1"])
+ shard_log = raw_client.fetch_shard_log(shard_id="notes", shard_epoch=1)
+ typed_shard_log = raw_client.fetch_shard_log_response(shard_id="notes", shard_epoch=1)
+ self.assertEqual(hello_response["transportProfiles"][0]["transports"][0]["protocol"], "tcp_framed")
+ self.assertEqual(hello_response["transportProfiles"][0]["transports"][0]["port"], server.port)
+ stale_shard_log = raw_client.fetch_shard_log(shard_id="notes", shard_epoch=0)
+ discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", server.port, timeout_seconds=2.0)
+ fetched_verses = discovery_client.fetch_verses(transport_version="cultmesh.v0")
+ fetched_peers = discovery_client.fetch_peers(verse_id="server-verse", roles=["read-replica"])
+ first_candidate = raw_client.request(
+ simulation_observation(
+ message_id="obs-1",
+ witness_runtime_id="watcher-1",
+ shard_id="arena",
+ shard_epoch=4,
+ frame=100,
+ subject_id="bob",
+ claim_kind="hit",
+ claim_hash=claim_hash,
+ claim_summary="alice shot bob first",
+ ),
+ expected_schema_version="cultnet.simulation_consensus_candidate.v0",
+ )
+ quorum_candidate = raw_client.request(
+ simulation_observation(
+ message_id="obs-2",
+ witness_runtime_id="watcher-2",
+ shard_id="arena",
+ shard_epoch=4,
+ frame=100,
+ subject_id="bob",
+ claim_kind="hit",
+ claim_hash=claim_hash,
+ claim_summary="alice shot bob first",
+ ),
+ expected_schema_version="cultnet.simulation_consensus_candidate.v0",
+ )
+ with raw_client.subscribe_database(subscription_id="sub-server", schema_ids=["mesh.server_note.v1"]) as subscription:
+ subscription_snapshot = subscription.read_next_snapshot_response()
+ subscription.send(document_put_raw(
+ message_id="server-put",
+ key="note:2",
+ schema_id="mesh.server_note.v1",
+ stored_at="2026-06-14T00:00:00Z",
+ payload=document.encode_payload({"body": "subscribed"}),
+ shard_id="primary",
+ shard_epoch=1,
+ ))
+ subscription_change = subscription.read_next_change()
+ subscription.send(document_delete(
+ message_id="server-delete",
+ schema_id="mesh.server_note.v1",
+ record_key="note:2",
+ shard_id="primary",
+ shard_epoch=1,
+ ))
+ subscription_delete = subscription.read_next_change()
+ finally:
+ unsubscribe_candidates()
+ server.stop()
+
+ self.assertEqual(hello_response["runtimeId"], "mesh-server")
+ self.assertEqual(hello_response["displayName"], "Mesh Server")
+ self.assertIn("cultnet.database_subscribe.v0", hello_response["supportedMessageVersions"])
+ self.assertIn("cultnet.error.v0", hello_response["supportedMessageVersions"])
+ self.assertIn("cultnet.document_put_raw.v0", hello_response["supportedMessageVersions"])
+ self.assertIn("cultnet.simulation_observation.v0", hello_response["supportedMessageVersions"])
+ self.assertEqual(hello_response["supportedMutationContracts"][0]["documentType"], "mesh.server_note")
+ self.assertIn("documentDelete", hello_response["supportedMutationContracts"][0]["operations"])
+ self.assertIn("shardLog", hello_response["supportedMutationContracts"][0]["operations"])
+ advertised_transports = {
+ transport["protocol"]: transport
+ for profile in hello_response["transportProfiles"]
+ for transport in profile["transports"]
+ }
+ self.assertEqual(advertised_transports["tcp_framed"]["port"], server.port)
+ self.assertEqual(advertised_transports["rudp"]["port"], server.port)
+ self.assertEqual(rudp_hello_response["runtimeId"], "mesh-server")
+ self.assertEqual(rudp_schema_response["schemas"][0]["schemaId"], "mesh.server_note.v1")
+ self.assertEqual(schema_response["schemas"][0]["schemaId"], "mesh.server_note.v1")
+ self.assertIn("schemaJson", schema_response["schemas"][0])
+ self.assertIn("cultnet.document_delete.v0", schema_response["schemas"][0]["wireContracts"])
+ self.assertIn("cultnet.shard_log_response.v0", schema_response["schemas"][0]["wireContracts"])
+ wire_descriptors = {schema["schemaVersion"]: schema for schema in wire_schema_response["schemas"]}
+ self.assertEqual(wire_descriptors["cultnet.error.v0"]["kind"], "wire_message")
+ self.assertEqual(wire_descriptors["cultnet.document_put_raw.v0"]["kind"], "wire_message")
+ self.assertIn("schemaJson", wire_descriptors["cultnet.document_put_raw.v0"])
+ self.assertIn("cultmesh.peer_exchange_response.v0", wire_descriptors)
+ self.assertIn("cultnet.error.v0", {descriptor.schema_version for descriptor in synced_wire_descriptors})
+ self.assertIn("cultnet.document_put_raw.v0", {descriptor.schema_version for descriptor in synced_wire_descriptors})
+ self.assertIsNotNone(synced_schema_catalog.get("https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-put-raw.schema.json"))
+ self.assertEqual(snapshot_response["documents"][0]["recordKey"], "note:1")
+ self.assertEqual(typed_snapshot_response.documents[0].record_key, "note:1")
+ self.assertEqual(typed_snapshot_response.documents[0].schema_id, "mesh.server_note.v1")
+ self.assertEqual(shard_catalog["shards"][0]["schemaIds"], ["mesh.server_note.v1"])
+ self.assertEqual(shard_catalog["shards"][0]["epoch"], 1)
+ self.assertEqual(shard_catalog["shards"][0]["shardId"], "notes")
+ self.assertEqual(synced_shards[0].shard_id, "notes")
+ self.assertEqual(synced_shards[0].epoch, 1)
+ self.assertEqual(synced_shard_catalog.get("notes"), synced_shards[0])
+ self.assertEqual(shard_log["entries"][0]["changeKind"], "added")
+ self.assertTrue(stale_shard_log["resyncRequired"])
+ self.assertEqual(stale_shard_log["reason"], "stale_epoch")
+ self.assertEqual(stale_shard_log["shardEpoch"], 1)
+ self.assertEqual(stale_shard_log["entries"], [])
+ self.assertEqual(typed_shard_log.last_sequence, 1)
+ self.assertEqual(typed_shard_log.entries[0].put["document"]["recordKey"], "note:1")
+ self.assertIsInstance(typed_shard_log.entries[0].raw_document, CultNetRawDocumentRecord)
+ self.assertEqual(typed_shard_log.entries[0].raw_document.record_key, "note:1")
+ self.assertEqual(fetched_verses[0].verse_id, "server-verse")
+ self.assertEqual(fetched_peers[0].peer_id, "mesh-server")
+ self.assertEqual(first_candidate["messageId"], "obs-1")
+ self.assertFalse(first_candidate["hasQuorum"])
+ self.assertEqual(quorum_candidate["messageId"], "obs-2")
+ self.assertEqual(quorum_candidate["claimHash"], claim_hash)
+ self.assertTrue(quorum_candidate["hasQuorum"])
+ self.assertEqual(quorum_candidate["witnessCount"], 2)
+ self.assertEqual([candidate.claim_hash for candidate in seen_candidates], [claim_hash, claim_hash])
+ self.assertIsInstance(subscription_snapshot, CultNetRawSnapshotResponse)
+ self.assertEqual(subscription_snapshot.to_wire()["schemaVersion"], "cultnet.snapshot_response_raw.v0")
+ self.assertEqual(subscription_change.change_kind, "added")
+ self.assertEqual(subscription_change.record_key, "note:2")
+ self.assertEqual(subscription_change.document["recordKey"], "note:2")
+ self.assertIsInstance(subscription_change.raw_document, CultNetRawDocumentRecord)
+ self.assertEqual(subscription_change.raw_document.schema_id, "mesh.server_note.v1")
+ self.assertEqual(subscription_delete.change_kind, "removed")
+ self.assertEqual(subscription_delete.record_key, "note:2")
+ self.assertIsNone(subscription_delete.raw_document)
+ self.assertIsNone(node.database.get(document, "note:2"))
+
+ def test_cultmesh_local_server_resolves_snapshot_and_shard_catalog_schema_aliases(self) -> None:
+ document = define_database_entry_type(
+ "mesh.alias_server_note",
+ [("body", 0)],
+ schema_id="sha256:mesh-alias-server-note",
+ schema_name="mesh.alias_server_note",
+ schema_version="mesh.alias_server_note.v1",
+ )
+ node = CultMesh.create_node(runtime_id="mesh-alias-server")
+ node.database.register_document(document)
+ node.database.put_raw_message(
+ document,
+ "note:alias",
+ {"body": "served through alias"},
+ shard_id="notes",
+ shard_epoch=1,
+ )
+
+ server = CultMesh.serve_node(node)
+ try:
+ raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
+ snapshot = raw_client.fetch_snapshot(schema_ids=["mesh.alias_server_note.v1"])
+ typed_snapshot = raw_client.fetch_snapshot_response(schema_ids=["mesh.alias_server_note.v1"])
+ shard_catalog = raw_client.fetch_shard_catalog(schema_ids=["mesh.alias_server_note.v1"])
+ finally:
+ server.stop()
+
+ local_schema_id = document.catalog_entry().schema_id
+ self.assertEqual(snapshot["documents"][0]["schemaId"], local_schema_id)
+ self.assertEqual(snapshot["documents"][0]["recordKey"], "note:alias")
+ self.assertEqual(typed_snapshot.documents[0].schema_id, local_schema_id)
+ self.assertEqual(shard_catalog["shards"][0]["schemaIds"], [local_schema_id])
+ self.assertEqual(shard_catalog["shards"][0]["shardId"], "notes")
+
+ def test_cultmesh_shard_snapshot_keeps_foreign_schema_alias_membership(self) -> None:
+ document = define_database_entry_type(
+ "mesh.alias_shard_note",
+ [
+ ("schema_version", 0),
+ ("body", 1),
+ ],
+ schema_id="sha256:mesh-alias-shard-note",
+ schema_name="mesh.alias_shard_note",
+ schema_version="mesh.alias_shard_note.v1",
+ )
+ node = CultMesh.create_node(runtime_id="mesh-alias-shard-server")
+ node.database.register_document(document)
+
+ node.database.apply_raw_put_message(document_put_raw(
+ message_id="put-foreign-shard-alias",
+ key="note:foreign-shard",
+ schema_id="runtime.generated.mesh.alias_shard_note.ui.99",
+ stored_at="2026-06-27T00:00:00Z",
+ payload=document.encode_payload({
+ "schema_version": "mesh.alias_shard_note.v1",
+ "body": "visible through shard snapshot",
+ }),
+ shard_id="notes",
+ shard_epoch=1,
+ ).to_wire())
+ node.database.put_raw_message(
+ document,
+ "note:other-shard",
+ {
+ "schema_version": "mesh.alias_shard_note.v1",
+ "body": "hidden by shard filter",
+ },
+ shard_id="other",
+ shard_epoch=1,
+ )
+
+ response = node.database.build_snapshot_response(
+ schema_ids=["mesh.alias_shard_note.v1"],
+ shard_id="notes",
+ shard_epoch=1,
+ )
+
+ local_schema_id = document.catalog_entry().schema_id
+ self.assertEqual(response.shard_id, "notes")
+ self.assertEqual(response.shard_epoch, 1)
+ self.assertEqual([(record.schema_id, record.record_key) for record in response.documents], [
+ (local_schema_id, "note:foreign-shard"),
+ ])
+ self.assertEqual(node.cache.get_required_envelope(document, "note:foreign-shard").schema_id, local_schema_id)
+
+ node.database.apply_raw_delete_message(document_delete(
+ message_id="delete-foreign-shard-alias",
+ schema_id="mesh.alias_shard_note.v1",
+ record_key="note:foreign-shard",
+ shard_id="notes",
+ shard_epoch=1,
+ ).to_wire())
+ deleted_response = node.database.build_snapshot_response(
+ schema_ids=["mesh.alias_shard_note.v1"],
+ shard_id="notes",
+ shard_epoch=1,
+ )
+ self.assertEqual(deleted_response.documents, ())
+
+ def test_cultmesh_local_server_notifies_schema_alias_subscription_for_raw_put(self) -> None:
+ document = define_database_entry_type(
+ "mesh.alias_sub_note",
+ [
+ ("schema_version", 0),
+ ("body", 1),
+ ],
+ schema_id="sha256:mesh-alias-sub-note",
+ schema_name="mesh.alias_sub_note",
+ schema_version="mesh.alias_sub_note.v1",
+ )
+ node = CultMesh.create_node(runtime_id="mesh-alias-sub-server")
+ node.database.register_document(document)
+
+ server = CultMesh.serve_node(node)
+ try:
+ raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
+ with raw_client.subscribe_database(
+ subscription_id="sub-alias",
+ schema_ids=["mesh.alias_sub_note.v1"],
+ include_snapshot=False,
+ ) as subscription:
+ subscription.send(document_put_raw(
+ message_id="put-foreign-alias",
+ key="note:alias-sub",
+ schema_id="runtime.generated.mesh.alias_sub_note.ui.99",
+ stored_at="2026-06-27T00:00:00Z",
+ payload=document.encode_payload({
+ "schema_version": "mesh.alias_sub_note.v1",
+ "body": "notified",
+ }),
+ ))
+ change = subscription.read_next_change()
+ finally:
+ server.stop()
+
+ local_schema_id = document.catalog_entry().schema_id
+ self.assertEqual(change.subscription_id, "sub-alias")
+ self.assertEqual(change.change_kind, "added")
+ self.assertEqual(change.record_key, "note:alias-sub")
+ self.assertIsInstance(change.raw_document, CultNetRawDocumentRecord)
+ self.assertEqual(change.raw_document.schema_id, "runtime.generated.mesh.alias_sub_note.ui.99")
+ self.assertEqual(node.database.get_required(document, "note:alias-sub")["body"], "notified")
+ self.assertEqual(node.cache.get_required_envelope(document, "note:alias-sub").schema_id, local_schema_id)
+
+ def test_cultmesh_local_server_notifies_schema_alias_subscription_for_delete(self) -> None:
+ document = define_database_entry_type(
+ "mesh.alias_delete_note",
+ [("body", 0)],
+ schema_id="sha256:mesh-alias-delete-note",
+ schema_name="mesh.alias_delete_note",
+ schema_version="mesh.alias_delete_note.v1",
+ )
+ node = CultMesh.create_node(runtime_id="mesh-alias-delete-server")
+ node.database.register_document(document)
+ node.database.put_raw_message(
+ document,
+ "note:delete-alias",
+ {"body": "remove me"},
+ shard_id="notes",
+ shard_epoch=1,
+ )
+
+ server = CultMesh.serve_node(node)
+ try:
+ raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
+ with raw_client.subscribe_database(
+ subscription_id="sub-delete-alias",
+ schema_ids=[document.catalog_entry().schema_id],
+ include_snapshot=False,
+ ) as subscription:
+ subscription.send(document_delete(
+ message_id="delete-foreign-alias",
+ schema_id="mesh.alias_delete_note.v1",
+ record_key="note:delete-alias",
+ shard_id="notes",
+ shard_epoch=1,
+ ))
+ change = subscription.read_next_change()
+ finally:
+ server.stop()
+
+ self.assertEqual(change.subscription_id, "sub-delete-alias")
+ self.assertEqual(change.change_kind, "removed")
+ self.assertEqual(change.schema_id, "mesh.alias_delete_note.v1")
+ self.assertEqual(change.record_key, "note:delete-alias")
+ self.assertIsNone(change.raw_document)
+ self.assertIsNone(node.database.get(document, "note:delete-alias"))
+
+ def test_cultmesh_local_server_rejects_oversized_snapshot_responses(self) -> None:
+ document = define_database_entry_type(
+ "mesh.snapshot_limit_note",
+ [("body", 0)],
+ schema_id="mesh.snapshot_limit_note.v1",
+ )
+ node = CultMesh.create_node(runtime_id="mesh-snapshot-limits")
+ node.database.register_document(document)
+ node.database.put_raw_message(document, "note:1", {"body": "first"})
+ node.database.put_raw_message(document, "note:2", {"body": "second"})
+
+ server = CultMesh.serve_node(node, max_snapshot_documents=1)
+ try:
+ raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
+ filtered_snapshot = raw_client.fetch_snapshot(record_keys=["note:1"])
+ error = raw_client.request(
+ snapshot_request(schema_ids=["mesh.snapshot_limit_note.v1"]),
+ expected_schema_version="cultnet.error.v0",
+ )
+ with self.assertRaisesRegex(CultNetPeerError, "Snapshot document limit exceeded"):
+ raw_client.fetch_snapshot(schema_ids=["mesh.snapshot_limit_note.v1"])
+ finally:
+ server.stop()
+
+ self.assertEqual(filtered_snapshot["schemaVersion"], "cultnet.snapshot_response_raw.v0")
+ self.assertEqual([document["recordKey"] for document in filtered_snapshot["documents"]], ["note:1"])
+ self.assertEqual(error["schemaVersion"], "cultnet.error.v0")
+ self.assertIn("Snapshot document limit exceeded", error["error"])
+ self.assertEqual(error["code"], "snapshot_document_limit_exceeded")
+ self.assertEqual(error["details"]["documentCount"], 2)
+ self.assertEqual(error["details"]["maxSnapshotDocuments"], 1)
+
+ def test_cultmesh_local_server_returns_errors_for_bad_requests(self) -> None:
+ document = define_database_entry_type(
+ "mesh.error_note",
+ [("body", 0)],
+ schema_id="mesh.error_note.v1",
+ )
+ node = CultMesh.create_node(runtime_id="mesh-error-server")
+ node.database.register_document(document)
+
+ server = CultMesh.serve_node(node)
+ try:
+ raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
+ unsupported = raw_client.request(
+ {"schemaVersion": "cultnet.nope.v0", "messageId": "bad-schema"},
+ expected_schema_version="cultnet.error.v0",
+ )
+ malformed_put = raw_client.request(
+ {"schemaVersion": "cultnet.document_put_raw.v0", "messageId": "bad-put"},
+ expected_schema_version="cultnet.error.v0",
+ )
+ malformed_delete = raw_client.request(
+ {"schemaVersion": "cultnet.document_delete.v0", "messageId": "bad-delete", "schemaId": "mesh.error_note.v1"},
+ expected_schema_version="cultnet.error.v0",
+ )
+ finally:
+ server.stop()
+
+ self.assertEqual(unsupported["messageId"], "bad-schema")
+ self.assertEqual(unsupported["code"], "unsupported_schema_version")
+ self.assertEqual(unsupported["details"]["schemaVersion"], "cultnet.nope.v0")
+ self.assertIn("Unsupported CultNet message schema", unsupported["error"])
+ self.assertEqual(malformed_put["messageId"], "bad-put")
+ self.assertEqual(malformed_put["code"], "malformed_document_put")
+ self.assertEqual(malformed_delete["messageId"], "bad-delete")
+ self.assertEqual(malformed_delete["code"], "malformed_document_delete")
+
+ def test_cultmesh_authority_lease_requires_live_matching_lease(self) -> None:
+ peer = CultMeshPeerCard(
+ peer_id="voidbot-local",
+ verse_id="local",
+ endpoints=("cultmesh://localhost",),
+ roles=("shard-primary",),
+ authority_lease_id="lease:voidbot-local",
+ )
+ leases = CultMeshAuthorityLeaseCatalog()
+ peers = CultMeshPeerCatalog()
+ peers.upsert(peer)
+ now = datetime.now(UTC)
+ self.assertFalse(leases.is_authorized(peer, "shard-primary", at=now))
+ self.assertEqual(peers.find_authorized("local", "shard-primary", leases, at=now), [])
+
+ leases.upsert(
+ CultMeshAuthorityLease(
+ lease_id="lease:voidbot-local",
+ verse_id="local",
+ peer_id="voidbot-local",
+ roles=("shard-primary", "shard-primary", ""),
+ valid_from=now - timedelta(seconds=1),
+ expires_at=now + timedelta(seconds=1),
+ shard_ids=("shard-a",),
+ issuer_runtime_id="odin",
+ signature="signed",
+ )
+ )
+
+ lease = leases.get("lease:voidbot-local")
+ self.assertIsNotNone(lease)
+ assert lease is not None
+ self.assertEqual(leases.leases, (lease,))
+ self.assertEqual(lease.roles, ("shard-primary",))
+ self.assertEqual(lease.issuer_runtime_id, "odin")
+ self.assertEqual(lease.signature, "signed")
+ self.assertTrue(lease.is_valid_at(now))
+ self.assertTrue(lease.covers(peer, "shard-primary", shard_id="shard-a", at=now))
+ self.assertFalse(lease.covers(peer, "shard-primary", shard_id="shard-b", at=now))
+ self.assertTrue(leases.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now))
+ self.assertEqual(
+ peers.find_authorized("local", "shard-primary", leases, shard_id="shard-a", at=now),
+ [peer],
+ )
+ self.assertEqual(
+ peers.first_authorized("local", "shard-primary", leases, shard_id="shard-a", at=now),
+ peer,
+ )
+ self.assertIsNone(peers.first_authorized("local", "read-replica", leases, at=now))
+ self.assertFalse(leases.is_authorized(peer, "shard-primary", shard_id="shard-b", at=now))
+ self.assertFalse(leases.is_authorized(peer, "read-replica", at=now))
+ self.assertIsNone(leases.get("missing"))
+
+ def test_cultmesh_authority_lease_catalog_can_require_verified_signatures(self) -> None:
+ peer = CultMeshPeerCard(
+ peer_id="voidbot-local",
+ verse_id="local",
+ endpoints=("cultmesh://localhost",),
+ roles=("shard-primary",),
+ shard_ids=("shard-a",),
+ authority_lease_id="lease:voidbot-local",
+ )
+ now = datetime.now(UTC)
+ unsigned = CultMeshAuthorityLease(
+ lease_id="lease:voidbot-local",
+ verse_id="local",
+ peer_id="voidbot-local",
+ roles=("shard-primary",),
+ valid_from=now - timedelta(seconds=1),
+ expires_at=now + timedelta(seconds=30),
+ shard_ids=("shard-a",),
+ issuer_runtime_id="odin",
+ )
+ verifier = CultMeshHmacAuthorityLeaseVerifier({"odin": b"lease-secret"})
+ signed = verifier.issue(unsigned)
+ strict_leases = CultMesh.create_authority_lease_catalog(
+ signature_verifier=verifier.verify,
+ require_verified_signatures=True,
+ )
+ strict_leases.upsert(unsigned)
+ self.assertFalse(strict_leases.is_verified(unsigned.lease_id))
+ self.assertFalse(strict_leases.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now))
+
+ strict_leases.upsert(signed)
+ self.assertTrue(strict_leases.is_verified(signed.lease_id))
+ self.assertTrue(strict_leases.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now))
+
+ wrong_key = CultMesh.create_authority_lease_catalog(
+ signature_verifier=CultMeshHmacAuthorityLeaseVerifier({"odin": b"wrong-secret"}).verify,
+ require_verified_signatures=True,
+ )
+ wrong_key.upsert(signed)
+ self.assertFalse(wrong_key.is_verified(signed.lease_id))
+ self.assertFalse(wrong_key.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now))
+
+ tampered = CultMeshAuthorityLease(
+ lease_id=signed.lease_id,
+ verse_id=signed.verse_id,
+ peer_id=signed.peer_id,
+ roles=("shard-primary", "read-replica"),
+ valid_from=signed.valid_from,
+ expires_at=signed.expires_at,
+ shard_ids=signed.shard_ids,
+ issuer_runtime_id=signed.issuer_runtime_id,
+ signature=signed.signature,
+ )
+ strict_leases.upsert(tampered)
+ self.assertFalse(strict_leases.is_verified(tampered.lease_id))
+ self.assertFalse(strict_leases.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now))
+
+ def test_cultmesh_stream_catalog_negotiates_transport_and_latest_frame(self) -> None:
+ streams = CultMeshStreamCatalog()
+ streams.declare(
+ CultMeshStreamDescriptor(
+ stream_id="mimir:kiyo-pro",
+ verse_id="studio",
+ owner_peer_id="starfire",
+ kind="video",
+ label="Kiyo Pro",
+ clock={"clockDomainId": "starfire-qpc", "confidence": 0.25},
+ video={"width": 1920, "height": 1080, "pixelFormat": "YUY2", "framesPerSecond": 30},
+ preferred_transports=("shared-d3d12-texture", "shared-memory", "cultcache-page"),
+ max_in_flight_frames=3,
+ )
+ )
+
+ negotiation = streams.negotiate(
+ "mimir:kiyo-pro",
+ CultMeshStreamConsumerProfile(
+ peer_id="fensalir",
+ verse_id="studio",
+ supported_transports=("shared-d3d12-texture", "cultcache-page"),
+ accepted_kinds=("video",),
+ can_import_gpu_handles=True,
+ max_in_flight_frames=2,
+ ),
+ )
+
+ self.assertEqual(negotiation.transport, "shared-d3d12-texture")
+ self.assertEqual(negotiation.max_in_flight_frames, 2)
+ self.assertEqual(negotiation.copy_budget, "zero-copy-target")
+
+ streams.publish_frame(
+ CultMeshStreamFrameHandle(
+ stream_id="mimir:kiyo-pro",
+ sequence=42,
+ timestamp_ns=1_000_000_000,
+ duration_ns=33_333_334,
+ transport="shared-d3d12-texture",
+ native_handle="0xfeed",
+ fence_handle="0xbeef",
+ fence_value=7,
+ unavoidable_copy_count=0,
+ )
+ )
+ self.assertEqual(streams.latest_frame("mimir:kiyo-pro").sequence, 42)
+
if __name__ == "__main__":
unittest.main()
diff --git a/packages/cultnet-py/tools/GameCult.Caching.Benchmark/GameCult.Caching.Benchmark.csproj b/packages/cultcache-py/tools/GameCult.Caching.Benchmark/GameCult.Caching.Benchmark.csproj
similarity index 100%
rename from packages/cultnet-py/tools/GameCult.Caching.Benchmark/GameCult.Caching.Benchmark.csproj
rename to packages/cultcache-py/tools/GameCult.Caching.Benchmark/GameCult.Caching.Benchmark.csproj
diff --git a/packages/cultnet-py/tools/GameCult.Caching.Benchmark/Program.cs b/packages/cultcache-py/tools/GameCult.Caching.Benchmark/Program.cs
similarity index 96%
rename from packages/cultnet-py/tools/GameCult.Caching.Benchmark/Program.cs
rename to packages/cultcache-py/tools/GameCult.Caching.Benchmark/Program.cs
index 14ec8d5e..698b07ec 100644
--- a/packages/cultnet-py/tools/GameCult.Caching.Benchmark/Program.cs
+++ b/packages/cultcache-py/tools/GameCult.Caching.Benchmark/Program.cs
@@ -1,7 +1,6 @@
using System.Diagnostics;
using System.Text.Json;
using GameCult.Caching;
-using MessagePack;
var records = 5000;
var emitJson = false;
@@ -94,15 +93,12 @@ internal sealed record BenchmarkResult(string Runtime, int Records, IReadOnlyLis
internal sealed record BenchmarkMetric(string Name, int Operations, double ElapsedMs, double OpsPerSecond);
[CultDocument("bench.item", "bench.item.v1")]
-[MessagePackObject(AllowPrivate = true)]
internal sealed class BenchItem
{
- [Key(0)] [CultName]
+ [CultName]
public string Name { get; set; } = string.Empty;
- [Key(1)]
public string Category { get; set; } = string.Empty;
- [Key(2)]
public int Value { get; set; }
}
diff --git a/packages/cultcache-rs/Cargo.toml b/packages/cultcache-rs/Cargo.toml
index 23d53801..b11a909e 100644
--- a/packages/cultcache-rs/Cargo.toml
+++ b/packages/cultcache-rs/Cargo.toml
@@ -1,8 +1,7 @@
[package]
name = "cultcache-rs"
-version = "0.2.0"
+version = "0.1.0"
edition = "2024"
-license = "MIT"
publish = false
[workspace]
@@ -18,7 +17,6 @@ chrono = { version = "0.4", features = ["serde"] }
cultcache-rs-derive = { path = "cultcache-rs-derive" }
fs2 = "0.4"
rmp-serde = "1.3"
-redb = "4.1.0"
serde = { version = "1", features = ["derive"] }
serde_bytes = "0.11"
serde_json = "1"
@@ -27,6 +25,3 @@ uuid = { version = "1", features = ["v4"] }
[dev-dependencies]
pretty_assertions = "1.4"
tempfile = "3"
-
-[target.'cfg(windows)'.dependencies]
-windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] }
diff --git a/packages/cultcache-rs/README.md b/packages/cultcache-rs/README.md
index 1ad01eb0..2d75d94e 100644
--- a/packages/cultcache-rs/README.md
+++ b/packages/cultcache-rs/README.md
@@ -53,7 +53,7 @@ struct Settings {
let mut cache = CultCache::new();
cache.register_entry_type::()?;
-cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new("cache.cc"));
+cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new("cache.msgpack"));
cache.pull_all_backing_stores()?;
cache.put("app", &Settings {
@@ -136,7 +136,7 @@ pub struct PlayerData {
let mut cache = CultCache::builder()
.with_generated_entries(game_entries())
- .with_generic_store(SingleFileMessagePackBackingStore::new("cache.cc"))
+ .with_generic_store(SingleFileMessagePackBackingStore::new("cache.msgpack"))
.build()?;
cache.pull_all_backing_stores()?;
@@ -173,71 +173,39 @@ without pretending Rust has runtime reflection hiding under the floorboards.
- `get_envelope::`
- `get_required_envelope::`
- `get_all::`
-- `get_all_with_keys::`
-- `soa::`
- `put::`
- `put_envelope::`
- `update::`
- `delete::`
- `snapshot`
-- `snapshot_soa`
-- `load_soa`
- `SingleFileMessagePackBackingStore`
-## Structure Of Arrays
-
-The C# CultCache hot path exposes CPU-local SoA scans as:
-
-```csharp
-Span x = cache.Soa()
- .Column(nameof(PlayerTransform.PositionX))
- .Span;
-```
-
-Rust cannot discover those columns through runtime reflection, so each domain
-type declares its column extractors explicitly with `SoaDocument` or the
-`cultcache_soa!` helper:
-
-```rust
-cultcache_soa!(PlayerTransform {
- "position_x" => |row: &PlayerTransform| row.position_x,
- "health" => |row: &PlayerTransform| row.health,
-});
-
-let table = cache.soa::()?;
-let health = table.column::("health")?;
-```
-
-This is the same public shape as the C# rite: callers ask the cache for a typed
-table, then pull contiguous typed columns by name. The Rust version is explicit
-about the extractor table because the closed-world schema boundary is a virtue,
-not a missing reflection party trick.
-
## Backing Store Routing
Generic store:
```rust
-cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new("cache.cc"))?;
+cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new("cache.msgpack"));
```
Type-specific store:
```rust
cache.add_backing_store(
- SingleFileMessagePackBackingStore::new("players.cc"),
+ SingleFileMessagePackBackingStore::new("players.msgpack"),
["player"],
-)?;
+);
```
-The contract, shared with every runtime, is one home store per document type
-(`src/GameCult.Caching/Contracts/cultcache-store-composition.md` in CultLib):
+When writing a `PlayerData`, the cache checks type-specific stores first. If none
+match, it writes to the first generic store. Later matching stores are mirrors.
-- a type-specific store owns its types; otherwise the generic store is the home
-- a second generic store is an error, and so is a type claimed by a second
- store; both registrations return `Err` and attach nothing
-- a write touches exactly one store; there are no mirrors
-- a write whose type has no home store is an error
+This mirrors the C# behavior:
+
+- specific domain stores own their domain
+- the first generic store is the primary generic write target
+- later generic stores mirror writes
+- this is not multi-master
## Persistence Semantics
diff --git a/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs b/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs
index 7d613194..f05a23f3 100644
--- a/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs
+++ b/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs
@@ -75,7 +75,6 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream {
};
let mut key: Option = None;
let mut default = false;
- let mut bytes = false;
for attribute in field
.attrs
.iter()
@@ -89,9 +88,6 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream {
} else if meta.path.is_ident("default") {
default = true;
Ok(())
- } else if meta.path.is_ident("bytes") {
- bytes = true;
- Ok(())
} else {
Err(meta.error("unsupported field cultcache attribute"))
}
@@ -121,7 +117,6 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream {
ident: field_ident,
ty: field.ty,
default,
- bytes,
});
}
slots.sort_by_key(|slot| slot.key);
@@ -130,14 +125,8 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream {
let serialize_elements = (0..=max_slot).map(|slot| {
if let Some(field) = slots.iter().find(|field| field.key == slot) {
let field_ident = &field.ident;
- if field.bytes {
- quote! {
- tuple.serialize_element(::serde_bytes::Bytes::new(&self.#field_ident))?;
- }
- } else {
- quote! {
- tuple.serialize_element(&self.#field_ident)?;
- }
+ quote! {
+ tuple.serialize_element(&self.#field_ident)?;
}
} else {
quote! {
@@ -149,14 +138,8 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream {
let option_declarations = slots.iter().map(|field| {
let variable = format_ident!("slot_{}", field.key);
let ty = &field.ty;
- if field.bytes {
- quote! {
- let mut #variable: ::std::option::Option<::serde_bytes::ByteBuf> = ::std::option::Option::None;
- }
- } else {
- quote! {
- let mut #variable: ::std::option::Option<#ty> = ::std::option::Option::None;
- }
+ quote! {
+ let mut #variable: ::std::option::Option<#ty> = ::std::option::Option::None;
}
});
@@ -164,17 +147,9 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream {
if let Some(field) = slots.iter().find(|field| field.key == slot) {
let variable = format_ident!("slot_{}", field.key);
let ty = &field.ty;
- if field.bytes {
- quote! {
- #slot => {
- #variable = seq.next_element::<::serde_bytes::ByteBuf>()?;
- }
- }
- } else {
- quote! {
- #slot => {
- #variable = seq.next_element::<#ty>()?;
- }
+ quote! {
+ #slot => {
+ #variable = seq.next_element::<#ty>()?;
}
}
} else {
@@ -189,16 +164,7 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream {
let build_fields = slots.iter().map(|field| {
let field_ident = &field.ident;
let variable = format_ident!("slot_{}", field.key);
- if field.bytes && field.default {
- quote! {
- #field_ident: #variable.unwrap_or_default().into_vec()
- }
- } else if field.bytes {
- let field_name = field_ident.to_string();
- quote! {
- #field_ident: #variable.ok_or_else(|| ::serde::de::Error::missing_field(#field_name))?.into_vec()
- }
- } else if field.default {
+ if field.default {
quote! {
#field_ident: #variable.unwrap_or_default()
}
@@ -274,5 +240,4 @@ struct CultCacheSlot {
ident: syn::Ident,
ty: Type,
default: bool,
- bytes: bool,
}
diff --git a/packages/cultcache-rs/examples/cultcache_interop.rs b/packages/cultcache-rs/examples/cultcache_interop.rs
index e625db9e..4049acf8 100644
--- a/packages/cultcache-rs/examples/cultcache_interop.rs
+++ b/packages/cultcache-rs/examples/cultcache_interop.rs
@@ -74,7 +74,7 @@ fn read_note(file: &str) -> Result<()> {
fn build_cache(file: &str) -> Result {
let mut cache = CultCache::new();
cache.register_entry_type::()?;
- cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(file))?;
+ cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(file));
Ok(cache)
}
diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs
index ed9cae32..67e88cdf 100644
--- a/packages/cultcache-rs/src/lib.rs
+++ b/packages/cultcache-rs/src/lib.rs
@@ -1,22 +1,15 @@
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
-use anyhow::bail;
-use anyhow::ensure;
use serde::Serialize;
use serde::de::DeserializeOwned;
-use std::any::Any;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::fs;
use std::fs::File;
use std::fs::OpenOptions;
-use std::marker::PhantomData;
use std::path::Path;
use std::path::PathBuf;
-use std::sync::Arc;
-
-use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition};
extern crate self as cultcache_rs;
@@ -31,83 +24,6 @@ pub trait CultCacheRegistry {
fn register_entries(&self, cache: &mut CultCache) -> Result<()>;
}
-pub trait SoaDocument: DatabaseEntry {
- fn soa_columns(rows: &[Self]) -> BTreeMap<&'static str, CultSoaColumnValues>;
-}
-
-pub struct CultSoaColumnValues {
- type_name: &'static str,
- values: Box,
-}
-
-impl CultSoaColumnValues {
- pub fn new(values: Vec) -> Self {
- Self {
- type_name: std::any::type_name::(),
- values: Box::new(values),
- }
- }
-}
-
-#[derive(Clone, Debug, PartialEq)]
-pub struct CultSoaColumn {
- name: String,
- values: Vec,
-}
-
-impl CultSoaColumn {
- pub fn name(&self) -> &str {
- &self.name
- }
-
- pub fn values(&self) -> &[TValue] {
- &self.values
- }
-}
-
-pub struct CultSoaTable {
- keys: Vec,
- documents: Vec,
- columns: BTreeMap,
- _document: PhantomData,
-}
-
-impl CultSoaTable {
- pub fn keys(&self) -> &[String] {
- &self.keys
- }
-
- pub fn documents(&self) -> &[TDocument] {
- &self.documents
- }
-
- pub fn len(&self) -> usize {
- self.keys.len()
- }
-
- pub fn is_empty(&self) -> bool {
- self.keys.is_empty()
- }
-
- pub fn column(&self, name: &str) -> Result> {
- let Some(column) = self.columns.get(name) else {
- return Err(anyhow!("SoA table has no column {name:?}"));
- };
- let Some(values) = column.values.downcast_ref::>() else {
- return Err(anyhow!(
- "SoA table column {:?} stores {}, not {}",
- name,
- column.type_name,
- std::any::type_name::()
- ));
- };
- Ok(CultSoaColumn {
- name: name.to_string(),
- values: values.clone(),
- })
- }
-}
-
#[macro_export]
macro_rules! cultcache_registry {
($name:ident { $($entry:ty),* $(,)? }) => {
@@ -125,22 +41,6 @@ macro_rules! cultcache_registry {
};
}
-#[macro_export]
-macro_rules! cultcache_soa {
- ($entry:ty { $($column:literal => $extractor:expr),* $(,)? }) => {
- impl $crate::SoaDocument for $entry {
- fn soa_columns(rows: &[Self]) -> ::std::collections::BTreeMap<&'static str, $crate::CultSoaColumnValues> {
- let mut columns = ::std::collections::BTreeMap::new();
- $(
- let values = rows.iter().map($extractor).collect::>();
- columns.insert($column, $crate::CultSoaColumnValues::new(values));
- )*
- columns
- }
- }
- };
-}
-
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CultCacheEnvelope {
@@ -154,93 +54,6 @@ pub struct CultCacheEnvelope {
pub schema_id: Option,
}
-#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct CultCacheSoaTable {
- pub keys: Vec,
- pub types: Vec,
- pub payloads: Vec>,
- pub stored_ats: Vec,
- pub schema_ids: Vec