Compare commits
975 changed files with 9300 additions and 268940 deletions
41
.gitattributes
vendored
41
.gitattributes
vendored
|
|
@ -1,43 +1,2 @@
|
|||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
|
||||
# Shell scripts keep LF in the working tree as well as in the index. Left to
|
||||
# `text=auto`, a Windows clone writes them with carriage returns, and the
|
||||
# documented Linux path then dies inside the container in under a second: the
|
||||
# shebang reads as the interpreter `bash\r`, which does not exist. Nothing about
|
||||
# that failure names line endings, and the same clone builds and tests fine
|
||||
# everywhere else, so it reads as a broken image rather than a broken checkout.
|
||||
#
|
||||
# A clone checked out before this rule keeps its carriage returns, because the
|
||||
# blob is unchanged and git has no reason to rewrite the file. The one-line
|
||||
# repair is in native/GameCult.Mesh.Quic.Native/README.md, beside the container
|
||||
# path it unblocks.
|
||||
*.sh text eol=lf
|
||||
|
||||
# The committed CultMesh QUIC native binaries (cut 6, packaging) ride the
|
||||
# deploy `git archive` as raw bytes. `* text=auto` already autodetects them as
|
||||
# binary by content, but that autodetection is a heuristic; say so explicitly
|
||||
# so no future text-processing rule (a diff driver, an export filter) can
|
||||
# apply text conversion and poison the committed digests in SHA256SUMS.
|
||||
packages/cultmesh-ts/native/**/*.dll binary
|
||||
packages/cultmesh-ts/native/**/*.so binary
|
||||
packages/cultmesh-ts/native/**/*.so.* binary
|
||||
|
||||
# The digest list and manifest beside them keep LF on every checkout. Left to
|
||||
# `text=auto`, a Windows clone writes them with carriage returns, and the
|
||||
# documented `sha256sum -c SHA256SUMS` then fails on every line (each filename
|
||||
# ends in `\r`) while CI, checking out on Linux, stays green.
|
||||
packages/cultmesh-ts/native/SHA256SUMS text eol=lf
|
||||
packages/cultmesh-ts/native/MANIFEST.txt text eol=lf
|
||||
|
||||
# CultMath source keeps CRLF in the working tree on every checkout. The release
|
||||
# gate compares the CultMath.dll it builds with the one org.gamecult.cultmath
|
||||
# tracks, byte for byte, and the bytes follow the checkout's line endings: the
|
||||
# pdb's document checksums feed the DLL's pdb id, timestamp and MVID. The
|
||||
# tracked DLL was built from CRLF source, so CRLF here keeps it valid, and an
|
||||
# LF-configured or Linux clone builds the same bytes as a Windows one. The index
|
||||
# stays LF; only the checkout changes.
|
||||
packages/cultmath/src/** text eol=crlf
|
||||
|
||||
# Stores the Python typed-leaf tests write for the C# reference to open.
|
||||
tests/vectors/**/*.cc binary
|
||||
|
|
|
|||
79
.github/workflows/cultmesh-portability.yml
vendored
79
.github/workflows/cultmesh-portability.yml
vendored
|
|
@ -95,82 +95,3 @@ jobs:
|
|||
dotnet test tests/GameCult.Mesh.Tests/GameCult.Mesh.Tests.csproj
|
||||
--filter FullyQualifiedName~Document_OneHundredThousandDistinctLeaseCyclesLeaveNoResourcesOrCallbacks
|
||||
--logger "console;verbosity=minimal"
|
||||
|
||||
# The TypeScript side of the CultMesh authority rules. This job is the
|
||||
# only one that runs on both Windows and Linux for main, codex/** and
|
||||
# pull requests: cultnet-interop.yml is windows-latest and main-only, so
|
||||
# until these steps existed the shared verifier's tests never ran on
|
||||
# Linux at all, and the browser client's never ran in CI on any OS.
|
||||
# packages/cultnet-ts verifies both vector files (content.test.ts judges both
|
||||
# C# content files, the Rust one and its own); cultmesh-browser proves
|
||||
# the browser client reads the shared rules rather than its own, and that its content reader releases only verified
|
||||
# bytes, requesting the same URLs as the C# HTTPS connector.
|
||||
# No workflow ran cultcache-ts's tests, and its test script listed two files by hand while a third sat compiled
|
||||
# and unrun. The wiring check fails when any TypeScript test file is run by no script, and the cache's own suite
|
||||
# runs here beside the packages built on it.
|
||||
- name: Check that every TypeScript test file is run by its package
|
||||
run: node scripts/check-typescript-tests-wired.mjs
|
||||
|
||||
- name: Verify the CultCache document owner with the TypeScript cache
|
||||
run: npm run test --workspace packages/cultcache-ts
|
||||
|
||||
# Packs the four TypeScript packages and installs them into a clean consumer, so a package that builds but does
|
||||
# not install or load fails here.
|
||||
- name: Verify the packed TypeScript packages install and load from a clean consumer
|
||||
run: npm run test:pack:ts
|
||||
|
||||
- name: Verify the CultMesh authority rules with the TypeScript verifier
|
||||
run: npm run test --workspace packages/cultnet-ts
|
||||
|
||||
# cultmesh-browser's test compiles test/interop, which imports cultmesh-ts/content, so cultmesh-ts builds first,
|
||||
# as in scripts/run-typescript-workspaces.mjs.
|
||||
# The realtime frame codec and the HTTP content chunk provider (content.test.ts,
|
||||
# its answers over node:http). The codec's byte parity rests on two vector files,
|
||||
# contracts/cultmesh/realtime-frame-vectors.json written by the C#
|
||||
# reference and decoded by this step, and realtime-frame-vectors.ts-written.json
|
||||
# written by this codec and decoded by the C# step below. Neither half ran
|
||||
# in CI on any OS until these two steps existed, so a codec that agreed
|
||||
# only with itself would have gone green.
|
||||
- name: Verify the realtime frame codec and the content provider with the TypeScript runtime
|
||||
run: npm run test --workspace packages/cultmesh-ts
|
||||
|
||||
- name: Verify the browser client against the shared TypeScript verifier
|
||||
run: npm run test --workspace packages/cultmesh-browser
|
||||
|
||||
# Both directions of the CultMesh authority vectors: the C#-written
|
||||
# file (contracts/cultmesh/authority-route-vectors.json) and the
|
||||
# TypeScript-signed file (authority-route-vectors.ts-signed.json) are
|
||||
# verified by the C# reference here; the TypeScript side verifies both
|
||||
# in the step above. CULTMESH_WRITE_VECTORS is never set in CI: the
|
||||
# committed files are the contract.
|
||||
- name: Verify the CultMesh authority vectors with the C# reference
|
||||
run: >-
|
||||
dotnet test tests/GameCult.Mesh.Tests/GameCult.Mesh.Tests.csproj
|
||||
--filter FullyQualifiedName~AuthorityProof
|
||||
--logger "console;verbosity=minimal"
|
||||
|
||||
# The other half of the frame parity: the C# reference decodes and
|
||||
# re-encodes the vector file the TypeScript codec wrote. A fixture
|
||||
# authored on one side alone would only agree with itself.
|
||||
- name: Verify the realtime frame vectors with the C# reference
|
||||
run: >-
|
||||
dotnet test tests/GameCult.Mesh.Tests/GameCult.Mesh.Tests.csproj
|
||||
--filter FullyQualifiedName~RealtimeWireProtocol
|
||||
--logger "console;verbosity=minimal"
|
||||
|
||||
# The content plane: the reference writes the manifest and record-key
|
||||
# vectors and judges the manifests the TypeScript packer wrote
|
||||
# (content-vectors.ts-written.json). This suite also holds the C#/Rust
|
||||
# content parity, which no CI step ran before this one. CULTNET_WRITE_VECTORS
|
||||
# is never set in CI: the committed files are the contract.
|
||||
- name: Verify the content vectors with the C# reference
|
||||
run: >-
|
||||
dotnet test tests/GameCult.Mesh.Tests/GameCult.Mesh.Tests.csproj
|
||||
--filter FullyQualifiedName~CultMeshContentVectorTests
|
||||
--logger "console;verbosity=minimal"
|
||||
|
||||
# The cross-process HTTPS content parity: the C# transfer service over the unmodified HTTPS connector and the
|
||||
# TypeScript reader fetch from the TypeScript provider in its own process. Both refuse bytes tampered in flight,
|
||||
# and the provider refuses a corrupt store.
|
||||
- name: Verify HTTPS content parity across C#, the TypeScript provider and the TypeScript reader
|
||||
run: npm run test:interop --workspace packages/cultmesh-browser
|
||||
|
|
|
|||
37
.github/workflows/cultnet-interop.yml
vendored
37
.github/workflows/cultnet-interop.yml
vendored
|
|
@ -12,13 +12,6 @@ jobs:
|
|||
name: C# / TypeScript / Rust / Python / Kotlin
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 60
|
||||
# Windows runners default to pwsh, which does not stop when a native command fails: a
|
||||
# multi-command step reported only its last command, and a failing cultnet-py suite
|
||||
# went green that way. Git Bash runs as `bash -e -o pipefail`, so every command counts.
|
||||
# Steps that name `shell: pwsh` check $LASTEXITCODE themselves.
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
DOTNET: dotnet
|
||||
|
|
@ -72,36 +65,6 @@ jobs:
|
|||
"KOTLIN_JAVA_HOME=$env:JAVA_HOME" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
"$kotlinHome\bin" | Out-File -FilePath $env:GITHUB_PATH -Append
|
||||
|
||||
- name: Verify CultNet schema contracts stayed consolidated
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
# packages/cultnet-rs/contracts/cultnet/ holds R-AB's vendored byte copies (the crate must build
|
||||
# when copied alone). Their drift guard is packages/cultnet-rs/tests/schema_discovery_contracts.rs:74-99.
|
||||
$tracked = git ls-files '*.schema.json'
|
||||
if ($LASTEXITCODE -ne 0) { throw "git ls-files failed" }
|
||||
$stray = $tracked | Where-Object { $_ -notmatch '^(packages/cultnet-rs/)?contracts/cultnet/' }
|
||||
# -I skips binary files: unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll
|
||||
# still embeds the retired namespace until it is rebuilt (REC-032 D4, deferred).
|
||||
$oldNamespace = git grep -I -lE 'GameCult/cultnet-ts/(integration/)?contracts' -- ':!.voidbot'
|
||||
if ($LASTEXITCODE -eq 2) {
|
||||
throw "git grep failed"
|
||||
}
|
||||
if ($stray) {
|
||||
Write-Host "Schema files found outside contracts/cultnet/:"
|
||||
$stray | ForEach-Object { Write-Host " $_" }
|
||||
}
|
||||
if ($oldNamespace) {
|
||||
Write-Host "Files still referencing the retired GameCult/cultnet-ts contracts namespace:"
|
||||
$oldNamespace | ForEach-Object { Write-Host " $_" }
|
||||
}
|
||||
if ($stray -or $oldNamespace) {
|
||||
throw "CultNet schema contracts drifted out of contracts/cultnet/ (REC-032). See CultLib/contracts/cultnet/ and rewrite any `$id back to https://github.com/GameCult/CultLib/contracts/cultnet/."
|
||||
}
|
||||
# git grep exits 1 when nothing matches, and the runner's pwsh wrapper
|
||||
# ends the step with that $LASTEXITCODE; a clean tree must pass.
|
||||
exit 0
|
||||
|
||||
- name: Install Node dependencies
|
||||
run: npm install --no-package-lock
|
||||
|
||||
|
|
|
|||
77
.github/workflows/publish-packages.yml
vendored
77
.github/workflows/publish-packages.yml
vendored
|
|
@ -10,8 +10,8 @@ name: Publish packages
|
|||
#
|
||||
# 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; scripts/release-after-suite.mjs refuses the release otherwise rather
|
||||
# than shipping a mislabelled artifact.
|
||||
# manifest; the workflow refuses the publish otherwise rather than shipping a
|
||||
# mislabelled artifact.
|
||||
|
||||
on:
|
||||
push:
|
||||
|
|
@ -42,12 +42,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
# fetch-depth: 0 so the semver check in the release step can see every previously
|
||||
# published tag, not just the one that triggered this run.
|
||||
- name: Check out CultLib
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
|
|
@ -55,30 +51,29 @@ jobs:
|
|||
node-version: 24.x
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
# The release-checks suite, which Publish runs, needs .NET for the API gate.
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
|
||||
- name: Install workspace dependencies
|
||||
run: npm ci
|
||||
|
||||
# Manual runs publish nothing, so they build and test here. A tag run's
|
||||
# Publish runs these tests itself.
|
||||
- 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
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
working-directory: packages/cultcache-ts
|
||||
run: npm test
|
||||
|
||||
# One process checks that the tag is the manifest version, runs the
|
||||
# release-checks suite, the semver policy check (docs/semver-policy.md)
|
||||
# and npm test, and only then runs npm publish --access public. Only
|
||||
# npm publish receives the token. It starts without NODE_OPTIONS, so no
|
||||
# preloaded module runs inside it.
|
||||
# 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')
|
||||
run: env -u NODE_OPTIONS node scripts/release-after-suite.mjs npm-publish packages/cultcache-ts "$GITHUB_REF"
|
||||
working-directory: packages/cultcache-ts
|
||||
run: npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
|
|
@ -110,13 +105,9 @@ jobs:
|
|||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# fetch-depth: 0 so the semver check in the release step can see every previously
|
||||
# published tag, not just the one that triggered this run.
|
||||
- name: Check out CultLib
|
||||
if: steps.selected.outputs.run == 'true'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
if: steps.selected.outputs.run == 'true'
|
||||
|
|
@ -124,33 +115,29 @@ jobs:
|
|||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up Node
|
||||
if: steps.selected.outputs.run == 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24.x
|
||||
|
||||
- name: Set up .NET
|
||||
if: steps.selected.outputs.run == 'true'
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
- 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
|
||||
- name: Install and test
|
||||
if: steps.selected.outputs.run == 'true'
|
||||
run: python -m pip install --upgrade build -e packages/cultcache-py -e packages/cultnet-py -e packages/cultmesh-py
|
||||
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
|
||||
|
||||
# One process checks, on a tag, that the tag is the manifest version, runs
|
||||
# the release-checks suite, on a tag the semver policy check
|
||||
# (docs/semver-policy.md), and the package's unit tests, and only then
|
||||
# builds the sdist and wheel. Publish uploads only what this step built,
|
||||
# so it has nothing to upload unless every check passed. It starts without
|
||||
# NODE_OPTIONS, so no preloaded module runs inside it.
|
||||
- name: Build sdist and wheel
|
||||
if: steps.selected.outputs.run == 'true'
|
||||
run: env -u NODE_OPTIONS node scripts/release-after-suite.mjs python-build packages/${{ matrix.package }} "$GITHUB_REF"
|
||||
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/')
|
||||
|
|
|
|||
202
.github/workflows/quic-native.yml
vendored
202
.github/workflows/quic-native.yml
vendored
|
|
@ -1,202 +0,0 @@
|
|||
name: CultMesh QUIC native
|
||||
|
||||
# Builds and, on a tag, releases the CultMesh QUIC native binaries that
|
||||
# packages/cultmesh-ts/native/{win32-x64,linux-x64} commits (cut 6,
|
||||
# docs/typescript-quic-realtime-cut.md:2108-2159). Under Q8 Option 1 the
|
||||
# operator downloads a tagged run's Release assets, verifies every digest
|
||||
# against this workflow's own logs and SHA256SUMS, and commits the bytes;
|
||||
# nothing here writes to the tree. Under Q6 A, the Linux artifact comes only
|
||||
# from the pinned debian:13 container below, never from a local or Yggdrasil
|
||||
# build.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "cultmesh-quic-native-v*"
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Push to main and pull_request run this job only: it proves the tree's
|
||||
# committed bytes still match the manifest they shipped with. A tag push
|
||||
# also runs it, alongside the two builds and the release.
|
||||
verify-committed:
|
||||
name: Verify committed binaries match SHA256SUMS
|
||||
if: >-
|
||||
github.event_name == 'pull_request' ||
|
||||
(github.event_name == 'push' &&
|
||||
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/cultmesh-quic-native-v')))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check out CultLib
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: sha256sum -c SHA256SUMS
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ ! -f packages/cultmesh-ts/native/SHA256SUMS ]; then
|
||||
echo "packages/cultmesh-ts/native/SHA256SUMS is missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
cd packages/cultmesh-ts/native
|
||||
sha256sum -c SHA256SUMS
|
||||
|
||||
# workflow_dispatch and a cultmesh-quic-native-v* tag run the two builds.
|
||||
# A dry (workflow_dispatch) run uploads artifacts and creates no release.
|
||||
build-win32-x64:
|
||||
name: Build win32-x64
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/cultmesh-quic-native-v'))
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Check out CultLib
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build the native QUIC bridge
|
||||
shell: pwsh
|
||||
run: .\scripts\build-quic-native.ps1 -OutputDirectory artifacts\quic-native\win32-x64
|
||||
|
||||
- name: Print toolchain version and digests
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$compilerCmake = Get-ChildItem -Recurse -LiteralPath "artifacts\quic-native-build\x64\Release" -Filter "CMakeCXXCompiler.cmake" |
|
||||
Select-Object -First 1
|
||||
if ($compilerCmake) {
|
||||
Select-String -LiteralPath $compilerCmake.FullName -Pattern "CMAKE_CXX_COMPILER_VERSION" |
|
||||
ForEach-Object { Write-Host $_.Line.Trim() }
|
||||
}
|
||||
Get-FileHash -Algorithm SHA256 artifacts\quic-native\win32-x64\gamecult_mesh_quic_native.dll
|
||||
Get-FileHash -Algorithm SHA256 artifacts\quic-native\win32-x64\msquic.dll
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: quic-native-win32-x64
|
||||
path: artifacts/quic-native/win32-x64
|
||||
if-no-files-found: error
|
||||
|
||||
build-linux-x64:
|
||||
name: Build linux-x64
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/cultmesh-quic-native-v'))
|
||||
runs-on: ubuntu-latest
|
||||
# Q6 A: this pinned Debian 13 container is the only permitted Linux
|
||||
# release builder. The digest is the pin; the `debian:13` tag is only a
|
||||
# label. The artifact links against this image's glibc (2.41); a bare
|
||||
# ubuntu-latest runner carries glibc 2.39, which is not the deploy host's
|
||||
# OS (docs/typescript-quic-realtime-cut.md:2192-2197).
|
||||
container:
|
||||
image: debian:13@sha256:f324c7ff54321e8d9c588493a20244965938ce0aa50bbd1022d38010e9ffc4b1
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Check out CultLib
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install build tools
|
||||
run: |
|
||||
set -euo pipefail
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends cmake ninja-build g++ curl ca-certificates dpkg
|
||||
|
||||
- name: Build the native QUIC bridge
|
||||
run: ./scripts/build-quic-native.sh artifacts/quic-native/linux-x64
|
||||
|
||||
- name: Print toolchain version and digests
|
||||
run: |
|
||||
g++ --version
|
||||
sha256sum artifacts/quic-native/linux-x64/libgamecult_mesh_quic_native.so
|
||||
sha256sum artifacts/quic-native/linux-x64/libmsquic.so.2
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: quic-native-linux-x64
|
||||
path: artifacts/quic-native/linux-x64
|
||||
if-no-files-found: error
|
||||
|
||||
# Only this job has write permission, and only a tag push runs it. It
|
||||
# attaches a draft MANIFEST.txt: the fields both build jobs cannot know
|
||||
# (host names, MSVC version, the pinned dependency sources) are left for
|
||||
# the operator to fill in from this run's own logs before committing the
|
||||
# binaries, per 11.5's manifest paragraph.
|
||||
release:
|
||||
name: Release native binaries
|
||||
needs: [build-win32-x64, build-linux-x64]
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/cultmesh-quic-native-v')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Check out CultLib
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download win32-x64 artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: quic-native-win32-x64
|
||||
path: release/win32-x64
|
||||
|
||||
- name: Download linux-x64 artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: quic-native-linux-x64
|
||||
path: release/linux-x64
|
||||
|
||||
- name: Write SHA256SUMS and a draft MANIFEST.txt
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd release
|
||||
# Relative paths matching packages/cultmesh-ts/native/ exactly, so
|
||||
# this file is the one the operator commits verbatim (11.5, 11.7).
|
||||
sha256sum \
|
||||
win32-x64/gamecult_mesh_quic_native.dll \
|
||||
win32-x64/msquic.dll \
|
||||
linux-x64/libgamecult_mesh_quic_native.so \
|
||||
linux-x64/libmsquic.so.2 \
|
||||
> SHA256SUMS
|
||||
cat > MANIFEST.txt <<EOF
|
||||
# Draft written by ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}.
|
||||
# The operator fills the remaining fields from this run's job logs
|
||||
# (build-win32-x64 and build-linux-x64 each print their toolchain
|
||||
# version) before committing packages/cultmesh-ts/native/MANIFEST.txt.
|
||||
msquic.version=2.5.9
|
||||
msquic.windows.source=
|
||||
msquic.linux.source=
|
||||
bridge.commit=${GITHUB_SHA}
|
||||
build.workflow_run=${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}
|
||||
build.release=${GITHUB_REF_NAME}
|
||||
build.win32-x64.host=
|
||||
build.linux-x64.host=
|
||||
date=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
EOF
|
||||
# Named after the upload-artifact names above, not after the parked
|
||||
# scoped per-platform npm package names from the registry-publication
|
||||
# seam (11.9): these are release archives, not npm packages, and
|
||||
# 11.7's negative grep for that parked naming must stay clean.
|
||||
tar -C win32-x64 -czf quic-native-win32-x64.tar.gz .
|
||||
tar -C linux-x64 -czf quic-native-linux-x64.tar.gz .
|
||||
|
||||
- name: Create release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd release
|
||||
gh release create "$GITHUB_REF_NAME" --verify-tag \
|
||||
--title "$GITHUB_REF_NAME" \
|
||||
--notes-file MANIFEST.txt \
|
||||
quic-native-win32-x64.tar.gz \
|
||||
quic-native-linux-x64.tar.gz \
|
||||
SHA256SUMS \
|
||||
MANIFEST.txt
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -6,9 +6,6 @@
|
|||
# dotenv files
|
||||
.env
|
||||
|
||||
# Stryker.NET mutation-testing output
|
||||
StrykerOutput/
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
|
|
|
|||
30
CultLib.sln
30
CultLib.sln
|
|
@ -49,10 +49,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CultMath.Tests", "packages\
|
|||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.ConsumerBuild", "tests\GameCult.Caching.ConsumerBuild\GameCult.Caching.ConsumerBuild.csproj", "{D3138F3C-9531-4906-A5D3-74BD857D0685}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.GeneratorConsumer", "tests\GameCult.Caching.GeneratorConsumer\GameCult.Caching.GeneratorConsumer.csproj", "{1A75EA8F-00B3-49AB-8398-B1B7723BE10B}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.OutsideStoreTests", "tests\GameCult.Caching.OutsideStoreTests\GameCult.Caching.OutsideStoreTests.csproj", "{A54C2C29-941A-46A9-9DDE-9711B2F2EADD}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
|
|
@ -279,30 +275,6 @@ Global
|
|||
{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
|
||||
{1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Release|x64.Build.0 = Release|Any CPU
|
||||
{1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Release|x86.Build.0 = Release|Any CPU
|
||||
{A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
|
@ -327,7 +299,5 @@ Global
|
|||
{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}
|
||||
{1A75EA8F-00B3-49AB-8398-B1B7723BE10B} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
|
||||
{A54C2C29-941A-46A9-9DDE-9711B2F2EADD} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
|
|
|||
27
README.md
27
README.md
|
|
@ -30,30 +30,6 @@ bespoke JSON bridge at every boundary. A frontend is just another CultMesh
|
|||
client: the daemon publishes a typed Eve/CultUI surface, and any compatible
|
||||
runtime can lower that surface where it needs to live.
|
||||
|
||||
## Entry Points
|
||||
|
||||
- C# reference runtime: `src/` (`GameCult.Caching`, `GameCult.Networking`,
|
||||
`GameCult.Mesh`, `GameCult.Geometry`, `GameCult.Unity`). Build and test with
|
||||
`dotnet build CultLib.sln` and `dotnet test CultLib.sln`.
|
||||
- Other runtimes, wire-compatible with the C# CultCache: `packages/cultcache-ts`,
|
||||
`cultcache-py`, `cultcache-rs`, and the CultNet and CultMesh packages beside
|
||||
them under `packages/`; portable math in `packages/cultmath`.
|
||||
- Smallest working path: `src/GameCult.Mesh/docs/durable-node-quickstart.md`.
|
||||
Application path: `src/GameCult.Mesh/docs/getting-started/README.md` and
|
||||
`samples/eve-two-runtime`.
|
||||
|
||||
## Live And Planned
|
||||
|
||||
Live: typed CultCache persistence, CultNet schema-v0 transports and native RUDP,
|
||||
CultMesh nodes with Verse discovery, shard replication and authority leases,
|
||||
the geometry documents, and CultUI for Unity. The browser client boundary
|
||||
(`packages/cultmesh-browser`, `GameCult.Networking.WebSockets`) is proven on
|
||||
loopback under an explicit `local-development` trust policy by
|
||||
`samples/eve-browser-network`.
|
||||
|
||||
Planned: a complete public Verse proof over deployed infrastructure; the
|
||||
remaining gates are listed in `src/GameCult.Mesh/docs/browser-verse-transport.md`.
|
||||
|
||||
## Which Package Do I Want?
|
||||
|
||||
If the job is shared game state, start here before inventing a fourth drawer
|
||||
|
|
@ -121,6 +97,7 @@ The solution includes:
|
|||
- `GameCult.Logging`: common logging abstraction plus console and file implementations
|
||||
- `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.Networking`: encrypted login/register/verify flows, schema-v0 contracts, transport adapters, and native RUDP sessions
|
||||
|
|
@ -148,6 +125,7 @@ src/
|
|||
GameCult.Logging/
|
||||
GameCult.Caching/
|
||||
GameCult.Caching.MessagePack/
|
||||
GameCult.Caching.NewtonsoftJson/
|
||||
GameCult.Geometry/
|
||||
GameCult.Networking/
|
||||
GameCult.Networking.WebSockets/
|
||||
|
|
@ -366,6 +344,7 @@ Each subproject has a local README with package-specific detail:
|
|||
- [GameCult.Logging](src/GameCult.Logging/README.md)
|
||||
- [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.Geometry](src/GameCult.Geometry/README.md)
|
||||
- [GameCult.Networking](src/GameCult.Networking/README.md)
|
||||
- [GameCult.Mesh](src/GameCult.Mesh/README.md)
|
||||
|
|
|
|||
|
|
@ -1,44 +0,0 @@
|
|||
{
|
||||
"nowUnixMilliseconds": 1800000000000,
|
||||
"odinRoot": {
|
||||
"keyId": "odin-vector",
|
||||
"x": "shJLGtmRrW6D87VQKv0xYQekLIfEkeN7dVawwpmIKWM=",
|
||||
"y": "iR6Gx3aVCghzSyo7TqXqr+1397L73KDNZuzN2cZ55IE="
|
||||
},
|
||||
"route": {
|
||||
"verseId": "aetheria",
|
||||
"authorityRuntimeId": "aetheria-daemon",
|
||||
"endpoint": "wss://provider.example/mesh",
|
||||
"protocolIds": [
|
||||
"cultmesh.content.v1",
|
||||
"cultmesh.documents.v1"
|
||||
],
|
||||
"priority": 7,
|
||||
"generation": "vector-generation-1",
|
||||
"certificate": {
|
||||
"providerKey": {
|
||||
"keyId": "provider-vector",
|
||||
"x": "6XIvgx3W1q7lJFEV0yYbeKWNFgssq54BHBuh9gCwcH8=",
|
||||
"y": "oi1FiKraueLic+VemzOx18MNwG6thmZMUWeqNw6vNOk="
|
||||
},
|
||||
"odinKeyId": "odin-vector",
|
||||
"issuedAtUnixMilliseconds": 1799999940000,
|
||||
"expiresAtUnixMilliseconds": 1800003600000,
|
||||
"signature": "ZHuELFuCNNfbJt1T2mx1D9ZHXyxBedacDetmryMYhWKQxfROncltymkmL5KXl+1h8tDK6NytJCGy+7mYlb/DMA=="
|
||||
}
|
||||
},
|
||||
"sessionProof": {
|
||||
"request": {
|
||||
"schemaVersion": "cultmesh.session_open.v2",
|
||||
"messageId": "vector-message-1",
|
||||
"sourceRuntimeId": "vector-consumer",
|
||||
"verseId": "aetheria",
|
||||
"authorityRuntimeId": "aetheria-daemon",
|
||||
"protocolId": "cultmesh.documents.v1",
|
||||
"routeGeneration": "vector-generation-1",
|
||||
"clientNonce": "VczqjH8NdBaOb+rh8xb6P0xZjWD1el2ReNB8Vbp52ko="
|
||||
},
|
||||
"providerKeyId": "provider-vector",
|
||||
"providerSignature": "6kGxSu/pwcR4lzh/7Av9nhblVIwIwVFzB7kY34vVvkpaFAbAxt8mWqIyZDTftE28G1g+NL4Ea41C3QsuImmkaA=="
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
{
|
||||
"nowUnixMilliseconds": 1800000000000,
|
||||
"odinRoot": {
|
||||
"keyId": "odin-ts-vector",
|
||||
"x": "lqw9TE0EyuDU5Ghe8SafIbBBYkhHMUF7HXJQUc0nXsw=",
|
||||
"y": "nUj+Xhakih97JiJcTUNMHM9JIo7MVQaauuAkCXph+tw="
|
||||
},
|
||||
"route": {
|
||||
"verseId": "aetheria",
|
||||
"authorityRuntimeId": "aetheria-daemon",
|
||||
"endpoint": "cultmesh-state+quic://provider.example:4433/?cert-sha256=ABCDEF",
|
||||
"protocolIds": [
|
||||
"cultmesh.documents.v1",
|
||||
"cultmesh.content.v1",
|
||||
"cultmesh.realtime.v1"
|
||||
],
|
||||
"priority": 13,
|
||||
"generation": "ts-generation-ünïcode-∞-1",
|
||||
"certificate": {
|
||||
"providerKey": {
|
||||
"keyId": "provider-ts-vector",
|
||||
"x": "JKyjtWnoE5fhSWbeqr188SbReQWCjssf5mSMEa5pV+E=",
|
||||
"y": "vtduzojDpbT49sbxBSsBQ+f3PG3rm+CUk/MTy2MM/ws="
|
||||
},
|
||||
"odinKeyId": "odin-ts-vector",
|
||||
"issuedAtUnixMilliseconds": 1799999940000,
|
||||
"expiresAtUnixMilliseconds": 1800003600000,
|
||||
"signature": "NS5a7X8fNqTi1CdpPAcJaMmEsozwFRoygTW4z+JlhHFq7uaZcb4G7NwoEpPqo/Fv+UA7pNl5N+qUMuSD/oAq1Q=="
|
||||
}
|
||||
},
|
||||
"sessionProof": {
|
||||
"request": {
|
||||
"schemaVersion": "cultmesh.session_open.v2",
|
||||
"messageId": "ts-vector-message-1",
|
||||
"sourceRuntimeId": "ts-vector-consumer",
|
||||
"verseId": "aetheria",
|
||||
"authorityRuntimeId": "aetheria-daemon",
|
||||
"protocolId": "cultmesh.documents.v1",
|
||||
"routeGeneration": "ts-generation-ünïcode-∞-1",
|
||||
"clientNonce": "HICtAmV++1MarssY04Tkyn1j9qVEcOMp/P5Ce1M0Yec="
|
||||
},
|
||||
"providerKeyId": "provider-ts-vector",
|
||||
"providerSignature": "OgJHkmw3fhPRS5TXxZm94yZvkWAV5TPDuvG+/zIVlnU7OFENZ0Nn0Pdx0IT/vDfEWXD+8/+sCZVFIFwa4tUjsg=="
|
||||
}
|
||||
}
|
||||
|
|
@ -1,702 +0,0 @@
|
|||
{
|
||||
"artifacts": [
|
||||
{
|
||||
"artifactIdUnits": [
|
||||
101,
|
||||
117,
|
||||
114,
|
||||
101,
|
||||
107,
|
||||
97,
|
||||
47,
|
||||
99,
|
||||
111,
|
||||
110,
|
||||
116,
|
||||
101,
|
||||
110,
|
||||
116,
|
||||
45,
|
||||
118,
|
||||
101,
|
||||
99,
|
||||
116,
|
||||
111,
|
||||
114
|
||||
],
|
||||
"versionUnits": [],
|
||||
"contentHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"recordKey": "mesh:cdn:artifact:eureka-content-vector:unversioned:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"artifactIdUnits": [
|
||||
32,
|
||||
32,
|
||||
77,
|
||||
105,
|
||||
120,
|
||||
101,
|
||||
100,
|
||||
32,
|
||||
67,
|
||||
97,
|
||||
115,
|
||||
101,
|
||||
47,
|
||||
80,
|
||||
97,
|
||||
116,
|
||||
104,
|
||||
32,
|
||||
32
|
||||
],
|
||||
"versionUnits": [
|
||||
32,
|
||||
32,
|
||||
32
|
||||
],
|
||||
"contentHash": "SHA256:E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
|
||||
"recordKey": "mesh:cdn:artifact:mixed-case-path:unversioned:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"artifactIdUnits": [
|
||||
304,
|
||||
115,
|
||||
116,
|
||||
97,
|
||||
110,
|
||||
98,
|
||||
117,
|
||||
108
|
||||
],
|
||||
"versionUnits": [
|
||||
49,
|
||||
46,
|
||||
48,
|
||||
46,
|
||||
48
|
||||
],
|
||||
"contentHash": " e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ",
|
||||
"recordKey": "mesh:cdn:artifact:\u0130stanbul:1.0.0:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"artifactIdUnits": [
|
||||
913,
|
||||
931
|
||||
],
|
||||
"versionUnits": [
|
||||
86,
|
||||
50,
|
||||
32,
|
||||
66,
|
||||
101,
|
||||
116,
|
||||
97
|
||||
],
|
||||
"contentHash": " sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"recordKey": "mesh:cdn:artifact:\u03B1\u03C3:v2-beta:sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"artifactIdUnits": [
|
||||
65279,
|
||||
98,
|
||||
111,
|
||||
109
|
||||
],
|
||||
"versionUnits": [
|
||||
252
|
||||
],
|
||||
"contentHash": "sha256:sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"recordKey": "mesh:cdn:artifact:bom:\u00FC:sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"artifactIdUnits": [
|
||||
133,
|
||||
110,
|
||||
101,
|
||||
108,
|
||||
133
|
||||
],
|
||||
"versionUnits": [],
|
||||
"contentHash": "Sha256:\u0130\u03A3\uD801\uDC00",
|
||||
"recordKey": "mesh:cdn:artifact:nel:unversioned:\u0130\u03C3\uD801\uDC28"
|
||||
},
|
||||
{
|
||||
"artifactIdUnits": [
|
||||
26085,
|
||||
26412,
|
||||
35486,
|
||||
47,
|
||||
36039,
|
||||
29987
|
||||
],
|
||||
"versionUnits": [
|
||||
32,
|
||||
32,
|
||||
32
|
||||
],
|
||||
"contentHash": "sha256:",
|
||||
"recordKey": "mesh:cdn:artifact:\u65E5\u672C\u8A9E-\u8CC7\u7523:unversioned:"
|
||||
},
|
||||
{
|
||||
"artifactIdUnits": [
|
||||
55357,
|
||||
56898,
|
||||
32,
|
||||
115,
|
||||
109,
|
||||
105,
|
||||
108,
|
||||
101
|
||||
],
|
||||
"versionUnits": [
|
||||
49,
|
||||
46,
|
||||
48,
|
||||
46,
|
||||
48
|
||||
],
|
||||
"contentHash": "\u017Fha256:abc",
|
||||
"recordKey": "mesh:cdn:artifact:smile:1.0.0:\u017Fha256:abc"
|
||||
},
|
||||
{
|
||||
"artifactIdUnits": [
|
||||
45,
|
||||
45,
|
||||
45
|
||||
],
|
||||
"versionUnits": [
|
||||
86,
|
||||
50,
|
||||
32,
|
||||
66,
|
||||
101,
|
||||
116,
|
||||
97
|
||||
],
|
||||
"contentHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"recordKey": "mesh:cdn:artifact:cb3f91d54eee30e53e35b2b99905f70f169ed549fd78909d3dac2defc9ed8d3b:v2-beta:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"artifactIdUnits": [
|
||||
97,
|
||||
55296,
|
||||
98
|
||||
],
|
||||
"versionUnits": [
|
||||
252
|
||||
],
|
||||
"contentHash": "SHA256:E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
|
||||
"recordKey": "mesh:cdn:artifact:a-b:\u00FC:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"artifactIdUnits": [
|
||||
55296
|
||||
],
|
||||
"versionUnits": [],
|
||||
"contentHash": " e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ",
|
||||
"recordKey": "mesh:cdn:artifact:83d544ccc223c057d2bf80d3f2a32982c32c3c0db8e2674820da5064783fb097:unversioned:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"artifactIdUnits": [
|
||||
120,
|
||||
178,
|
||||
121
|
||||
],
|
||||
"versionUnits": [
|
||||
32,
|
||||
32,
|
||||
32
|
||||
],
|
||||
"contentHash": " sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"recordKey": "mesh:cdn:artifact:x-y:unversioned:sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"artifactIdUnits": [
|
||||
1635,
|
||||
100
|
||||
],
|
||||
"versionUnits": [
|
||||
49,
|
||||
46,
|
||||
48,
|
||||
46,
|
||||
48
|
||||
],
|
||||
"contentHash": "sha256:sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"recordKey": "mesh:cdn:artifact:\u0663d:1.0.0:sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"artifactIdUnits": [
|
||||
55297,
|
||||
56320,
|
||||
65
|
||||
],
|
||||
"versionUnits": [
|
||||
86,
|
||||
50,
|
||||
32,
|
||||
66,
|
||||
101,
|
||||
116,
|
||||
97
|
||||
],
|
||||
"contentHash": "Sha256:\u0130\u03A3\uD801\uDC00",
|
||||
"recordKey": "mesh:cdn:artifact:a:v2-beta:\u0130\u03C3\uD801\uDC28"
|
||||
},
|
||||
{
|
||||
"artifactIdUnits": [
|
||||
32,
|
||||
32,
|
||||
45,
|
||||
45,
|
||||
45,
|
||||
32,
|
||||
32
|
||||
],
|
||||
"versionUnits": [
|
||||
252
|
||||
],
|
||||
"contentHash": "sha256:",
|
||||
"recordKey": "mesh:cdn:artifact:fcd2a2fb27957aa9a10d2d9eca16ccbc9c35a76933b03f470211d327f6946c3f:\u00FC:"
|
||||
},
|
||||
{
|
||||
"artifactIdUnits": [
|
||||
9398
|
||||
],
|
||||
"versionUnits": [],
|
||||
"contentHash": "\u017Fha256:abc",
|
||||
"recordKey": "mesh:cdn:artifact:7d9633ae307dae04a8b362df41dca4b38a653823d69f27a259e7b6928508d3a6:unversioned:\u017Fha256:abc"
|
||||
},
|
||||
{
|
||||
"artifactIdUnits": [
|
||||
8555
|
||||
],
|
||||
"versionUnits": [
|
||||
32,
|
||||
32,
|
||||
32
|
||||
],
|
||||
"contentHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"recordKey": "mesh:cdn:artifact:8c0e55c55f47de02406174ec976279a977e2e73ea5aec8ab90f94c40a6ab2d60:unversioned:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"artifactIdUnits": [
|
||||
101,
|
||||
117,
|
||||
114,
|
||||
101,
|
||||
107,
|
||||
97,
|
||||
47,
|
||||
99,
|
||||
111,
|
||||
110,
|
||||
116,
|
||||
101,
|
||||
110,
|
||||
116,
|
||||
45,
|
||||
118,
|
||||
101,
|
||||
99,
|
||||
116,
|
||||
111,
|
||||
114
|
||||
],
|
||||
"versionUnits": [],
|
||||
"contentHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"recordKey": "mesh:cdn:artifact:eureka-content-vector:unversioned:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"artifactIdUnits": [
|
||||
101,
|
||||
117,
|
||||
114,
|
||||
101,
|
||||
107,
|
||||
97,
|
||||
47,
|
||||
99,
|
||||
111,
|
||||
110,
|
||||
116,
|
||||
101,
|
||||
110,
|
||||
116,
|
||||
45,
|
||||
118,
|
||||
101,
|
||||
99,
|
||||
116,
|
||||
111,
|
||||
114
|
||||
],
|
||||
"versionUnits": [
|
||||
32,
|
||||
32,
|
||||
32
|
||||
],
|
||||
"contentHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"recordKey": "mesh:cdn:artifact:eureka-content-vector:unversioned:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"artifactIdUnits": [
|
||||
101,
|
||||
117,
|
||||
114,
|
||||
101,
|
||||
107,
|
||||
97,
|
||||
47,
|
||||
99,
|
||||
111,
|
||||
110,
|
||||
116,
|
||||
101,
|
||||
110,
|
||||
116,
|
||||
45,
|
||||
118,
|
||||
101,
|
||||
99,
|
||||
116,
|
||||
111,
|
||||
114
|
||||
],
|
||||
"versionUnits": [
|
||||
49,
|
||||
46,
|
||||
48,
|
||||
46,
|
||||
48
|
||||
],
|
||||
"contentHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"recordKey": "mesh:cdn:artifact:eureka-content-vector:1.0.0:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"artifactIdUnits": [
|
||||
101,
|
||||
117,
|
||||
114,
|
||||
101,
|
||||
107,
|
||||
97,
|
||||
47,
|
||||
99,
|
||||
111,
|
||||
110,
|
||||
116,
|
||||
101,
|
||||
110,
|
||||
116,
|
||||
45,
|
||||
118,
|
||||
101,
|
||||
99,
|
||||
116,
|
||||
111,
|
||||
114
|
||||
],
|
||||
"versionUnits": [
|
||||
86,
|
||||
50,
|
||||
32,
|
||||
66,
|
||||
101,
|
||||
116,
|
||||
97
|
||||
],
|
||||
"contentHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"recordKey": "mesh:cdn:artifact:eureka-content-vector:v2-beta:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"artifactIdUnits": [
|
||||
101,
|
||||
117,
|
||||
114,
|
||||
101,
|
||||
107,
|
||||
97,
|
||||
47,
|
||||
99,
|
||||
111,
|
||||
110,
|
||||
116,
|
||||
101,
|
||||
110,
|
||||
116,
|
||||
45,
|
||||
118,
|
||||
101,
|
||||
99,
|
||||
116,
|
||||
111,
|
||||
114
|
||||
],
|
||||
"versionUnits": [
|
||||
252
|
||||
],
|
||||
"contentHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"recordKey": "mesh:cdn:artifact:eureka-content-vector:\u00FC:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"artifactIdUnits": [
|
||||
101,
|
||||
117,
|
||||
114,
|
||||
101,
|
||||
107,
|
||||
97,
|
||||
47,
|
||||
99,
|
||||
111,
|
||||
110,
|
||||
116,
|
||||
101,
|
||||
110,
|
||||
116,
|
||||
45,
|
||||
118,
|
||||
101,
|
||||
99,
|
||||
116,
|
||||
111,
|
||||
114
|
||||
],
|
||||
"versionUnits": [],
|
||||
"contentHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"recordKey": "mesh:cdn:artifact:eureka-content-vector:unversioned:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"artifactIdUnits": [
|
||||
101,
|
||||
117,
|
||||
114,
|
||||
101,
|
||||
107,
|
||||
97,
|
||||
47,
|
||||
99,
|
||||
111,
|
||||
110,
|
||||
116,
|
||||
101,
|
||||
110,
|
||||
116,
|
||||
45,
|
||||
118,
|
||||
101,
|
||||
99,
|
||||
116,
|
||||
111,
|
||||
114
|
||||
],
|
||||
"versionUnits": [],
|
||||
"contentHash": "SHA256:E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
|
||||
"recordKey": "mesh:cdn:artifact:eureka-content-vector:unversioned:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"artifactIdUnits": [
|
||||
101,
|
||||
117,
|
||||
114,
|
||||
101,
|
||||
107,
|
||||
97,
|
||||
47,
|
||||
99,
|
||||
111,
|
||||
110,
|
||||
116,
|
||||
101,
|
||||
110,
|
||||
116,
|
||||
45,
|
||||
118,
|
||||
101,
|
||||
99,
|
||||
116,
|
||||
111,
|
||||
114
|
||||
],
|
||||
"versionUnits": [],
|
||||
"contentHash": " e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ",
|
||||
"recordKey": "mesh:cdn:artifact:eureka-content-vector:unversioned:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"artifactIdUnits": [
|
||||
101,
|
||||
117,
|
||||
114,
|
||||
101,
|
||||
107,
|
||||
97,
|
||||
47,
|
||||
99,
|
||||
111,
|
||||
110,
|
||||
116,
|
||||
101,
|
||||
110,
|
||||
116,
|
||||
45,
|
||||
118,
|
||||
101,
|
||||
99,
|
||||
116,
|
||||
111,
|
||||
114
|
||||
],
|
||||
"versionUnits": [],
|
||||
"contentHash": " sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"recordKey": "mesh:cdn:artifact:eureka-content-vector:unversioned:sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"artifactIdUnits": [
|
||||
101,
|
||||
117,
|
||||
114,
|
||||
101,
|
||||
107,
|
||||
97,
|
||||
47,
|
||||
99,
|
||||
111,
|
||||
110,
|
||||
116,
|
||||
101,
|
||||
110,
|
||||
116,
|
||||
45,
|
||||
118,
|
||||
101,
|
||||
99,
|
||||
116,
|
||||
111,
|
||||
114
|
||||
],
|
||||
"versionUnits": [],
|
||||
"contentHash": "sha256:sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"recordKey": "mesh:cdn:artifact:eureka-content-vector:unversioned:sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"artifactIdUnits": [
|
||||
101,
|
||||
117,
|
||||
114,
|
||||
101,
|
||||
107,
|
||||
97,
|
||||
47,
|
||||
99,
|
||||
111,
|
||||
110,
|
||||
116,
|
||||
101,
|
||||
110,
|
||||
116,
|
||||
45,
|
||||
118,
|
||||
101,
|
||||
99,
|
||||
116,
|
||||
111,
|
||||
114
|
||||
],
|
||||
"versionUnits": [],
|
||||
"contentHash": "Sha256:\u0130\u03A3\uD801\uDC00",
|
||||
"recordKey": "mesh:cdn:artifact:eureka-content-vector:unversioned:\u0130\u03C3\uD801\uDC28"
|
||||
},
|
||||
{
|
||||
"artifactIdUnits": [
|
||||
101,
|
||||
117,
|
||||
114,
|
||||
101,
|
||||
107,
|
||||
97,
|
||||
47,
|
||||
99,
|
||||
111,
|
||||
110,
|
||||
116,
|
||||
101,
|
||||
110,
|
||||
116,
|
||||
45,
|
||||
118,
|
||||
101,
|
||||
99,
|
||||
116,
|
||||
111,
|
||||
114
|
||||
],
|
||||
"versionUnits": [],
|
||||
"contentHash": "sha256:",
|
||||
"recordKey": "mesh:cdn:artifact:eureka-content-vector:unversioned:"
|
||||
},
|
||||
{
|
||||
"artifactIdUnits": [
|
||||
101,
|
||||
117,
|
||||
114,
|
||||
101,
|
||||
107,
|
||||
97,
|
||||
47,
|
||||
99,
|
||||
111,
|
||||
110,
|
||||
116,
|
||||
101,
|
||||
110,
|
||||
116,
|
||||
45,
|
||||
118,
|
||||
101,
|
||||
99,
|
||||
116,
|
||||
111,
|
||||
114
|
||||
],
|
||||
"versionUnits": [],
|
||||
"contentHash": "\u017Fha256:abc",
|
||||
"recordKey": "mesh:cdn:artifact:eureka-content-vector:unversioned:\u017Fha256:abc"
|
||||
}
|
||||
],
|
||||
"chunks": [
|
||||
{
|
||||
"chunkHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"recordKey": "mesh:cdn:chunk:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"chunkHash": "SHA256:E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
|
||||
"recordKey": "mesh:cdn:chunk:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"chunkHash": " e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ",
|
||||
"recordKey": "mesh:cdn:chunk:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"chunkHash": " sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"recordKey": "mesh:cdn:chunk:sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"chunkHash": "sha256:sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"recordKey": "mesh:cdn:chunk:sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"chunkHash": "Sha256:\u0130\u03A3\uD801\uDC00",
|
||||
"recordKey": "mesh:cdn:chunk:\u0130\u03C3\uD801\uDC28"
|
||||
},
|
||||
{
|
||||
"chunkHash": "sha256:",
|
||||
"recordKey": "mesh:cdn:chunk:"
|
||||
},
|
||||
{
|
||||
"chunkHash": "\u017Fha256:abc",
|
||||
"recordKey": "mesh:cdn:chunk:\u017Fha256:abc"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,120 +0,0 @@
|
|||
{
|
||||
"vectors": [
|
||||
{
|
||||
"label": "ts multi-chunk body",
|
||||
"kind": "manifest",
|
||||
"input": {
|
||||
"artifactId": "Pixel Heroes/Hüt ☃",
|
||||
"kind": "asset",
|
||||
"version": "",
|
||||
"mimeType": "application/x-cultnet-ts-vector",
|
||||
"createdAtUtc": "2026-10-19T00:00:00.0000000+00:00",
|
||||
"chunkSizeBytes": 131072,
|
||||
"tags": [
|
||||
"a",
|
||||
"b"
|
||||
],
|
||||
"metadata": [
|
||||
[
|
||||
"10",
|
||||
"x"
|
||||
],
|
||||
[
|
||||
"alpha",
|
||||
"y"
|
||||
],
|
||||
[
|
||||
"2",
|
||||
"z"
|
||||
]
|
||||
],
|
||||
"body": {
|
||||
"length": 777777,
|
||||
"multiplier": 7,
|
||||
"modulus": 256
|
||||
}
|
||||
},
|
||||
"recordKey": "mesh:cdn:artifact:pixel-heroes-hüt:unversioned:cf8a453a36981321977355448d24df49bcefee22be6e3730b9140e87c29013f9",
|
||||
"messagePackBase64": "mrVQaXhlbCBIZXJvZXMvSMO8dCDimIOlYXNzZXSg2UBjZjhhNDUzYTM2OTgxMzIxOTc3MzU1NDQ4ZDI0ZGY0OWJjZWZlZTIyYmU2ZTM3MzBiOTE0MGU4N2MyOTAxM2Y5zgAL3jG/YXBwbGljYXRpb24veC1jdWx0bmV0LXRzLXZlY3RvctkhMjAyNi0xMC0xOVQwMDowMDowMC4wMDAwMDAwKzAwOjAwlpXZQDM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDgAzgACAADZT21lc2g6Y2RuOmNodW5rOjM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDihMJXZQDM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDjOAAIAAM4AAgAA2U9tZXNoOmNkbjpjaHVuazozNzdjN2IwY2MyMjlmNDcwNTY3MGQ2M2Q1NDRjMjc1YjY2M2RmNmU4YzgzMWIwOTg0NmE1YzQ4Y2NhMDhiODQ4pjEzMTA3MpXZQDM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDjOAAQAAM4AAgAA2U9tZXNoOmNkbjpjaHVuazozNzdjN2IwY2MyMjlmNDcwNTY3MGQ2M2Q1NDRjMjc1YjY2M2RmNmU4YzgzMWIwOTg0NmE1YzQ4Y2NhMDhiODQ4pjI2MjE0NJXZQDM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDjOAAYAAM4AAgAA2U9tZXNoOmNkbjpjaHVuazozNzdjN2IwY2MyMjlmNDcwNTY3MGQ2M2Q1NDRjMjc1YjY2M2RmNmU4YzgzMWIwOTg0NmE1YzQ4Y2NhMDhiODQ4pjM5MzIxNpXZQDM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDjOAAgAAM4AAgAA2U9tZXNoOmNkbjpjaHVuazozNzdjN2IwY2MyMjlmNDcwNTY3MGQ2M2Q1NDRjMjc1YjY2M2RmNmU4YzgzMWIwOTg0NmE1YzQ4Y2NhMDhiODQ4pjUyNDI4OJXZQGEwYmI2OWY0NDg2MWViM2RiNTYwMjZhMWFiYmY5MWZmYTYyMTMyNTA4ZDVhMDZlMGQwZGRjNWNjMjZiZTMzZmPOAAoAAM4AAd4x2U9tZXNoOmNkbjpjaHVuazphMGJiNjlmNDQ4NjFlYjNkYjU2MDI2YTFhYmJmOTFmZmE2MjEzMjUwOGQ1YTA2ZTBkMGRkYzVjYzI2YmUzM2ZjpjY1NTM2MJKhYaFig6IxMKF4pWFscGhhoXmhMqF6"
|
||||
},
|
||||
{
|
||||
"label": "ts empty body",
|
||||
"kind": "manifest",
|
||||
"input": {
|
||||
"artifactId": "Pixel Heroes/Hüt ☃",
|
||||
"kind": "asset",
|
||||
"version": "",
|
||||
"mimeType": "application/x-cultnet-ts-vector",
|
||||
"createdAtUtc": "2026-10-19T00:00:00.0000000+00:00",
|
||||
"chunkSizeBytes": 131072,
|
||||
"tags": [
|
||||
"a",
|
||||
"b"
|
||||
],
|
||||
"metadata": [
|
||||
[
|
||||
"10",
|
||||
"x"
|
||||
],
|
||||
[
|
||||
"alpha",
|
||||
"y"
|
||||
],
|
||||
[
|
||||
"2",
|
||||
"z"
|
||||
]
|
||||
],
|
||||
"body": {
|
||||
"length": 0,
|
||||
"multiplier": 7,
|
||||
"modulus": 256
|
||||
}
|
||||
},
|
||||
"recordKey": "mesh:cdn:artifact:pixel-heroes-hüt:unversioned:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"messagePackBase64": "mrVQaXhlbCBIZXJvZXMvSMO8dCDimIOlYXNzZXSg2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AL9hcHBsaWNhdGlvbi94LWN1bHRuZXQtdHMtdmVjdG9y2SEyMDI2LTEwLTE5VDAwOjAwOjAwLjAwMDAwMDArMDA6MDCRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAA2U9tZXNoOmNkbjpjaHVuazplM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1oTCSoWGhYoOiMTCheKVhbHBoYaF5oTKheg=="
|
||||
},
|
||||
{
|
||||
"label": "ts single chunk, versioned",
|
||||
"kind": "manifest",
|
||||
"input": {
|
||||
"artifactId": "Pixel Heroes/Hüt ☃",
|
||||
"kind": "asset",
|
||||
"version": "V1 Beta",
|
||||
"mimeType": "application/x-cultnet-ts-vector",
|
||||
"createdAtUtc": "2026-10-19T00:00:00.0000000+00:00",
|
||||
"chunkSizeBytes": 4194304,
|
||||
"tags": [],
|
||||
"metadata": [],
|
||||
"body": {
|
||||
"length": 1000,
|
||||
"multiplier": 7,
|
||||
"modulus": 256
|
||||
}
|
||||
},
|
||||
"recordKey": "mesh:cdn:artifact:pixel-heroes-hüt:v1-beta:89f4ff56a25dd1db06a4ce6033603775d705fb96f30f8693733fef602a1ca532",
|
||||
"messagePackBase64": "mrVQaXhlbCBIZXJvZXMvSMO8dCDimIOlYXNzZXSnVjEgQmV0YdlAODlmNGZmNTZhMjVkZDFkYjA2YTRjZTYwMzM2MDM3NzVkNzA1ZmI5NmYzMGY4NjkzNzMzZmVmNjAyYTFjYTUzMs0D6L9hcHBsaWNhdGlvbi94LWN1bHRuZXQtdHMtdmVjdG9y2SEyMDI2LTEwLTE5VDAwOjAwOjAwLjAwMDAwMDArMDA6MDCRldlAODlmNGZmNTZhMjVkZDFkYjA2YTRjZTYwMzM2MDM3NzVkNzA1ZmI5NmYzMGY4NjkzNzMzZmVmNjAyYTFjYTUzMgDNA+jZT21lc2g6Y2RuOmNodW5rOjg5ZjRmZjU2YTI1ZGQxZGIwNmE0Y2U2MDMzNjAzNzc1ZDcwNWZiOTZmMzBmODY5MzczM2ZlZjYwMmExY2E1MzKhMJCA"
|
||||
},
|
||||
{
|
||||
"label": "ts whitespace version, padded kind and mime",
|
||||
"kind": "manifest",
|
||||
"input": {
|
||||
"artifactId": "Pixel Heroes/Hüt ☃",
|
||||
"kind": " build ",
|
||||
"version": " ",
|
||||
"mimeType": " text/plain; charset=utf-8 ",
|
||||
"createdAtUtc": "2026-10-19T00:00:00.0000000+00:00",
|
||||
"chunkSizeBytes": 4194304,
|
||||
"tags": [],
|
||||
"metadata": [],
|
||||
"body": {
|
||||
"length": 1000,
|
||||
"multiplier": 7,
|
||||
"modulus": 256
|
||||
}
|
||||
},
|
||||
"recordKey": "mesh:cdn:artifact:pixel-heroes-hüt:unversioned:89f4ff56a25dd1db06a4ce6033603775d705fb96f30f8693733fef602a1ca532",
|
||||
"messagePackBase64": "mrVQaXhlbCBIZXJvZXMvSMO8dCDimIOnIGJ1aWxkIKIgINlAODlmNGZmNTZhMjVkZDFkYjA2YTRjZTYwMzM2MDM3NzVkNzA1ZmI5NmYzMGY4NjkzNzMzZmVmNjAyYTFjYTUzMs0D6L0gIHRleHQvcGxhaW47IGNoYXJzZXQ9dXRmLTggINkhMjAyNi0xMC0xOVQwMDowMDowMC4wMDAwMDAwKzAwOjAwkZXZQDg5ZjRmZjU2YTI1ZGQxZGIwNmE0Y2U2MDMzNjAzNzc1ZDcwNWZiOTZmMzBmODY5MzczM2ZlZjYwMmExY2E1MzIAzQPo2U9tZXNoOmNkbjpjaHVuazo4OWY0ZmY1NmEyNWRkMWRiMDZhNGNlNjAzMzYwMzc3NWQ3MDVmYjk2ZjMwZjg2OTM3MzNmZWY2MDJhMWNhNTMyoTCQgA=="
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,177 +0,0 @@
|
|||
{
|
||||
"frames": [
|
||||
{
|
||||
"label": "reliable-ordered, small payload",
|
||||
"fields": {
|
||||
"channelId": "aetheria.zone-1",
|
||||
"schemaId": "cultmesh.realtime.v1",
|
||||
"bodyId": "body-7",
|
||||
"producerEpoch": 1,
|
||||
"sequence": 2,
|
||||
"delivery": "reliable-ordered",
|
||||
"payload": "AH+A/yo="
|
||||
},
|
||||
"encoding": "Q1NUMQABAAAAAAAAAAIAAAAAAAAADwAUAAYABQAAACUAAAABAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03AH+A/yo=",
|
||||
"encodedLength": 83,
|
||||
"sha256OfEncoding": "d04539b99790560e5e570208d92d3ac25f85909a36bfeb190c2080288cc9f1d4"
|
||||
},
|
||||
{
|
||||
"label": "latest-only, small payload",
|
||||
"fields": {
|
||||
"channelId": "aetheria.zone-1",
|
||||
"schemaId": "cultmesh.realtime.v1",
|
||||
"bodyId": "body-7",
|
||||
"producerEpoch": 3,
|
||||
"sequence": 4,
|
||||
"delivery": "latest-only",
|
||||
"payload": "AQID"
|
||||
},
|
||||
"encoding": "Q1NUMQEDAAAAAAAAAAQAAAAAAAAADwAUAAYAAwAAACUAAAABAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03AQID",
|
||||
"encodedLength": 81,
|
||||
"sha256OfEncoding": "2c875718cf4968979f3cd4083ba2b279e8c03815ad3447c66037ee4530b2814a"
|
||||
},
|
||||
{
|
||||
"label": "unreliable, small payload",
|
||||
"fields": {
|
||||
"channelId": "aetheria.zone-1",
|
||||
"schemaId": "cultmesh.realtime.v1",
|
||||
"bodyId": "body-7",
|
||||
"producerEpoch": 5,
|
||||
"sequence": 6,
|
||||
"delivery": "unreliable",
|
||||
"payload": "/g=="
|
||||
},
|
||||
"encoding": "Q1NUMQIFAAAAAAAAAAYAAAAAAAAADwAUAAYAAQAAACUAAAABAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03/g==",
|
||||
"encodedLength": 79,
|
||||
"sha256OfEncoding": "b932adf333a0ca73d3a9077e15cf9062070a3084a50a3a30c3baa53312d864b1"
|
||||
},
|
||||
{
|
||||
"label": "empty payload",
|
||||
"fields": {
|
||||
"channelId": "aetheria.zone-1",
|
||||
"schemaId": "cultmesh.realtime.v1",
|
||||
"bodyId": "body-7",
|
||||
"producerEpoch": 0,
|
||||
"sequence": 0,
|
||||
"delivery": "reliable-ordered",
|
||||
"payload": ""
|
||||
},
|
||||
"encoding": "Q1NUMQAAAAAAAAAAAAAAAAAAAAAADwAUAAYAAAAAACUAAAABAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03",
|
||||
"encodedLength": 78,
|
||||
"sha256OfEncoding": "06c0362f2b395bf7855522190f6a59c45c46947ce7a9a4b8be115cf54a48f419"
|
||||
},
|
||||
{
|
||||
"label": "multi-byte identities",
|
||||
"fields": {
|
||||
"channelId": "aetheria.zöne-∞",
|
||||
"schemaId": "cultmesh.realtime.€1",
|
||||
"bodyId": "bødy-\uD83D\uDD25",
|
||||
"producerEpoch": 11,
|
||||
"sequence": 12,
|
||||
"delivery": "latest-only",
|
||||
"payload": "cGF5bG9hZC3iiJ4="
|
||||
},
|
||||
"encoding": "Q1NUMQELAAAAAAAAAAwAAAAAAAAAEgAWAAoACwAAACUAAAABAGFldGhlcmlhLnrDtm5lLeKInmN1bHRtZXNoLnJlYWx0aW1lLuKCrDFiw7hkeS3wn5SlcGF5bG9hZC3iiJ4=",
|
||||
"encodedLength": 98,
|
||||
"sha256OfEncoding": "90c6ae8f1362d9e000aa57682612bb1d7606cabe2cd0c0969fdd07b816f44b1c"
|
||||
},
|
||||
{
|
||||
"label": "epoch and sequence at long.MaxValue",
|
||||
"fields": {
|
||||
"channelId": "aetheria.zone-1",
|
||||
"schemaId": "cultmesh.realtime.v1",
|
||||
"bodyId": "body-7",
|
||||
"producerEpoch": 9223372036854775807,
|
||||
"sequence": 9223372036854775807,
|
||||
"delivery": "unreliable",
|
||||
"payload": "Wg=="
|
||||
},
|
||||
"encoding": "Q1NUMQL/////////f/////////9/DwAUAAYAAQAAACUAAAABAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03Wg==",
|
||||
"encodedLength": 79,
|
||||
"sha256OfEncoding": "208068a668b48dc1abde9b3d7f025e7f7b6de6c480faea25c1383b6de9fd37ca"
|
||||
},
|
||||
{
|
||||
"label": "identities at exactly 65535 UTF-8 bytes",
|
||||
"fields": {
|
||||
"channelId": {
|
||||
"repeat": "ä€",
|
||||
"byteLength": 65535
|
||||
},
|
||||
"schemaId": {
|
||||
"repeat": "ۊ",
|
||||
"byteLength": 65535
|
||||
},
|
||||
"bodyId": {
|
||||
"repeat": "ñ€",
|
||||
"byteLength": 65535
|
||||
},
|
||||
"producerEpoch": 13,
|
||||
"sequence": 14,
|
||||
"delivery": "reliable-ordered",
|
||||
"payload": "ESI="
|
||||
},
|
||||
"encoding": null,
|
||||
"encodedLength": 196644,
|
||||
"sha256OfEncoding": "e3c1a353b606e86b35169efb875e76349dcfcb2d036973ee3bbe3c601fbda3e1"
|
||||
},
|
||||
{
|
||||
"label": "64 MiB payload",
|
||||
"fields": {
|
||||
"channelId": "aetheria.zone-1",
|
||||
"schemaId": "cultmesh.realtime.v1",
|
||||
"bodyId": "body-7",
|
||||
"producerEpoch": 15,
|
||||
"sequence": 16,
|
||||
"delivery": "reliable-ordered",
|
||||
"payload": {
|
||||
"ramp": 67108864
|
||||
}
|
||||
},
|
||||
"encoding": null,
|
||||
"encodedLength": 67108942,
|
||||
"sha256OfEncoding": "4d99183bdb450af854d87a81ba86a3a4c92ecc1b208675d916f1b75a9f1e7928"
|
||||
}
|
||||
],
|
||||
"malformed": [
|
||||
{
|
||||
"label": "truncated header",
|
||||
"encoding": "Q1NUMQABAAAAAAAAAAIAAAAAAAAADwAUAAYAAwAAACUAAAAB",
|
||||
"message": "Realtime frame header is truncated."
|
||||
},
|
||||
{
|
||||
"label": "wrong magic",
|
||||
"encoding": "vFNUMQABAAAAAAAAAAIAAAAAAAAADwAUAAYAAwAAACUAAAABAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03CQkJ",
|
||||
"message": "Realtime frame magic is invalid."
|
||||
},
|
||||
{
|
||||
"label": "wire version 2",
|
||||
"encoding": "Q1NUMQABAAAAAAAAAAIAAAAAAAAADwAUAAYAAwAAACUAAAACAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03CQkJ",
|
||||
"message": "Realtime frame wire version is unsupported."
|
||||
},
|
||||
{
|
||||
"label": "header size 36",
|
||||
"encoding": "Q1NUMQABAAAAAAAAAAIAAAAAAAAADwAUAAYAAwAAACQAAAABAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03CQkJ",
|
||||
"message": "Realtime frame wire version is unsupported."
|
||||
},
|
||||
{
|
||||
"label": "delivery 3",
|
||||
"encoding": "Q1NUMQMBAAAAAAAAAAIAAAAAAAAADwAUAAYAAwAAACUAAAABAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03CQkJ",
|
||||
"message": "Realtime frame delivery mode is invalid."
|
||||
},
|
||||
{
|
||||
"label": "delivery 255",
|
||||
"encoding": "Q1NUMf8BAAAAAAAAAAIAAAAAAAAADwAUAAYAAwAAACUAAAABAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03CQkJ",
|
||||
"message": "Realtime frame delivery mode is invalid."
|
||||
},
|
||||
{
|
||||
"label": "payload length -1",
|
||||
"encoding": "Q1NUMQABAAAAAAAAAAIAAAAAAAAADwAUAAYAAwAAgCUAAAABAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03CQkJ",
|
||||
"message": "Realtime frame length is invalid."
|
||||
},
|
||||
{
|
||||
"label": "length sum off by one",
|
||||
"encoding": "Q1NUMQABAAAAAAAAAAIAAAAAAAAADwAUAAYAAwAAACUAAAABAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03CQkJAA==",
|
||||
"message": "Realtime frame length is invalid."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,177 +0,0 @@
|
|||
{
|
||||
"frames": [
|
||||
{
|
||||
"label": "ts reliable-ordered, small payload",
|
||||
"fields": {
|
||||
"channelId": "ts.zone-a",
|
||||
"schemaId": "cultmesh.realtime.v1",
|
||||
"bodyId": "ts-body-1",
|
||||
"producerEpoch": 21,
|
||||
"sequence": 22,
|
||||
"delivery": "reliable-ordered",
|
||||
"payload": "AAH+/w=="
|
||||
},
|
||||
"encoding": "Q1NUMQAVAAAAAAAAABYAAAAAAAAACQAUAAkABAAAACUAAAABAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0xAAH+/w==",
|
||||
"encodedLength": 79,
|
||||
"sha256OfEncoding": "68c264a42ce34e540e9dbee31dab5b73dec98c767f0b44591ce6a477cd17fc04"
|
||||
},
|
||||
{
|
||||
"label": "ts latest-only, small payload",
|
||||
"fields": {
|
||||
"channelId": "ts.zone-a",
|
||||
"schemaId": "cultmesh.realtime.v1",
|
||||
"bodyId": "ts-body-1",
|
||||
"producerEpoch": 23,
|
||||
"sequence": 24,
|
||||
"delivery": "latest-only",
|
||||
"payload": "f4A="
|
||||
},
|
||||
"encoding": "Q1NUMQEXAAAAAAAAABgAAAAAAAAACQAUAAkAAgAAACUAAAABAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0xf4A=",
|
||||
"encodedLength": 77,
|
||||
"sha256OfEncoding": "3d4f49f4aa2c1f132c3a09ee83555c13e0bc036d636524a972e2829cf417b959"
|
||||
},
|
||||
{
|
||||
"label": "ts unreliable, small payload",
|
||||
"fields": {
|
||||
"channelId": "ts.zone-a",
|
||||
"schemaId": "cultmesh.realtime.v1",
|
||||
"bodyId": "ts-body-1",
|
||||
"producerEpoch": 25,
|
||||
"sequence": 26,
|
||||
"delivery": "unreliable",
|
||||
"payload": "pQ=="
|
||||
},
|
||||
"encoding": "Q1NUMQIZAAAAAAAAABoAAAAAAAAACQAUAAkAAQAAACUAAAABAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0xpQ==",
|
||||
"encodedLength": 76,
|
||||
"sha256OfEncoding": "2c2faec604850959d44ee65d87d2ea59462b667c5d0db8565a8c159d75301851"
|
||||
},
|
||||
{
|
||||
"label": "ts empty payload",
|
||||
"fields": {
|
||||
"channelId": "ts.zone-a",
|
||||
"schemaId": "cultmesh.realtime.v1",
|
||||
"bodyId": "ts-body-1",
|
||||
"producerEpoch": 0,
|
||||
"sequence": 0,
|
||||
"delivery": "latest-only",
|
||||
"payload": ""
|
||||
},
|
||||
"encoding": "Q1NUMQEAAAAAAAAAAAAAAAAAAAAACQAUAAkAAAAAACUAAAABAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0x",
|
||||
"encodedLength": 75,
|
||||
"sha256OfEncoding": "6d2a04b399f32db4086e728194249b0279330ffa5517cdd82228f691a0f10708"
|
||||
},
|
||||
{
|
||||
"label": "ts multi-byte identities",
|
||||
"fields": {
|
||||
"channelId": "ts.zøne-∂",
|
||||
"schemaId": "cultmesh.realtime.µ1",
|
||||
"bodyId": "ts-bödy-🧊",
|
||||
"producerEpoch": 27,
|
||||
"sequence": 28,
|
||||
"delivery": "unreliable",
|
||||
"payload": "dHMtcGF5bG9hZC3iiII="
|
||||
},
|
||||
"encoding": "Q1NUMQIbAAAAAAAAABwAAAAAAAAADAAVAA0ADgAAACUAAAABAHRzLnrDuG5lLeKIgmN1bHRtZXNoLnJlYWx0aW1lLsK1MXRzLWLDtmR5LfCfp4p0cy1wYXlsb2FkLeKIgg==",
|
||||
"encodedLength": 97,
|
||||
"sha256OfEncoding": "d5d22458bd50a446c7bdce4e2598fe8e5e7bfac3646d8fcd7215363c7c5c8ca1"
|
||||
},
|
||||
{
|
||||
"label": "ts epoch and sequence at long.MaxValue",
|
||||
"fields": {
|
||||
"channelId": "ts.zone-a",
|
||||
"schemaId": "cultmesh.realtime.v1",
|
||||
"bodyId": "ts-body-1",
|
||||
"producerEpoch": 9223372036854775807,
|
||||
"sequence": 9223372036854775807,
|
||||
"delivery": "reliable-ordered",
|
||||
"payload": "Bw=="
|
||||
},
|
||||
"encoding": "Q1NUMQD/////////f/////////9/CQAUAAkAAQAAACUAAAABAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0xBw==",
|
||||
"encodedLength": 76,
|
||||
"sha256OfEncoding": "2abb67a876dc4ed006f33708b4c4dda55a90a6f95da7352749225195b10d45ba"
|
||||
},
|
||||
{
|
||||
"label": "ts identities at exactly 65535 UTF-8 bytes",
|
||||
"fields": {
|
||||
"channelId": {
|
||||
"repeat": "ü☃",
|
||||
"byteLength": 65535
|
||||
},
|
||||
"schemaId": {
|
||||
"repeat": "☃ü",
|
||||
"byteLength": 65535
|
||||
},
|
||||
"bodyId": {
|
||||
"repeat": "ç☃",
|
||||
"byteLength": 65535
|
||||
},
|
||||
"producerEpoch": 29,
|
||||
"sequence": 30,
|
||||
"delivery": "latest-only",
|
||||
"payload": "M0Q="
|
||||
},
|
||||
"encoding": null,
|
||||
"encodedLength": 196644,
|
||||
"sha256OfEncoding": "8bd4cd4c5232339f76b8e394eb93b66e8d14302398ecce2ae5bdb78070ef783a"
|
||||
},
|
||||
{
|
||||
"label": "ts 64 MiB payload",
|
||||
"fields": {
|
||||
"channelId": "ts.zone-a",
|
||||
"schemaId": "cultmesh.realtime.v1",
|
||||
"bodyId": "ts-body-1",
|
||||
"producerEpoch": 31,
|
||||
"sequence": 32,
|
||||
"delivery": "unreliable",
|
||||
"payload": {
|
||||
"ramp": 67108864
|
||||
}
|
||||
},
|
||||
"encoding": null,
|
||||
"encodedLength": 67108939,
|
||||
"sha256OfEncoding": "f2c20e0b47a7681fbaeff6fcba024363542805de4cd95eeb7fa9f125ba646af0"
|
||||
}
|
||||
],
|
||||
"malformed": [
|
||||
{
|
||||
"label": "ts truncated header",
|
||||
"encoding": "Q1NUMQAVAAAAAAAAABYAAAAAAAAACQAUAAkABAAAACUAAAAB",
|
||||
"message": "Realtime frame header is truncated."
|
||||
},
|
||||
{
|
||||
"label": "ts wrong magic",
|
||||
"encoding": "Q1OrMQAVAAAAAAAAABYAAAAAAAAACQAUAAkABAAAACUAAAABAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0xAAH+/w==",
|
||||
"message": "Realtime frame magic is invalid."
|
||||
},
|
||||
{
|
||||
"label": "ts wire version 2",
|
||||
"encoding": "Q1NUMQAVAAAAAAAAABYAAAAAAAAACQAUAAkABAAAACUAAAACAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0xAAH+/w==",
|
||||
"message": "Realtime frame wire version is unsupported."
|
||||
},
|
||||
{
|
||||
"label": "ts header size 38",
|
||||
"encoding": "Q1NUMQAVAAAAAAAAABYAAAAAAAAACQAUAAkABAAAACYAAAABAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0xAAH+/w==",
|
||||
"message": "Realtime frame wire version is unsupported."
|
||||
},
|
||||
{
|
||||
"label": "ts delivery 3",
|
||||
"encoding": "Q1NUMQMVAAAAAAAAABYAAAAAAAAACQAUAAkABAAAACUAAAABAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0xAAH+/w==",
|
||||
"message": "Realtime frame delivery mode is invalid."
|
||||
},
|
||||
{
|
||||
"label": "ts delivery 128",
|
||||
"encoding": "Q1NUMYAVAAAAAAAAABYAAAAAAAAACQAUAAkABAAAACUAAAABAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0xAAH+/w==",
|
||||
"message": "Realtime frame delivery mode is invalid."
|
||||
},
|
||||
{
|
||||
"label": "ts payload length -1",
|
||||
"encoding": "Q1NUMQAVAAAAAAAAABYAAAAAAAAACQAUAAkABAAAgCUAAAABAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0xAAH+/w==",
|
||||
"message": "Realtime frame length is invalid."
|
||||
},
|
||||
{
|
||||
"label": "ts length sum off by one",
|
||||
"encoding": "Q1NUMQAVAAAAAAAAABYAAAAAAAAACQAUAAkABAAAACUAAAABAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0xAAH+/wA=",
|
||||
"message": "Realtime frame length is invalid."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.database-subscribe.v1.schema.json",
|
||||
"title": "CultNet Database Subscribe Message v1",
|
||||
"type": "object",
|
||||
"required": ["schemaVersion", "messageId", "subscriptionId", "selection"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.database_subscribe.v1" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"subscriptionId": { "type": "string", "minLength": 1 },
|
||||
"selection": { "$ref": "cultnet.selection.schema.json" },
|
||||
"includeSnapshot": { "type": "boolean", "default": true },
|
||||
"consumerRuntimeId": { "type": "string", "minLength": 1 },
|
||||
"bodyIds": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"supportedBodyTransports": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.error.schema.json",
|
||||
"title": "CultNet Error Message",
|
||||
"description": "R-X (docs/cultnet-selection-cut.md, fix batch 4): CultNetErrorMessage is a MessagePack-CSharp map-mode object, so every declared key is written on the wire, nil included - routingHint, code and details are `required` here the same as cultnet.raw-document-header.schema.json's nullable fields, not optional just because a given refusal leaves them null.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"error",
|
||||
"routingHint",
|
||||
"code",
|
||||
"details"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.error.v0" },
|
||||
"error": { "type": "string", "minLength": 1 },
|
||||
"routingHint": {
|
||||
"description": "Optional shard routing detail for an authority failure. Null for every selection/cursor refusal code (R-N).",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"required": ["shard", "reason"],
|
||||
"properties": {
|
||||
"shard": { "type": ["object", "null"] },
|
||||
"reason": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"code": {
|
||||
"description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it. The set is a closed enum: Rust and TypeScript refuse a message whose code is not listed, so adding a code is one coordinated change across every runtime (this schema, the C# factory, the Rust CultNetErrorCode, and a captured-bytes case for each).",
|
||||
"type": ["string", "null"],
|
||||
"enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported", "unowned_schema", "write_not_authorized"]
|
||||
},
|
||||
"details": {
|
||||
"description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"required": ["field", "value", "asOf", "current"],
|
||||
"properties": {
|
||||
"field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended. unowned_schema: \"schemaId\". write_not_authorized: \"recordKey\".", "type": ["string", "null"] },
|
||||
"value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended. unowned_schema: the schema id no shard owns. write_not_authorized: the key of the refused write.", "type": ["string", "null"] },
|
||||
"asOf": { "description": "cursor_stale: the cursor's own minted asOf.", "type": ["integer", "null"], "minimum": 0 },
|
||||
"current": { "description": "cursor_stale: the answering server's current asOf.", "type": ["integer", "null"], "minimum": 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.raw-document-header.schema.json",
|
||||
"title": "CultNet Raw Document Header",
|
||||
"description": "R-S (docs/cultnet-selection-cut.md, fix batch 3): CultNetRawDocumentRecord minus payload/payloadEncoding - CultNetRawDocumentHeader has no such members at all, so a header must not $ref the record schema (which requires them). Matches MessagePack-CSharp's map-mode output: every declared key is written, nil included, so every property here is `required` even where its value may be null.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaId",
|
||||
"schemaName",
|
||||
"schemaVersion",
|
||||
"schemaContentHash",
|
||||
"recordKey",
|
||||
"storedAt",
|
||||
"sourceRuntimeId",
|
||||
"sourceAgentId",
|
||||
"sourceRole",
|
||||
"tags"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaId": { "type": "string", "minLength": 1 },
|
||||
"schemaName": { "type": ["string", "null"], "minLength": 1 },
|
||||
"schemaVersion": { "type": ["string", "null"], "minLength": 1 },
|
||||
"schemaContentHash": { "type": ["string", "null"], "minLength": 1 },
|
||||
"recordKey": { "type": "string", "minLength": 1 },
|
||||
"storedAt": { "type": "string", "minLength": 1 },
|
||||
"sourceRuntimeId": { "type": ["string", "null"], "minLength": 1 },
|
||||
"sourceAgentId": { "type": ["string", "null"], "minLength": 1 },
|
||||
"sourceRole": { "type": ["string", "null"], "minLength": 1 },
|
||||
"tags": {
|
||||
"type": ["array", "null"],
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.raw-document-record.schema.json",
|
||||
"title": "CultNet Raw Document Record",
|
||||
"description": "R-S (docs/cultnet-selection-cut.md, fix batch 3): matches MessagePack-CSharp's map-mode output for CultNetRawDocumentRecord - every declared key is written, nil included, so every property here is `required` (present on the wire) even where its value may be null.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaId",
|
||||
"schemaName",
|
||||
"schemaVersion",
|
||||
"schemaContentHash",
|
||||
"recordKey",
|
||||
"storedAt",
|
||||
"payloadEncoding",
|
||||
"payload",
|
||||
"sourceRuntimeId",
|
||||
"sourceAgentId",
|
||||
"sourceRole",
|
||||
"tags"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaId": { "type": "string", "minLength": 1 },
|
||||
"schemaName": { "type": ["string", "null"], "minLength": 1 },
|
||||
"schemaVersion": { "type": ["string", "null"], "minLength": 1 },
|
||||
"schemaContentHash": { "type": ["string", "null"], "minLength": 1 },
|
||||
"recordKey": { "type": "string", "minLength": 1 },
|
||||
"storedAt": { "type": "string", "minLength": 1 },
|
||||
"payloadEncoding": { "const": "messagepack" },
|
||||
"payload": {
|
||||
"type": "string",
|
||||
"contentEncoding": "base64",
|
||||
"x-cult-runtime-type": "bytes"
|
||||
},
|
||||
"sourceRuntimeId": { "type": ["string", "null"], "minLength": 1 },
|
||||
"sourceAgentId": { "type": ["string", "null"], "minLength": 1 },
|
||||
"sourceRole": { "type": ["string", "null"], "minLength": 1 },
|
||||
"tags": {
|
||||
"type": ["array", "null"],
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.selection.schema.json",
|
||||
"title": "CultNet Typed Selection",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemas": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 },
|
||||
"minItems": 1,
|
||||
"description": "Absent means every schema. Present and empty is refused at the door (selection_invalid), never treated as \"every schema\": omit the field instead."
|
||||
},
|
||||
"keys": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 },
|
||||
"minItems": 1,
|
||||
"description": "Absent means every key. Present and empty is refused at the door (selection_invalid), never treated as \"every key\": omit the field instead."
|
||||
},
|
||||
"fields": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/fieldPredicate" }
|
||||
},
|
||||
"cites": { "$ref": "#/$defs/citation" },
|
||||
"cited": { "$ref": "#/$defs/incoming" },
|
||||
"projection": {
|
||||
"type": "string",
|
||||
"enum": ["header", "document"],
|
||||
"default": "header"
|
||||
},
|
||||
"descending": { "type": "boolean", "default": false },
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 200,
|
||||
"description": "Clamped to 1..=200 by the answering server, not refused outside that range: an absent value or one outside the bound is treated as the nearer bound (absent = 200)."
|
||||
},
|
||||
"cursor": { "type": "string" }
|
||||
},
|
||||
"$defs": {
|
||||
"recordRef": {
|
||||
"type": "object",
|
||||
"required": ["schemaId", "recordKey"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaId": { "type": "string", "minLength": 1 },
|
||||
"recordKey": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
},
|
||||
"fieldPredicate": {
|
||||
"type": "object",
|
||||
"required": ["index", "op"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"index": { "type": "string", "minLength": 1 },
|
||||
"op": { "type": "string", "enum": ["any_of", "lt", "le", "ge", "gt"] },
|
||||
"values": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"minItems": 1
|
||||
},
|
||||
"number": {
|
||||
"type": "string",
|
||||
"description": "Q-J: a canonical decimal string, never a JSON number. Matches ^-?(0|[1-9][0-9]*)(\\.[0-9]*[1-9])?$ and is not \"-0\"; any other spelling is refused typed at the door (selection_invalid), never normalised.",
|
||||
"pattern": "^(?!-0$)-?(0|[1-9][0-9]*)(\\.[0-9]*[1-9])?$"
|
||||
}
|
||||
},
|
||||
"oneOf": [
|
||||
{
|
||||
"required": ["values"],
|
||||
"not": { "required": ["number"] },
|
||||
"properties": { "op": { "const": "any_of" } }
|
||||
},
|
||||
{
|
||||
"required": ["number"],
|
||||
"not": { "required": ["values"] },
|
||||
"properties": { "op": { "enum": ["lt", "le", "ge", "gt"] } }
|
||||
}
|
||||
]
|
||||
},
|
||||
"citation": {
|
||||
"type": "object",
|
||||
"required": ["target"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"target": { "$ref": "#/$defs/recordRef" },
|
||||
"role": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
},
|
||||
"incoming": {
|
||||
"type": "object",
|
||||
"required": ["role", "exists"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"role": { "type": "string", "minLength": 1 },
|
||||
"exists": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.snapshot-request.v1.schema.json",
|
||||
"title": "CultNet Snapshot Request Message v1",
|
||||
"type": "object",
|
||||
"required": ["schemaVersion", "messageId", "selection"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.snapshot_request.v1" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"selection": { "$ref": "cultnet.selection.schema.json" },
|
||||
"shardId": { "type": "string", "minLength": 1 },
|
||||
"shardEpoch": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.snapshot-response-raw.v1.schema.json",
|
||||
"title": "CultNet Snapshot Response Raw Message v1",
|
||||
"description": "R-S (docs/cultnet-selection-cut.md, fix batch 3): matches MessagePack-CSharp's map-mode output for CultNetSnapshotResponseRawV1Message - every declared key is written, nil included, so every property is `required` (present on the wire) even where its value may be null. `headers`/`documents` are still exactly one populated by projection (C5), but the OTHER key is present too, as an explicit null, not absent - the prior `oneOf: [{required:[headers]},{required:[documents]}]` matched both branches on every real page (both keys always present) and so never validated at all.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion", "messageId", "matched", "asOf", "next",
|
||||
"headers", "documents", "edges", "shardId", "shardEpoch", "shardLogSequence"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"oneOf": [
|
||||
{
|
||||
"required": ["headers", "documents"],
|
||||
"properties": { "headers": { "type": "array" }, "documents": { "type": "null" } }
|
||||
},
|
||||
{
|
||||
"required": ["headers", "documents"],
|
||||
"properties": { "headers": { "type": "null" }, "documents": { "type": "array" } }
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.snapshot_response_raw.v1" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"matched": { "type": "integer", "minimum": 0 },
|
||||
"asOf": { "type": "integer", "minimum": 0 },
|
||||
"next": { "type": ["string", "null"] },
|
||||
"headers": {
|
||||
"type": ["array", "null"],
|
||||
"items": { "$ref": "cultnet.raw-document-header.schema.json" }
|
||||
},
|
||||
"documents": {
|
||||
"type": ["array", "null"],
|
||||
"items": { "$ref": "cultnet.raw-document-record.schema.json" }
|
||||
},
|
||||
"edges": {
|
||||
"type": ["array", "null"],
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["from", "role", "to", "payloadEncoding", "payload"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"from": { "$ref": "cultnet.selection.schema.json#/$defs/recordRef" },
|
||||
"role": { "type": "string", "minLength": 1 },
|
||||
"to": { "$ref": "cultnet.selection.schema.json#/$defs/recordRef" },
|
||||
"payloadEncoding": { "type": ["string", "null"] },
|
||||
"payload": {
|
||||
"type": ["string", "null"],
|
||||
"contentEncoding": "base64",
|
||||
"x-cult-runtime-type": "bytes"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"shardId": { "type": ["string", "null"], "minLength": 1 },
|
||||
"shardEpoch": { "type": ["integer", "null"] },
|
||||
"shardLogSequence": { "type": ["integer", "null"] }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,334 +0,0 @@
|
|||
{
|
||||
"vectors": [
|
||||
{
|
||||
"name": "any_of_plus_ge_conjunction",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHOShKVpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORpndlYXBvbqZudW1iZXLAhKVpbmRleKRtYXNzom9womdlpnZhbHVlc8CmbnVtYmVyoTWlY2l0ZXPApWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq"
|
||||
],
|
||||
"matched": 1,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "descending_with_limit",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHPApWNpdGVzwKVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8OlbGltaXQDpmN1cnNvcsA=",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [
|
||||
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema",
|
||||
"sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/zz-tie-schema"
|
||||
],
|
||||
"matched": 18,
|
||||
"hasNext": true,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "gt_numeric_boundary",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRtYXNzom9womd0pnZhbHVlc8CmbnVtYmVyoTGlY2l0ZXPApWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-lo",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
|
||||
"sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/b-hi",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass"
|
||||
],
|
||||
"matched": 4,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "incoming_exists_true",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzkdlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmaka2V5c8CmZmllbGRzwKVjaXRlc8ClY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8OqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key"
|
||||
],
|
||||
"matched": 2,
|
||||
"hasNext": false,
|
||||
"expectedEdges": [
|
||||
{
|
||||
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1",
|
||||
"role": "Design",
|
||||
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
|
||||
"payloadBase64": null
|
||||
},
|
||||
{
|
||||
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target",
|
||||
"role": "Design",
|
||||
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key",
|
||||
"payloadBase64": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "incoming_negation",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzkdlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmaka2V5c8CmZmllbGRzwKVjaXRlc8ClY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8KqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-lo",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-1",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-3",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/\uD83D\uDE02-row",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema"
|
||||
],
|
||||
"matched": 7,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "shared_index_value_three_rows",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORpnNoYXJlZKZudW1iZXLApWNpdGVzwKVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-1",
|
||||
"sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/shared-2",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-3"
|
||||
],
|
||||
"matched": 3,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "float_tie_exact_ge",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRtYXNzom9womdlpnZhbHVlc8CmbnVtYmVyqjIyMzM3NTkuMjWlY2l0ZXPApWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass"
|
||||
],
|
||||
"matched": 1,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "hop_by_role_cites_exact_id",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzkadjaXRlci0xpmZpZWxkc8ClY2l0ZXOCpnRhcmdldIKoc2NoZW1hSWTZR3NoYTI1NjpmZjM1ZjNkOWQ2ZGUzZjEwNjZiOTM2OWU3M2ZlNmM3NjQ4ZTg4MzY4ZDE4ZWZjMjI3NWM5OTQ3MzBjZDg0Y2ZmqXJlY29yZEtleaRhLWVxpHJvbGWmRGVzaWdupWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [
|
||||
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1"
|
||||
],
|
||||
"matched": 1,
|
||||
"hasNext": false,
|
||||
"expectedEdges": [
|
||||
{
|
||||
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1",
|
||||
"role": "Design",
|
||||
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
|
||||
"payloadBase64": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "cites_target_by_name_alias",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzkadjaXRlci0xpmZpZWxkc8ClY2l0ZXOCpnRhcmdldIKoc2NoZW1hSWSpbGVhZl9hLnY5qXJlY29yZEtleaRhLWVxpHJvbGWmRGVzaWdupWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [
|
||||
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1"
|
||||
],
|
||||
"matched": 1,
|
||||
"hasNext": false,
|
||||
"expectedEdges": [
|
||||
{
|
||||
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1",
|
||||
"role": "Design",
|
||||
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
|
||||
"payloadBase64": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "schemas_by_name_alias",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzkalsZWFmX2Eudjmka2V5c8CmZmllbGRzwKVjaXRlc8ClY2l0ZWTAqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfCpWxpbWl0wKZjdXJzb3LA",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-lo",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-1",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-3",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/\uD83D\uDE02-row",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema"
|
||||
],
|
||||
"matched": 9,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "astral_key_code_point_order",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORp3VuaWNvZGWmbnVtYmVywKVjaXRlc8ClY2l0ZWTAqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfCpWxpbWl0wKZjdXJzb3LA",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/\uD83D\uDE02-row"
|
||||
],
|
||||
"matched": 2,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "header_projection",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORp3VuaWNvZGWmbnVtYmVywKVjaXRlc8ClY2l0ZWTAqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfCpWxpbWl0wKZjdXJzb3LA",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/\uD83D\uDE02-row"
|
||||
],
|
||||
"matched": 2,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "document_projection",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORp3VuaWNvZGWmbnVtYmVywKVjaXRlc8ClY2l0ZWTAqnByb2plY3Rpb26oZG9jdW1lbnSqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/\uD83D\uDE02-row"
|
||||
],
|
||||
"matched": 2,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "row_tiebreak_schema_id_before_record_key_at_tied_ordinal",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORqnRpZS1zY2hlbWGmbnVtYmVywKVjaXRlc8ClY2l0ZWTAqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfCpWxpbWl0wKZjdXJzb3LA",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [
|
||||
"sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/zz-tie-schema",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema"
|
||||
],
|
||||
"matched": 2,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "shared_key_narrow_ref_resolves_leaf_within_target",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzkbVuYXJyb3ctY2l0ZXItYWNjZXB0ZWSmZmllbGRzwKVjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5p2R1cC1rZXmkcm9sZapuYXJyb3dfcmVmpWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [
|
||||
"sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d/narrow-citer-accepted"
|
||||
],
|
||||
"matched": 1,
|
||||
"hasNext": false,
|
||||
"expectedEdges": [
|
||||
{
|
||||
"fromId": "sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d/narrow-citer-accepted",
|
||||
"role": "narrow_ref",
|
||||
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key",
|
||||
"payloadBase64": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "shared_key_design_resolves_the_first_in_target_candidate_in_row_order",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzkbRjaXRlci1ib3RoLWluLXRhcmdldKZmaWVsZHPApWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlk2UdzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZqlyZWNvcmRLZXmnZHVwLWtleaRyb2xlpkRlc2lnbqVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [
|
||||
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target"
|
||||
],
|
||||
"matched": 1,
|
||||
"hasNext": false,
|
||||
"expectedEdges": [
|
||||
{
|
||||
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target",
|
||||
"role": "Design",
|
||||
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key",
|
||||
"payloadBase64": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "refuses_empty_schemas_list",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzkKRrZXlzwKZmaWVsZHPApWNpdGVzwKVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": "schemas",
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [],
|
||||
"matched": 0,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "refuses_blank_key_entry",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzkqRhLWVxoyAgIKZmaWVsZHPApWNpdGVzwKVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": "keys",
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [],
|
||||
"matched": 0,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "refuses_unmatched_cites_target_schema",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHPApWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlkvHNoYTI1Njpub3QtYS1kZWNsYXJlZC1zY2hlbWGpcmVjb3JkS2V5pGEtZXGkcm9sZcClY2l0ZWTAqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfCpWxpbWl0wKZjdXJzb3LA",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": "cites.target.schemaId",
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [],
|
||||
"matched": 0,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "refuses_cites_target_by_hash_shaped_alias",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHPApWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlk2UpzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZi52MalyZWNvcmRLZXmkYS1lcaRyb2xlwKVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": "cites.target.schemaId",
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [],
|
||||
"matched": 0,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "shared_key_narrow_ref_refuses_when_only_candidate_is_outside_target",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzkbRuYXJyb3ctY2l0ZXItcmVmdXNlZKZmaWVsZHPApWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlk2UdzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZqlyZWNvcmRLZXmrbmFycm93LW9ubHmkcm9sZapuYXJyb3dfcmVmpWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": "reference_outside_target",
|
||||
"expectedIds": [],
|
||||
"matched": 0,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
{
|
||||
"//": "CultNet typed selection, Cut 1 fix batch (docs/cultnet-selection-cut.md, Self's rulings for the Cut 1 fix batch, R-E and the shared-fixture ruling, 2026-09-22). The one row set both the C# reference and cultnet-rs build their parity vectors from. schemaId is each schema's real SHA-256 content-hash id, exactly as GameCult.Caching.CultDocumentRegistry computes it for the fixture's C# document types (leaf_a=ParityLeafA, leaf_b=ParityLeafB, citer=ParityCiter, citer_narrow=ParityCiterNarrow, tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs) - CultCache.cs's Sha256(semanticFingerprint), which carries no '.vN' suffix of its own; the version is folded into the hash, never appended as text. Both writers read this file instead of building their own fixture rows.",
|
||||
"//aliases": "Two DIFFERENT alias forms, now deliberate rather than a cross-runtime defect (S-12, 2026-09-22: the Rust alias port made this so - see docs/cultnet-selection-cut.md, 'Self's ruling, 2026-09-22: the C# reference's alias rule is the rule'). Both runtimes resolve a schema alias the same one way: strip a trailing .vN and compare the remainder against the descriptor's declared SchemaName (CultNetSchemaAliasMatching.Matches(candidate, CultDocumentDescriptor), src/GameCult.Networking/CultNetDatabase.cs:74-87; cultnet_rs::selection::schema_alias::matches ports it name-for-name and deletes the hash-stripping overload it used to carry). nameAlias is <SchemaName>.v9 and resolves through both runtimes under that one rule. hashAlias is <schemaId>.v1 and is refused as unmatched by both - it is not a wire form either runtime's production call sites ever produce or accept, and it is kept in the fixture specifically to pin that neither runtime resolves it.",
|
||||
"//references": "R-AF, 2026-09-23: a reference's `references[].targetKey` is the only thing a stored edge actually carries on the wire - GameCult.Caching.MessagePack.CultRecordRefFormatter<T> serializes a CultRecordRef<T> as a bare key string (probed: MessagePackSerializer.Serialize on a real Design field produced the single msgpack string 'a-eq', nothing else). This fixture used to also carry a `targetSchema` per reference; it was never anything but documentation - SelectionParityVectorTests.BuildDocument and cultnet-rs's build_row both read only targetKey to construct the edge - and it is removed rather than kept as a field the wire does not have. Resolving which row an edge names is by targetKey alone, filtered to the declared role's target leaves (D9); see citer_narrow below for the shared-key shape that rule is pinned against.",
|
||||
"schemas": {
|
||||
"leaf_a": {
|
||||
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
|
||||
"nameAlias": "leaf_a.v9",
|
||||
"hashAlias": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff.v1",
|
||||
"indexes": [
|
||||
{ "name": "kind", "numeric": false },
|
||||
{ "name": "mass", "numeric": true }
|
||||
]
|
||||
},
|
||||
"leaf_b": {
|
||||
"schemaId": "sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42",
|
||||
"nameAlias": "leaf_b.v9",
|
||||
"hashAlias": "sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42.v1",
|
||||
"indexes": [
|
||||
{ "name": "kind", "numeric": false },
|
||||
{ "name": "mass", "numeric": true }
|
||||
]
|
||||
},
|
||||
"citer": {
|
||||
"schemaId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc",
|
||||
"nameAlias": "citer.v9",
|
||||
"hashAlias": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc.v1",
|
||||
"roles": [
|
||||
{ "name": "Design", "targets": ["leaf_a", "leaf_b"] }
|
||||
]
|
||||
},
|
||||
"citer_narrow": {
|
||||
"//": "R-AF: a citer whose declared reference target is one leaf only (leaf_a), so a record key shared by a leaf_a row and a leaf_b row has exactly one candidate inside the declared target and exactly one outside it - the shape neither runtime's parity vectors covered before this cut (zero duplicate record keys across the original ten rows).",
|
||||
"schemaId": "sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d",
|
||||
"nameAlias": "citer_narrow.v9",
|
||||
"hashAlias": "sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d.v1",
|
||||
"roles": [
|
||||
{ "name": "narrow_ref", "targets": ["leaf_a"] }
|
||||
]
|
||||
}
|
||||
},
|
||||
"rows": [
|
||||
{ "schema": "leaf_a", "key": "a-lo", "ordinal": 1, "fields": { "kind": "weapon", "mass": "4" } },
|
||||
{ "schema": "leaf_a", "key": "a-eq", "ordinal": 2, "fields": { "kind": "weapon", "mass": "5" } },
|
||||
{ "schema": "leaf_b", "key": "b-hi", "ordinal": 3, "fields": { "kind": "armor", "mass": "6" } },
|
||||
{ "schema": "leaf_a", "key": "shared-1", "ordinal": 4, "fields": { "kind": "shared", "mass": "1" } },
|
||||
{ "schema": "leaf_b", "key": "shared-2", "ordinal": 5, "fields": { "kind": "shared", "mass": "1" } },
|
||||
{ "schema": "leaf_a", "key": "shared-3", "ordinal": 6, "fields": { "kind": "shared", "mass": "1" } },
|
||||
{
|
||||
"schema": "citer",
|
||||
"key": "citer-1",
|
||||
"ordinal": 7,
|
||||
"references": [ { "role": "Design", "targetKey": "a-eq" } ]
|
||||
},
|
||||
{
|
||||
"//": "R-D: 2233759.25 is exactly representable as an f32 and sits on a round-to-even tie between two shortest-form spellings; its exact canonical decimal is 2233759.25 either way. kind=tie so no weapon/armor/shared case can sweep it up.",
|
||||
"schema": "leaf_a", "key": "tie-mass", "ordinal": 8, "fields": { "kind": "tie", "mass": "2233759.25" }
|
||||
},
|
||||
{
|
||||
"//": "R-C: BMP private-use key (U+E000), same ordinal as the astral row below so the tiebreak - not the ordinal - decides order. kind=unicode, mass=0 so no unfiltered or mass>1 case reaches these.",
|
||||
"schema": "leaf_a", "key": "-row", "ordinal": 9, "fields": { "kind": "unicode", "mass": "0" }
|
||||
},
|
||||
{
|
||||
"//": "R-C: astral key (U+1F602, encoded as a surrogate pair whose high half 0xD83D is less than 0xE000 - a UTF-16 code-unit compare sorts this first; code-point order sorts it after the BMP row above).",
|
||||
"schema": "leaf_a", "key": "😂-row", "ordinal": 9, "fields": { "kind": "unicode", "mass": "0" }
|
||||
},
|
||||
{
|
||||
"//": "R-AF shape 1: the only row at this key is outside citer_narrow's declared target (leaf_a only) - resolving the edge below must refuse reference_outside_target in both runtimes, not silently accept an empty page. kind=narrow-fixture (not \"shared\") so this row never sweeps into shared_index_value_three_rows, which pins an exact count of 3.",
|
||||
"schema": "leaf_b", "key": "narrow-only", "ordinal": 10, "fields": { "kind": "narrow-fixture", "mass": "1" }
|
||||
},
|
||||
{
|
||||
"//": "R-AF shape 2: two rows share this key, one inside citer_narrow's declared target and one outside it - resolving the edge below must accept, deterministically picking the leaf_a row, in both runtimes.",
|
||||
"schema": "leaf_a", "key": "dup-key", "ordinal": 11, "fields": { "kind": "narrow-fixture", "mass": "1" }
|
||||
},
|
||||
{
|
||||
"schema": "leaf_b", "key": "dup-key", "ordinal": 12, "fields": { "kind": "narrow-fixture", "mass": "1" }
|
||||
},
|
||||
{
|
||||
"schema": "citer_narrow",
|
||||
"key": "narrow-citer-refused",
|
||||
"ordinal": 13,
|
||||
"references": [ { "role": "narrow_ref", "targetKey": "narrow-only" } ]
|
||||
},
|
||||
{
|
||||
"schema": "citer_narrow",
|
||||
"key": "narrow-citer-accepted",
|
||||
"ordinal": 14,
|
||||
"references": [ { "role": "narrow_ref", "targetKey": "dup-key" } ]
|
||||
},
|
||||
{
|
||||
"//": "R-AJ: a tied ordinal across two DIFFERENT schemas (leaf_a and leaf_b), not two rows of the same leaf like the astral/BMP pair above - the shape the row order's schemaId tiebreak needs and the original ten rows never had. Keys are anti-correlated with the real schema ids on purpose: leaf_b's real id sorts before leaf_a's (sha256:b1f2... < sha256:ff35...), so leaf_b gets the alphabetically-*later* key (\"zz-tie-schema\") and leaf_a gets the *earlier* one (\"aa-tie-schema\") - a schemaId-first order and a recordKey-first order disagree here on purpose, so only the correct composition produces leaf_b's row first.",
|
||||
"schema": "leaf_b", "key": "zz-tie-schema", "ordinal": 15, "fields": { "kind": "tie-schema", "mass": "1" }
|
||||
},
|
||||
{
|
||||
"schema": "leaf_a", "key": "aa-tie-schema", "ordinal": 15, "fields": { "kind": "tie-schema", "mass": "1" }
|
||||
},
|
||||
{
|
||||
"//": "R-AO, 2026-09-23: 'dup-key' already carries two rows (leaf_a ordinal 11, leaf_b ordinal 12) - both are in citer's Design role's declared target ([leaf_a, leaf_b], unlike citer_narrow's narrow_ref above, which only ever had one in-target candidate at a shared key). This citer is the first row in this file (hence in both runtimes' row order) to name 'dup-key' through Design, so resolving it must deterministically pick the leaf_a row - the first in-target candidate in the caller's order - never the leaf_b row, in both runtimes.",
|
||||
"schema": "citer",
|
||||
"key": "citer-both-in-target",
|
||||
"ordinal": 16,
|
||||
"references": [ { "role": "Design", "targetKey": "dup-key" } ]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,282 +0,0 @@
|
|||
{
|
||||
"vectors": [
|
||||
{
|
||||
"name": "any_of_plus_ge_conjunction",
|
||||
"selectionMessagePackBase64": "g6ZmaWVsZHOSg6VpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORpndlYXBvboOlaW5kZXikbWFzc6JvcKJnZaZudW1iZXKhNapwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
|
||||
"asOf": 1,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq"
|
||||
],
|
||||
"matched": 1,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "descending_with_limit",
|
||||
"selectionMessagePackBase64": "g6pwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nw6VsaW1pdAM=",
|
||||
"asOf": 1,
|
||||
"expectedIds": [
|
||||
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema",
|
||||
"sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/zz-tie-schema"
|
||||
],
|
||||
"matched": 18,
|
||||
"hasNext": true,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "gt_numeric_boundary",
|
||||
"selectionMessagePackBase64": "g6ZmaWVsZHORg6VpbmRleKRtYXNzom9womd0pm51bWJlcqExqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfC",
|
||||
"asOf": 1,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-lo",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
|
||||
"sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/b-hi",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass"
|
||||
],
|
||||
"matched": 4,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "incoming_exists_true",
|
||||
"selectionMessagePackBase64": "hKdzY2hlbWFzkdlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmalY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8OqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
|
||||
"asOf": 1,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key"
|
||||
],
|
||||
"matched": 2,
|
||||
"hasNext": false,
|
||||
"expectedEdges": [
|
||||
{
|
||||
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1",
|
||||
"role": "Design",
|
||||
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
|
||||
"payloadBase64": null
|
||||
},
|
||||
{
|
||||
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target",
|
||||
"role": "Design",
|
||||
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key",
|
||||
"payloadBase64": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "incoming_negation",
|
||||
"selectionMessagePackBase64": "hKdzY2hlbWFzkdlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmalY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8KqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
|
||||
"asOf": 1,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-lo",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-1",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-3",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/😂-row",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema"
|
||||
],
|
||||
"matched": 7,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "shared_index_value_three_rows",
|
||||
"selectionMessagePackBase64": "g6ZmaWVsZHORg6VpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORpnNoYXJlZKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
|
||||
"asOf": 1,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-1",
|
||||
"sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/shared-2",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-3"
|
||||
],
|
||||
"matched": 3,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "hop_by_role_cites_exact_id",
|
||||
"selectionMessagePackBase64": "hKRrZXlzkadjaXRlci0xpWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlk2UdzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZqlyZWNvcmRLZXmkYS1lcaRyb2xlpkRlc2lnbqpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
|
||||
"asOf": 1,
|
||||
"expectedIds": [
|
||||
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1"
|
||||
],
|
||||
"matched": 1,
|
||||
"hasNext": false,
|
||||
"expectedEdges": [
|
||||
{
|
||||
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1",
|
||||
"role": "Design",
|
||||
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
|
||||
"payloadBase64": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "hop_by_role_cites_name_alias",
|
||||
"selectionMessagePackBase64": "hKRrZXlzkadjaXRlci0xpWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlkqWxlYWZfYS52OalyZWNvcmRLZXmkYS1lcaRyb2xlpkRlc2lnbqpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
|
||||
"asOf": 1,
|
||||
"expectedIds": [
|
||||
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1"
|
||||
],
|
||||
"matched": 1,
|
||||
"hasNext": false,
|
||||
"expectedEdges": [
|
||||
{
|
||||
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1",
|
||||
"role": "Design",
|
||||
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
|
||||
"payloadBase64": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "schemas_by_hash_alias",
|
||||
"selectionMessagePackBase64": "g6dzY2hlbWFzkdlKc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmYudjGqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
|
||||
"asOf": 1,
|
||||
"expectedIds": [],
|
||||
"matched": 0,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "schemas_by_name_alias",
|
||||
"selectionMessagePackBase64": "g6dzY2hlbWFzkalsZWFmX2EudjmqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
|
||||
"asOf": 1,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-lo",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-1",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-3",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/😂-row",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema"
|
||||
],
|
||||
"matched": 9,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "float_tie_exact_ge",
|
||||
"selectionMessagePackBase64": "g6ZmaWVsZHORg6VpbmRleKRtYXNzom9womdlpm51bWJlcqoyMjMzNzU5LjI1qnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfC",
|
||||
"asOf": 1,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass"
|
||||
],
|
||||
"matched": 1,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "astral_key_code_point_order",
|
||||
"selectionMessagePackBase64": "g6ZmaWVsZHORg6VpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORp3VuaWNvZGWqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
|
||||
"asOf": 1,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/😂-row"
|
||||
],
|
||||
"matched": 2,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "row_tiebreak_schema_id_before_record_key_at_tied_ordinal",
|
||||
"selectionMessagePackBase64": "g6ZmaWVsZHORg6VpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORqnRpZS1zY2hlbWGqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
|
||||
"asOf": 1,
|
||||
"expectedIds": [
|
||||
"sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/zz-tie-schema",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema"
|
||||
],
|
||||
"matched": 2,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "shared_key_narrow_ref_resolves_leaf_within_target",
|
||||
"selectionMessagePackBase64": "hKRrZXlzkbVuYXJyb3ctY2l0ZXItYWNjZXB0ZWSlY2l0ZXOCpnRhcmdldIKoc2NoZW1hSWTZR3NoYTI1NjpmZjM1ZjNkOWQ2ZGUzZjEwNjZiOTM2OWU3M2ZlNmM3NjQ4ZTg4MzY4ZDE4ZWZjMjI3NWM5OTQ3MzBjZDg0Y2ZmqXJlY29yZEtleadkdXAta2V5pHJvbGWqbmFycm93X3JlZqpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
|
||||
"asOf": 1,
|
||||
"expectedIds": [
|
||||
"sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d/narrow-citer-accepted"
|
||||
],
|
||||
"matched": 1,
|
||||
"hasNext": false,
|
||||
"expectedEdges": [
|
||||
{
|
||||
"fromId": "sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d/narrow-citer-accepted",
|
||||
"role": "narrow_ref",
|
||||
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key",
|
||||
"payloadBase64": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "shared_key_design_resolves_the_first_in_target_candidate_in_row_order",
|
||||
"selectionMessagePackBase64": "hKRrZXlzkbRjaXRlci1ib3RoLWluLXRhcmdldKVjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5p2R1cC1rZXmkcm9sZaZEZXNpZ26qcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
|
||||
"asOf": 1,
|
||||
"expectedIds": [
|
||||
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target"
|
||||
],
|
||||
"matched": 1,
|
||||
"hasNext": false,
|
||||
"expectedEdges": [
|
||||
{
|
||||
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target",
|
||||
"role": "Design",
|
||||
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key",
|
||||
"payloadBase64": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "refuses_empty_schemas_list",
|
||||
"selectionMessagePackBase64": "g6dzY2hlbWFzkKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": "schemas",
|
||||
"expectedIds": [],
|
||||
"matched": 0,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "refuses_blank_key_entry",
|
||||
"selectionMessagePackBase64": "g6RrZXlzkqRhLWVxoyAgIKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": "keys",
|
||||
"expectedIds": [],
|
||||
"matched": 0,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "refuses_unmatched_cites_target_schema",
|
||||
"selectionMessagePackBase64": "g6VjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZLlzaGEyNTY6bm90LWEta25vd24tc2NoZW1hqXJlY29yZEtleaRhLWVxpHJvbGWmRGVzaWduqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfC",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": "cites.target.schemaId",
|
||||
"expectedIds": [],
|
||||
"matched": 0,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "refuses_cites_target_by_hash_shaped_alias",
|
||||
"selectionMessagePackBase64": "g6VjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZNlKc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmYudjGpcmVjb3JkS2V5pGEtZXGkcm9sZaZEZXNpZ26qcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": "cites.target.schemaId",
|
||||
"expectedIds": [],
|
||||
"matched": 0,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "shared_key_narrow_ref_refuses_when_only_candidate_is_outside_target",
|
||||
"selectionMessagePackBase64": "hKRrZXlzkbRuYXJyb3ctY2l0ZXItcmVmdXNlZKVjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5q25hcnJvdy1vbmx5pHJvbGWqbmFycm93X3JlZqpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
|
||||
"asOf": 1,
|
||||
"expectedEvaluationRefusal": "reference_outside_target",
|
||||
"expectedIds": [],
|
||||
"matched": 0,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,441 +0,0 @@
|
|||
# C2a land map
|
||||
|
||||
What C2a (`hands/variants-c2a`, a5c7954e) must still prove to merge to main, measured
|
||||
on 2026-10-22 against main 7a8933b0 and the 2.0.0 release candidate
|
||||
`hands/cultlib-unity-release-2.0.0` (5b5d8573, which carries f91e30ed). The land cut
|
||||
is the merge plus the gate. Everything else is classified below. Governing ruling:
|
||||
`cultlib-gaps:ruling:release-now-c2a-bumps-format`.
|
||||
|
||||
## Cut sequence to main (2026-10-27, branch head 7c7cc799)
|
||||
|
||||
Landed since the facts below: `land-c2a` (h1), `land-c2a-merge-api-docs` (h1-h6), the 2.0.1 and
|
||||
cultcache-rs 0.3.1 merges (5baa02fa, f54472e5), `land-c2a-gate` r4 (h1), `store-gate-one-owner`
|
||||
(h1-h7, Soul s6) and `restamp-on-write` (h1-h2, 8ee66714..7c7cc799, Soul s2 holds). One static `Owns`
|
||||
decides ownership for cache and cacheless writers, and the High finding
|
||||
`cut-land-c2a-gate.s1.reflist-record-read-only-at-head` is Fixed by 7c7cc799. origin/main is 73e684b1:
|
||||
21 commits past the merge base 02cf04b5, all docs and research (15 files, +1879, none under src/,
|
||||
tests/ or packages/, none on a path the branch changed). What stands between the branch and main, in
|
||||
order:
|
||||
|
||||
1. **claimant-one-owner** (spec r1 at 7c7cc799, Hands then Soul). Restamp's Soul s2 left four Lows
|
||||
on the landing path. Which entry reads a persisted entry is chosen twice (`Claimant` at
|
||||
CultCache.cs:703-750, `ClaimantEntry` at :797-799), and so a cacheless writer handed registry {A,B}
|
||||
overwrites a record that a cache with {A,B} holds read-only (probe X4). The cut keeps one `Claimant`
|
||||
precedence over a by-id/by-name lookup that the registry or a single entry supplies. A cacheless
|
||||
writer with a registry then takes its claimant from that registry. The cut also pins the
|
||||
unpublished-id case and fixes the stale test comment. It closes `claimant-selection-duplicated`,
|
||||
`cacheless-inverts-registry-claimant`, `unpublished-id-guard-unpinned` and `stale-flag-copy-comment`.
|
||||
It is kept apart from the gate so that Soul can falsify it alone. Its files (CultCache.cs, the
|
||||
persistence contract, RestampOnWriteTests.cs) are disjoint from the gate's, so it can run before or
|
||||
beside step 2. The second cut to land rebases.
|
||||
2. **land-c2a-gate** (spec r7 at 7c7cc799, Hands h2 then Soul). The ref-list gate does the same write
|
||||
at 2.0.1 and at head and compares schema ids. The committed vectors are pinned byte for byte under a
|
||||
fixed clock, and every stage counts its cases. r7 drops r6's head-only restamp assertions, which
|
||||
`RestampOnWriteTests.TheCommitted1060VectorsListRecordIsWrittenUnderTheHoldersOwnId` already pins.
|
||||
It also drops the red-at-193cf1c3 run, because restamp is measured. The cut closes
|
||||
`single-vector-hand-edit-passes`, `gate-green-with-no-refusal-probes` and follow-up
|
||||
`gate-reads-1x-written-store`.
|
||||
3. **Ledger** (Self, no Hands; can run now, beside steps 1 and 2). Each of these is in force though
|
||||
its fix has landed:
|
||||
- The seven `cut-land-c2a.s1.*` findings. `land-c2a-merge-api-docs` carried their pins (source reads
|
||||
2026-10-27: GameCult.Caching grants InternalsVisibleTo only to `.Tests` and `.MessagePack`, the
|
||||
residual being follow-up `caching-messagepack-ivt`; PreCut2StoreFormatTests pins "with a suffix
|
||||
of"). Close each one against its commit, or defer it. `stale-unity-plugin-dlls` defers to the
|
||||
Unity 3.0.0 release cut (the plugins are 2.0.1 builds, 1eeb1a01).
|
||||
- `cut-c2a-write-set.s1.untouched-dropped-slot-resurrects` and follow-up `slot-reuse-contract-doc`.
|
||||
Ruling `slot-reuse-documented` decided the behaviour, and the paragraph is in
|
||||
cultcache-schema-compatibility.md:64-66.
|
||||
- `cut-restamp-on-write.s1.websockets-tests-unreported`, Fixed by h2 (s2 promise
|
||||
`websockets-tests-reported` holds).
|
||||
- Follow-up `gate-bound-test-tighten`, carried by restamp r2 (50362447, a768b821). Close it if
|
||||
restamp's Soul passes measured it; otherwise route it.
|
||||
- After steps 1 and 2 hold: the four s2 Lows, the two remaining `cut-land-c2a-gate.s1` Lows and
|
||||
`gate-reads-1x-written-store`.
|
||||
4. **Consumer pins** (ruling `unpinned-consumers-at-land-3`, follow-up `consumer-pins-before-land`).
|
||||
Brokkr, Aquarium, Ymir, AquaSynth and Mimir have no `CultLibRevision`/`VerifyCultLibRevision` guard,
|
||||
and each builds against the sibling `..\CultLib` checkout. In its own repo, each adopts Aetheria's
|
||||
guard at the 2.0.0 tag, with one Hands per repo, before the merge reaches main. The pins touch no
|
||||
CultLib file, so they can start now and run in parallel with steps 1-3. Eve and Gjallar get no pin
|
||||
(ruled). The step is done when each repo's guard fails a build against a CultLib checkout at
|
||||
another revision and passes at the tag, and the follow-up is resolved.
|
||||
5. **Main into the branch, then full verification.** After steps 1, 2 and 4, `git merge origin/main`
|
||||
into `hands/variants-c2a`. It should be clean, because main's commits since 02cf04b5 are docs and
|
||||
research only. Re-check that before merging (`git diff --stat 02cf04b5 origin/main -- src tests
|
||||
packages unity`). Then run every suite at the merged head on Yggdrasil: Caching, Mesh, Networking,
|
||||
Geometry, WebSockets, ConsumerBuild, InteropPeer, cultcache-ts/-rs/-py, cultnet-ts and cultmesh-ts.
|
||||
Run `old-reader-refusal.sh gate` for csharp, typescript, python and rust. If main gained code by then,
|
||||
a Soul pass covers the merge.
|
||||
6. **Land.** The land is `git merge --no-ff`, never a squash, so the SHAs that every report and verdict cites stay reachable from main (skip the branch's non-building commits with `git bisect --first-parent`). Steps 1 and 2 closed at 723b7996 (claimant-one-owner, land-c2a-gate); step 5 closed at the merge commit 3157d4b3 (Soul `cut-land-c2a-main.s1` holds). Step 3 closed except `cut-land-c2a.s1.stale-unity-plugin-dlls` and `cut-land-c2a-gate.s2.mutation-table-reads-gate-as-zero`, which wait on the 3.0.0 release cut and the Eureka tools respectively; step 4 closed with `consumer-pins-before-land` (pins since 2026-10-22). Run `git merge --no-ff hands/variants-c2a` onto main, with an empty diff against the
|
||||
verified head. There is no tag: tags, the Unity plugin rebuild and `declared-range-gate-own-version`
|
||||
belong to the 3.0.0 release cut. Open follow-ups `c2a-load-slower` (load 2 to 2.3 times slower than
|
||||
main on a synthetic Aetheria catalog) and `drift-write-ownership-leaves` do not say "before land".
|
||||
`c2a-load-slower` still needs a reason or a fix before the 3.0.0 release, under
|
||||
`cultlib-gaps:ruling:database-shaped-built-for-load`.
|
||||
|
||||
## Facts
|
||||
|
||||
**1. The merge.** A trial `git merge origin/main` on a5c7954e gives 12 conflicted files and
|
||||
33 hunks of about 950 marker lines (CultCache.cs 17 hunks/196; cult-cache.test.ts 337;
|
||||
single-file-messagepack-backing-store.ts 4/106; rs lib.rs 2/51; VariantTests 51; three
|
||||
CHANGELOG add/adds; store-composition contract 32; Directory store 14; InteropPeer 21).
|
||||
Main moved 4.8k lines in the cache runtimes since base 1a12bc2d. Three semantic collisions
|
||||
are not textual:
|
||||
|
||||
- *Durable record identity has two owners.* Main added `CultStoredDocument.PersistedSchemaId`
|
||||
and `CacheBackingStore.MarkWritten` (73d438db, afd799ba, 3f6dda8b). Conditional commit
|
||||
compares the persisted schema id plus storedAt. C2a already owns this as
|
||||
`StoredSchemaId` + `StoredVersion` (SHA-256 of the stored bytes, which names the schema
|
||||
id and storedAt) set by `CultCache.Wrote`. After the merge, 9 references in
|
||||
ConditionalCommitTests and the contract name main's owner. The version is the owner.
|
||||
PersistedSchemaId and MarkWritten are deleted, and main's +309 test lines are ported as
|
||||
pins against StoredVersion. 2.0.0 ships MarkWritten as protected API, and C2a also
|
||||
removes `SerializeSnapshot`/`DeserializeSnapshot` overrides and changes `ConditionsHold`.
|
||||
C2a is therefore a major release (3.0.0), which semver forces.
|
||||
- *TS store commit moved.* Main's `store-file.ts` (0e6452c9, 0010ee92; 391 lines) now owns
|
||||
commit, replace and the `<path>.lock`. C2a's v3 read and keep-v3-on-rewrite
|
||||
(`STORE_FORMAT_ELEMENT_IDS`) lives in the old store file and must move into store-file.ts.
|
||||
- *Rust decode door.* Main's cultcache-rs 0.3.0 grown decoder (`decode_stack`, fc630d3b)
|
||||
must sit under C2a's `readable_store_format` and v3 `decode_store_file`.
|
||||
|
||||
**2. The gate already holds; it needs a test, not a format change.** C2a stamps
|
||||
`cultcache.store.v3` (single file) or `...v5.directory-content-addressed-pages` exactly when
|
||||
a record holds an id (`HeaderFor`, CultCache.cs:3543). It also marks when a copied record
|
||||
decodes holding one (`CopiedHoldIds`, :4285). The 2.0 C# reader accepts only v1/v2
|
||||
(CultDocumentMessagePackSerialization.cs:229) and directory v4 (Directory store :452).
|
||||
Main's TS, Rust and Python readers accept only v1 (store-format.ts `isV1Snapshot`, lib.rs:1274,
|
||||
stores.py:192). Probe on Yggdrasil at 5b5d8573 plus C2a's `v3-base.msgpack` (job
|
||||
`gate1.log`): 4/4 pass. The single file is refused naming v3, and a later flush leaves it
|
||||
byte-identical. A v5-stamped directory store is refused naming v5, and its 4 files are
|
||||
byte-identical after a later add and flush. **The pin:** C2a already ships
|
||||
`tests/vectors/document-variants-c2a/old-reader-refusal.sh`, which stages an old revision
|
||||
plus probes on a scratch ref and runs them per runtime. The land cut sets its
|
||||
`OLD_READER_REV` default to the tag `cultlib-unity-v2.0.0`. It also adds the never-rewritten
|
||||
assertion and the v5 directory case to the C# probe, and runs all four runtimes at the
|
||||
tag. If the tag is absent, the land waits for it. The tag, not a package, is the pin: the tag's
|
||||
tree holds every runtime's released reader.
|
||||
|
||||
*Outside C2a's reach (release session):* in 2.0, `CultMesh.WriteSingleFileDocument`/
|
||||
`WriteSingleFileDocumentPayload` (CultMeshSingleFileDocuments.cs:211-245 at 7a8933b0) build
|
||||
a fresh one-record v1 snapshot and replace the file without reading it. A 2.0 Mesh write
|
||||
to a C2a v3 path therefore discards it whole, ids and other records included, and no header
|
||||
gate can stop a writer that never reads. C2a's version reads under the lock and refuses.
|
||||
Fix it in 2.0.0 before tagging, or record it.
|
||||
|
||||
**5. Consumers.** Aetheria pins a CultLib revision (`VerifyCultLibRevision`) and Unity tags.
|
||||
Ghostlight, Eve's Rust crates, Huginn and the Brokkr daemon pin cultcache-rs by git rev.
|
||||
None of them moves when main moves. Two C# consumers build against an unpinned sibling
|
||||
`..\CultLib` checkout: Eve `GameCult.Eve.Surface.csproj:16` and Brokkr's three csprojs.
|
||||
Eve.Surface has `[MessagePackObject]` element arrays with no `[CultElementId]`
|
||||
(`EveAssetCatalogEntry[]`, `EveEntitySoaBuffer[]`, `EveInputActionDocument[]`,
|
||||
`IReadOnlyList<EveCommandTemplate>`, 0 ids in the repo), so C2a registration refuses its
|
||||
documents as soon as the checkout pulls the land. Grep finds no object lists in Brokkr's
|
||||
`[CultDocument]` files (not build-verified). Removed C2a API (`Options`, `AsSchemaAlias`) is
|
||||
unused by Eve and Brokkr. Fork: `variants:question:unpinned-consumers-at-land`.
|
||||
|
||||
## Classification
|
||||
|
||||
| Item | Class | Why |
|
||||
|---|---|---|
|
||||
| c2a-format-version-gate | **a** | The gate test above. No new header: v3/v5 already refuse. |
|
||||
| slot-reuse-contract-doc | **a** | The ruled consequence of the write set C2a lands. One paragraph in cultcache-schema-compatibility.md, absent at a5c7954e (grep). |
|
||||
| foreign-ids-under-v1-header | **b** | Obsoleted by fb065f0d (write-time `CopiedHoldIds`, after the finding) for every C# writer with the type. The residual (non-C# or opaque writers) is what `non-csharp-writers-mark-ids-2` ruled to carry, and the contract states it (persistence-format.md:434-439). The gate changes neither side, so no fork remains. Resolve as Superseded by that ruling. |
|
||||
| custom-resolver-element-ids | **b** | Fixed by b091d21f (19:52Z, after the 18:27Z follow-up): `ListElementsClaimedByAResolverAreRefused`, `AResolverClaimingTheListTypeOfAnObjectListIsRefused`. s6 holds on the claim loop. |
|
||||
| codec-door-proven-mismatch | **b** | Done in h6 57fdf70c (s6 `only-proven-mismatch` Holds; Geometry 76/0). |
|
||||
| consumer-element-id-sweep-r2 | **c** | Each repo's sweep is its own work (q3). Only the unpinned trackers matter at land, and that is the question below. Aetheria bumps a pin on purpose (C4). |
|
||||
| schema-discovery-holder-recogniser | **c** | `TryGetDictionaryRefTypes` is identical on main (CultCache.cs:1288). It describes record-ref dictionaries and is not on the id path. Changing it moves schema hashes, which is its own epoch event. |
|
||||
| copy-check-memo | **c** | Performance only (about 3x flush on unmarked stores of 20k records). Correct as is. |
|
||||
| string-key-field-and-shadow-pins | **c** | No wrong behaviour at 64ec14f9. These are test pins and may ride the land cut if Hands touches the file. |
|
||||
| declared-range-gate-own-version, range-check-in-ci | **c** | The script exists only on C2a and runs in no CI. It guards a TS publish, not a merge. Fix both before the first TS release from main after the land. |
|
||||
| s6 Unproven: Unity/IL2CPP identity, other MessagePack versions | **c** | The merge reaches no Unity build. Unity sees C2a only at the 3.0.0 tag, so measure it in the Unity release cut (operator Unity check, C3). |
|
||||
| multi-store-commit-atomicity, store-replace-cut, reader-refuses-duplicate-keys, soft-flush-all-runtimes, writer-dangling-link-tolerance, unique-index-cut, py-shared-write-lock, rust-lock-open-waits, ts-lock-test-hardening, c0/legacy/directory vectors, culture-test, wire-id-cuts, remaining-variant-cuts, golden-path, paged store | **c** | None says "before land". Each is pre-existing on main or C2a-independent. None lets a released reader strip ids or corrupt a C2a store. |
|
||||
|
||||
## The land cut's shape (transcribe once the question is ruled)
|
||||
|
||||
- **Base.** Merge main at or after the `cultlib-unity-v2.0.0` tag into `hands/variants-c2a`.
|
||||
Versions stay main's. CHANGELOGs get Unreleased entries, and the Unity entry says major.
|
||||
- **Deletes and collapses.** Delete `PersistedSchemaId`, `PersistedIdentity`,
|
||||
`CultStoredDocument.MarkWritten` and `CacheBackingStore.MarkWritten` (main, about 60 lines),
|
||||
and the store-composition contract's MarkWritten paragraph. C2a's `StoredVersion`/`Wrote`
|
||||
is the one owner. In TS, delete C2a's header handling in
|
||||
single-file-messagepack-backing-store.ts and move it into store-file.ts. In Rust, C2a's v3
|
||||
decode goes through the grown decoder.
|
||||
- **Adds.** Retarget old-reader-refusal.sh to the tag. Add the C# probe's never-rewritten
|
||||
assertion and the v5 directory case (as in gate1.log). Add the slot-reuse paragraph.
|
||||
Port main's ConditionalCommitTests to the version identity.
|
||||
- **Verification.** Full suites: Caching, Mesh, Networking, Geometry, WebSockets,
|
||||
ConsumerBuild, InteropPeer, cultcache-ts/-rs/-py, cultnet-ts and cultmesh-ts, all on
|
||||
Yggdrasil. Run old-reader-refusal.sh for all four runtimes at the tag, and it must refuse.
|
||||
Negative greps over the merged tree: `PersistedSchemaId|MarkWritten`, conflict markers.
|
||||
Mutation: drop the v3/v5 stamp in `HeaderFor` and the gate probe must fail.
|
||||
- **Estimate.** About 950 conflict lines resolved. Net -60 to -150 from the identity
|
||||
collapse and TS de-duplication, +80 to +120 tests and docs. One Hands pass of 100-150
|
||||
calls, then one Soul pass.
|
||||
- **Precondition (question).** Eve.Surface, and Brokkr if confirmed, are pinned or swept
|
||||
before the merge reaches main.
|
||||
|
||||
## Store gate: attachment is the attach's hold (store-gate-one-owner r2)
|
||||
|
||||
Spec `variants:cut_spec:cut-store-gate-one-owner.r2` (base ec94ab98), question
|
||||
`variants:question:pulled-variant-codec`. It closes Soul s3's
|
||||
`attach-window-cacheless-write` (Medium) and `codec-loan-pinned-by-reflection` (Low).
|
||||
|
||||
**Facts (source reads at ec94ab98 and a probe, 2026-10-25).**
|
||||
|
||||
- The window. AddBackingStore holds the cache's gate, then the store's gate through
|
||||
`InStoreHold`, lends the registry and codec, sets `Loaded`, adds the store to `_stores`,
|
||||
pulls, and only then sets `store.Cache` (CultCache.cs 2268-2312). `InStoreHold` lets any
|
||||
call on the same store's gate re-enter it (2825-2831). So a journal the pull's Admit runs
|
||||
on the attaching thread can call `store.WriteDocument`: `Gate(cacheless)` guesses null,
|
||||
re-enters the store's gate, finds `Cache == null` and runs the write, and its `finally`
|
||||
calls `UseRegistry(null, null)` (Soul s3 P1, full1.log lines 49-53). Other threads are not
|
||||
in the window: they queue on the store's gate and see either the published cache or a
|
||||
store that the failed attach put back.
|
||||
- The codec loan has no reader on the attach path. A pulled variant's codec is read only
|
||||
by `CultStoredDocument.HoldsIds` (543-557). Header decisions read `HoldsIds` only on
|
||||
staged entries (single-file 4318, 4447 and 4472; directory store 362), and a staged entry
|
||||
is always the document the cache pushed (`Push` sets `Entries[key] = entry`, 4211-4215).
|
||||
A single-file `PullAll` returns without loading while the store is dirty (4157-4159), so
|
||||
a pulled copy is never staged. A refused write unstages its keys before it adopts the
|
||||
reload. The directory store refuses variants (ruling `q6-directory-and-cultnet-refuse`;
|
||||
DirectoryMessagePackBackingStore.cs 139-144 and 642-643). Admit re-resolves every pulled
|
||||
variant with the cache's codec (CultCache.cs 3259). Soul's N4 mutant (the attach lends
|
||||
no codec) was caught only by the reflection test, and h4's M2 only by a bare-store test
|
||||
that sets `Loaded` and calls `UseRegistry` by hand.
|
||||
- Probe (scratch clone `imagination-store-gate-r2/clone`, commits 4663934e and c099b9e8 on
|
||||
ec94ab98; Yggdrasil t1.log, t2.log and t3.log; 2026-10-25 around 20:50 CEST). The
|
||||
probe did three things. `InStoreHold` refuses a cacheless call that re-enters the same
|
||||
store's gate. The `_codec` field and the two-argument `UseRegistry` are deleted. The pull
|
||||
builds variants with a null codec. Results:
|
||||
- Mesh is 342/0/4.
|
||||
- In the Caching suite, the only failures were two probe tests, both from authoring
|
||||
errors: a key `AddAsync` minted, and an element id that was not 12 hex characters.
|
||||
No existing test failed, `UserCodeInACachelessCallMayCallItsOwnStore` included.
|
||||
- Soul's P1 as a test: the journal's write is refused with the new message. The attach
|
||||
keeps its registry and the file holds only `a`. After the attach, a cache write, a
|
||||
flush and `store.PullAll()` all succeed.
|
||||
- A cacheless write nested in another on the same store is refused, the outer write
|
||||
fails, and no file is written.
|
||||
- Through a real attach, a store holding a name-override variant flushes an unrelated
|
||||
commit with a `cultcache.store.v2` header. With no codec loan, the pulled copy does not
|
||||
mark the store.
|
||||
- Mutant `return cacheless` changed to `return false`, which is ec94ab98's behaviour,
|
||||
fails both new gate tests. In that mutant the nested write succeeds and the file exists.
|
||||
|
||||
**Rationale.**
|
||||
|
||||
- Attachment has one fact again. While the attach runs, the store's gate is the attach's
|
||||
hold, and `Cache` is published when that hold ends. A cacheless write is a write the
|
||||
store owns outright, so it must be the outermost hold of its store's gate. One more
|
||||
argument to the existing owner of per-thread gate facts (`InStoreHold`) refuses it. No
|
||||
field mirrors `Cache`, and `Loaded` and `_registry` are not read. Precedent:
|
||||
`ConcurrentHashMap.computeIfAbsent` refuses a mapping function that updates the same
|
||||
map with `IllegalStateException("Recursive update")` since Java 9. That is from
|
||||
memory, not fetched this pass; the 2026-10-23 prior-art note covers the documented
|
||||
restriction in its counter-positions. Non-cacheless store calls from user code still
|
||||
re-enter: `UserCodeInACachelessCallMayCallItsOwnStore` stays green.
|
||||
- The late publish stays. It is what bounds `Gate` at two passes (s2's finding). Publishing
|
||||
early would bring back the failure-rate-bounded retry.
|
||||
- The codec loan goes: it is a value nothing on the attach path reads. A pulled variant
|
||||
carries no codec and counts as holding ids (the documented conservative answer), and
|
||||
the cache's resolved copy is what any header decision sees. This reverses the codec
|
||||
half of Defaulted ruling `late-publish-needs-attach-codec`, so it goes to Self as
|
||||
`variants:question:pulled-variant-codec`. The spec is written for the recommended option.
|
||||
- Scar check: `git log -S` over CultCache.cs for `reentrant`, `outermost` and `_attaching`
|
||||
finds nothing. `Cache?.Codec` appears in 1468a83a (h4) and 619e089f only. No earlier
|
||||
re-entry refusal was built and deleted.
|
||||
- Behaviour change, stated in the CHANGELOG: a cacheless write made from user code that
|
||||
another cacheless write of the same store runs used to succeed, and its `finally` took
|
||||
the outer call's registry. It is now refused.
|
||||
|
||||
## Store gate: Cache is the one attachment fact (store-gate-one-owner r3)
|
||||
|
||||
Spec `variants:cut_spec:cut-store-gate-one-owner.r3` (base 0fa836cb), superseding r2. It
|
||||
closes Soul s4's `attach-window-held-store-writes` (Medium), `cacheless-refusal-order-unpinned`
|
||||
(Low) and `interop-consumer-suite-unrun` (Low). This is the third time the attach window has
|
||||
beaten the rule (s3 from the cacheless side, s4 from the held side), so r3 moves the
|
||||
decision instead of patching the next entry point.
|
||||
|
||||
**Facts: every entry point that reads or decides attachment, at 0fa836cb.** Source reads of
|
||||
CultCache.cs, DirectoryMessagePackBackingStore.cs and CultMeshSingleFileDocuments.cs. The
|
||||
window column is a journal on the attaching thread, through a real attach of a store holding
|
||||
`a`. Probe: scratch clone `imagination-store-gate-r3/clone`, commit b2330b6d (test R3Probes),
|
||||
Yggdrasil `probe-base.log`, 2026-10-25 around 23:00 CEST. "Disagree" means that after the
|
||||
attach, a commit of `later` and a flush, the attaching cache holds `a,later` and a fresh cache
|
||||
over the same file reads something else.
|
||||
|
||||
| Entry point (line) | Fact it reads | In the attach window at 0fa836cb |
|
||||
| --- | --- | --- |
|
||||
| Single-file `PullAll` (4156) | `Gate`: `Cache` guessed unlocked, then re-read under the re-entered store gate | Runs as detached; its `Loaded` re-enters the cache and is refused as a journal write. Agrees. |
|
||||
| Single-file `Push` (4212) | same | Runs as detached and stages `b`. Disagree: fresh reads `a,b,later`. |
|
||||
| Single-file `Delete` (4223) | same | Runs as detached and stages a removal the cache never made (dirty after the attach). |
|
||||
| Single-file `PushAll` (4239), and `Dispose` (3722) through it | same, then in-body `Cache == null` (4531, 4543, 4588, 4603, 4611, 4661) | Runs as a cacheless writer. Push then PushAll writes `b` to the file inside the attach (disagree). Delete then PushAll is refused with the cacheless writer's text. On a variant store it is refused as "a writer with no CultCache" (Soul s4). |
|
||||
| Single-file `CommitBatch` (4256) | same | Runs as detached and commits `b`. Disagree. |
|
||||
| Single-file `JudgeChecks` (4568), `RefuseRemovalOfUndeclared` and `StageChecks` (4558, 3915) | same | Run as detached. Agree in the probe, but under the wrong gate's rules. |
|
||||
| Single-file `ReadStoreSnapshot` (4687) | same | Runs as detached and reads the file. |
|
||||
| `WriteRecord` (4342), `WriteDocument` (4366) | `InStoreHold`'s per-thread hold (cacheless re-entry), before `Cache` | Refused with the re-entry text (h5). Agree. |
|
||||
| Directory `PullAll` (48) | `Gate`, as above | Refused as a journal write. Agree. |
|
||||
| Directory `Push` (147), `Delete` (160), `CommitBatch` (172) | same | Run as detached. Disagree: fresh reads `a,b,later` after Push or CommitBatch, and `later` after Delete. |
|
||||
| Directory `PushAll` (235) | same | Runs as detached. Push then PushAll and Delete then PushAll disagree. |
|
||||
| Directory `JudgeChecks` (269), `RefuseRemovalOfUndeclared` (259) | same | Run as detached. Agree. |
|
||||
| In-body reads: `StoredAtOver` clock (4028), refusal text and `DurableAt` (4531, 4543), declares and cacheless refusal (4588, 4603, 4611), `Wrote` (4661) | `Cache`, under the gate `Gate` chose | On the attaching thread they read null while the thread holds the cache's gate, so a held call runs as a cacheless writer. |
|
||||
| `AddBackingStore` (2259-2314) | writes `Cache` under both gates, after the pull | The window's author: the store is attached for the pull (registry lent, `Loaded` set, in `_stores`) while `Cache` is null. |
|
||||
| Mesh `CultMeshSingleFileDocuments` writes and reads (99, 143, 174, 226) | none of the attached store's | `StoreAt` (239) builds a new store per call, so they never touch an attached store object. A file it shares with an attached store is the file lock's and the write set's business. |
|
||||
| `IsDirty`, `HasStagedChecks`, `ForeignRecords`, `LastSchemaMigrationReports`, `HeaderFor`, `HeaderForWrite` | none | Ungated values or static decisions. They read no attachment fact. |
|
||||
|
||||
So during an attach, cacheless entry points decide attachment from the hold, and every held
|
||||
entry point, plus every in-body read, decides it from `Cache`, which is still null. These are
|
||||
two facts.
|
||||
|
||||
**Probe of the chosen shape** (commit 4695390c on b2330b6d; `proto-full.log`). `AddBackingStore`
|
||||
publishes `Cache` before the pull and takes it back in the same hold if the pull fails. `Gate`
|
||||
becomes straight-line code: guess, then the store's gate, where `Cache` is settled, then the
|
||||
settled cache's gate. Results:
|
||||
|
||||
- All 21 probe cases are refused. The cacheless writes get the re-entry text and every other
|
||||
case gets "A journal entered the cache it journals". After the flush, the attaching cache
|
||||
and a fresh cache both read `a,later`.
|
||||
- Caching: the only failures are the two tests that pin the late publish:
|
||||
`AnAttachLendsTheRegistryAndPublishesTheCacheAfterThePull` (asserts no cache during the
|
||||
pull) and `ACallWhoseGateChangesTwiceUnderItThrowsInsteadOfLooping` (asserts the old message
|
||||
text; the tampering reader still throws). The churn, queued-reader and failed-attach tests
|
||||
pass unchanged.
|
||||
- Mesh and Networking are green.
|
||||
- The cultcache-ts suite, which builds and drives the C# InteropPeer, passes on this shape:
|
||||
175 tests, 171 pass, 0 fail, 4 skipped. The C#, Rust and Python interop tests ran, the C#
|
||||
edit-in-place one included (`proto-ts2.log`, commit f5f02053). Recipe `clone/r3ts.sh`:
|
||||
image node:24-bookworm, apt python3-msgpack, dotnet-install for 10.0, rustup minimal.
|
||||
|
||||
**Shapes weighed (rationale).**
|
||||
|
||||
- (a) Publish `Cache` before the pull, as a616431e did. Chosen. What s2 found was not that an
|
||||
early publish is unbounded. It was that `Gate` re-read `Cache` *unlocked* after a stale
|
||||
guess, so a churn of failing attaches could hand it a new stale guess every pass. The fix is
|
||||
where `Gate` reads on its second step. Under the store's gate, `Cache` is settled: an attach
|
||||
publishes and takes it back inside one hold of that gate, and no other thread can hold that
|
||||
gate during the attach. A settled cache is permanent, because there is no detach. So `Gate`
|
||||
needs at most three acquisitions, with no loop and no counter: the guess's gate, the store's
|
||||
gate, and then the settled cache's gate, which cannot miss. This deletes a fact rather than
|
||||
reconciling two. During the attach the store *is* attached, so every entry point, cacheless
|
||||
or held, and every in-body read sees one answer. A journal's call is then refused by the rule
|
||||
journals already have (AddJournal: "any hold on this cache while a journal runs throws"),
|
||||
exactly as it is after the attach. Prior art: `ConcurrentHashMap.computeIfAbsent` installs a
|
||||
ReservationNode in the bin *before* running the mapping function, so a recursive update on
|
||||
the same thread finds the owner and throws, and on failure the reservation is removed (see
|
||||
`docs/research/2026-10-23-store-lock-user-code-prior-art.md` item 4 on main: the re-check
|
||||
after locking, and the ReservationNode on the empty-bin path). The validate-after-acquire in
|
||||
`Gate` is the same item.
|
||||
- (b) Keep the late publish and make the attach's hold the one fact for every entry point.
|
||||
Rejected. `InStoreHold` knows that this thread holds the store's gate, not *whose* hold it is.
|
||||
Refusing every same-store re-entry kills `UserCodeInACachelessCallMayCallItsOwnStore` (r2's
|
||||
R5), so (b) needs an "attaching" marker beside `Cache`. That is the second fact that s3 and
|
||||
s4 rule out, plus every in-body `Cache == null` read would need to consult it as well.
|
||||
- (c) Run no user code on the attaching thread under the store's hold during a pull: defer the
|
||||
pull's journals to after the hold (open calls). Rejected as insufficient on its own. Getters
|
||||
and constructors still run during the pull's decode, so the window stays open to any user
|
||||
code that is not a journal. It also moves journal timing, which is a documented contract
|
||||
("right after an admission is applied").
|
||||
- Scar check: `git log -S` over CultCache.cs finds `store.Cache = null` and `MaxStaleGuesses`
|
||||
only in ba658965 and 1468a83a. The early publish was not removed for a correctness reason of
|
||||
its own: 1468a83a removed it to bound `Gate` and to lend the codec, and the codec loan is gone
|
||||
(`pulled-variant-codec`). No settled read under the store's gate was built before. The
|
||||
precedent recipe (no root filter) finds no same decision.
|
||||
- Not a product fork. A journal's store call during an attach now throws, as it already does
|
||||
after the attach. Cacheless writes on the attaching thread stay refused, with the same text.
|
||||
Getters and constructors during the pull see an attached store, as they do during any held
|
||||
call. It changes the late-publish clause of Defaulted ruling `variants:ruling:pulled-variant-codec`,
|
||||
so Self defaults a superseding ruling (recommended label `cache-published-for-the-pull`)
|
||||
before dispatch.
|
||||
- Residual, not in r3. `Push`, `Delete`, `PushAll` and `CommitBatch` are public, so user code
|
||||
that is not a journal (another thread, or a getter during any held call) can stage records
|
||||
behind an attached store's cache. Soul s4 shows this with `other-thread-push`: file `a,b`,
|
||||
cache `a`. That is true before, during and after an attach, so it is not the window's
|
||||
two-fact problem. Closing it changes public API (a store's writes taken only from its
|
||||
cache), so it is a follow-up and an operator question for Self, not part of this cut.
|
||||
|
||||
## Body facts: claimant-one-owner r2 (Imagination, 2026-10-27, base fc74cf6f)
|
||||
|
||||
Why a revision: Soul beat the same rule three times (s1 claimant choice, s2 compatible ids, s3 shape). Each time a
|
||||
cacheless write lent a registry let the registry's entry for the written schema id decide ownership beside the entry
|
||||
the caller passed. Two descriptions of one type reached `Owns`; each fix (h2 schema-id compare, h3 subset of compatible
|
||||
ids) compared one more facet of the two and left the rest open. r2 removes the second description from the lookup.
|
||||
|
||||
### Where the two descriptions met (source read, fc74cf6f)
|
||||
|
||||
- `OwnsOnDisk` cacheless arm (`CultCache.cs:4725-4740`): with a lent registry it called
|
||||
`lent.Owns(id, catalog, declared, written)`, whose lookup is the registry's `_indexes` and whose `entryOf` is
|
||||
`descriptor.ToCatalogEntry()` (`:824-825`). The claimant for the written schema id was the registry's descriptor,
|
||||
so `ReadsWhole(persisted, claimantEntry)` (`:848`) judged shape against the registry's entry, and the claimant
|
||||
precedence (`:756-806`) walked the registry's compatible ids. `written` (`:4734-4736`) then compared the two entries
|
||||
by schema id plus a subset of compatible ids.
|
||||
- `WriteDocument` (`:4495-4524`) takes its entry from the registry (`resolvedRegistry.GetRequired<TDocument>().ToCatalogEntry()`),
|
||||
so its two descriptions are always identical. Only `WriteRecord(record, catalogEntry, holdsIds, registry)` (`:4471`) can
|
||||
pass an entry that disagrees with the registry's entry for the same id.
|
||||
- `CopiedHoldIds` (`:4600-4630`) also reads `AttachedRegistry`, to decide the v3 header from copied records. It is a
|
||||
positive-only header decision, not ownership, and out of this cut.
|
||||
|
||||
### Consumer audit (2026-10-27)
|
||||
|
||||
- `WriteRecord` and `WriteDocument` are new in the Unreleased 3.0.0 entry; `main` has neither (`git show main:src/GameCult.Caching/CultCache.cs`).
|
||||
- Per-repo `rg` over every git repo directly under `F:\Projects` for `.WriteRecord(`, `.WriteDocument(`, `WriteSingleFileDocument`
|
||||
(Library, obj, bin, PackageCache excluded): hits only in CultLib and in vendored CultLib copies under Heimdall and StreamPixels
|
||||
(`vendor\CultLib`, older pins without these methods). No consumer outside CultLib calls either method.
|
||||
- Inside CultLib at fc74cf6f: Mesh `WriteSingleFileDocument` calls `WriteDocument(key, document, storedAt, registry)`;
|
||||
Mesh's raw-payload path calls `WriteRecord(..., registry: null)` (`CultMeshSingleFileDocuments.cs:129, 226`). The only
|
||||
`WriteRecord` calls with a registry are tests: `CachelessSingleFileTests.cs:453, 660` (a registry `{IdDeck}` that does
|
||||
not hold the written entry `One` at all, used for the copied-record header), `RestampOnWriteTests.cs:595, 599`, and
|
||||
Soul's probes. Packages: no TS, Python or Rust counterpart takes a registry (cultnet-rs `write_record` is an example helper).
|
||||
- Consequence: the registry's documented role in `WriteRecord` is to describe the other records in the store. A shape
|
||||
that derives the written entry from the registry (or refuses an entry the registry does not hold) breaks
|
||||
`CachelessSingleFileTests.cs:660` and removes that role.
|
||||
|
||||
### Candidate shapes weighed
|
||||
|
||||
| Shape | One description? | Public API | Kills s2/s3 | Cost |
|
||||
|---|---|---|---|---|
|
||||
| A. Lent registry's entry decides; refuse a passed entry that disagrees | yes, after a door check | signature kept; new refusal | yes | an equality check over a redundant parameter; breaks the registry-describes-others use (`:660`) when the registry lacks the written type unless it falls back to the passed entry, which is two descriptions again |
|
||||
| B. Passed entry is the only description of its schema id; the registry supplies other types only | yes, by construction | signature kept; doc only | yes (probed) | one lookup class gains the registry; one registry overload deleted |
|
||||
| C. Overloads: `WriteRecord(record, catalogEntry, holdsIds)` and `WriteRecord(record, registry, holdsIds)` | yes, by type | changes a 3.0.0 signature | yes | loses the registry-describes-others use |
|
||||
| D. Delete `WriteRecord`'s registry parameter | yes | changes a 3.0.0 signature | yes | loses the copied-record header decision for raw writes |
|
||||
|
||||
Picked B. It is the only shape that keeps every documented use of the registry, needs no public signature change,
|
||||
and deletes the comparison instead of extending it. It is ordinary scoping (an inner binding shadows the outer one
|
||||
for its name), applied to schema ids.
|
||||
|
||||
### Probes (Starfire, local filtered runs, 2026-10-27)
|
||||
|
||||
Scratch clone of fc74cf6f at `<scratchpad>/imagination-claimant-r2/clone`, Soul's `SoulClaimantProbe.cs`,
|
||||
`SoulClaimant2Probe.cs`, `SoulClaimant3Probe.cs` copied into `tests/GameCult.Caching.Tests`.
|
||||
|
||||
Base fc74cf6f (`probe-base.log`):
|
||||
- P7 (registry {N lists own, A, B}; passed entry N with the list varied; record k under O, named like N): lent R / no
|
||||
registry C for rows b, b2, e, g, h, k, l, l2; lent C for a, c, d, f, i, j. Confirms `subset-refuses-own-drift`.
|
||||
- P9 (registry {NW: Name, Value, Colour}; passed NW minus Colour): lent C (restamped, Colour lost), no registry R.
|
||||
Confirms `lent-shape-widens-passed-entry`.
|
||||
- P6 (bare X1 entry, registry {X1} listing OX, record under OX): lent R, no registry R, cache C. h3 closed the id axis.
|
||||
- P4: 12 states x 8 registries, violations=0. P1d: a cache {N,L} commits N over L's listed old id (`cache-replaces-other-types-record`).
|
||||
|
||||
Prototype B (scratch commit 349ea3f3 on the clone, not for landing; `probe-protoB2.log`): `CatalogEntryLookup` takes
|
||||
the written entries and an optional registry and drops every registry descriptor whose schema id a written entry has;
|
||||
the registry's `Owns(schemaId, catalog, declared, mayClaim)` overload is deleted; the cacheless arm always asks
|
||||
`CultDocumentRegistry.Owns(id, catalog, declared, new CatalogEntryLookup(writes, AttachedRegistry), entry => entry,
|
||||
claimant => writes has claimant's schema id)`.
|
||||
- P7: lent == no registry on all 14 rows (all C).
|
||||
- P9: lent R, no registry R, wide entry C.
|
||||
- P6: unchanged (R, R, C). P4: identical table to base, violations=0. P1a-f, P2a-d: identical to base.
|
||||
- `RestampOnWriteTests`, `CachelessSingleFileTests` and the probes: 139/139 passed locally.
|
||||
- Yggdrasil, 349ea3f3, `ygg-protoB-full.log`: GameCult.Caching.Tests 1180 passed / 0 failed / 2 skipped;
|
||||
GameCult.Mesh.Tests 342 / 0 / 4.
|
||||
|
||||
Negative greps, each tested at fc74cf6f (each matches there, and none may match after the cut, src and tests):
|
||||
`claimant.CompatibleSchemaIds.All(` (1, the subset), `lent.Owns(` (1), `Func<CultSchemaCatalogEntry, bool> mayClaim) =>`
|
||||
(1, the deleted overload), `CatalogEntryLookup(writes)` (3, one-argument construction). CHANGELOG and docs:
|
||||
`never widens what the passed entry declares` (1), `with no compatible id the passed entry does not list` (1).
|
||||
|
||||
### Not settled by r2
|
||||
|
||||
- `cache-replaces-other-types-record` (s2, PreExisting, cache arm): r2 touches only the cacheless lookup, so it does
|
||||
not settle whether a cache may replace another registered type's record at a shared key.
|
||||
- Whether a registry-less `WriteDocument` should consult `CultDocumentRegistry.Shared` for claimants at all (s1
|
||||
`shared-decides-registryless-write`, closed Fixed by documentation in h2). r2 keeps today's answer.
|
||||
- `variants:finding:cut-restamp-on-write.s2.cacheless-inverts-registry-claimant` is still in force although r1 was
|
||||
mapped to close it; Self should close or re-triage it after r2's Soul pass.
|
||||
|
|
@ -1,840 +0,0 @@
|
|||
# CultCache publication: the cache owns "published once", and order stays data
|
||||
|
||||
Status, 2026-09-29: Cuts 1-3 landed on `main`. Cut 3 merged at `ae8576d`. Imagination pass 4 re-took Cuts 4-6
|
||||
against `3bf1c0c`. Q-P5 is ruled (A). Cut 4 is ready for Hands.
|
||||
- F4, pinning `Held`'s publish-on-throw guard, is done: Cut 3 pinned it.
|
||||
- F3 (observer removal on unsubscribe) and F1 (the false "getters run once" claim for conditional commits on variant
|
||||
stores) were Cut 3 follow-ups; confirm against the Cut 3 merge.
|
||||
|
||||
Recorded:
|
||||
- F2, the Q-P2 hazard. A rethrown observer exception makes a committed write look cancelled or refused. The
|
||||
only live typed catch around a cache write is AetheriaEve `Aetheria.State.Daemon/Program.cs:3722`. It needs a
|
||||
fail-fast handler, and no production code installs one.
|
||||
- F5: subscribe and unsubscribe cost O(n).
|
||||
- F6: writes after `Dispose` are dropped silently.
|
||||
|
||||
Map written by Imagination pass 1, 2026-09-29. The ends are the
|
||||
operator ruling of 2026-09-30: every committed cache change reaches subscribers
|
||||
exactly once. This document owns the means.
|
||||
|
||||
- **Body.** CultLib `main` `65311c9ad770828adafe40c2c14b2d3da517628e`. Every
|
||||
`file:line` is against it unless it names the branch.
|
||||
- **Superseded attempt.** `origin/hands/cultnet-publish-commits`, tip
|
||||
`28248eb`, base `f9f107a`. Soul pass 3 said do not merge it, and that stands.
|
||||
- Main has not touched `CultCache.cs`, `CultNetDatabase.cs`, the composition
|
||||
doc or the networking tests since `f9f107a`: `git diff --stat` is empty.
|
||||
- `git merge-tree 65311c9 28248eb` is clean.
|
||||
- **Scope.** The C# cache's publication and CultNetDatabase's use of it. There
|
||||
is no wire-format change and no other runtime changes (§6).
|
||||
|
||||
|
||||
## Rulings (operator, 2026-09-30)
|
||||
|
||||
- **Q-P1 A: order as data.** Each change is published exactly once, on its writer's thread, before the write
|
||||
returns, and carries its `Sequence`. The shard log is written in cache order by the gate-held journal. Wire
|
||||
peers drop changes that are stale by `Sequence`. The turnstile (`bcae483`/`4562340`) and the drainer are
|
||||
both rejected.
|
||||
- **Q-P2 A:** a throwing observer does not stop the others. Every observer runs, the exception is rethrown to
|
||||
the writer afterwards, and the observer stays subscribed. The commit stands.
|
||||
- **Q-P3 A:** a public `CultCache.AddJournal(...)`, with reentry guarded.
|
||||
- **Q-P4 A:** Cut 6, which drops stale changes per key on the wire, lands in this campaign.
|
||||
- **Q-P5 A (2026-09-29):** a primary that cannot log a committed change throws a typed `CultNetShardLogException`
|
||||
naming the shard and the burned sequence, after publication, not derived from `InvalidOperationException`.
|
||||
|
||||
## The finding that shapes this map
|
||||
|
||||
The direction Imagination was handed was: "ordered delivery belongs in the
|
||||
cache's own publication, on the writer's thread, so a writer delivers its own
|
||||
changes before returning". That design **has been built before, and it was
|
||||
deleted**.
|
||||
|
||||
- `bcae483` (2026-09-13 20:09) made the cache a delivery scheduler: tickets, a
|
||||
delivery gate, turn waits and per-thread queues for reentrant writes.
|
||||
- `4562340`, 34 minutes later, deleted it: "It deadlocked inside CultLib: a
|
||||
load's delivery reaching a CultNetDatabaseSubscriptionServer observer that
|
||||
needs _lifecycleGate, while a subscribe's demand handler holding that gate
|
||||
writes the cache and waits for the delivery's turn."
|
||||
- It replaced it with **order as data**: a per-cache `Sequence`, and no
|
||||
delivery scheduler.
|
||||
- The regression tests it left, `ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries`
|
||||
(`ConditionalCommitTests.cs:763`) and
|
||||
`SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock`
|
||||
(`NetworkingTests.cs:2244`), are live on main.
|
||||
|
||||
This pass rebuilt that scheduler as a prototype. It is the per-cache turnstile
|
||||
with same-thread trampolining at `79f4639`, and it reproduces the deletion:
|
||||
|
||||
- Caching tests: 278 of 279 pass, and `ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries`
|
||||
fails.
|
||||
- The networking suite hangs, and Yggdrasil's idle watchdog killed it at 900 s.
|
||||
The blame-hang run that names the hung test is recorded below.
|
||||
|
||||
The reason is structural, not an implementation slip. Once a writer waits for
|
||||
another thread's delivery, any lock that an observer takes and a writer holds
|
||||
becomes a deadlock:
|
||||
|
||||
- total cross-thread order;
|
||||
- a writer delivering before it returns;
|
||||
- no writer waiting on another thread.
|
||||
|
||||
Pick two. The branch's drainer chose order plus no-wait, and lost
|
||||
before-return (F3 and F4). The turnstile chose order plus before-return, and
|
||||
lost no-wait (the `bcae483` deadlock).
|
||||
|
||||
**So this map keeps order as data (the `4562340` ruling) and moves the one
|
||||
order-sensitive consumer, the CultNet mutation log, to the place order is
|
||||
decided: under the cache gate.**
|
||||
|
||||
- Every writer keeps delivering its own changes, on its own thread, before it
|
||||
returns, exactly as main does. F3 is satisfied by not regressing it.
|
||||
- The log becomes a **journal** written in the admitting hold, in Sequence
|
||||
order.
|
||||
- The reorder buffer and drainer die, and no scheduler replaces them. This
|
||||
departs from Soul's stated direction on purpose, and **Q-P1** puts it to the
|
||||
operator.
|
||||
|
||||
## Probe evidence
|
||||
|
||||
All of this ran on Yggdrasil via `ygg-verify.sh`, from scratch commits in a
|
||||
throwaway worktree that no branch holds.
|
||||
|
||||
- Probe file: `tests/GameCult.Caching.Tests/PublicationProbeTests.cs` in scratch commit `79f4639`. The worktree has been removed. The commit is unreferenced in the local object store, where gc can collect it; it also survives on the Yggdrasil mirror as `refs/verify/79f4639f8f5b95db7ce16f244a081c1ec101a468`.
|
||||
- `9c5abf5` is the probes on main. `79f4639` is the same probes plus the
|
||||
turnstile prototype in `CultCache.cs`.
|
||||
|
||||
| # | Probe | main `65311c9` | Turnstile prototype `79f4639` |
|
||||
|---|---|---|---|
|
||||
| Q1 | A batch `{ok, boom}` where boom's `[CultName]` getter throws | `TargetInvocationException`. `_sequence` goes from 0 to 1, because ok was minted. **Both ok and boom are in memory**, and `GetByName("ok")` finds ok. **Nothing is published**, and the next write gets Sequence 2. F1 is worse than stated: memory is corrupt and unpublished. In a store-backed cache, `land` (`:2325`) already ran, so the store holds it too. | No stall, but memory is still corrupt. |
|
||||
| Q2 | Two `Watch` observers, the first throws | With the default R3 handler, the second observer sees 2 of 2 and the writer sees nothing. **With a rethrowing handler, the second observer sees 0 of 2 and every writer gets the exception.** The comment at `CultCache.cs:2374`, "OnNext does not throw", is false under fail-fast. | With its own observer array and a per-observer catch, the second observer sees 2 of 2 and the writer gets the exception afterwards. |
|
||||
| Q3 | Writer A's observer blocks on `a`, then B writes `b` | The recorder sees `b(2)` before `a(1)`. There is no cross-thread order, by design since `4562340`. | Sees `a` then `b`, because B waited for A. |
|
||||
| Q4 | An observer on `x` writes `y` | `y(2)` before `x(1)`: nested, depth-first, and the nested write returns after `y` is published. | `x` then `y`, and the nested write returns before `y` is published. |
|
||||
| Q5 | `OnUpdate` on Upsert and on Commit | 0 fires, so loads only. `Watch` sees 2. | Unchanged. |
|
||||
| Q6 | 8 threads × 1000 writes, plus reentrant writes | 67 ms, 2645 of 8800 cross-thread inversions, and 0 writes returned undelivered. | 516 ms, 0 inversions, 0 undelivered. |
|
||||
| Q7 | Soul's P2 shape: a handler waits 1 s for another thread's write | Observed in time. | Not observed in time. |
|
||||
| Suites | Caching, Networking and Mesh | (green on main) | Caching fails `ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries`, and Networking **hangs** until the 900 s idle kill. Rerun with `--blame-hang-timeout 90s`: 286 passed, then the test host hung and was dumped. The blame sequence file was not extracted, so **the hung test is not named**. It is consistent with, but not proven to be, `SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock`, whose own comment says "disposal would hang too". |
|
||||
|
||||
## Imagination pass 4: Cut 4 re-taken on the landed journal
|
||||
|
||||
Cut 3 landed at `ae8576d`. Its Soul left two questions that Cut 4 had to settle before relying on the
|
||||
journal: (a) what a throwing journal means for the log, and (b) whether a journal needs the change's origin.
|
||||
Both have one coherent answer. The one fork, Q-P5, was about who hears of a log failure; the operator ruled it A (2026-09-29).
|
||||
|
||||
### Probe evidence (pass 4)
|
||||
|
||||
- Probe file: `Pub4ProbeTests.cs` (Networking tests). It was run on Yggdrasil from scratch commits:
|
||||
- `0d7397d`: main plus the probes;
|
||||
- `6ec2f73`: the same, merged with the branch `28248eb`;
|
||||
- `376cd74`: main plus probe C.
|
||||
- The commits survive on the Yggdrasil mirror. The scratch worktree is gone.
|
||||
- The probes report facts and assert nothing. `FlakyLogStore` is an in-memory `ICultNetShardMutationLogStore`
|
||||
that can refuse `Append` or run a hook inside `GetCompactedThrough`.
|
||||
|
||||
| # | Probe | main `3bf1c0c` | branch `28248eb` (queue) |
|
||||
|---|---|---|---|
|
||||
| A1 | Primary with a durable log. Put 1, Put 2 (its `Append` throws), Put 3. A replica pulls, then retries. | Writer gets `IOException`. The cache holds 1, 2 and 3, but **2 is never published** (2 of 3). Memory log `[1,2,3]`, durable `[1,3]`. The replica pull throws "log has a gap. Expected 2, received 3", and so does **every retry**: the replica is stuck for good. | **Writer sees nothing**: R3 swallowed the drainer's exception. 2 is not published. The replica is stuck in the same way. |
|
||||
| A2 | The same failure at the tail, then the primary restarts over the same cache and log store. | The replica applied 1. After restart, note 3 is **minted sequence 2**. The replica takes it, reports applied 2, and holds `1,3` against the primary's `1,2,3`: **silent divergence**, a reused sequence. | Same. |
|
||||
| A3 | A replica whose log `Append` throws once. | The pull throws and the cursor stays at 0. The retry converges (`1,2`), but the memory log is `[1,1,2]`: a duplicate, because memory is written before disk and kept as a `List`. | The pull "succeeds": the exception was swallowed and the cursor went to 2. The durable log is `[2]`, so **entry 1 is lost**, and a chained replica sees a gap. |
|
||||
| B1 | Primary with a file store. Another writer puts `pulled` into the file, the primary runs `PullAll`, and a replica pulls the log. | `pulled` is published but **not logged**. The replica gets nothing. | Logged. The replica converges on `pulled`. |
|
||||
| B2 | A store attached after the database exists. | Nothing is logged. | All three hydrated records are logged. |
|
||||
| S | `CreateShardSnapshotResponse` with a write landing between its document read and its sequence read (hooked in `GetCompactedThrough`). | The snapshot holds `[1]` at log sequence 2. The replica applies it and pulls, and still lacks 2: **divergence**. | Same. |
|
||||
| C | Compact through 2, restart, then Put 3. | Note 3 is **minted sequence 1**, and `GetLatestMutationLogSequence` returns 1, below the floor of 2. | (not run; the code is unchanged) |
|
||||
|
||||
What the probes establish:
|
||||
- A1, A2, S and C are pre-existing defects on main. Cut 4 inherits them and must not build on them: the
|
||||
resync path it relies on for (a) is itself broken by S and C.
|
||||
- On a replica, the pull loop already retries. The cursor advances only after an entry applies
|
||||
(`CultNetDatabase.cs:1038-1083`), so a replica needs no new mechanism. It needs only to keep the exception
|
||||
reaching that loop, which the branch's queue broke (A3).
|
||||
- Loads on a primary must be logged, or replicas diverge (B1).
|
||||
|
||||
### (a) A journal that cannot log a committed change: one answer
|
||||
|
||||
The facts:
|
||||
- The log store is a second durable store written after the cache's store. Without an atomic commit across
|
||||
the two, the log can lag the store. It must never lead: a replica would then apply a change the primary
|
||||
refused.
|
||||
- So lag is unavoidable, and it has to be detectable and repaired.
|
||||
- The protocol already has a repair, and nothing else needs to be invented.
|
||||
- "History the primary cannot serve" is `compacted_history`: `CultNetDatabaseServer.cs:189-201` answers
|
||||
`ResyncRequired`.
|
||||
- `CultNetShardReplication.cs:199-214` then snapshot-resyncs.
|
||||
|
||||
**Rule: a primary that cannot log a committed change burns that change's sequence and compacts through it.**
|
||||
|
||||
- In the database journal, on a primary, for each change that is not `NoLog`:
|
||||
1. Mint `S`.
|
||||
2. Build the entry: the wire message from the door, or `ToLogEntryMessage`.
|
||||
3. `Append` durably, **then** record it in memory. Disk goes first, so memory never serves what disk lacks.
|
||||
- If step 2 or 3 throws:
|
||||
- record nothing;
|
||||
- raise the database's in-memory floor for the shard to `S`;
|
||||
- try `store.CompactThrough(shard, S)`;
|
||||
- still set `_lastWriteSequence` to `S`, because the row was written at `S`;
|
||||
- carry on with the rest of the admission;
|
||||
- throw `CultNetShardLogException` once at the end of the journal call (Q-P5 A).
|
||||
- Every replica behind `S` is then told `compacted_history` and resyncs by snapshot. The sequence gap at `S`
|
||||
is never served, because the floor covers it.
|
||||
- This **replaces** the old §4 step 3 claim, "a serialization failure then mints nothing, so the log can never
|
||||
hold a gap". Minting nothing hides a replication gap behind a dense sequence. That is exactly A2's silent
|
||||
divergence.
|
||||
- **Replica:** there is no floor. The journal's exception reaches `ApplyShardLogResponseAsync` and the cursor
|
||||
does not advance. The next pull re-applies the entry: a fresh instance, so the door matches again, and the
|
||||
entry is re-recorded under the primary's sequence. The branch's absent-key direct log follows the same
|
||||
disk-first order.
|
||||
- **Required fixes** that the rule depends on, all in Cut 4:
|
||||
- `GetCompactedMutationLogSequence` (`CultNetDatabase.cs:597-602`) returns
|
||||
`max(store floor, in-memory floor)`.
|
||||
- `InitializeLogSequencesFromStore` (`:1363-1395`) resumes at
|
||||
`max(highest retained, store.GetCompactedThrough(shard)) + 1`. This is probe C.
|
||||
- `GetLatestMutationLogSequence` (`:607-616`) never reports below the floor.
|
||||
- `CreateShardSnapshotResponse` (`:624-670`) reads `GetLatestMutationLogSequence` **before** `SelectAll`, on
|
||||
both returns, `:645` and `:668`. The documents then reflect at least that sequence, and replaying later
|
||||
entries converges: they are whole-document puts and deletes, applied in order. This is probe S.
|
||||
- `RecordMutationLogEntry` (`:1331-1352`) writes disk first. The in-memory log becomes
|
||||
`SortedList<long, entry>` per shard, replacing by sequence as the durable store does. This fixes A3's
|
||||
duplicate.
|
||||
- **Residual, recorded rather than forked.** Suppose the store refuses both `Append` and `CompactThrough`, and
|
||||
then the process restarts.
|
||||
- The durable log does not know about the hole, so A2's tail reuse can recur.
|
||||
- Fixing that needs a durable write per mint, a sequence reservation, which doubles log I/O under the gate.
|
||||
- The thrown exception names both failures, so the outage is loud.
|
||||
- Refusing later writes ("fail closed") is not coherent. The database can refuse only its own doors, while
|
||||
bare cache writes are journaled regardless: that would be split authority.
|
||||
|
||||
### (b) A pulled or loaded change on a primary: no origin needed
|
||||
|
||||
- On a primary the log is the shard's **state history**, whoever wrote the state. B1 shows that leaving loads
|
||||
out of the log leaves replicas permanently diverged.
|
||||
- The only origins that change what is logged are already carried by the door table, keyed by instance
|
||||
identity and matched by shape. They are:
|
||||
- `Replica` (log the primary's entry);
|
||||
- `NoLog` (prediction, snapshot resync);
|
||||
- `Put`/`Delete` (the wire message);
|
||||
- `Predicts`/`Authoritative`.
|
||||
- A load or pull has no door, so on a primary it is logged like any unattributed commit. On a replica it logs
|
||||
nothing, like any local write. That is what the branch does, and B1 converges under it.
|
||||
- Cost:
|
||||
- B2: a store attached **after** the database logs its hydration.
|
||||
- `CultNetHost` opens the cache before building the database (`CultNetLocal.cs:146-152`), so production
|
||||
hydration is not journaled.
|
||||
- A re-pull admits only the records that changed. In B1 the seed was not re-logged.
|
||||
- `AddJournal` keeps its signature. If a future consumer (audit, say) needs "load or commit", the fact goes on
|
||||
`CultCacheDocumentChange` as an additive property, as `Sequence` did. The cache already knows it: `Admit`'s
|
||||
`source != null` at `CultCache.cs:2420`. It never needs a new `AddJournal` shape.
|
||||
- This is recorded, not forked: no consumer needs it today.
|
||||
|
||||
### The two recorded hazards, settled for Cut 4
|
||||
|
||||
- **Q-P2 typed-exception hazard.** Cut 4 makes it frequent. Every cache write on a primary with a durable log
|
||||
now does log I/O inside its admission.
|
||||
- AetheriaEve opts into durable logs (`Aetheria.State/AetheriaStateNode.cs:144`).
|
||||
- Its `AetheriaHangarCommandJournal.ValidateAsync` writes an envelope (`Program.cs:6509`) inside the `catch
|
||||
(InvalidOperationException)` at `Program.cs:3722`.
|
||||
- A log failure surfacing as `InvalidOperationException` would therefore produce a "denied" receipt for a
|
||||
committed envelope.
|
||||
- Q-P5 A (ruled): the writer gets a typed `CultNetShardLogException` that **does not derive from
|
||||
`InvalidOperationException`**.
|
||||
- **Journal deadlock (the Cut 3 note).** The database journal waits only on `_logGate` and on the log store's
|
||||
own lock.
|
||||
- Lock order: cache gate, then `_logGate`, then the log store's lock.
|
||||
- Invariant: **no code holding `_logGate` calls the cache, and no log store calls the cache or waits on a
|
||||
thread that does.**
|
||||
- Readers that take `_logGate` read only log state: `GetMutationLog`, `LastWriteSequence`, `CurrentAsOf`.
|
||||
Readers already inside a cache read, such as the selection evaluator's `ordinalOf`, nest `_logGate` under
|
||||
the cache gate, which is the same order.
|
||||
- The store rule goes into the `ICultNetShardMutationLogStore` XML doc (`CultNetShardMutationLogStorage.cs:13-34`),
|
||||
because a user store runs under the cache's gate.
|
||||
- A log store that writes the cache already throws, through Cut 3's guard. Cut 4 pins it with a test.
|
||||
|
||||
## Authority map
|
||||
|
||||
- **Owner.** `CultCache`. The hold that mints a Sequence range also:
|
||||
1. journals the range under the gate, in Sequence order (new);
|
||||
2. publishes the range to every observer, isolated, on the minting writer's
|
||||
thread, after it releases the gate and before it returns (main's model,
|
||||
now isolated).
|
||||
|
||||
Nothing waits on another call's delivery.
|
||||
- **Inputs.**
|
||||
- The changes returned by `Apply` (`CultCache.cs:2707`), the single mutation
|
||||
site.
|
||||
- The pre-mint projection of names and indexes (Cut 1).
|
||||
- The journal list, the observer array and `OnUpdate`.
|
||||
- **Outputs.**
|
||||
- Journal calls: one per admission, under the gate, in Sequence order.
|
||||
- `Watch` / `WatchRecord` notifications that carry `Sequence`.
|
||||
- `OnUpdate` for loads.
|
||||
- Observer and journal exceptions, rethrown to the writer.
|
||||
- **Derived state.**
|
||||
- `Sequence` is order as data.
|
||||
- CultNet's primary log sequence is minted in the journal, so log order is
|
||||
cache order.
|
||||
- A replica's log sequence is copied from the primary entry.
|
||||
- The database change kind (`Predicted`, `Reconciled`, `Added`, `Updated`,
|
||||
`Removed`) is decided in the journal.
|
||||
- `CultNetDatabaseChange.Sequence` (new) is copied from the cache change.
|
||||
- Wire projections drop stale by it (Cut 6).
|
||||
- **Forbidden writers.**
|
||||
- Nothing re-sequences or schedules cache changes. The branch's `_parked`,
|
||||
`_ready`, `_nextSequence`, `_draining`, `PendingChange` and the drain body
|
||||
of `OnCacheChange` die, and so do the turnstile, `bcae483` and the
|
||||
prototype.
|
||||
- No R3 `Subject` fans out cache changes (Q2).
|
||||
- `Apply` and `Index` run no user code.
|
||||
- Doors never read or write prediction state: the branch's `HasPrediction`
|
||||
and `_predictionGate` die.
|
||||
- The database's Watch observer never logs; only the journal logs.
|
||||
- A journal never enters the cache.
|
||||
- **Shared paths.** Every path goes `Held` → `Admit` → `Resolve` → **Project**
|
||||
→ `land` → `Apply` → **journal** → leave gate → **publish**:
|
||||
- loads: `AddBackingStore` `:1909`, `Loaded` `:1940`, `PullAll*` `:1959` and
|
||||
`:3052`;
|
||||
- `Commit` / `TryCommit` (`Land`, `:2272`);
|
||||
- `UpsertAsync`, `UpsertVariantAsync`, `FlattenAsync` and `Remove`
|
||||
(`:2004-2056`, `Write` `:2252`);
|
||||
- a variant dependent's re-resolution (`VariantPlan.Dependents`, in the same
|
||||
`Apply`);
|
||||
- direct calls on an attached store (`:2924`);
|
||||
- `Dispose` (`:2142`);
|
||||
- the database doors, replica apply and snapshot resync, which only write
|
||||
the cache and hand context to the journal.
|
||||
|
||||
The single exception is the replica's absent-key delete, which has no cache
|
||||
change. It logs directly (§4).
|
||||
- **Deletion line.**
|
||||
- In the cache: `Subject<Change> _changes` (`:1809`), the `_changes.OnNext`
|
||||
call and its false comment (`:2374-2375`), `_changes.Dispose()`
|
||||
(`:2154`), and the accessor calls in `Index` (`:2798-2804`).
|
||||
- In the database, on the salvaged branch: the reorder buffer and drainer,
|
||||
`_predictionGate` and `HasPrediction`, the logging in `Deliver`, and the
|
||||
`Subject<object> _changes` fan-out.
|
||||
|
||||
## The design, question by question
|
||||
|
||||
### 1. Validation before minting: a refused change mints nothing
|
||||
|
||||
- **Where.** Add **Project** to `Admit` (`:2313`), between `Resolve` (`:2324`)
|
||||
and `land` (`:2325`).
|
||||
- **What.** Over `plan.Admitted ∪ plan.Dependents`, it evaluates once per
|
||||
record: `NameAccessor(document)` and every `IndexAccessors[alias](document)`.
|
||||
It keeps `(Name, (Alias, Value)[])` per key on the plan.
|
||||
- **A throwing getter refuses the whole admission.** It raises an
|
||||
`InvalidOperationException` that names the key, the member and the unwrapped
|
||||
message; today the caller sees a bare `TargetInvocationException`. The
|
||||
refusal comes before `land`, so the admission writes no store, touches no
|
||||
memory and mints nothing.
|
||||
- **Readers take the projection, not the document.**
|
||||
- `Index` (`:2793-2806`).
|
||||
- `RefuseVariantIndexSharing` (`:2659-2660`).
|
||||
- The variant-name rule in `ResolveOne` (`:2616`). Its ancestor read at
|
||||
`:2621` reads held documents, pre-land, and may stay.
|
||||
|
||||
`Resolve` currently runs the refusal rules before any projection exists.
|
||||
Hands makes Project the first pass over the resolved plan and moves both
|
||||
rules after it, so rules read the projection and only the projection reads
|
||||
documents.
|
||||
- **Apply becomes user-code-free.** It then does only dictionary and
|
||||
`ConditionalWeakTable` work on precomputed data.
|
||||
- **Loads refuse too.** A store whose record's getter throws fails to open,
|
||||
loudly. That is the rule "hydration failure on open is loud" (composition doc
|
||||
`:88-92`). Today it half-indexes silently.
|
||||
|
||||
### 2. Observer isolation
|
||||
|
||||
- **Replace the `Subject<Change>` with the cache's own observer array**,
|
||||
copy-on-write under a small lock.
|
||||
- `Watch<T>` (`:1887`) becomes `Observable.Create<Change>(register)` followed
|
||||
by the existing `Where` and `Select`.
|
||||
- `Dispose` calls `OnCompleted` on each observer, which is what
|
||||
`Subject.Dispose` did.
|
||||
- **Publish loops change → observer, and wraps each `OnNext` in its own
|
||||
try/catch.**
|
||||
- R3's `Observer<T>` already catches handler exceptions (`OnErrorResume`, then
|
||||
the unhandled handler). So an exception reaches the cache only when fail-fast
|
||||
was asked for: a rethrowing `ObservableSystem` handler, or a custom observer.
|
||||
- Escaped exceptions join the `OnUpdate` failures. They are rethrown to the
|
||||
writer after every observer has received every change of the hold. That is
|
||||
the existing `OnUpdate` policy (composition doc `:77-82`).
|
||||
- The observer stays subscribed (**Q-P2**). The commit stands either way.
|
||||
- **Prototype.** At `79f4639` (Q2) this took about 15 lines. Hands keeps that
|
||||
part and drops the turnstile around it.
|
||||
|
||||
### 3. Delivery thread, order and reentrancy
|
||||
|
||||
- **Unchanged from main, on purpose.** Each outermost hold publishes exactly its
|
||||
own changes, on its thread, after it leaves the gate and before it returns
|
||||
(composition doc `:59-65`, `Held` `:2339`).
|
||||
- A write from inside a handler is its own outermost hold. It delivers
|
||||
depth-first before returning, as Q4 shows on main.
|
||||
- There is no cross-thread order. Every change carries `Sequence`, and
|
||||
latest-state consumers drop stale changes by it (doc `:68-73`, and the Mesh
|
||||
mirrors at `CultMesh.cs:1534-1540`).
|
||||
- **Why this is not a regression of the ruling.** The ruling asks for
|
||||
exactly-once, not for a total delivery order.
|
||||
- Exactly-once in the cache comes from the single mint site plus isolation
|
||||
plus Cut 1 (§1-2).
|
||||
- Order is needed by exactly one consumer, the shard log. It gets that order
|
||||
from the journal (§4), not from delivery.
|
||||
- **The journal is the new mechanism.**
|
||||
- Signature: `IDisposable CultCache.AddJournal(Action<IReadOnlyList<CultCacheDocumentChange<object>>>)`.
|
||||
Its name and shape are **Q-P3**.
|
||||
- It is called in `Admit` right after `Apply` (`:2328-2329`), under the gate,
|
||||
once per admission, with that admission's changes in Sequence order.
|
||||
Journals run in registration order.
|
||||
- A journal that enters the cache, meaning any hold on this cache while a
|
||||
journal runs, throws `InvalidOperationException`. It is guarded by a flag
|
||||
checked in `Held`'s `Monitor.IsEntered` branch (`:2341`). This is the
|
||||
store rule ("a store call must not wait for a write to its own cache"),
|
||||
enforced.
|
||||
- Journal exceptions follow the observer policy: the admission stands, it is
|
||||
still published, and the exception is rethrown to the writer after
|
||||
publication.
|
||||
- Adding or removing a journal takes the gate, so registration is atomic with
|
||||
respect to admissions.
|
||||
- **Lock order.** Cache gate, then journal-internal leaf locks (the database's
|
||||
`_logGate`). Observers still run outside the gate, so the `bcae483` cycle
|
||||
(an observer needing `_lifecycleGate` while a gate holder writes the cache)
|
||||
cannot form. No journal takes `_lifecycleGate`, and no writer waits on a
|
||||
delivery.
|
||||
- **Soul's P2 passes.** A handler waiting on another thread's write sees it,
|
||||
because nothing orders deliveries (Q7 on main).
|
||||
|
||||
### 4. What CultNetDatabase keeps
|
||||
|
||||
The database is rebuilt on the journal. It has no queue.
|
||||
|
||||
- **Journal (under the cache gate, per admitted change, in Sequence order):**
|
||||
1. `TakeDoor(change)`, with the shape rule salvaged from the branch
|
||||
(`:1219-1234`). A put door matches a change whose new document is its
|
||||
instance and whose previous document is not. A removal door matches a
|
||||
change whose previous document is its instance and which has no new one.
|
||||
- The consumption now happens inside the door's own hold, before any
|
||||
observer runs. So an observer's later write to the door's key or
|
||||
instance can never wear the context (the `f3343c8` defect), and
|
||||
unconditional `Dispose`-removal stays correct.
|
||||
2. Predictions. A `Predicts` door adds the prediction. On an `Authoritative`
|
||||
door (`ApplyPutAsync`, replica put), if a prediction exists for the key,
|
||||
the kind is `Reconciled` and the prediction is removed.
|
||||
- The gate serializes this, so `_predictionGate` and `HasPrediction` are
|
||||
deleted (F5).
|
||||
- Soul's P6 cannot happen: the prediction is recorded before
|
||||
`PutPredictedAsync` returns.
|
||||
3. Primacy.
|
||||
- On a shard primary, where the door is not `NoLog`: mint, build, append disk-first and record, with a failed
|
||||
append burning the sequence and compacting through it. See "(a) A journal that cannot log a committed
|
||||
change" in Imagination pass 4; the earlier claim that a serialization failure mints nothing is withdrawn.
|
||||
- On a replica, record the primary's entry from the `Replica` door,
|
||||
under its own sequence.
|
||||
- For a local write on a replica, log nothing.
|
||||
4. Stash `(cache Sequence → CultNetDatabaseChange)` under `_logGate`, for the
|
||||
publisher.
|
||||
- **Watch observer (after the gate, on the writer's thread, before it
|
||||
returns):** it removes the stash entry for `change.Sequence` and publishes it.
|
||||
- A change with no stash was admitted before the database existed and is
|
||||
ignored.
|
||||
- Register the observer first and the journal second, in the constructor, so
|
||||
a stash can never be orphaned.
|
||||
- **Log-before-publish** is structural: the journal runs before the gate opens.
|
||||
- **The replica's absent-key delete (F6)** logs directly under `_logGate`. It
|
||||
is in order because every earlier entry of the same pull loop was journaled
|
||||
inside its own hold, before that `UpsertAsync`/`Remove` returned. This holds
|
||||
even when a pull runs inside a handler, since nested holds journal
|
||||
synchronously.
|
||||
- **The in-memory log is keyed and ordered by sequence.** It becomes a
|
||||
`SortedList<long, entry>` per shard, to match the durable store
|
||||
(`CultNetShardMutationLogStorage.cs:55-64, 75-89`: that store replaces by
|
||||
sequence and orders by sequence).
|
||||
- **Kept from the branch:**
|
||||
- the door table keyed by instance identity, and the shape rule;
|
||||
- "only a primary mints; replicas publish";
|
||||
- log-before-publish;
|
||||
- the snapshot resync "logs nothing" rule. Its removal door (branch `:733`)
|
||||
must set `Removal = true`, or the shape rule never matches it.
|
||||
- **Database fan-out isolation.** The database's `Subject<object> _changes`
|
||||
(main `:487`) has the Q2 defect one layer up: the subscription server's
|
||||
per-peer handlers sit on it. Replace it with the same isolated
|
||||
observer-array pattern as §2 (about 20 lines). Its exceptions reach the
|
||||
writer through the cache's own per-observer catch.
|
||||
- **`CultNetDatabaseChange<T>` gains `long Sequence`**, the cache change's
|
||||
Sequence, as an optional constructor argument, exactly as
|
||||
`CultCacheDocumentChange` did (composition doc `:57-58`). Without it,
|
||||
in-process latest-state consumers cannot drop stale changes.
|
||||
- **Gone:** F3 (no deferred delivery), F4 (no drainer), F7 (no queue to
|
||||
strand) and F8 (no guard).
|
||||
|
||||
### 5. Backpressure and memory bounds
|
||||
|
||||
- **No buffer exists.** A writer delivers its own changes, and they are held
|
||||
only in its `mine` list for the length of its own call.
|
||||
- **The journal stash** holds at most the changes of holds between their
|
||||
journal and their publication. That is one hold per writing thread, and each
|
||||
entry is removed by the same thread that added it.
|
||||
- **A slow observer slows only the writer whose change it is handling.** That
|
||||
is main's behaviour. It does not slow other writers, and it does not grow
|
||||
memory. This replaces the branch's unbounded drainer backlog (F4, Soul P5).
|
||||
- **The journal runs under the gate, so a slow journal slows every writer.**
|
||||
- The database journal does in-memory work plus one durable
|
||||
`mutationLogStore.Append` per logged change. `Append` rewrites the shard
|
||||
file (`CultNetShardMutationLogStorage.cs:83-89`).
|
||||
- That is I/O under the gate, which the locking doctrine already allows for
|
||||
stores ("A store doing I/O on behalf of its cache ... holds the gate",
|
||||
composition doc `:98-101`). It is still a new cost, so Cut 4's
|
||||
verification measures commit latency with a file log store.
|
||||
- The whole-file rewrite is a pre-existing monolith. It is recorded as a
|
||||
follow-up, not fixed here.
|
||||
|
||||
### 6. Cross-runtime impact
|
||||
|
||||
The Eyes survey was run at `65311c9`.
|
||||
|
||||
- **`cultcache-rs`, `cultcache-ts` and `cultcache-py` have no publication
|
||||
surface:** no watch, no listener and no Sequence.
|
||||
- Rust: `lib.rs:1970-1975, 2213-2420`.
|
||||
- TypeScript: `cult-cache.ts:164-181`, "Observer delivery is not scheduled
|
||||
here".
|
||||
- Python: `cache.py:30-45`, which refuses reentrant mutation.
|
||||
- **`cultnet-rs`, `cultnet-ts` and `cultnet-py` have no CultNetDatabase
|
||||
equivalent and no cache watcher.** The only server-side shard log is the
|
||||
Python interop test peer (`interop_peer.py:565, 583`), which mints
|
||||
`len(log)+1` under its own lock, inline with the write. That is already the
|
||||
journal's shape.
|
||||
- **Wire parity is unaffected.**
|
||||
- Cache `Sequence` never goes on the wire (composition doc `:47-49`).
|
||||
- `CultNetShardLogEntryMessage` does not change.
|
||||
- `CultNetDatabaseChangeRawMessage` (`CultNetSchemaMessages.cs:1016`) carries
|
||||
no sequence, and this map adds none: stale protection for wire
|
||||
subscriptions is server-side (Cut 6).
|
||||
- No vector in `tests/vectors/` encodes publication order.
|
||||
- **No other runtime must change.**
|
||||
- **Follow-up.** `cultmesh-py`'s `CultMeshDatabase` watch (`node.py:421-505,
|
||||
1059-1065`) has F2 (a throwing callback skips the rest and reaches the
|
||||
writer), and it has no Sequence. When Python grows cache publication, it
|
||||
takes this contract.
|
||||
|
||||
## Cuts
|
||||
|
||||
Every cut is on CultLib, branch `hands/cultcache-publication` from `main`
|
||||
`65311c9`, in a worktree Self creates. Cuts 1-3 touch only the cache and land
|
||||
first. Cuts 4-6 depend on Cut 3. Heavy verification runs on Yggdrasil.
|
||||
|
||||
**The negative checks that stay green in every cut** are the `4562340` tests:
|
||||
|
||||
- `ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries`;
|
||||
- `SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock`;
|
||||
- `SequenceIncreasesInAdmissionOrder`;
|
||||
- `WatchRecordIgnoresStaleSequence`.
|
||||
|
||||
They are the proof that no scheduler has come back.
|
||||
|
||||
### Cut 1. Project before mint (cache, behaviour)
|
||||
|
||||
- **First.** Commit Q1 as a failing test named for the rule.
|
||||
- **Deletes first.**
|
||||
- Accessor calls in `Index` (`:2798-2804`).
|
||||
- Direct accessor reads in `RefuseVariantIndexSharing` (`:2659-2660`) and
|
||||
`ResolveOne` (`:2616`).
|
||||
- **Adds.**
|
||||
- `Project` in `Admit` (`:2324-2325`).
|
||||
- A per-key projection on `VariantPlan` (`:2456`).
|
||||
- The early returns in `Resolve` (`:2477-2478`, `:2530-2534`) still pass
|
||||
through `Project`, because it is called from `Admit`.
|
||||
- **Authority.** Project owns "this admission's name and index values", and
|
||||
the rules and `Index` read them. `Apply` runs no user code.
|
||||
- **Verification.**
|
||||
- Tests:
|
||||
- **A refused getter mints nothing and changes nothing.** `GetWithSequence`
|
||||
is unchanged, both keys are absent, the name lookup misses, nothing is
|
||||
published, and the next write is previous+1.
|
||||
- **Store-backed batch:** the store file is byte-identical, and a reopen
|
||||
holds neither key.
|
||||
- **A load whose getter throws** refuses the open and leaves the file
|
||||
byte-identical.
|
||||
- **Once per landed record:** a counting getter runs exactly once per
|
||||
landed record, including a variant dependent re-resolved by a base edit.
|
||||
- **The refusal names** the key and the member.
|
||||
- Soul P4.
|
||||
- Negative grep: `rg -n "NameAccessor\?\.Invoke|IndexAccessors" src/GameCult.Caching/CultCache.cs`
|
||||
has no hit in `Apply`, `Index` or `Unindex`. `:315` is store-side and
|
||||
legitimate.
|
||||
- Stryker.NET on the diff.
|
||||
- **Ledger.** About −15 and +45.
|
||||
|
||||
### Cut 2. Observer isolation (cache, behaviour)
|
||||
|
||||
- **Deletes first.**
|
||||
- `Subject<Change> _changes` (`:1809`).
|
||||
- `_changes.OnNext` and the false comment (`:2374-2375`).
|
||||
- `_changes.Dispose()` (`:2154`).
|
||||
- **Adds.**
|
||||
- The observer array and `Observable.Create` in `Watch<T>` (`:1887-1897`).
|
||||
- The per-observer catch in `Publish` (`:2369`), with escapes joining
|
||||
`failures`.
|
||||
- `OnCompleted` on `Dispose`.
|
||||
- **Docs.** Composition doc `:77-82`: escaped observer exceptions follow the
|
||||
`OnUpdate` policy.
|
||||
- **Verification.**
|
||||
- Tests:
|
||||
- **Q2 with a rethrowing handler:** the second observer receives every
|
||||
change. The writer gets the escaped exception after delivery, and the
|
||||
thrower is still subscribed (per Q-P2).
|
||||
- **Subscription order:** subscribing and unsubscribing during a delivery
|
||||
leaves every other observer's stream complete.
|
||||
- **Dispose** completes observers.
|
||||
- Soul P3.
|
||||
- The four `4562340` tests stay green.
|
||||
- Stryker on the diff, especially removing the catch and breaking out of
|
||||
the loop on the first failure.
|
||||
- **Ledger.** About −5 and +30.
|
||||
|
||||
### Cut 3. The journal (cache, behaviour)
|
||||
|
||||
- **Adds.**
|
||||
- `AddJournal` (Q-P3), with registration under the gate.
|
||||
- The invocation in `Admit` after `Apply` (`:2328`).
|
||||
- The no-reentry guard in `Held` (`:2341`).
|
||||
- Exceptions routed like observer failures.
|
||||
- **Docs.** A composition-doc paragraph in "Store composition" and in
|
||||
"Locking": journals run under the gate, in Sequence order, and must not
|
||||
enter the cache or wait for anything that needs it.
|
||||
- **Verification.**
|
||||
- Tests:
|
||||
- **Order:** 8 threads × 1000 writes. The journal sees contiguous,
|
||||
strictly increasing Sequences, and each call's changes arrive together.
|
||||
- **Timing:** the journal runs before any observer of the same change, and
|
||||
after `Apply` (`Get` inside the journal sees the new document).
|
||||
- **Reentry:** a journal that writes the cache throws, and the admission
|
||||
stands.
|
||||
- **Refusal:** a refused admission journals nothing.
|
||||
- **Dependents:** a variant dependent is journaled in the same call as its
|
||||
base edit.
|
||||
- **Failure:** a throwing journal still lets the change publish, and the
|
||||
writer gets the exception.
|
||||
- The `4562340` tests stay green.
|
||||
- Stryker on the diff.
|
||||
- **Ledger.** About +50.
|
||||
|
||||
### Cut 4. Salvage the branch and rebuild CultNetDatabase on the journal (networking)
|
||||
|
||||
- **4a (history).** `git merge origin/hands/cultnet-publish-commits` into the cut branch from main
|
||||
`3bf1c0c`.
|
||||
- It is clean: the probe merge `6ec2f73` auto-merged `NetworkingTests.cs` and the composition doc, then
|
||||
built and ran.
|
||||
- The merge brings the tests. The queue dies in 4b.
|
||||
- Self may squash-salvage instead, with the same content.
|
||||
- **4b (subtraction first).** Delete at branch `28248eb` `CultNetDatabase.cs`:
|
||||
- `_parked`, `_ready`, `_nextSequence` and `_draining` (`:502-505`);
|
||||
- `_predictionGate` (`:506-507`);
|
||||
- the Watch subscription and handshake (`:529-531`);
|
||||
- `OnCacheChange` (`:1080-1135`);
|
||||
- `PendingChange` (`:1265-1275`);
|
||||
- `HasPrediction` (`:1209-1213`) and its calls (`:916`, `:1574`);
|
||||
- the logging and prediction bookkeeping in `Deliver` (`:1137-1201`).
|
||||
|
||||
Main's `PublishCacheUpdate` / `OnUpdate +=` (`:505`, `:1157`, `:1161-1191`) is already gone on the branch.
|
||||
Hands confirms it is absent after the merge.
|
||||
- **4c (the journal).** Register the Watch observer first and the journal second (`AddJournal`), in the
|
||||
constructor. Per admission, under the cache gate, per change in Sequence order:
|
||||
1. `TakeDoor(change)`, keeping the branch's shape rule (`:1219-1234`).
|
||||
2. Resolve the shard and kind. Predictions: a `Predicts` door adds its key. An `Authoritative` door
|
||||
(`ApplyPutAsync`, replica put) holding a prediction for the key makes the kind `Reconciled` and removes
|
||||
the key.
|
||||
3. Log, per §(a):
|
||||
- a replica door records the primary's entry, disk first;
|
||||
- a primary change that is not `NoLog` mints, builds, appends and records, with failure going to the
|
||||
floor;
|
||||
- anything else logs nothing.
|
||||
4. Stash `(cache Sequence → CultNetDatabaseChange)` under `_logGate`.
|
||||
5. If step 3 failed, throw once at the end of the call, after all changes are handled (Q-P5).
|
||||
- **4c, publication.** The Watch observer removes the stash entry for `change.Sequence` and publishes it. A
|
||||
missing stash means the change was admitted before this database existed, so it is ignored. `Dispose`
|
||||
removes the journal and the observer and clears the stash.
|
||||
- **4d (the §(a) fixes).**
|
||||
- The floor: `GetCompactedMutationLogSequence`, `GetLatestMutationLogSequence`, and resume in
|
||||
`InitializeLogSequencesFromStore`.
|
||||
- Disk-first `RecordMutationLogEntry`.
|
||||
- A `SortedList` per shard.
|
||||
- The snapshot reads its sequence first (`:660` moves below `:668`'s read).
|
||||
- `_logGate` around `_lastWriteSequence`/`_nextLogSequences` readers (`:1302`, `:1314`, `:1325`).
|
||||
- **Door changes.**
|
||||
- Flags: `Predicts` and `Authoritative`.
|
||||
- The snapshot removal door (branch `:733`) sets `Removal = true`.
|
||||
- The replica absent-key delete logs directly and publishes nothing (branch `:1612-1621`).
|
||||
- Doors neither log nor publish. They register context, write the cache, and dispose the registration.
|
||||
- **Verification.**
|
||||
- Every salvaged test passes: `CultNetPublishesCommitsTests` (13) and `CultNetPublicationOrderTests` (S1,
|
||||
S1b, S2, S3, S4, S6, S7, log-before-publish, in-place re-upsert).
|
||||
- Soul pass 3's P1-P7 are committed and passing.
|
||||
- The pass 4 probes become tests named for the rule:
|
||||
- **A1:** a refused primary `Append` publishes the change. The writer gets `CultNetShardLogException`,
|
||||
which names the shard and `S`. A replica pull answers `compacted_history`, and a snapshot resync
|
||||
converges.
|
||||
- **A2:** the same at the tail with a restart: the replica converges after resync, and no sequence is
|
||||
reused. The store's `CompactThrough` succeeded.
|
||||
- **A3:** a refused replica `Append` leaves the cursor unchanged. The retry converges, and the memory log
|
||||
holds `[1,2]`.
|
||||
- **B1:** a pull on a primary is logged, and the replica converges.
|
||||
- **S:** a snapshot racing a write converges.
|
||||
- **C:** after compacting through 2 and restarting, the next mint is 3.
|
||||
- New tests:
|
||||
- a reentrant `database.PutAsync` from inside a database handler is logged with its context and published
|
||||
once;
|
||||
- a reentrant `ApplyShardLogResponseAsync` from inside a handler keeps the replica log in primary order;
|
||||
- a log store whose `Append` writes the cache fails with Cut 3's reentry guard, wrapped, and the admission
|
||||
stands.
|
||||
- Commit latency with `CultNetFileShardMutationLogStore`, main against Cut 4. Also measure a `CultMesh` node
|
||||
with `EnableDurableShardLogs`, where every bookkeeping write is now logged. Report it; do not gate on it.
|
||||
- Negative grep: `rg -n "_parked|_ready|_draining|_nextSequence|_predictionGate|HasPrediction|PublishCacheUpdate" src/GameCult.Networking`
|
||||
returns nothing.
|
||||
- Review check: no `_cache.` call inside a `lock (_logGate)` block.
|
||||
- The `4562340` tests stay green.
|
||||
- Test runs use `--blame-hang-timeout 2m`.
|
||||
- Stryker on the diff: dropping the floor raise, memory-before-disk, and the snapshot read order must each
|
||||
kill a test.
|
||||
- **Docs.**
|
||||
- Composition doc `:50-53`: replace the "streams derive from `OnUpdate`" sentence. CultNet derives from
|
||||
`Watch` plus one journal, and the log is written in cache order.
|
||||
- A new paragraph: a primary that cannot log a change compacts through it.
|
||||
- The log-store rule under Locking.
|
||||
- **Blockers.** None. Q-P5 is ruled A.
|
||||
- **Ledger.** Against the branch, about −130 and +95. The extra over the old estimate is the floor, the
|
||||
disk-first order, the `SortedList`, the snapshot order, and the exception type.
|
||||
|
||||
### Cut 5. Database fan-out isolation and Sequence (networking, behaviour)
|
||||
|
||||
- **Deletes first.** The database's `Subject<object> _changes` (main `:485`, branch `:487`) and its
|
||||
`Dispose` (`:1158`).
|
||||
- **Adds.**
|
||||
- The isolated observer array, the same pattern as the cache's `Register`/`Publish`
|
||||
(`CultCache.cs:1929-1944`, `:2470-2503`), behind `Watch<T>`, `WatchAllChanges` and the rest (`:1089-1145`).
|
||||
- `CultNetDatabaseChange<T>.Sequence` (`:319-371`), as an optional constructor argument. The journal's stash
|
||||
sets it from the cache change. A change with no cache change behind it carries 0.
|
||||
- **Docs.** The binary-signature note, as the composition doc gave for `CultCacheDocumentChange` (`:57-58`).
|
||||
- **Verification.**
|
||||
- With a rethrowing handler and one throwing database subscriber, the other subscriber and a live
|
||||
subscription-server peer receive every change. The writer gets the exception after delivery (Q-P2).
|
||||
- The disposed-database test.
|
||||
- `Sequence` equals the cache change's.
|
||||
- **Blockers.** Cut 4.
|
||||
- **Ledger.** About −10 and +35.
|
||||
|
||||
### Cut 6. Stale protection on wire projections (networking, behaviour; Q-P4 A)
|
||||
|
||||
- **The rule.** A projection applies a change to a key only if its `Sequence` is above what the projection
|
||||
last applied for that key. A snapshot or reconcile applies everything at or below the cache sequence it read
|
||||
**before** reading documents.
|
||||
- That read is `Cache.GetWithSequence(<any key>).Sequence`, the branch's idiom (branch `:531`). No new API
|
||||
is needed.
|
||||
- **Subscription server** (`CultNetDatabaseSubscriptionServer.cs`):
|
||||
- `SubscriptionProjectionState` (`:630`) gains `long Floor` and `Dictionary<string, long> Applied`.
|
||||
- The single-row fast path in `Watch` (`:296-340`) reads the change's `Sequence` (reflection, like `Key`).
|
||||
Under `_lifecycleGate` it drops the change if `Sequence <= max(Floor, Applied[key])`. Otherwise it applies
|
||||
the change and records it. Removals are included.
|
||||
- `Reconcile` (`:347`) and the subscribe snapshot (`:168-181`) read `seq0` before `CreateProjectedSnapshot`
|
||||
(`:402`), set `Floor = seq0`, and clear `Applied`.
|
||||
- This is sound: every change applied earlier was applied under the same gate, after its admission, so
|
||||
its `Sequence` is at most `seq0`.
|
||||
- Hop and unauthorized paths already reconcile from current state, and they set the floor too.
|
||||
- **`CultNetDatabaseServer.CreateSubscription`** (`:501-513`):
|
||||
- A per-subscription `(Floor, Applied)` under a per-subscription lock, held across check, record and send.
|
||||
- `HandleSubscribeAsync`'s snapshot (`:362-369`) takes `seq0` first and sets the floor.
|
||||
- **Verification.**
|
||||
- Two threads write one key, with a blocking cache observer that forces the inverted delivery (Q3 shape).
|
||||
On both servers, the peer's last state for the key equals the cache's.
|
||||
- A removal overtaken by an older upsert leaves the key absent at the peer.
|
||||
- The negative check: without the drop, both tests fail.
|
||||
- **Blockers.** Cut 5, which provides `Sequence` on database changes.
|
||||
- **Ledger.** About +45.
|
||||
|
||||
### Salvage from `hands/cultnet-publish-commits` (revised)
|
||||
|
||||
- **Keep:**
|
||||
- both test files;
|
||||
- the `NetworkingTests.cs` edits;
|
||||
- the door table and the shape rule;
|
||||
- "replicas never mint";
|
||||
- log-before-publish;
|
||||
- "the snapshot logs nothing";
|
||||
- "loads and pulls on a primary are logged" (B1);
|
||||
- the composition-doc paragraph, rewritten to name the journal.
|
||||
- **Drop:**
|
||||
- the reorder buffer and the drainer;
|
||||
- `_predictionGate`;
|
||||
- logging from the observer;
|
||||
- the swallowed exception path (A1 and A3 on the branch).
|
||||
|
||||
## Subtraction ledger (estimate)
|
||||
|
||||
| Cut | Removed | Added | Other |
|
||||
|---|---|---|---|
|
||||
| 1 | ~15 | ~45 | none |
|
||||
| 2 | ~5 | ~30 | the cache no longer uses R3 `Subject` |
|
||||
| 3 | 0 | ~50 | one public method (Q-P3) |
|
||||
| 4 | ~130 vs branch | ~95 | the merge brings +1109/-243, of which about 780 are tests; the floor, disk-first order, `SortedList`, snapshot order and `CultNetShardLogException` are the extra over the first estimate |
|
||||
| 5 | ~10 | ~35 | the database `Subject` goes, and one public property |
|
||||
| 6 | 0 | ~45 | none |
|
||||
|
||||
- **Source, net against main:** about +245.
|
||||
- **Source against the branch:** no scheduler anywhere.
|
||||
- The budget is pressure, not a metric.
|
||||
|
||||
## Operator questions
|
||||
|
||||
- Q-P1..Q-P4 are ruled (see Rulings). Their option analysis is in the map's git history.
|
||||
|
||||
- **Q-P5 A (ruled 2026-09-29): a primary that cannot log a committed change throws a typed
|
||||
`CultNetShardLogException` naming the shard and the burned sequence, after publication, not derived from
|
||||
`InvalidOperationException`.**
|
||||
- The commit stands, the change is published, and replicas behind it snapshot-resync (Imagination pass 4 (a)).
|
||||
- The writer hears of it. AetheriaEve `Program.cs:3722` cannot mistake it for a denial.
|
||||
|
||||
Recorded, not forked:
|
||||
- (b): no origin on the journal. Loads and pulls are logged on primaries.
|
||||
- (a)'s restart residual when the store refuses both writes.
|
||||
- The replica keeps retry-by-cursor.
|
||||
- A throwing getter refuses loads too.
|
||||
- Reentrant writes keep main's depth-first delivery. Order is data.
|
||||
- Journal I/O under the gate follows the store precedent.
|
||||
|
||||
## Follow-ups outside this campaign
|
||||
|
||||
- **`cultmesh-py` watch.** It has no isolation and no Sequence
|
||||
(`node.py:1059-1065`).
|
||||
- **Durable log whole-file rewrite.** `CultNetShardMutationLogStorage.cs:83-89`
|
||||
rewrites the whole file per `Append`. It is a monolith, and under the gate
|
||||
its cost is now every writer's.
|
||||
- **Mesh database-backed handles.** `CultMesh.cs:2362` and `:3396` can drop
|
||||
stale changes once `CultNetDatabaseChange.Sequence` exists.
|
||||
- **Skill scar.** The Eureka SKILL.md already says "a ticket system that
|
||||
serialized delivery was built, deadlocked, and was deleted". The Soul pass 3
|
||||
direction re-proposed it anyway. The Soul brief template should tell Soul to
|
||||
check `git log -S` for a proposed mechanism before recommending it.
|
||||
- **A chained replica after a snapshot.** A replica that snapshot-resyncs has no log entries below the snapshot
|
||||
sequence. It should raise its own floor to that sequence, so a replica chained on it resyncs instead of seeing
|
||||
a gap (`ApplyShardSnapshotResponseAsync` `:675-736`). It predates this campaign.
|
||||
- **AetheriaEve `Program.cs:3722`.** It reads a committed write's `InvalidOperationException` as a denial. Under
|
||||
Q-P5 A, Cut 4's log failures cannot reach it. Observer and `OnUpdate` exceptions still can, so the Cuts 1-2
|
||||
recording stands.- **Cut 4 Soul, recorded 2026-09-30 (Self), not fixed in Cut 4:**
|
||||
- **File log store is not crash-safe (hard prerequisite).** `CultNetFileShardMutationLogStore.Append` and
|
||||
`CompactThrough` use `File.WriteAllBytes` (truncate, rewrite, no temp+rename, no fsync). A failure mid-write
|
||||
truncates the log; every later append burns a sequence and a restart cannot construct the database. It also
|
||||
rewrites the whole file per append, which Cut 4 now does under the cache gate (2.2 ms per write at 100
|
||||
entries, 33 ms at 950, a 2.49 s stall). An atomic, append-only store must land before any live daemon
|
||||
enables durable shard logs. Today only AetheriaEve's offline tools do; the live Aetheria daemon passes
|
||||
`enableDurableShardLogs: false`.
|
||||
- **A slow log append blocks cache readers** (`CultCache.Get` takes the gate). Accepted with a note in the
|
||||
store's XML doc.
|
||||
- **Crash between the store write and the log append** leaves the log lagging the store, and nothing detects
|
||||
it; a restarted primary reuses that sequence (A2's divergence reached by a plain kill). The spec called this
|
||||
lag detectable; for a crash it is not.
|
||||
- **Cut 4 final Soul (4d), recorded 2026-09-30 (Self), not fixed in Cut 4:**
|
||||
- **F2, read cost.** `CultNetDatabase.Serves` (`:1980-1989`) runs per row in `GetAll`/`GetByName`/`GetByIndex` for every
|
||||
layout except a single owns-everything shard: a reflection `GetTrackedKey`, a gate lock and a shard re-scan per row.
|
||||
`database.GetAll` measured 24.8x `cache.GetAll` on 20,000 rows under a key-prefixed shard. Fix: filter by key inside
|
||||
the cache enumeration (the `rowFilter` path already has keys) and hoist the owns-everything check.
|
||||
- **F4, dispose drain edges.** An observer that removed its stash entry before the drain can publish after `Dispose`
|
||||
returns (dropped); a throwing `_cacheJournal.Dispose()` skips the drain. Neither double-publishes or deadlocks.
|
||||
- **F5, refusal naming.** A write whose schema is owned but whose key is outside the shard's prefix also answers
|
||||
`unowned_schema` with details `{field:"schemaId"}`; the peer cannot tell a key refusal from a schema refusal.
|
||||
- **Pre-existing, separate task.** Python emits error code `unsupported_schema_version`
|
||||
(`cultnet-py/.../interop_peer.py:415`, `cultmesh-py/.../server.py:161`), which is not in the error-code enum; TS and
|
||||
Rust refuse the whole message.
|
||||
|
|
@ -1,307 +0,0 @@
|
|||
# CultCache Union Parity: Map
|
||||
|
||||
Campaign `cultlib-gaps`, follow-up `cultlib-gaps:follow_up:union-parity-and-unknown-arms` (from
|
||||
`thing2:follow_up:cultcache-union-parity`), under operator ruling `thing2:ruling:contract-decode-tolerance`
|
||||
("CultCache refuses unknown union arms in every runtime, the C# reference included"). This file holds body facts,
|
||||
the model page and rationale; the cut specs and the question are documents in the mind and cite this file by
|
||||
section and fact number.
|
||||
|
||||
Pinned base: `origin/main` at `aaf0d7f6` (2026-10-10). Imagination pass `imagination-cl-union-parity`, session
|
||||
`self-2026-10-10c-eureka`. Probes live under the session scratchpad, `imagination-cl-union-parity/` (`cs` payload
|
||||
probe, `cs2` store probe, `rs` payload probe, `py` store walkers, `il` decompiled MessagePack-CSharp). None of it is
|
||||
committed. Earlier evidence is not repeated here: Eve `docs/thing2-map.md` P12-P13 (the cross-runtime probe) and
|
||||
`docs/research/cultcache-union-decode-tolerance-prior-art.md` (prior art).
|
||||
|
||||
## Cut order
|
||||
|
||||
1. `union-refusal-cs`: the C# reference refuses an unknown union arm on read and a subtype the union does not name
|
||||
on write. Ruled; nothing blocks it.
|
||||
2. `union-golden`: the recursive-union golden and the arm refusal goldens in `tests/vectors/union-parity/`,
|
||||
written by the C# reference and held by its tests.
|
||||
3. `union-derive-rs` and `union-codec-ts`, in either order or together: cultcache-rs and cultcache-ts gain a
|
||||
union (and, in TS, a slot record) codec that writes the reference shape and decodes the golden byte-identically.
|
||||
`union-codec-py` follows the same golden for cultcache-py.
|
||||
4. `slot-tolerance` (C# reference), then `slot-readonly-parity` (Rust, TypeScript, Python): a record carrying a
|
||||
slot past its reader's declared slots, at any depth, is read-only (ruling
|
||||
`cultlib-gaps:ruling:extra-trailing-slots`; the refinement is settled by section S). Both change a slot rule the
|
||||
cuts in step 3 build, so they wait for 1-3 rather than reopening them.
|
||||
5. `union-parity-release`: changelogs and tags, Breaking where `docs/semver-policy.md` says so.
|
||||
|
||||
Thing2's `cut-contract-rs` waits on step 3 (Rust) only: its first step pins cultcache-rs at the commit that lands
|
||||
`union-derive-rs` and re-runs P12 or the `union-golden` vector both ways.
|
||||
|
||||
Why this order: the C# reference is the wire authority, so its refusal and its golden come first and the other
|
||||
runtimes are held to bytes C# wrote, as `document-variants-c1` was (`docs/runtime-parity-scope.md`). Arms are
|
||||
ruled and slots are not, so the slot rule is its own cut rather than a blocked step inside every runtime's cut.
|
||||
|
||||
## Model page (step 0b)
|
||||
|
||||
| Kind | What names it | Over time | Who decides |
|
||||
|---|---|---|---|
|
||||
| Union arm | An integer key, `[Union(k, typeof(Arm))]` on the abstract base (C#), assigned by the union's author. The wire carries `[k, armSlots]`; the arm's type name never travels. `nil` is a null reference, not an arm. | **Added:** a key never used before. **Retired:** the attribute is removed or commented out and the key is never reused (Aetheria `Behaviors.cs:160,173`; Aetheria-legacy `Behaviors.cs:37-38`). **Renamed type:** free, the key is the identity. **Unknown to a reader** (a newer writer's arm, or a retired arm in an old store): refused, naming the union and the key (ruling). | The union's author assigns keys. The C# reference defines the shape; every runtime keeps parity with it (doctrine, `runtime-parity-scope.md`). |
|
||||
| Arm subtype on write | The value's runtime type must be one the union names. | A subtype the union does not name is today written as `nil` (U4). After cut 1 it is refused. | The union's author, by naming the arm. |
|
||||
| Arm slots, record slots | `[Key(n)]` integers, base-class keys included (an arm is a flat array). Gaps are written `nil`. | **Added:** appended at the next free key. **Retired:** kept as a gap, never reused (Aetheria `ItemInstance.cs:35`, `EntitySerializer.cs` key 5). **Missing in an older store:** the reader's default (C# field initializer; Rust and Python need a declared default). **Extra past the reader's last key, at any depth:** the record is read and is read-only; every write or removal of it is refused, and it is persisted from its original bytes (ruling, S). | The type's author. The slot rule is CultCache's (`src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md`). |
|
||||
| Serde-enum member (Rust) | serde's external tag: the variant **name** (`{"Name": fields}`, a unit variant the bare string). | Persisted already by Rust consumers (section C). They keep their bytes: the union derive is opt-in and serde enums are untouched. | The Rust consumer. Not a cross-runtime shape. |
|
||||
|
||||
## U. Mechanism facts (C# reference, Rust, TS)
|
||||
|
||||
- **U1. The miss branch is `Skip` then `null`, in both union formatters MessagePack-CSharp 3.1.7 can hand out.**
|
||||
`DynamicUnionResolver.BuildDeserialize` (decompiled from `MessagePack.dll` 3.1.7 net9.0 with ilspycmd): after
|
||||
`ReadArrayHeader() == 2` (else `InvalidOperationException "Invalid Union data was detected"`) and `ReadInt32`, a
|
||||
non-zero-start key set is mapped through `keyToJumpMap.TryGetValue`, a miss sets the index to `-1`, the `switch`
|
||||
default calls `reader.Skip()`, and the result local stays `null`. The source generator's `UnionTemplate`
|
||||
(`MessagePackAnalyzer` 3.1.7, `MessagePack.SourceGenerator.dll`) emits the same: `default: reader.Skip(); break;`
|
||||
with `result = null`. Its serializer writes `nil` when `typeToKeyAndJumpMap` has no entry for `value.GetType()`.
|
||||
- **U2. The C# reference decodes an unknown arm to null and does not re-encode to its bytes.** Probe `cs`
|
||||
(payload built by hand with `MessagePackWriter`, decoded with `CultDocumentMessagePackSerialization.OptionsFor`):
|
||||
unknown key 9 in a union field gives `Root=NULL`, in a list `Items=[TextNode,NULL]`; re-encode equal: False in
|
||||
both. The formatter the reference composite hands out for the union is `MessagePack.Formatters.NodeFormatter1`
|
||||
(DynamicUnionResolver's emitted type; the probe assembly carries no generated resolver).
|
||||
- **U3. Missing and extra trailing slots, C# payload level.** Same probe: a payload one slot short decodes with
|
||||
the field initializer (`Added=42`); a payload one slot long decodes with the extra slot skipped. Neither
|
||||
re-encodes to its bytes.
|
||||
- **U4. The reference writes an unnamed subtype as nil.** Same probe: `Root = new RogueNode()` (a subclass the
|
||||
union does not list) serializes to `94 a2 64 32 c0 90 2a`: the root slot is `nil`, and it reads back `Root=NULL`.
|
||||
Silent loss on the write side, the mirror of U2.
|
||||
- **U5. Refusal can live at the CultCache layer; no MessagePack-CSharp fork.** Same probe, `strict` options: a
|
||||
resolver placed ahead of `StandardResolver` returns, for any abstract or interface type with `[Union]`s, a
|
||||
formatter that peeks (`CreatePeekReader`) the `[key, ...]` header, throws
|
||||
`MessagePackSerializationException("union Node has no arm 9")` for a key the union does not list, refuses to
|
||||
serialize an unnamed subtype, and otherwise delegates to the formatter `StandardResolver` would have handed out.
|
||||
Baseline round-trips byte-identical; both unknown-arm cases and U4 are refused; slot behaviour is unchanged.
|
||||
Every C# decode path reaches the composite through `CultDocumentMessagePackSerialization.OptionsFor`
|
||||
(`CultCacheMessagePack.CreateCodec`, `DirectoryMessagePackBackingStore.cs:345`, `CultMesh.cs:2779,3493,3499`,
|
||||
`CultMeshPrimitives.cs:1158,1230,2357`), so one resolver covers the cache, the directory store and CultMesh.
|
||||
Consumer resolvers named by `[CultCacheFormatterResolver]` sit ahead of `CultDocumentResolver` today
|
||||
(`CultDocumentMessagePackSerialization.cs:58-62`); the guard goes first and delegates to the rest of the
|
||||
composite, so a consumer formatter still encodes the arm while the key check still runs.
|
||||
- **U6. A top-level extra slot is dropped on write-back.** Probe `cs2`: a C#-written store, mutated as a newer
|
||||
writer would (catalog member slot 2 added, payload slot 2 `"newer writer data"`), is pulled, the record edited
|
||||
and flushed. The read succeeds, and the written payload is `['n1', 'edited by the older build']`: the newer
|
||||
writer's slot is gone. (The schema id was left unchanged, so the migration report said `Kind=Exact`; with a
|
||||
changed id the documented drift path reports `IgnoredExtraSlots`, `CultCache.cs:840-924`, but writes back the
|
||||
same way.)
|
||||
- **U7. Rust at `aaf0d7f6`, before the union cuts.** Probe `rs` (`cultcache-rs` at `aaf0d7f6`, payloads built with `rmpv`, decoded with
|
||||
`rmp_serde::from_slice`): a serde enum's unknown variant is refused (`unknown variant 'Exploded'`); the C# arm
|
||||
shape `[0, [...]]` is refused (`expected variant identifier`); a non-nil value in a gap slot is skipped; a missing
|
||||
trailing slot is refused (`missing field 'added'`) unless the field is `#[cultcache(default)]`; an extra trailing
|
||||
slot is skipped (`cultcache-rs-derive/src/lib.rs`, the `IgnoredAny` loop after the slot loop). The derive
|
||||
accepts named structs only (`lib.rs:51-67`) and writes an empty member list into the catalog
|
||||
(`src/lib.rs:2706`).
|
||||
- **U8. TypeScript at `aaf0d7f6`, before the union cuts.** No typed codec: with no `formatter` a document encodes as a MessagePack **map** of the
|
||||
zod-parsed object and decodes raw (`cult-cache.ts:679-692`; `@msgpack/msgpack` `encode({id:'d',version:7})` is
|
||||
`82 a2 69 64 ...`, a fixmap, where the reference writes an array); positional slots exist only in hand-written
|
||||
formatters (`test/cult-cache.test.ts:1263-1310`). P13 measured that a hand codec matches whichever encoding its
|
||||
author chose.
|
||||
- **U9. Python at `aaf0d7f6`, before the union cuts.** `define_database_entry_type` (`packages/cultcache-py/src/cultcache_py/documents.py:185-276`)
|
||||
is a slot codec: gaps written `nil`, extra slots ignored, a missing slot defaulted or refused. No union codec.
|
||||
- **U10. The written contracts.** `cultcache-schema-compatibility.md:75-91` classifies "persisted slot is missing
|
||||
locally and can be ignored" as soft-migratable drift, reported as `IgnoredExtraSlots`;
|
||||
`cultcache-persistence-format.md:215-218` says a store reader "never skips a slot it does not understand" for
|
||||
record envelope slots, and every runtime refuses `extra-slot-full-payload.msgpack`
|
||||
(`tests/vectors/document-variants-c0`). `docs/persisted-record-encoding.md` says consumers verify canonicality
|
||||
by re-encoding and comparing bytes. The two contracts disagree about payload slots; the question below is
|
||||
about that disagreement.
|
||||
- **U11. Fixture home.** `tests/vectors/<name>/`: committed bytes every runtime's tests read
|
||||
(`document-variants-c0` by Python `generate.py`, `document-variants-c1` written by C#), consumed by
|
||||
`tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs`, `packages/cultcache-rs/src/lib.rs`,
|
||||
`packages/cultcache-ts/test/cult-cache.test.ts` and `packages/cultcache-py/tests/test_cultcache.py`. The CultNet
|
||||
precedent for a reference-written vector is `CULTNET_WRITE_VECTORS=1` (`docs/cultnet-error-contract-cut.md:170`).
|
||||
|
||||
## S. Slot read-only probe (any depth)
|
||||
|
||||
Imagination pass `imagination-cl-slot-readonly` (session `self-2026-10-10c-eureka`), under ruling
|
||||
`cultlib-gaps:ruling:extra-trailing-slots` ("b, and probe the any-depth refinement"). Probes live under the session
|
||||
scratchpad, `imagination-cl-slot-readonly/` (`cs` C# synthetic, `aeth` C# on Aetheria's game database, `rs`, `py`,
|
||||
`ts`, `il` decompiled MessagePack-CSharp 3.1.7). None is committed. The ruling's test: every runtime's decoder
|
||||
carries a "slot skipped" mark to the record cheaply, on the one decode path, with no second pass and no per-type
|
||||
consumer code.
|
||||
|
||||
- **S1. Where C# skips, and why a resolver sees it.**
|
||||
`DynamicObjectTypeBuilder.BuildDeserializeInternalDeserializeLoopIntKey` emits the slot loop's `switch` default as
|
||||
a direct `MessagePackReader.Skip()` call; the source generator's `FormatterTemplate` writes
|
||||
`default: reader.Skip(); break;`. `MessagePackReader` is a struct, and neither path raises an event or reads an
|
||||
option, so the skip itself cannot be observed without a fork. Both fetch every member formatter from
|
||||
`options.Resolver` (the generated code's `formatterResolver = options.Resolver`), and the union formatters fetch arm
|
||||
formatters the same way (U1). A resolver ahead of the composite is therefore handed every nested object, list
|
||||
element and union arm, and can read each one's array header before delegating.
|
||||
- **S2. The mark arrives in C#.** Probe `cs`: a guard resolver returns, for each concrete int-keyed
|
||||
`[MessagePackObject]` type, a formatter that reads the array count (from `NextCode` for a fixarray, a peek for
|
||||
array16 and array32), compares it with the highest `[Key]` plus one (base keys included, MessagePack's member
|
||||
visibility rule), notes a skip into a `[ThreadStatic]` scope the decode's caller opened, and delegates to the
|
||||
formatter the rest of the composite hands out. An extra slot at depth 0 (record), depth 1 (nested object), depth 2
|
||||
(object in an object), in a union arm and in a list element each arrives as one mark naming the type and both
|
||||
counts; the clean payload gives none. The guard needs `CompositeResolver.Create` around it so formatters are
|
||||
cached (uncached, it was 40 times slower).
|
||||
- **S3. C# cost.** Aetheria `GameData/Aetheria.cc` (12.1 MB; 237 of 238 records decodable with Aetheria.Shared's
|
||||
types; decoded with `OptionsFor` that assembly; best of 9, three rounds): plain 165-209 ms, a second plain composite
|
||||
(A/A) 174-193 ms, guarded 179-196 ms. That is 3.7-8.9% over the best plain run, inside the A/A spread. Worst case,
|
||||
synthetic tiny objects (120,000 records, 23.1 MB, about 24 objects each): plain 618-637 ms with A/A within 4%,
|
||||
guarded 704-705 ms: 11-19%, about 30 ns per decoded object.
|
||||
- **S4. A live case in Aetheria.** The same probe, run with the AetherDb release binaries (built 2026-09-30) against
|
||||
today's `Aetheria.cc`, marks 25 records: `WeaponItemData` whose `BehaviorData` arms (`AutoWeaponData`,
|
||||
`ChargedWeaponData` and others) carry 34 slots where that build declares 21 or 33. The extra slot is
|
||||
`WeaponData.Tracking` (key 33, Aetheria `8cd71ed6`, 2026-10-07): inside a union arm, inside a list. Today that tool
|
||||
reads these records and drops `Tracking` from all 25 at its next landing (S5). Refusing nested extra slots would
|
||||
stop it opening `Aetheria.cc` at all; read-only lets it open the store and refuses writes to those 25.
|
||||
- **S5. A C# flush re-encodes every held record, not only the edited one.** `PushAll` and an unconditional
|
||||
`CommitBatch` write `ToPersistedRecord(entry, SerializePayload)` for every entry (`CultCache.cs:3191-3201`, `3355`,
|
||||
`3384`), under the local descriptor's schema id and catalog entry. So U6's loss reaches every held record with
|
||||
unread slots at the next flush of anything. Rust (holds payload bytes and decodes on `get`, `lib.rs:2280-2293`),
|
||||
Python (holds envelopes and decodes at load, `cache.py:257-285`) and TypeScript (holds payload bytes) carry an
|
||||
untouched record's bytes. In C#, read-only therefore also means persisted from the original record (bytes, schema
|
||||
id, catalog entry), never from the decoded document.
|
||||
- **S6. Rust.** Probe `rs`: the derive's slot loop, with the trailing `IgnoredAny` loop counting into a
|
||||
`thread_local`, marks depth 1, depth 2 and a list element. Depth 0 is the `DatabaseEntry` derive's own trailing
|
||||
loop (`cultcache-rs-derive/src/lib.rs:258`), the same edit. A plain serde struct nested in a record already refuses
|
||||
an extra element (`array had incorrect length, expected 2`), so only derived slot types skip. Cost (120,000
|
||||
records, 21.4 MB): inside the A/A spread (A/A from -3% to +9%; marked against plain from -14% to +13% over six
|
||||
runs). The added code is one branch per object, never taken on a clean payload. Rust decodes on `get`, so whether
|
||||
a held record is writable is answered by decoding its held bytes through the same derive, on the write path only.
|
||||
- **S7. Python and TypeScript.** Probes `py` and `ts`: a slot codec tree passing a context argument (the shape
|
||||
`union-codec-py` and `union-codec-ts` build) marks all five positions. Python (40,000 records, 11.1 MB, msgpack
|
||||
unpack included): +14% against an A/A spread of 8%. TypeScript (120,000 decoded records, codec walk only; no
|
||||
msgpack in the scratch copy): +20% against an A/A spread of 18%. The check is one length comparison per object.
|
||||
- **S8. Kotlin has no library slot codec.** `CultDocumentCodec<T>.decode` is written per type by the consumer
|
||||
(`packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt:31-36`), and the package's own Eve
|
||||
decoders skip extras by hand at 8 sites (`repeat((count - N).coerceAtLeast(0)) { reader.skip() }` in
|
||||
`eve/EveDocuments.kt`). No slot rule, refusal or read-only, can be held by the library there until it owns slot
|
||||
decoding. That is a follow-up, not a per-runtime rule.
|
||||
- **S9. CultMesh decode paths** (`CultMesh.cs:2779,3493,3499`, `CultMeshPrimitives.cs:1158,1230,2357`) go through
|
||||
`OptionsFor`, so through the guard, with no scope open: unchanged. Whether a Mesh replica that re-publishes a
|
||||
decoded document drops slots is a follow-up.
|
||||
- **S10. The refinement is in.** Every runtime with a library slot decoder (C#, Rust, Python, and the TypeScript
|
||||
codec being built) carries the mark to the record on its one decode path, with no second pass and no consumer
|
||||
code. The cost on Aetheria's real store is inside the A/A noise (S3); the worst synthetic case is 11-19% in C#.
|
||||
A nested extra slot makes the record read-only; it is not refused.
|
||||
|
||||
## A. Aetheria (C#, the only consumer persisting `[Union]`s)
|
||||
|
||||
Aetheria consumes the reference through Unity packages pinned by tag (`Packages/manifest.json:55-57`:
|
||||
`caching-unity-v1.4.0`, `cultlib-unity-v1.0.60`), so a CultLib change reaches it only when it bumps the pin.
|
||||
Its only consumer resolver is `MathResolver` (`AssemblyInfo.cs:4`), which supplies no union formatter.
|
||||
|
||||
- **A1. Unions inside persisted documents** (Aetheria `aac2cf91`, `Assets/Scripts/ServerShared`):
|
||||
`BehaviorData` (`Behaviors/Behaviors.cs:147-191`, keys 0-11, 15, 16, 18, 20-24, 28, 31-40; 12 and 26 retired,
|
||||
14, 17, 19, 25, 27, 29, 30 commented out) in `List<BehaviorData> Behaviors` of the item data documents
|
||||
(`ItemData.cs:346,368`); `WeaponData` (`Behaviors/Weapon.cs:15-18`); `EntityPack` (`EntitySerializer.cs:172-173`)
|
||||
in `aetheria.savedzone` contents and its own `Children`; `ItemInstance` and `CraftedItemInstance`
|
||||
(`ItemInstance.cs:18-31`, a union whose arms are also a union) in entity cargo and docking bays
|
||||
(`EntitySerializer.cs:186-187`); `Provenance` (`Provenance.cs:96-98`) in `aetheria.provenanceledger`
|
||||
(`Provenance.cs:11-15`, `Lot.Origin`); `SavedActionBarBinding` (`SavedGame.cs:221-223`) in `aetheria.savedgame`;
|
||||
the input layout rows and keys (`InputLayout.cs:29-50`) in `aetheria.inputlayout`. `Brush`
|
||||
(`Environment.cs:73`) was not traced to a persisted document.
|
||||
- **A2. `GameData/Aetheria.cc` carries no unknown arm.** Walker `py/store_unions.py` over the git-tracked game
|
||||
database (12.7 MB, `cultcache.store.v1`, 238 records, 13 catalog entries): `Behaviors` arm keys
|
||||
{0:3, 1:2, 2:10, 3:5, 4:2, 5:2, 6:3, 8:7, 9:23, 11:2, 16:4, 23:7, 24:5, 31:1, 32:1, 34:1, 35:2, 37:4, 38:10, 39:2,
|
||||
40:1}; none retired or commented out; no `nil` element.
|
||||
- **A3. The run save carries no unknown arm, and does carry missing trailing slots.** Walker `py/run_unions.py`
|
||||
over `GameData/run.cc` (2026-09-30): `EntityPack` arms 0 and 1 (3 each), `ItemInstance` arm 2 (12),
|
||||
`Provenance` arm 0 (64), `SavedActionBarBinding` 9 `nil` (empty bar slots). The `EntityPack` arms carry 21 slots
|
||||
(orbital) and 20 (ship) against today's 22: the `Boss` slot (key 21, added 2026-10-07 in `db4617d8`) is missing,
|
||||
so today's Aetheria reads this save only because a missing trailing slot defaults. `py/gaps.py`: no non-nil
|
||||
retired slot (ItemInstance 2, 9, 10; EntityPack 5). `GameData/stale-2026-09-22/run.cc` is the same shape;
|
||||
`player.cc` holds only `aetheria.playersettings`. `py/slots.py`: every top-level record matches its own store's
|
||||
catalog exactly.
|
||||
- **A4. What changes for Aetheria.** After it adopts a CultLib with cut 1, its current stores load unchanged
|
||||
(A2, A3). A store written later by a build that has added an arm is refused by a build that lacks it, naming the
|
||||
union and key, where today the arm silently becomes `null` (a gear item quietly loses a behaviour, a lot loses
|
||||
its provenance) and the next save writes the loss back. Builds already pinned to 1.0.60 keep the null path; a
|
||||
shipped binary cannot change. Aetheria-legacy does not use CultCache and has no store.
|
||||
- **A5. Other C# stores.** `%LOCALAPPDATA%Low/GameCult/Aetheria/aetheria-unity.cc` (2026-08-08, 1.2 KB) is a
|
||||
`cultcache.store.v2.directory-indexed` header from an older layout, not a union carrier.
|
||||
|
||||
## C. Rust, TypeScript and other consumers
|
||||
|
||||
Eyes audit for this pass (session `self-2026-10-10c-eureka`), `git grep` at each repo's `origin/HEAD`, Rust reach
|
||||
by a lexical script (it can over-count; it does not under-count named fields). Aetheria and CultLib's tests excluded.
|
||||
|
||||
- **C1. Rust: no `DatabaseEntry` record crosses runtimes as a union, and none uses the reference arm shape.**
|
||||
Records that reach serde enums, each keeping its bytes because the derive is opt-in:
|
||||
Huginn `epiphany.pipeline.resolution.v2` (`ResolutionOutcome`, external tag, `eureka-pipeline/src/lib.rs:746`,
|
||||
live on Yggdrasil); Idunn `idunn.deployment_transaction` and `idunn.admitted_generation` (v4 and legacy forms;
|
||||
internally tagged `#[serde(tag = ..., deny_unknown_fields)]` enums in `control_plane.rs`, `deployment_plan.rs`,
|
||||
and two `#[serde(untagged)]` enums kept so older transactions decode, `drivers.rs:259,387`; live at
|
||||
`/var/lib/gamecult/idunn/control.cc`); CodexConnector `gamecult.codex.replay_record.v1` (`CodexReplayState`,
|
||||
`lib.rs:1017`); Epiphany's Atlas, model-request, persona and receipt records (internal, adjacent, external and
|
||||
untagged representations; `.cc` files under `F:\Projects\.epiphany-runtime\` and on Yggdrasil). Unit-only serde
|
||||
enums (a bare string) are widespread in Idunn, Huginn (`Faculty`, the pipeline's unit enums), CodexConnector and
|
||||
Epiphany. No enum reaches any record in Odin, Muninn, Ghostlight, Huginn `mind-body`, or cultnet-rs and
|
||||
cultmesh-rs production records. None of these is read by another runtime as a union today, so none is affected
|
||||
by the arm refusal; the derive changes nothing for them.
|
||||
- **C2. TypeScript: no consumer encodes a tagged union.** The only hand formatters are StreamPixels
|
||||
`apps/service/src/realtime/overlay-state-documents.ts:190,232,277` (positional arrays, no union). Bifrost,
|
||||
Heimdall, Hermodr, Odin scripts, Stonks, Vili, VoidBot (vendored `cultcache-ts`) and weksa have none.
|
||||
- **C3. C# outside Aetheria: StreamPixels' overlay declares unions; its tracked store holds none of them.**
|
||||
`apps/overlay-unity/Assets/Scripts/Assembly-CSharp/` (`8d40541`): `ActivationCondition` (10 arms),
|
||||
`BattleEffect` (2), `StatusEffect` (4), `ItemInstance` (3), `EquippableItemInstance` (2), reachable from
|
||||
`streampixels.status`, `streampixels.action_item`, `streampixels.skill` and gear documents. Walker
|
||||
`py/sp_catalog.py` over `apps/overlay-unity/Data/catalog.cc` (`cultcache.store.v1`, 20 records): only
|
||||
`outfit`, `skin_color`, `hair_style`, `emote` and `hair_color` records, and no `[int, array]` pair at any depth.
|
||||
Its only consumer resolver is `MathResolver` (`Catalog/CatalogAssembly.cs:6`). CultLib's own
|
||||
`GameCult.Networking` `Message` union (`Messages.cs:10`) is a CultNet wire message, decoded with
|
||||
`MessageSerialization.Options` (`MessageSerialization.cs:7`, `DefaultOptions`), outside `OptionsFor`: the arm
|
||||
guard does not reach it (follow-up).
|
||||
- **C4. Python and Kotlin: no union-shaped record.** repixelizer uses `define_database_entry_type` with flat
|
||||
slots; `cultmesh-kotlin` documents are `data class`es with no sealed type.
|
||||
|
||||
So the stores the arm refusal can affect today are Aetheria's (A2-A4, none carrying an unknown arm) and
|
||||
StreamPixels' overlay catalog (C3, carrying no arm). Every other runtime's persisted enums are serde shapes that
|
||||
the opt-in derive leaves alone.
|
||||
|
||||
## D. Payload depth bound (body at `c028c511`)
|
||||
|
||||
One bound, 256 nested MessagePack arrays and maps, on read and on write (ruling
|
||||
`cultlib-gaps:ruling:payload-depth-limit-256`; prior art `docs/research/payload-depth-limit.md`). The C# reference
|
||||
landed in `ead5260c`, Python in `5618d8dd`, Rust in `c028c511`. TypeScript is not landed (spec
|
||||
`cultlib-gaps:cut_spec:cut-union-bounds-ts.r2`, blocked on question `cultlib-gaps:question:ts-payload-opaque-type`).
|
||||
|
||||
| Runtime | Owner of the decision (constant and walker) | Doors routed through it |
|
||||
|---|---|---|
|
||||
| C# | `CultPayloadDepth.Check` (`src/GameCult.Caching.MessagePack/CultPayloadDepth.cs`), limit `CultMessagePackSecurity.MaximumPayloadDepth`. MessagePack's own graph counter is set to the same value as a backstop only. | Write: `CultDocumentMessagePackSerialization.SerializeUntyped`. Read: `DeserializeUntyped`. The cache store (`SerializePayload`/`DeserializePayload`), the directory store, `CultMesh` and single-file documents all call those two; C# has no separate raw-envelope or prepared-batch door. |
|
||||
| Rust | `cultcache_rs::check_payload_depth` and `MAX_PAYLOAD_DEPTH` (`packages/cultcache-rs/src/lib.rs`). | Read: `decode_payload` (get, list, `validate_envelope`/`put_envelope`, `finish_prepared_entry`). Write: `put`/`prepare_entry`/`prepare_entry_named` reach it through `finish_prepared_entry`, which decodes what it just encoded. Raw envelope: `put_raw_envelope`, the door CultMesh network puts use (`cultmesh-rs` `lib.rs`, two call sites). Prepared batch: `put_prepared_batch` via `require_payload_depth`. cultnet-rs: every free-form or skippable wire payload decodes through `cultcache_rs::decode_payload` (message decode in `contracts.rs`, `provider_session`, `media_stream_wire`, `replication`, `service_identity`). |
|
||||
| Python | `_refuse_past_depth_limit` and `MAX_PAYLOAD_DEPTH` (`packages/cultcache-py/src/cultcache_py/documents.py`); a msgpack `StackError` on read maps to the same error. | `DocumentDefinition.encode_payload` (write) and `decode_payload` (read). `CultCache.put`, `put_envelope`, `put_envelopes` and load-time hydration go through them; there is no raw-envelope door. cultmesh-py round trips call the same pair. |
|
||||
|
||||
- **Doors deliberately not routed.** cultnet-rs `idunn_lifecycle_brake`, `provider_health_contracts` and
|
||||
`runtime_authority_contracts` decode records whose positional array length is checked exactly before decoding. A
|
||||
fixed shape has no undeclared slot to skip and no free-form content, so the derive's depth is its shape (commit
|
||||
`84bb1e28`). The five media records have no length check and are held by the test instead. C# `CultCacheMessagePack.CreateCodec`
|
||||
serialises member values and deserialises member types without the document guard, by design (codec comment); only
|
||||
the document type goes through the guard.
|
||||
- **Forbidden writers.** No second walker and no second constant: a runtime's decoders, stores and wire layers call
|
||||
the owner above, never re-count depth or restate 256. An added payload door routes through the owner or names
|
||||
itself in the exact-length list.
|
||||
- **Shared path.** `tests/vectors/union-parity/` (`bounds-outcomes.msgpack`, `depth-*.msgpack`; table in its
|
||||
`README.md`), written by the C# reference and held by `tests/GameCult.Caching.Tests/UnionParityVectorTests.cs` and
|
||||
`PayloadDepthTests.cs`, `packages/cultcache-rs/tests/union_parity.rs`, `packages/cultcache-py/tests/test_union_parity.py`.
|
||||
- **Cut line.** `packages/cultnet-rs/tests/payload_depth.rs` and the `service_identity`/`cultmesh-rs` tests pin the
|
||||
Rust wire doors. cultnet-py and cultmesh-py message and replication decoders call `msgpack.unpackb` directly and
|
||||
are outside the Python cut; they hold only msgpack's own stack limit.
|
||||
- **Verification layer.** Vector held by each runtime's own suite (limit 256 accepted, 257 refused, skipped content
|
||||
counted, non-recursive walk); Rust wire doors by the cultnet-rs test above.
|
||||
|
||||
## Rationale
|
||||
|
||||
- **Why a guard formatter, not a fork or a re-implementation.** U5 shows a guard that only checks the key and the
|
||||
subtype and delegates the encoding is enough. It keeps MessagePack-CSharp's union encoding as the one encoder,
|
||||
costs one small file, and covers every path that already goes through `OptionsFor`. Forking the generated
|
||||
template or vendoring `DynamicUnionResolver` would put a second encoder in CultLib.
|
||||
- **Why the Rust derive is opt-in.** Serde enums already persisted by Rust consumers keep their bytes
|
||||
(`docs/persisted-record-encoding.md`); a changed default would make every such store unreadable or
|
||||
non-canonical. A new derive with explicit arm keys is how a Rust type says "this crosses runtimes".
|
||||
- **Why arms use newtype variants over slot structs.** A C# arm is a subclass whose slots include the base class's
|
||||
keys. A Rust newtype variant over a struct with keyed slots is the same thing, and it needs the slot code the
|
||||
`DatabaseEntry` derive already has, factored so both derives share one slot generator.
|
||||
- **Why the slot rule is a separate, asked cut.** The ruling settles arms and asks for extra trailing slots to be
|
||||
"settled against the same re-encode rule". Two written contracts disagree (U10), and the current behaviour drops
|
||||
data silently (U6), so the choice is the operator's and it should change every runtime at once.
|
||||
- **The recommendation on extra slots was refusal; the ruling chose read-only, and S settles its depth.** An extra trailing slot is the slot-level twin of an unknown
|
||||
arm: data from a newer writer that the reader's types do not describe. The operator ruled refusal for arms
|
||||
knowing it costs an older reader the newer record; the same reasoning gives refusal for slots, and record
|
||||
envelope slots are already refused in every runtime (U10). The alternative with precedent is
|
||||
`variants:ruling:undeclared-read-only` (a record a type can read but does not declare loads read-only): it would
|
||||
let an older reader see a newer record without overwriting it, but only where a catalog shows the extra slot
|
||||
(top level), while most evolution here is nested (A3's `Boss` slot sits inside a union arm).
|
||||
- **Missing trailing slots stay tolerated.** The reader knows a missing slot and its default, so it carries no
|
||||
bytes it does not understand, and Aetheria's live run save depends on it (A3). A store whose guard compares
|
||||
re-encoded bytes (Idunn's) refuses such a record by its own stricter rule, which is that guard's purpose.
|
||||
|
|
@ -1,827 +0,0 @@
|
|||
# CultLib Gaps: Map
|
||||
|
||||
Campaign `cultlib-gaps` (target: `docs/cultlib-gaps-target.md`). This file holds body facts, probes and
|
||||
rationale for the cuts the mind's `cut_spec`s name; the specs cite it by section and fact number.
|
||||
|
||||
Pinned base: `origin/main` at `6bdc23c9` (2026-10-10). Imagination pass `imagination-cl-gaps-cultmath`, session
|
||||
`self-2026-10-10-ag`. Probes live under the session scratchpad, `imagination-cl-gaps-cultmath/` (`probe1` traced
|
||||
copy of the solver and the warm/cold sweep, `suite` a scratch copy of `packages/cultmath` with the candidate fix,
|
||||
`probe2` the sweep against that copy, `probe3` the overload probe). None of it is committed.
|
||||
|
||||
## Cut order
|
||||
|
||||
1. `bls-release-stop`: BoundedLeastSquares stops on a release only when the release is not real.
|
||||
2. `bls-input-field`: a validation helper names the invalid input (follow-up `bls-invalid-input-field`). Blocked on
|
||||
question `cultlib-gaps:question:bls-input-error-shape`.
|
||||
3. `cultmath-release-0-4-1`: release `org.gamecult.cultmath` with cuts 1 and 2. Version follows question
|
||||
`cultlib-gaps:question:bls-fix-release-lane`.
|
||||
4. `cultmath-rotation-algebra`: quaternion algebra, swing-twist and joint clamps (ruling
|
||||
`aetheria-release:ruling:solver-in-gamecult-animation`). Ships in the CultMath release that the first
|
||||
GameCult.Animation release pins; that release is mapped with GameCult.Animation.
|
||||
5. `py-msgpack-default` revision 4 (section P, "Revision 4"): the fix batch (h4) on `hands/py-msgpack-default`
|
||||
at `a59bbc7d`. `migrate_payloads` is replaced by `convert` (ruling `py-migration-shape-ruled`), and Soul s3's
|
||||
open codec Lows are fixed. Its merge to main waits on the Brokkr cut (follow-up `brokkr-msgpack-payloads-r4`).
|
||||
5a. Not mapped: the Python store takes the cross-process sidecar lock the C# and Rust stores take (follow-up
|
||||
`py-ts-store-cross-process-lock`). It is a store parity cut of its own; `convert` does not need it (P15).
|
||||
6. Not mapped: the C# leaf-vectors revision (`leaf-vectors-next`) gains the P2 map-key rows, so the Python
|
||||
key tests read C#-written bytes instead of literals.
|
||||
7. `decode-stack-safety` (section D): cultcache-rs's one decode door grows its stack at every container level
|
||||
(ruling `decode-stack-mechanism-ruled`). Independent of 5 and 6; thing2's contract-rs merge waits on it.
|
||||
8. `expect-persisted-schema` (section E): an observed entry keeps the schema id it was persisted under and
|
||||
conditional commit compares that (ruling `aetheria-release:ruling:expect-fix-in-cultcache`). C# only. The
|
||||
release that carries it to Aetheria follows question `cultlib-gaps:question:expect-fix-release-lane`; the
|
||||
Aetheria pin bump is an Aetheria follow-on, not this cut.
|
||||
9. `aot-record-ref-generator` (section A, AOT cut 1): a consumer with MessagePack's source generator on can declare
|
||||
`CultRecordRef<T>` members and dictionary keys. Base: the C2a branch head `a768b821`; Hands starts after C2a merges
|
||||
to main or rebases onto it. Serves `record-ref-breaks-msgpack-generator`, the first cut of
|
||||
`cs-aot-safe-registration` (operator 2026-10-27: "I'm not a fan of requiring reflection. ... AoT is a nice thing to
|
||||
support anyway.").
|
||||
10. Not mapped (AOT cut 2): the generator reaches consumers that reference only `GameCult.Caching.MessagePack` (today its
|
||||
analyzer is `PrivateAssets="all"`, so they run every document on Reflection.Emit).
|
||||
11. Not mapped (AOT cut 3): registration descriptors are generated instead of reflected (`BuildDescriptor`,
|
||||
`DiscoverMembers`, `CultDocumentResolver`, `CultMessagePackSecurity`), and C2a's element-id walk stops reflecting
|
||||
(A6), so a NativeAOT consumer round-trips a ref-keyed dictionary.
|
||||
|
||||
## A. AOT cut 1: CultRecordRef under MessagePack's source generator (cut `aot-record-ref-generator`)
|
||||
|
||||
Imagination pass `imagination-aot-cut1`, session `self-2026-10-27-eureka`, pinned to `origin/hands/variants-c2a` at
|
||||
`a768b821` (C2a's head; merge base with main `02cf04b5`). Starting evidence: `research/cultmesh-garden-path/cultcache-aot-probe-2026-10-27.md`
|
||||
(main `eb6a8f2c`). Probes: the scratch console from that note, copied to the session scratchpad
|
||||
`imagination-aot-cut1/aot-probe/` and committed only as dangling commits on top of `a768b821` (`a0f06b2c`, `6d382961`,
|
||||
`dc6cf9dc`, `99cae039`, `07b89982`), run on Yggdrasil in `mcr.microsoft.com/dotnet/sdk:10.0` (MessagePack 3.1.7, .NET
|
||||
10.0.12). Logs `job1.log` to `job5.log` beside the probe. Variants: G = `CultRecordRef<T>` member only, H = ref-keyed
|
||||
`Dictionary<CultRecordRef<T>, float>` only, B = both. Every variant references `MessagePack` directly (generator on).
|
||||
|
||||
### Body facts
|
||||
|
||||
- A1. The generator does not discover `CultRecordRefFormatter<T>`. For a generic type it does not know, it collects
|
||||
the type's definition as an object and names the formatter it would generate, `<Type>Formatter<T>`, nested in
|
||||
`GeneratedMessagePackResolver`. For a type from another assembly it then generates nothing, so the resolver names
|
||||
`GeneratedMessagePackResolver.GameCult.Caching.CultRecordRefFormatter<T>`, which does not exist (CS0426). The match
|
||||
with our formatter's name is a coincidence. Evidence (job1): `[ExcludeFormatterFromSourceGeneratedResolver]` on the
|
||||
formatter, and an `internal` formatter, both still fail G, H and B with the same line. Upstream issue
|
||||
MessagePack-CSharp#2133 (open, no maintainer reply) reports the same failure for any open-generic type with a
|
||||
formatter in another package.
|
||||
- A2. Consumer MessagePack 3.1.12, the newest stable, fails G, H and B the same way (job1). Upgrading does not fix it.
|
||||
- A3. `[MessagePackFormatter(typeof(CultRecordRefFormatter<>))]` on the `CultRecordRef<T>` struct stops the
|
||||
generator naming a formatter for it (the mangled line is gone), but the generator then reports `MsgPack006` (error)
|
||||
in every consumer: it checks `typeof(X<>)`'s interfaces, which an unbound generic does not list. Adding the
|
||||
non-generic `IMessagePackFormatter` marker to the formatter does not help (job2 `ta2`). With `MsgPack006`
|
||||
suppressed by the consumer, G, H and B build and run (job2). Rejected: it needs the formatter moved into
|
||||
`GameCult.Caching`, which then needs the whole `MessagePack` package instead of `MessagePack.Annotations`, and every
|
||||
generator consumer still has to suppress an error.
|
||||
- A4. `[assembly: MessagePackAssumedFormattable(typeof(CultRecordRef<>))]` in the consumer assembly fixes G, H and B
|
||||
with no library change (job2 `base@ASSUMED`). The generator stops collecting `CultRecordRef<T>`; the generated
|
||||
document formatter asks the resolver, and `CultDocumentResolver` serves the ref. This is the attribute MessagePack
|
||||
documents for "a formatter added through a custom resolver at runtime". The same attribute as an MSBuild
|
||||
`AssemblyAttribute` item in the consumer project (`_Parameter1_IsLiteral`), which is what a package's
|
||||
`buildTransitive` targets injects, does the same (job3 MG, MH, MB). The attribute declared in the library assembly
|
||||
does not reach the consumer's generator (job4 LIBATTR: CS0426).
|
||||
- A5. Generated and dynamic formatters write the same bytes. RefDoc (B) payload is `93A172A17081A170CA3F000000` from
|
||||
the Reflection.Emit fallback with no generator (job3 BASE) and from the generated formatter (job3 MB). The document
|
||||
formatter is `GeneratedMessagePackResolver+Probe+RefDocFormatter` (`dynamic=False`); the ref formatter is the
|
||||
library's `CultRecordRefFormatter<PlainDoc>` through `CultDocumentResolver`.
|
||||
- A6. NativeAOT (`PublishAot`, probe assembly rooted, linux-x64): G round-trips (payload, upsert, flush, reopen) with
|
||||
payload `92A172A170`, identical to JIT (job4 G0). B fails, in C2a code, not in the generator path: upsert fails in
|
||||
`CultElementIds.MapEntries`, whose `MakeGenericMethod(key, value)` has no native code for a struct key (job4 B0). With
|
||||
that walk reading the map through non-generic `IDictionary` instead, upsert passes under AOT and JIT is unchanged
|
||||
(job5 B1), but the payload and flush still throw `TargetInvocationException` from a second reflective call on the
|
||||
serialize path. By source read the candidate is `ResolverClaim`'s `GetFormatterMethod.MakeGenericMethod(type)`
|
||||
(`CultDocumentMessagePackSerialization.cs:84`), not proven. Both are runtime reflection over element-id paths, which is
|
||||
AOT cut 3's ground, so cut 1 proves AOT on the member shape and leaves the key shape's AOT round trip to cut 3.
|
||||
- A7. `GameCult.Geometry` already runs the generator (it references `MessagePack` directly) and carries the per-member
|
||||
workaround on three members (`CultGeometryDocuments.cs:164,239,328`). Under A4 those attributes are redundant.
|
||||
|
||||
### Shape the cut lands, and why
|
||||
|
||||
`GameCult.Caching.MessagePack` ships `buildTransitive/GameCult.Caching.MessagePack.targets`, which adds the A4 assembly
|
||||
attribute to every project that consumes the package. NuGet consumers need no line of their own, which is what a
|
||||
consumer expects from a library whose type breaks the generator. Unity consumers and in-repo `ProjectReference`
|
||||
consumers do not get `buildTransitive`: Unity consumers write the one attribute line (README), and in-repo consumers
|
||||
import the targets file, so they exercise the file that ships. The attribute type comes from `MessagePack.Annotations`,
|
||||
which `GameCult.Caching` already brings, so the injection compiles in a consumer with or without the generator. Wire
|
||||
bytes do not change: no formatter changes, only which code path asks for it (A5).
|
||||
|
||||
Rejected: a type-level formatter attribute (A3); excluding or hiding the formatter (A1); upgrading MessagePack (A2);
|
||||
declaring the attribute in the library (A4); keeping per-member attributes, which cannot reach dictionary keys.
|
||||
|
||||
## B. BoundedLeastSquares stopping
|
||||
|
||||
Finding `aetheria-release:finding:cut-thrust-allocator-core.s1.solver-converged-early` (Soul, Medium): a cold start
|
||||
reports `Converged` at a non-optimal point, so Aetheria's thrust allocator (tolerance 1e-12) answers differently
|
||||
warm and cold, by up to .078 throttle.
|
||||
|
||||
### Body facts
|
||||
|
||||
- **B1. One runtime ships the solver.** `BoundedLeastSquares` exists only in
|
||||
`packages/cultmath/src/CultMath/BoundedLeastSquares.cs` (C#, netstandard2.1 and net8.0). The type doc says there is
|
||||
no HLSL mirror; grep over `packages/*` finds no Rust, TypeScript, Python or Kotlin sibling, and no other CultLib
|
||||
project calls it. Its consumer outside CultLib is Aetheria's `ThrustAllocator`.
|
||||
- **B2. The defect reproduces against CultMath at base, outside Aetheria.** `probe1` rebuilds Soul's allocator
|
||||
matrix (Soul's `ProbeAllocator2`, constants H=100, Wy=1000, W=.3, kappa=10, rho=.1, EMax=2) for hull
|
||||
`Synthetic(8, 7005)` and the 8th intent of `System.Random(5)`, (-0.8505, 0.6739, 0.5587): 15 rows, 12 columns.
|
||||
Real `BoundedLeastSquares.Solve` at tolerance 1e-12 from x = 0: `Converged`, 13 iterations, cost 36.0313737,
|
||||
x7 = 1. Re-solving from that answer: 1 iteration, cost 36.0312608, x7 = 0.9222145, which is also the warm answer.
|
||||
The same literals (dumped with `"R"` floats, `probe1 dump`) reproduce it at tolerance 1e-9 and 1e-12, and not at
|
||||
the default 1e-6, where the cold solve stops at iteration 10 inside the tolerance and the re-solve takes 0
|
||||
iterations.
|
||||
- **B2a. The W5 problem as literals** (`probe1 dump`, float `"R"` round trip; m = 15, n = 12, row-major; the
|
||||
regression fixture for `bls-release-stop`). Nonzero entries of A:
|
||||
- row 0: a[0,2] = 100f, a[0,8] = -100f, a[0,9] = 100f
|
||||
- row 1: 13.882518f, 20.86695f, 0, 14.8965845f, 12.38046f, 22.115599f, -13.706654f, 15.85789f, 0, 0, -100f, 100f
|
||||
- row 2: -187.72859f, 239.31168f, 158.87828f, -270.31165f, 235.38467f, -14.704258f, 121.61232f, 244.81305f, 0, 0,
|
||||
0, 0
|
||||
- rows 3-6: a[3+k, 8+k] = 0.3f (k = 0..3); rows 7-14: a[7+i, i] = 0.1f (i = 0..7)
|
||||
- b = {0, 67.385445f, 558.70917f, -3f, -3f, -3f, -3f, 0 x 8}; lo = 0; hi = 1f for columns 0-7, 2f for 8-11.
|
||||
At base, tolerance 1e-12 from x = 0: `Converged`, 13 iterations, x[7] = 1, cost 36.031373704487095; a re-solve
|
||||
from that answer: 1 iteration, x[7] = 0.92221445f, cost 36.031260771352414.
|
||||
- **B3. The stopping rule that fires is the progress rule, after a release** (`BoundedLeastSquares.cs:273-278`). A
|
||||
traced copy of the solver, bit-equal to the real one on this problem, shows the last two passes:
|
||||
- iteration 12: full step lands; column 2 sits at its lower bound with gradient -0.00205 (violation 2.05e-3,
|
||||
tolerance 1.5e-7); the solver releases it and records fMark = f.
|
||||
- iteration 13: the re-solve moves x2 from 0 to 2.05e-7. The cost falls by about 2.1e-10, and the stall band
|
||||
`1e-15 * fScale` is 2.11e-10, so `f >= fMark - band` holds and the solver reports `Converged`. Column 7 is then at
|
||||
its upper bound with gradient +0.00145, ten thousand times the tolerance; releasing it is worth 1.1e-4 of cost.
|
||||
- **B4. Why the progress rule cannot judge a release.** Near a minimiser, a cost difference resolves only about the
|
||||
square root of what a gradient comparison resolves: a real step of size dx against gradient g lowers the cost by
|
||||
about g*dx, while the cost itself is a sum of terms of size fScale. Here g*dx (2e-10) sat exactly at the cost's
|
||||
rounding band. The gradient comparison is far from its own rounding: for column 7, the bound on the double
|
||||
gradient's rounding error, `(n+1) * eps * (|A^T b|_7 + sum_k |(A^T A)_7k x_k|)` (Higham's inner-product bound,
|
||||
gamma_(n+1)), is 9.3e-10, and the violation is 1.56e6 times that.
|
||||
- **B5. What the progress rule is for** (commit 43035bc8, and the tests at `BoundedLeastSquaresTests.cs:774-942`).
|
||||
It stops two kinds of pass that can never reach the tolerance: a free-set re-solve whose residual is rounding on a
|
||||
near-singular free set, and a release whose violation is itself rounding (kktTol at or below the noise, including
|
||||
kktTol = 0 when the box point nearest the origin is stationary). Deleting it outright breaks both: with a noise
|
||||
gate on every test and no progress rule, 8 of 66 BLS tests fail (`wide`, `integer-tie`, `collinear`, `cond` spin to
|
||||
the cap; `NearSingularFreeSetsConfirmOnceAndStop` sees 0 confirmations). Exempting releases from the progress rule
|
||||
with no noise gate fails one: `StalledPassesConvergeInsteadOfSpinningToTheCap("plain")` seed 141, b = 0,
|
||||
kktTol = 0, cycles release 3 (violation 1.7e-18) / stationarity miss / block on 3 until the cap.
|
||||
- **B6. The candidate fix, measured.** In a scratch copy of the package (`suite`, diff `suite/clean.diff`, 44 lines):
|
||||
(a) a bound column is a release candidate only if its violation exceeds both kktTol and the rounding bound of B4;
|
||||
(b) the progress rule judges only passes that release nothing; a pass that releases sets fMark to +Inf, so a
|
||||
release is never measured against a cost recorded before it. Results:
|
||||
- all 66 tests in `BoundedLeastSquaresTests` pass, including `DefaultSolvesAreBitIdenticalToTheirGoldenBits` and
|
||||
every stall and noise-floor test; the same holds with the bound's factor at 1, 16 and 256 in place of n+1;
|
||||
- the W5 problem: cold `Converged` in 14 iterations at the warm answer (x7 = 0.92221, cost 36.03126077);
|
||||
- 400 synthetic hulls x 60 random intents (24,000 calls, warm and cold) at tolerance 1e-12: every call `Converged`,
|
||||
max iterations 45, warm and cold agree within 6e-8 on every call (the original: one call off by .078 in the first
|
||||
2,400). At the default tolerance warm and cold still differ, by more than .1 in 10,273 of 24,000 calls: that is
|
||||
the documented resolution of 1e-6 times the gradient scale, pinned by
|
||||
`AnAllocatorShapedProblemIsStartIndependentAtATightTolerance` (spread >= 0.5), and not this cut's subject.
|
||||
- **B7. Released columns the factor drops.** With the fix, 266 releases in the suite land on a column that
|
||||
`SolveFreeSet` marks dependent (p = 0). The next pass is then a stationarity miss with no release, and the
|
||||
progress rule stops it, as before. No release in the suite or the 24,000 allocator calls produced a step that moves
|
||||
the released column outward. A Lawson-Hanson style rejection guard (re-bind the column, try the next candidate)
|
||||
was prototyped and is not needed by any measured case; it is not in the cut.
|
||||
|
||||
### Prior art, and which one the solver follows
|
||||
|
||||
- The solver is a primal active-set method on the normal equations: solve the free set, step to the first bound
|
||||
crossed, release the most violated bound only after a full step lands. That is the outer/inner loop of
|
||||
Lawson-Hanson NNLS (1974) generalised to two-sided bounds, which is the shape of Stark-Parker BVLS (1995) and of
|
||||
scipy's `lsq_linear(method='bvls')`. It differs in factorisation (Cholesky of `A^T A`, not QR of `A`) and in
|
||||
stopping.
|
||||
- Lawson-Hanson stop on the dual test only (netlib `NNLS`, label 350: `IF (WMAX .le. ZERO)`), and guard roundoff
|
||||
per candidate: a column whose new norm is negligible against rounding (`DIFF(UNORM+...)`) or whose new
|
||||
coefficient is not positive (`ZTEST .gt. ZERO`) is rejected with `W(J)=ZERO` and the next candidate tried. There is
|
||||
no cost-based stop.
|
||||
- scipy's BVLS (`scipy/optimize/_lsq/bvls.py`, main) measures KKT exactly as CultMath's status doc states
|
||||
(`g * on_bound` for bound variables, `|g|` for free ones, max over components) and has two stops: optimality below
|
||||
`tol` (status 1) and a relative cost change below `tol` (status 2), reported as different statuses.
|
||||
- CultMath reported its cost stop as `Converged`, which its status doc defines as "the KKT conditions hold". The fix
|
||||
follows Lawson-Hanson: a release is judged on the gradient against its rounding (the role of their `DIFF` test),
|
||||
and the cost stop is kept only where no gradient test can decide (a free-set residual at rounding level), which is
|
||||
where scipy's status 2 also lives.
|
||||
|
||||
### Rejected alternatives
|
||||
|
||||
- Remove the progress rule and stop on the gradient alone: B5, 8 failures.
|
||||
- Report the cost stop as a new status (scipy's status 2): the W5 answer would still be wrong, just labelled, and a
|
||||
new enum member is a breaking change for callers that switch on the status.
|
||||
- Measure progress from the step (the exact model decrease `t(1-t/2)|L^-1 g|^2`) instead of differencing the cost:
|
||||
exact, but a noise-level gradient still yields a tiny positive decrease, so it needs the same noise threshold, on a
|
||||
quantity further from the KKT test than the gradient itself.
|
||||
|
||||
## I. Naming the invalid input
|
||||
|
||||
Follow-up `cultlib-gaps:follow_up:bls-invalid-input-field` (from Soul finding
|
||||
`cut-bls-kkt-tolerance.s1.invalid-input-names-no-field`): every validation failure returns the same `InvalidInput`.
|
||||
|
||||
- **I1.** Validation today is three sites in `Solve`: tolerance (`:147-148`), bounds (`:149-151`), and finiteness of
|
||||
A and b folded into the `A^T b` build (`:153-161`), which cannot tell A from b.
|
||||
- **I2.** Precedent: LAPACK reports a bad argument as `INFO = -i`, naming the argument and never its value. .NET
|
||||
throws `ArgumentException` with a parameter name, which the solver's no-throw, zero-allocation contract rules out.
|
||||
- Recommended shape (question `bls-input-error-shape`, option `validate-helper`): a public
|
||||
`BoundedLeastSquares.Validate(m, n, a, b, lo, hi, kktRelativeTolerance)` returning a new enum
|
||||
`BoundedLeastSquaresInput { Valid, InvalidTolerance, InvalidBounds, NonFiniteMatrix, NonFiniteTarget }`, the first
|
||||
failure in that order. `Solve` calls it and returns `InvalidInput` for anything but `Valid`, so validation has
|
||||
one owner and the folded finiteness check in the `A^T b` loop is deleted. Additive: `InvalidInput` and every
|
||||
existing result stay.
|
||||
|
||||
## R. Release 0.4.1
|
||||
|
||||
- **R1.** `org.gamecult.cultmath` is at 0.4.0 (`package.json:4`, `CultMath.csproj:13`, tag
|
||||
`cultmath-unity-v0.4.0`). `scripts/check-changelog-semver.mjs:97-101`: in 0.y.z a minor bump is the breaking lane;
|
||||
additions and fixes take the patch lane (0.2.4 added `erf` as a patch).
|
||||
- **R2.** Cut 1 changes results only where the old solver stopped after a release whose violation was above both the
|
||||
caller's tolerance and the gradient's rounding, that is, where it reported `Converged` in breach of its status doc.
|
||||
The default path's golden bits are unchanged (B6). The 0.4.0 precedent (ruling `cultmath-0-4-0-breaking-r2`)
|
||||
declared the snoise tie fix Breaking because two valid functions gave different values; here the old value broke
|
||||
the documented contract. Question `bls-fix-release-lane` asks which lane; the recommendation is `fixed-patch`
|
||||
(0.4.1, `### Fixed`).
|
||||
- The release procedure is the 0.4.0 cut's (`cultlib-gaps:cut_spec:cut-cultmath-release.r1`): Windows-only DLL rebuild
|
||||
on Starfire, tests on Yggdrasil, public-surface diff, Unity import smoke, Soul before the tag.
|
||||
|
||||
## Q. Rotation algebra
|
||||
|
||||
Ruling `aetheria-release:ruling:solver-in-gamecult-animation`: CultMath gains quaternion algebra, swing-twist and
|
||||
joint clamps; the aim-chain solve lives in a new package GameCult.Animation. The interface the consumer needs is the
|
||||
Aetheria map's section "The rig's interface to GameCult.Animation and CultMath" (Aetheria `origin/master`
|
||||
`30d02719`, `docs/aetheria-release-map.md:7559-7601`).
|
||||
|
||||
### Body facts
|
||||
|
||||
- **Q1. What exists.** `quaternion` (`src/CultMath/quaternion.cs`, 95 lines): public float fields x, y, z, w;
|
||||
`identity`; `LookRotation(float3 forward, float3 up)`; implicit conversions to and from `float4`; scalar `==`.
|
||||
`math.normalize(quaternion)` (`math.cs:225-235`) returns identity below length 1e-20. No mul, rotate, conjugate,
|
||||
inverse, axis-angle, slerp, swing-twist or clamp exists. `math` is a partial class (`math.cs`,
|
||||
`math.Interval.cs`, `math.Affine.cs`, `math.Phacelle.cs`); `math.rotate(float2, float)` exists (`math.cs:240`).
|
||||
- **Q2. Serialization.** `GameCult.Caching.MessagePack` serialises `quaternion` as the float array [x, y, z, w]
|
||||
(`CultMathResolver.cs:170-171`) and as an xyzw object in JSON (`CultMathJson.cs:468`). No other runtime
|
||||
(`cultcache-rs`, `-ts`, `-py`) has a quaternion type. The cut adds static functions only: no field, layout or
|
||||
formatter changes, so no wire change and no cross-runtime fixture.
|
||||
- **Q3. Runtimes.** The algebra is C#-only, like `LookRotation`: shaders carry rotations as `float4` (the type's doc),
|
||||
the mirror tests map shader functions to C# and never the reverse (`HlslSourceCompatibilityTests.cs:114-117`), and
|
||||
the native core (`native/cultmath-core`) is a Voronoi tone helper. The Unity bridge already converts `quaternion`
|
||||
(`UnityConversions.cs:16,23`); it needs no change.
|
||||
- **Q4. An overload trap, probed.** `quaternion` converts implicitly from `float4`, and CultMath has no
|
||||
`mul(float4, float4)`. `probe3` declares `mul(quaternion, quaternion)` beside scalar and matrix `mul` overloads and
|
||||
calls it with two `float4`: it compiles and returns the quaternion product. HLSL's `mul(vector, vector)` is the
|
||||
inner product, and Unity.Mathematics 1.3.3 (the copy in Aetheria's PackageCache) defines `mul(float4 a, float4 b)`
|
||||
as such (`matrix.gen.cs:129`), so the same call there is a dot product. The cut therefore adds HLSL's vector
|
||||
`mul(float2|3|4, float2|3|4)` (dot) beside the quaternion `mul`, so a `float4` pair keeps its shader meaning.
|
||||
- **Q5. Unity.Mathematics as reference, not template.** Its signatures for `mul`, `rotate`, `conjugate`, `inverse`,
|
||||
`AxisAngle` and `slerp` (`quaternion.cs:99,493,502,630,651,678`) are the consumer's stated convention ("`mul(a, b)`
|
||||
applies `b` first"). CultMath's design rule is to state semantics in code and not copy Unity.Mathematics
|
||||
(`docs/design.md:465-469`), so the formulas are written from the definitions below.
|
||||
|
||||
### Semantics the cut lands
|
||||
|
||||
Quaternions are Hamilton, stored xyzw, unit for rotation; angles are radians, right-handed about the axis.
|
||||
|
||||
- `math.mul(quaternion a, quaternion b)`: the Hamilton product; `rotate(mul(a, b), v) == rotate(a, rotate(b, v))`.
|
||||
- `math.rotate(quaternion q, float3 v)`: `t = 2 cross(q.xyz, v); v + q.w t + cross(q.xyz, t)`; q assumed unit.
|
||||
- `math.conjugate(q)`: (-x, -y, -z, w). `math.inverse(q)`: `conjugate(q) / dot(q, q)`, no guard (zero in, non-finite
|
||||
out, as HLSL division).
|
||||
- `quaternion.AxisAngle(float3 unitAxis, float radians)`: (axis sin(r/2), cos(r/2)); the axis is not normalised.
|
||||
- `math.slerp(a, b, t)`: shortest arc (negate b when `dot(a, b) < 0`); normalised lerp when the quaternions are
|
||||
nearly parallel; t is not clamped.
|
||||
- `math.swing_twist(q, unitTwistAxis, out swing, out twist)`: twist is q's projection onto the axis, normalised
|
||||
(identity when that projection vanishes, a half-turn swing); `swing = mul(q, conjugate(twist))`, so
|
||||
`q == mul(swing, twist)` (twist applied first).
|
||||
- `math.clamp_twist(twist, unitAxis, min, max)`: angle `2 atan2(dot(twist.xyz, axis), twist.w)` wrapped to (-pi, pi];
|
||||
inside [min, max] the input is returned unchanged, otherwise `AxisAngle(axis, clamp(angle, min, max))`.
|
||||
- `math.clamp_hinge(q, unitAxis, min, max)`: `clamp_twist` of q's twist about the axis; the swing is discarded.
|
||||
- `math.clamp_cone(swing, unitAxis, maxAngle)`: the angle between `rotate(swing, axis)` and `axis`; inside the cone
|
||||
the input is returned unchanged, otherwise the rotation by `maxAngle` about `normalize(cross(axis,
|
||||
rotate(swing, axis)))` (at a half turn, where that cross product vanishes, about the swing's own axis).
|
||||
- Preconditions (`min <= max`, unit axes) are documented, not checked, as everywhere in `math`.
|
||||
|
||||
### What GameCult.Animation will need from this cut (not mapped here)
|
||||
|
||||
- From cut 4: `mul`, `rotate`, `AxisAngle` for `AimChain.Pose` and `EffectorDirection`; `conjugate` or `inverse` for
|
||||
frame changes; `swing_twist` and the clamps if its joints ever carry cone or twist limits (the presenter's per-axis
|
||||
boxes do not).
|
||||
- From cut 1: start-independent `BoundedLeastSquares` at a tight tolerance. `AimChainSolver.Solve` is warm-started
|
||||
from `current`; without cut 1 its answer would depend on the previous frame exactly as the allocator's did.
|
||||
- A CultMath release that carries cut 4, which `org.gamecult.animation`'s `package.json` pins. That release is
|
||||
mapped with GameCult.Animation, so one Soul-verified release pair ships both.
|
||||
|
||||
## P. cultcache-py payload format (py-msgpack-default, revisions 2 to 4)
|
||||
|
||||
Imagination pass `imagination-py-msgpack-r2`, session `self-2026-10-12-eureka`, against `hands/py-msgpack-default`
|
||||
at `7f2c7477`. Probes ran from the session scratchpad and were removed afterwards; their results are recorded here (`jobK` ran
|
||||
the C# reference on Yggdrasil at `7f2c7477`; `p1`-`p3` ran Python 3.13.15 with msgpack 1.2.2 on Starfire). Revision 1's facts
|
||||
are in `docs/research/py-msgpack-default/README.md`, and Soul's are in verdict `cut-py-msgpack-default.s1`.
|
||||
|
||||
### Body facts
|
||||
|
||||
P1. **What decides a payload's format at 7f2c7477.** A search of every `packb`, `unpackb`, `json.loads` and
|
||||
`json.dumps` call in cultcache-py, cultnet-py and cultmesh-py found five places:
|
||||
- (a) `PayloadCodec.encode`/`decode`, through `DocumentDefinition.encode_payload`/`decode_payload`.
|
||||
- (b) `_check_payload_tree` (documents.py:75), which holds the map-key rule. `MESSAGEPACK_PAYLOAD_CODEC.encode`
|
||||
does not apply that rule, so a direct call writes `{1: 'a'}` and its own `decode` refuses the bytes.
|
||||
- (c) `stores.py:217`, `_infer_schema_version_from_payload`. A v1 record whose schema id is missing from the
|
||||
catalog is read by unpacking its payload as MessagePack to find a schema stamp.
|
||||
- (d) `replication.py:186`, the same stamp reader copied into cultnet-py behind a lazy `import msgpack`.
|
||||
- (e) `stores.py:257`. A legacy envelope whose payload is an inline map or array is repacked as MessagePack.
|
||||
|
||||
The other JSON sites are wire messages, schema JSON, CLI output and the cultmesh ready file. None of them is a
|
||||
document payload.
|
||||
|
||||
P2. **The C# reference's object slot, map keys** (`jobK`, `NumericLeafVectorTests.AnySlot` under
|
||||
`CultDocumentMessagePackSerialization.OptionsFor`, MessagePack `UntrustedData` security):
|
||||
- Accepted: str, int at every width (negative fixint, u64 max, i64 min), float32, float64, bool, and timestamp
|
||||
ext. The reference rewrites each one at its own width, for example `9181ff01` becomes `9181d0ffcc01`.
|
||||
- Refused: nil (inner `ArgumentNullException`); bin, array and map keys (inner `TypeAccessException`, because
|
||||
UntrustedData has no collision-resistant comparer for them).
|
||||
- Refused as duplicates (inner `ArgumentException`): `{1:1,1:2}`, `{"a":1,"a":2}`, and `{01:1, cc01:2}`, which
|
||||
is the same value written at two widths.
|
||||
- Accepted as two keys: `{1:1, 1.0:2}` and `{1:1, true:2}`. The CLR types differ, so the keys are distinct.
|
||||
- An `object` value of u64 max or i64 min is accepted and rewritten byte-identical.
|
||||
|
||||
P3. **msgpack-python with `strict_map_key=False`** (`p2`, `p3`). Every key kind decodes: nil becomes None, bin
|
||||
becomes bytes, and a timestamp ext becomes `msgpack.Timestamp`. An array or map key raises TypeError
|
||||
(unhashable) without a hook. Duplicates are silently last-wins: `{1:1,1:2}`, `{"a":1,"a":2}`, `{01, cc01}`,
|
||||
`{1, 1.0}` and `{1, true}` each decode to a single key. An `object_pairs_hook` sees every key kind, lists and
|
||||
dicts included. An exception it raises propagates with `__context__` None. On 100k small maps the hook costs
|
||||
2.2x the decode time (0.087 s becomes 0.200 s). `packb` raises an untyped `OverflowError` for 2**64 and
|
||||
-(2**63)-1.
|
||||
|
||||
P4. **Hash flooding with non-str keys** (`p1`). This is msgpack-python's reason for `strict_map_key=True`.
|
||||
Within the 64-bit MessagePack integer range, at most 13 integers share one CPython hash. The full-hash collision
|
||||
attack on int keys therefore needs unbounded integers, and MessagePack cannot carry them. Keys crafted to share
|
||||
low hash bits (`k << 43`) took 0.44 s for 200k keys and 1.34 s for 400k. 200k str keys take 0.25 s. The cost is
|
||||
mildly superlinear, not quadratic. The reference defends itself with UntrustedData's collision-resistant
|
||||
comparers. Python's dict has no such comparer, and the measured bound makes one unnecessary.
|
||||
|
||||
P5. **Schema-stamp recovery.** The C# store deleted its stamp recovery in `a3cc3942` (2026-09-13), so a persisted
|
||||
schema id that is absent from the embedded catalog is refused ("Persisted schema ... is not present in the
|
||||
embedded catalog", `CultCache.cs:504-507`). The C# CultNet registry keeps payload-stamp routing for raw wire
|
||||
records (`CultNetDocumentRegistry.ResolveDescriptorForRawDocument`, `TryResolveDescriptorByPayloadSchema`).
|
||||
cultcache-py's store copy (P1c) is therefore a divergence from the reference. cultnet-py's copy (P1d) mirrors
|
||||
the reference. The TS store (`single-file-messagepack-backing-store.ts:336`) also keeps the store copy.
|
||||
|
||||
P6. **Legacy envelope arrays.** The C# reference does not read them. TS (`normalizePayload`, line 490) and Python
|
||||
both repack an inline payload with MessagePack `encode`. A legacy inline payload is a subtree of a MessagePack
|
||||
document, so it is MessagePack by construction. No JSON-payload writer ever produced one: the 0.3 JSON default
|
||||
wrote bytes, which land as bin.
|
||||
|
||||
P7. **Brokkr** (read-only, `f97d4dc`). The bridge imports cultcache-py from `F:/Projects/CultLib/packages`
|
||||
(`blender_target.py:19`) and defines 8 types with `define_document_type` (630-637). Merging to that checkout's
|
||||
main is therefore the switch itself: Soul s1 measured that the next `_open_node` pull refuses the whole Blender
|
||||
store. The daemon writes JSON into the Blender store and the Unity store with `put_json_document`
|
||||
(`main.rs:1424`, called at 790-1392 and in tests from 2710). It also has `put_messagepack_document` (1435) and
|
||||
reads JSON first, then MessagePack (`decode_payload`, 1447). The daemon is Rust and cannot call cultcache-py.
|
||||
|
||||
### Authority, and why
|
||||
|
||||
- **Payload format.** The definition's `PayloadCodec` owns its bytes: the encoding, and which maps and integers
|
||||
the format can carry. On read, `MESSAGEPACK_PAYLOAD_CODEC` refuses key kinds and duplicates through its
|
||||
`object_pairs_hook`; that is the only place they are visible, because a dict cannot show a collapsed duplicate
|
||||
afterwards. On write, its encode walks the tree first, so a direct `codec.encode` call cannot write bytes
|
||||
its own `decode` refuses (Soul's single path).
|
||||
- **What the definition owns.** `DocumentDefinition.encode_payload`/`decode_payload` own the cross-codec rules
|
||||
(depth 256) and the translation of codec faults into the typed refusals, naming the type. `PayloadCodec.map_keys`
|
||||
is deleted, and `_check_payload_tree` goes back to checking depth only.
|
||||
- **The key rule is P2's.** It is not `strict_map_key`'s. Duplicates are refused as the reference refuses them.
|
||||
- **Residual.** Keys Python holds as one but the reference holds as two (`1` and `1.0`, `1` and `true`) are
|
||||
refused, because a dict cannot hold both. The refusal is typed (`duplicate_map_key`) and pinned so a change is
|
||||
seen. Matching the reference here would need a decoded map type that is not a dict. No consumer is known to
|
||||
write such maps.
|
||||
- **Prior art for refusing duplicates** (from knowledge, not fetched this pass): CBOR's RFC 8949 section 5.6
|
||||
expects decoders to reject duplicate keys; I-JSON (RFC 7493) requires the same of JSON; MessagePack-CSharp's
|
||||
`UntrustedData` refuses duplicates and keys it cannot hash safely (P2). msgpack-python's `strict_map_key` exists
|
||||
for hash flooding, which P4 bounds.
|
||||
- **Integer range.** Beyond 64 bits is a format limit, so the MessagePack codec's write walk refuses it with the
|
||||
code `integer_out_of_range`. The value is never named, and neither is a map key on its path, because in a
|
||||
free-form tree a key is payload data. Entry slots keep their leaf refusals, which name the slot and the field.
|
||||
A bigint was never wire-parity data: MessagePack has no integer wider than 64 bits (P3), and the 0.3 JSON
|
||||
payloads that could hold one were unreadable by the reference (`py-default-payload-json`).
|
||||
- **The stamp readers.** P1c is deleted and its record is refused as the reference refuses it (P5). P1d stays as
|
||||
cultnet-py's raw-record router, mirroring C#. It reads a stamp and decides no format: the resolved definition's
|
||||
codec decodes the payload or refuses it. It loses its lazy import.
|
||||
- **Legacy envelopes.** P1e is demoted and stays (P6). It extracts the MessagePack bytes the inline subtree
|
||||
already is. A definition whose codec is not MessagePack refuses the record like any other foreign record.
|
||||
- **The default codec.** The `DocumentDefinition` field is its only author. `define_document_type`'s parameter
|
||||
defaults to None and forwards only a codec it was given.
|
||||
- **Refusal text.** The JSON remedy sentence is dropped from the read refusal: it was false for every entry
|
||||
definition. Each refusal carries a `code` (`not_one_value`, `map_key_kind`, `duplicate_map_key`,
|
||||
`integer_out_of_range`) and names the kind or the fault class. It is raised after the `except` block, so
|
||||
`__context__` holds no decoder exception and no payload.
|
||||
|
||||
### Migration: what CultLib owns
|
||||
|
||||
Revision 1's README procedure (read under JSON, then put each value) was per record and not atomic. Soul s1
|
||||
stranded a store with it. Revision 2 replaces the procedure with
|
||||
`migrate_payloads(store, definitions, *, source_codec=JSON_PAYLOAD_CODEC) -> int` in cultcache-py:
|
||||
|
||||
1. It pulls every envelope from the store.
|
||||
2. For each record whose type is among `definitions`, it decodes under that definition with `source_codec` and
|
||||
encodes under the definition itself. That is the whole definition path, validation and every refusal included.
|
||||
3. Any refusal stops the migration before the store is touched. The error names the type, the record key and
|
||||
the refusal's code, never the value.
|
||||
4. Otherwise one `push_all` writes the store whole. `SingleFileMessagePackBackingStore` and
|
||||
`JsonLinesBackingStore` each do that as a temp file and a rename.
|
||||
5. Records of other types keep their bytes and `stored_at`. The return value is the count rewritten.
|
||||
|
||||
It never sniffs, and it cannot leave a store half-migrated. Revision 2 also claimed that a second run is refused;
|
||||
Soul s2 falsified that (P10), and a concurrent writer strands the store (P11). Revision 3 replaced this shape with a
|
||||
backup-and-swap (P12), which Soul s3 broke three more ways. Revision 4 deletes the in-place migration: CultLib owns
|
||||
only `convert`, which writes a new file and never writes its source (P14).
|
||||
|
||||
Rejected alternatives:
|
||||
- A sniffing reader that accepts both formats: P1's one-digit payloads are valid in both formats, and r1's map
|
||||
forbade readers that try one format and then another.
|
||||
- A consumer-run loop over `put`: that is the procedure that stranded the store.
|
||||
|
||||
### Brokkr landing contract (follow-up `brokkr-msgpack-payloads-r4`)
|
||||
|
||||
The merge of `hands/py-msgpack-default` to main waits on three things:
|
||||
1. Soul holds on the fix batch.
|
||||
2. A Brokkr branch, Soul-held, is ready in its own campaign. On it, the daemon writes MessagePack
|
||||
(`put_messagepack_document`) into both stores. The JSON-first read becomes a read of exactly one MessagePack
|
||||
value with a trailing-bytes check, and the bridge's 8 definitions are unchanged. The Blender store is converted
|
||||
by `cultcache_py.convert` over those 8 definitions into a new file beside it, because those payloads came from
|
||||
cultcache-py's old default (P14). The daemon-written JSON receipts in the Unity store are the daemon's own, and
|
||||
it rewrites them, also into a new file.
|
||||
3. An operator window: close Blender and stop sync, Self merges CultLib and Brokkr together, build the daemon,
|
||||
run both conversions, open each new file, swap it into place, then restart. The JSON originals are kept under
|
||||
another name for as long as the operator wants them.
|
||||
|
||||
### Revision 3
|
||||
|
||||
Imagination pass `imagination-py-msgpack-r3`, session `self-2026-10-12-eureka`, against `hands/py-msgpack-default`
|
||||
at `57f85acd`. Python probes ran on Starfire (Python 3.13, msgpack 1.2.2) over a `git archive` of the cultcache-py
|
||||
and cultnet-py sources at that head, and were removed afterwards. The C# facts are Soul s2's (`jobB`, Yggdrasil),
|
||||
not rerun here.
|
||||
|
||||
P8. **What still decides map keys at 57f85acd.** One search found three MessagePack readers that see document
|
||||
payload maps. (a) The codec's `_unpack_tree` applies the reference rule through `_checked_map`. (b) The store's
|
||||
whole-file `msgpack.unpackb(data, raw=False)` (`stores.py:98`) applies msgpack's `strict_map_key=True`. A legacy
|
||||
envelope file whose inline payload is `{1: 'a'}` is refused with "int is not allowed for map key". Read through
|
||||
`_unpack_tree` instead, the same file yields the payload `8101a161`, and a v1 snapshot's framing decodes
|
||||
identically either way. (c) cultnet-py's stamp router (`replication.py:192`) applies `strict_map_key=True`. Read
|
||||
through the codec, `['x.v1', {1: 'a'}]` and `{'schemaVersion': 'x.v1', 'v': {1: 'a'}}` both route to `x.v1` (C#
|
||||
routes both, Soul s2 STAMPPROBE). A nil-keyed payload gives `None`, as it does today and in C#. Every other
|
||||
`unpackb` in cultnet-py and cultmesh-py reads wire messages, cursor files or fixed-slot protocol records, whose
|
||||
maps are frames rather than payload data. Their bound is follow-up `py-wire-decoders-unbounded`. Unmeasured: C#
|
||||
reads a map stamp as `IReadOnlyDictionary<string, object>`, so a top-level non-str key may stop C# routing where
|
||||
Python routes. The resolved definition decides the record either way.
|
||||
|
||||
P9. **The two depth counters.** `_check_writable` (documents.py:184) counts every node, scalars included, to
|
||||
`_PACK_DEPTH_LIMIT = 511`. `_check_payload_tree` counts containers to 256. Commit `eef1cb15` moved the codec
|
||||
from 256 to 511 so that `test_union_parity.py:650` could build its 257-deep read fixture with
|
||||
`document.payload_codec.encode(past)`. The fixture needs bytes that are 257 deep, not a codec that writes them:
|
||||
`msgpack.packb(past, use_bin_type=True)` and `json.dumps(past).encode()` build them, and both writers nest to 511
|
||||
or further. One container walk, bounded at `MAX_PAYLOAD_DEPTH`, serves both the definition and the codec. It also
|
||||
ends a cycle, because a cycle reaches 257.
|
||||
|
||||
P10. **Rerun.** No byte in a CultCache store records a payload format, and no wire change is wanted for one. A
|
||||
migration that rewrites the live store in place therefore cannot tell its own output from its input when a
|
||||
payload is valid in both formats. `b'5'` is the JSON 5 or the MessagePack 53, and Soul s2 saw 53 become 5. No
|
||||
check on the payload content can separate the two, so the rerun guard has to come from outside the payload.
|
||||
|
||||
P11. **Concurrency in the store.** cultcache-py's file stores hold a per-object `threading.RLock` only, so every
|
||||
`push` is a read-modify-write that loses another process's write. The C# store opens a `.lock` sidecar with
|
||||
`FileShare.None` (`CultCache.cs:3489-3509`), and cultcache-rs takes an `fs2` lock on its sidecar (`lib.rs:853-859`).
|
||||
cultcache-ts has none (a search of `packages/cultcache-ts/src` finds no lock). The migration race is one case of a
|
||||
store that lacks the reference's lock.
|
||||
|
||||
P12. **Prior art and the shape chosen** (primary docs fetched 2026-10-12). SQLite `VACUUM INTO`: "the original
|
||||
database file is unchanged", and the target "must not previously exist, or else it must be an empty file". LMDB
|
||||
`mdb_env_copy`: the target directory "must already exist and be writable but must otherwise be empty". Realm is
|
||||
the counter-example to Self's hypothesis. It migrates in place on open, and a rerun is safe because the file
|
||||
records its schema version, which CultCache's payload format does not (P10). The chosen shape follows `VACUUM
|
||||
INTO`, with the destination role taken by a mandatory backup:
|
||||
|
||||
`migrate_payloads(store, definitions, *, backup, source_codec=JSON_PAYLOAD_CODEC) -> int`
|
||||
|
||||
1. Copy the live store's bytes to `backup` with exclusive create (`open(backup, "xb")`). If the backup exists,
|
||||
the call is refused before anything is read. Every rerun is refused this way, whatever the payloads hold.
|
||||
2. Read the records from the backup, the frozen copy, and rewrite the listed ones. Any refusal of any exception
|
||||
class raises `CultCacheError` naming the type, the record key and the code or class.
|
||||
3. Compare the live bytes with the copied bytes. A difference means a writer ran during the migration, and the
|
||||
call refuses and says to stop every writer.
|
||||
4. Replace the live file in one `_replace_all` (temp file and rename).
|
||||
5. On any failure after step 1, the backup is removed and the live store is unchanged. A count of zero also
|
||||
removes it.
|
||||
|
||||
Probe (`probe2`, this shape over the 57f85acd sources). The first run over `b'53'`, `b'5'` and an unlisted type
|
||||
gives 53 and 5, keeps the unlisted bytes, and keeps the JSON in the backup. A second identical call raises
|
||||
`FileExistsError` and leaves the live bytes unchanged. A push between the read and the swap (the s2 interleave)
|
||||
is refused, with the backup removed and the live store holding the writer's JSON record. A validate that raises
|
||||
`AssertionError` is named by class and the backup is removed.
|
||||
|
||||
What remains and is stated: the window between the compare and the rename, closed by the lock in `5a`. The
|
||||
precondition is to stop every writer, as it is for SQLite's offline tools. If someone deletes the backup and then
|
||||
reruns the migration on its own output, P10 recurs. The README says to delete the backup only after the
|
||||
migrated store has been opened, and never to rerun after that.
|
||||
|
||||
P13. **Float32 keys.** `Float32(0.1).value` is `0.1`, so a `Float32` key is not rounded until it is packed.
|
||||
Decoded, it is the float32-rounded value. Writing `{1, F32(1.0)}`, `{True, F32(1.0)}`, `{1.5, F32(1.5)}` or
|
||||
`{0, F32(-0.0)}` produces bytes that the codec's own decode refuses as `duplicate_map_key`. `{0.1, F32(0.1)}` and
|
||||
two NaN keys read back as two keys. The rule that matches decode exactly compares each key's decoded image (a
|
||||
`Float32` key's image is `struct.unpack('>f', struct.pack('>f', v))[0]`) and refuses a collision on write as
|
||||
`duplicate_map_key`. The reference reads all four collision maps as two keys (Soul s2 KEYPROBE). Python cannot,
|
||||
for the same reason it cannot read `{1, 1.0}`, which is the residual already recorded. Refusing on write keeps
|
||||
Python inside what it can read back, and it is the write half of that residual.
|
||||
|
||||
### Revision 4
|
||||
|
||||
Imagination pass `imagination-py-msgpack-r4`, session `self-2026-10-14-eureka`, against `hands/py-msgpack-default`
|
||||
at `a59bbc7d`, under ruling `py-migration-shape-ruled` (convert-to-new-file). Source reads only; nothing was run.
|
||||
|
||||
P14. **The shape ruled.** `convert(source, destination, definitions, *, source_codec=JSON_PAYLOAD_CODEC) -> int`
|
||||
replaces `migrate_payloads`. It creates `destination` with exclusive create (`open(path, "xb")`) before it reads
|
||||
anything, so an existing file (the source itself, an earlier output, anything) is refused and left alone. It reads
|
||||
the source once through the source store's `pull_all`, rewrites the listed records in memory, writes the store
|
||||
bytes into the file it created, and fsyncs it. Any failure after the create removes that file. It never opens the
|
||||
source for writing. The destination is the same store kind as the source, and its bytes come from the same
|
||||
per-kind encoder `_replace_all` uses, so the two writers cannot drift. The caller stops writers, converts, opens
|
||||
the new file and swaps it in. This follows SQLite `VACUUM INTO` and LMDB `mdb_env_copy` (P12) without the backup
|
||||
role that r3 added to them.
|
||||
|
||||
P15. **What the shape makes impossible, and what stays.** The library no longer writes a live store during a
|
||||
format change. That leaves no code to fix for s2's `migrate-races-concurrent-writer` or s3's
|
||||
`inflight-writer-strands-migration`, `rerun-guard-is-per-backup-path` and `stopped-migration-has-no-recovery`,
|
||||
because the backup, the compare and the swap are deleted. The library does not lose a write made during
|
||||
`convert`. The write lands in the source, and only the caller's swap can drop it. The README makes stopping
|
||||
writers the caller's precondition, as SQLite's offline tools do. A killed run leaves the source intact and possibly
|
||||
a partial destination; recovery is to delete the destination and run again. A partial JSON Lines destination can
|
||||
parse as a smaller store, so the README says the destination is complete only once `convert` has returned.
|
||||
Converting a store that is already MessagePack can still produce wrong values in the destination (P10: `b'5'`),
|
||||
but the source is untouched. The cross-process store lock (follow-up `py-ts-store-cross-process-lock`) is still a
|
||||
store parity gap, but nothing in this cut waits on it.
|
||||
|
||||
P16. **The codec decodes only what it writes.** At `a59bbc7d`, `MESSAGEPACK_PAYLOAD_CODEC.decode` is `_unpack_tree`
|
||||
alone. It returns trees 257 or more deep, `ExtType` values, and timestamps outside .NET `DateTime` (Soul s3); the
|
||||
definition or the reference refuses every one of them. Revision 4 also runs the write check over the decoded tree
|
||||
(`_check_carried`, renamed from `_check_writable`). That gives one rule in both directions. The depth walk, the
|
||||
integer range and the key kinds apply as before. No `ExtType` node is carried (code `value_kind`, kind `ext`).
|
||||
A timestamp's seconds must lie within `-62135596800..253402300799` (code `timestamp_out_of_range`). That interval
|
||||
is `DateTime.MinValue` to `DateTime.MaxValue`. It is derived from MessagePack-CSharp's timestamp reader, which adds
|
||||
the seconds to the Unix epoch and then the nanoseconds as ticks. It was not measured at the seam here, and a
|
||||
C#-written seam row belongs to follow-up `leaf-vectors-next`. The store's whole-file read keeps `_unpack_tree`
|
||||
alone, because a store frame is not a payload. The `any` leaf keeps its own ext refusal, which is the slot's rule
|
||||
for a value handed to it directly.
|
||||
|
||||
P17. **NaN keys.** The reference holds a map's keys in a `Dictionary` keyed by CLR type and value, where a NaN
|
||||
equals a NaN of the same width. It therefore refuses two float64 NaN keys and accepts a float64 NaN beside a
|
||||
float32 NaN (Soul s3). Python reads both widths as one `float` and cannot tell them apart. Revision 4 gives
|
||||
`_key_image` one NaN image, used by `_checked_map` on read and by `_check_carried` on write. Any two NaN keys are
|
||||
refused both ways as `duplicate_map_key`. On the mixed-width pair this refuses something the reference accepts,
|
||||
the same kind of residual as `1` beside `1.0`. The other choice would read two float64 NaN keys that the
|
||||
reference refuses.
|
||||
|
||||
Revision 5 records this as a deliberate exception to ruling `leaf-acceptance-follows-reference`, because Python
|
||||
cannot tell the two widths apart (msgpack-python decodes both to one `float`) and no ruling lets the codec read a
|
||||
pair the reference refuses. Soul s4 probed the C# behaviour: `Dictionary<object,object>{float.NaN, double.NaN}`
|
||||
serialises as `82 ca ffc00000 01 cb fff8000000000000 02`, the reference reads it back as two keys, and cultcache-py
|
||||
refuses it on read as `duplicate_map_key` (base `a59bbc7d` accepted that read). Every same-width NaN pair, with
|
||||
equal or different payload bits and with +NaN beside -NaN, is refused by both runtimes. The exception lives where a
|
||||
consumer reads it: the cultcache-py README's Wire Parity section, pinned by a test vector (the bytes above).
|
||||
|
||||
## D. cultcache-rs decode stack safety (cut `decode-stack-safety`)
|
||||
|
||||
Pinned base: `origin/main` at `ae6ef5a8` (2026-10-14). Imagination pass `imagination-decode-stack`, session
|
||||
`self-2026-10-14-eureka`. Ruling `decode-stack-mechanism-ruled` (grow-stack); prior art
|
||||
`docs/research/decode-stack-safety.md`. Probes live under the session scratchpad, `imagination-decode-stack/`:
|
||||
`probe` decodes through a scratch copy of cultcache-rs at the base whose derive calls a hook per derived level
|
||||
(off, observe the stack used between levels, or `stacker::maybe_grow`), against Eve's `thing-contract` at
|
||||
`2467e1b`; `wasmprobe` is the wasm run. Yggdrasil rust image (rustc 1.95.0, rmp-serde 1.3.1, serde_stacker
|
||||
0.1.14, stacker 0.1.25, psm 0.1.32), jobs `job1` to `job5`. None of it is committed.
|
||||
|
||||
Shapes: `box` is a thing-contract Box chain (3 containers a level, the bound admits 83), `repeat` a Repeat chain
|
||||
(2 a level, admits 125), `fat` a 32-slot record of `[u64; 32]` with an `Option<Box<Self>>` slot (1 a level,
|
||||
admits 255, so the leaf arrays sit at depth 256), `json` a one-slot record holding a `serde_json::Value` nested
|
||||
255 arrays, `ignored` a one-slot record whose extra trailing slot nests 255 arrays (skipped as `IgnoredAny`).
|
||||
|
||||
### Body facts
|
||||
|
||||
D1. **Today's abort, reproduced.** `decode_payload` at the bound, first level that aborts (process abort, no
|
||||
exit code, every time):
|
||||
|
||||
| Profile, thread | box (83) | repeat (125) | fat (255) | json, 64 KiB | ignored, 32 KiB |
|
||||
|---|---|---|---|---|---|
|
||||
| release, 1 MiB | 44 | 56 | 13 | 115 | 24 |
|
||||
| release, 2 MiB | ok | 114 | 25 | | |
|
||||
| debug, 1 MiB | 11 | 14 | 5 | 15 | 8 |
|
||||
| debug, 2 MiB | 23 | 30 | 10 | | |
|
||||
|
||||
One level past the bound is refused typed (`PayloadTooDeep { depth: 257, limit: 256 }`) for every shape and
|
||||
every variant, before any recursion.
|
||||
|
||||
D2. **Recursion points.** Per container level a typed decode passes through: the derive's generated
|
||||
`Deserialize::deserialize` (`cultcache-rs-derive/src/lib.rs:294-330` for slots, `534-684` for unions), which
|
||||
calls `deserialize_tuple`; rmp-serde's `Deserializer::any_inner` (`decode.rs:530`), which calls
|
||||
`visitor.visit_seq`/`visit_map`/`visit_newtype_struct` under `depth_count!` (566, 583, 616); the derive's
|
||||
`visit_seq`, whose frame holds one `Option<T>` per slot; and `next_element`, which re-enters either a derived
|
||||
type, serde's own impls (`Vec`, `Option`, `Box`) or another crate's (`serde_json::Value`, `rmpv::Value`).
|
||||
rmp-serde 1.3.1 has no skip of its own: `IgnoredAny` goes through `any_inner` and a visitor like any value.
|
||||
`check_payload_depth` is iterative.
|
||||
|
||||
D3. **Where the grow must sit.** A `maybe_grow` in the derive's `deserialize` covers derived levels only: `json`
|
||||
on a 64 KiB debug thread still aborts at 15 with a 32 KiB red zone, and passes only when the first derived
|
||||
level happens to hand it a whole new segment. A `serde_stacker::Deserializer` over rmp-serde at the door checks
|
||||
at every `deserialize_*` call it forwards (`maybe_grow` at `de.rs:476, 489, 502, 511, 520`), so
|
||||
rmp-serde's recursion, serde's impls, foreign `Value` types and `IgnoredAny` skips all re-enter through a check:
|
||||
every shape passes in both profiles (D4). Consumers decode non-derived recursive types through the door today:
|
||||
cultnet-rs `replication.rs:525` (`serde_json::Value`) and `contracts.rs:607` (`rmpv::Value`).
|
||||
|
||||
D4. **The frame between checks, measured.** Stack used from one derived `deserialize` to the next nested one,
|
||||
at the bound: release box 13,168 B, repeat 12,192 B, fat 84,944 B; debug box 52,064 B, repeat 49,760 B, fat
|
||||
207,504 B. The door wrapper checks more often, so its gap is smaller: release, every shape passes at a red zone
|
||||
of 32 KiB on 128 KiB, 1 MiB and 2 MiB threads; debug, box and repeat pass at 32 KiB, fat fails at 128 KiB and
|
||||
passes at 144 KiB through 256 KiB (1 MiB thread). So the worst gap constructed is between 128 and 144 KiB,
|
||||
the debug `visit_seq` of the 32-slot record. serde_stacker's defaults (red zone 64 KiB, segment 2 MiB,
|
||||
`param.rs:10-11`) fail debug fat. The cut sets 256 KiB and 2 MiB: 1.8x the worst measured gap. A type whose
|
||||
single `visit_seq` frame exceeds the red zone can still overflow; that is documented, not prevented.
|
||||
|
||||
D5. **Same values, same errors.** Through the wrapper, box 83, repeat 125 and a 1,000-node surface decode equal
|
||||
to the plain decode. The wrapper forwards `is_human_readable` (`de.rs:313`) and returns rmp-serde's own error
|
||||
type, so `opaque_decode_error` and the derive's refusal texts are unchanged.
|
||||
|
||||
D6. **Cost.** No growth (release, repeat 125 on an 8 MiB thread): 322-339 us through the wrapper against 320 us
|
||||
plain. A shallow 27 KB, 1,000-node surface on 1 MiB: 1.57 ms plain, 1.61-1.80 ms wrapped (run-to-run noise is
|
||||
about that wide). When the decode does grow, it pays for faulting in fresh segment pages: repeat 125 on a 1 MiB
|
||||
thread 1.5-1.7 ms; fat 255 on 1 MiB 25 ms against 9.3 ms on a 64 MiB thread. A 1 MiB and a 2 MiB segment cost
|
||||
the same.
|
||||
|
||||
D7. **Windows x86_64: source read, not run.** stacker 0.1.25 does not use psm on Windows: `backends/windows.rs`
|
||||
grows by fibers (`ConvertThreadToFiber`, `CreateFiber`, `SwitchToFiber`, `SetThreadStackGuarantee`), reads the
|
||||
limit with `VirtualQuery`, and links a C helper built by `cc`. psm's README marks its own Windows stack switching
|
||||
disabled; that is the other layer, so the research's conflict (C) is two layers, not a contradiction. Yggdrasil
|
||||
has no Windows: the rust image carries `x86_64-pc-windows-gnu` (cross-compile, no run). Whether it actually grows
|
||||
on Starfire is question `decode-stack-windows-check`.
|
||||
|
||||
D8. **wasm32: stacker is not a no-op.** psm's `build.rs:53` ships a precompiled `wasm32.o` and sets
|
||||
`switchable_stack`; stacker then allocates segments from the global allocator with no guard page
|
||||
(`alloc_stack_restore_guard.rs`). `remaining_stack()` is `None` on wasm, so the first check always grows and
|
||||
the limit is known from then on. Under wasmtime 48.0.5 (wasm32-wasip1, release), a nested 9-slot record: plain
|
||||
decodes 48 levels and traps at 64 ("out of bounds memory access" at `0xffffe510`: the stack-first shadow stack
|
||||
wrapped below zero); wrapped decodes 64, 255, 400 and 600 levels (linear memory 1,088 KiB before, 8,896 KiB
|
||||
after 255) and traps at 1,000 with "call stack exhausted", the engine's own stack, which stacker cannot grow.
|
||||
`wasm32-unknown-unknown` builds. This corrects the ruling's premise that wasm keeps the abort: the shadow stack
|
||||
grows and the engine stack is the host's setting, so the documentation says that instead.
|
||||
|
||||
D9. **Dependency.** `serde_stacker` 0.1.14 (dtolnay), pulling `stacker` 0.1.25 and `psm` 0.1.32 (rust-lang),
|
||||
all `MIT OR Apache-2.0`; plus `cfg-if`, `libc`, `cc` (build) and `windows-sys` on Windows. psm assembles and
|
||||
stacker compiles C at build time, so a C toolchain becomes a build requirement (gcc in the rust image, MSVC on
|
||||
Starfire). cultcache-rs is MIT. Written by hand, the wrapper would be serde_stacker's ~650 lines of forwarding.
|
||||
|
||||
### Body facts, revision 2 (verdict `cut-decode-stack-safety.s1`)
|
||||
|
||||
Imagination pass `imagination-decode-stack-r2`, session `self-2026-10-14-eureka`, on branch
|
||||
`hands/decode-stack-safety` at `2505c0c4`. Probes live under the session scratchpad,
|
||||
`imagination-decode-stack-r2/` (`gen.py`, `gen2.py`, `driver*.rs.in`, jobs `jobP1` to `jobP4`), run on Yggdrasil
|
||||
in the rust image (rustc 1.95.0). None of it is committed. `jobP1` patches a copy of serde_stacker 0.1.14 so
|
||||
each of its five `maybe_grow` calls (`de.rs:476-520`, the wrapper visitor's container entries) records the
|
||||
stack pointer, and reads the door's red zone and segment from the environment. Shapes: `wide` is the committed
|
||||
`Wide32` (32 slots of `[u64; 32]`, 8 KiB inline); `bigN` is 32 slots of an N-array record (32·N KiB inline:
|
||||
32, 64, 128, 256 KiB), each with an `Option<Box<Self>>` slot; `lim` is 31 slots of a 2-array record (15,880 B).
|
||||
A caller is one `#[inline(never)]` function holding the `Result<T>`.
|
||||
|
||||
D10. **The gap between checks is linear in the record's inline size.** Stack used between two consecutive
|
||||
checks, the worst over a 6-level decode on a 1 GiB thread (every gap in a shape is the same value):
|
||||
|
||||
| Shape (inline) | wide (8 KiB) | big4 (32) | big8 (64) | big16 (128) | big32 (256) |
|
||||
|---|---|---|---|---|---|
|
||||
| debug gap | 115 KiB | 451 | 899 | 1,795 | 3,587 |
|
||||
| debug frames before the first check | 131 KiB | 515 | 1,027 | 2,051 | 4,099 |
|
||||
| release gap | 48 KiB | 192 | 384 | 768 | 1,536 |
|
||||
| release frames before the first check | 48 KiB | 192 | 384 | 768 | 1,536 |
|
||||
|
||||
So the gap is 14·S + 3 KiB in debug and 6·S in release, for S = `size_of` of the record entered: the frames
|
||||
between a container check and the next one each hold the record being built or a `Result` of it (rmp-serde's
|
||||
`any_inner`, the wrapper visitor, the derive's `visit_seq` with one `Option` per slot, the seed). D4's 128-144
|
||||
KiB debug gap for `fat` is this line at 8 KiB plus a margin. The stack touched below the deepest check is 6 KiB
|
||||
in debug and 0 in release, so the excursions between checks are the gaps above.
|
||||
|
||||
D11. **Why the thread size decided, in two ways.** (a) Before the first check the decode has already used the
|
||||
second row of D10: `rmp-serde`'s `deserialize_tuple`, `any_inner` and the wrapper's frames sit between
|
||||
`decode_payload` and the wrapper's first `maybe_grow`, and each holds `Result<T>`. A thread smaller than that
|
||||
row aborts whatever the red zone: `wide` aborts on every thread up to 144 KiB in debug. (b) Mid-decode, a check
|
||||
with between R and the gap remaining lets the next gap overrun; after the first grow the positions in each
|
||||
segment are fixed, so which check first grows (set by the thread size) decides, which is the non-monotonic
|
||||
pattern Soul measured. Thread sweeps at the bound (threads from 64 KiB in steps of a 64th of the span, plus
|
||||
1, 1.5, 2, 3, 4 and 64 MiB):
|
||||
|
||||
| Profile, shape | R = 256 KiB | R = gap - 48 KiB | R = gap rounded up + 64 KiB |
|
||||
|---|---|---|---|
|
||||
| debug wide | 6 of 65 sweep threads abort (64-144 KiB); none at 1-64 MiB | all 65; and at 1-4 MiB | 6 (64-144 KiB); none at 1-64 MiB |
|
||||
| debug big4 | 39 of 65; and at 1.5, 2, 3, 4, 64 MiB | 29; and at 2, 64 MiB | 24, the lowest from 64 KiB; none at 1-64 MiB |
|
||||
| release wide | none | 14, spread from 112 to 864 KiB; and at 1, 4 MiB | none |
|
||||
| release big4 | 9 (64-192 KiB) | 18, including 448-480 KiB | 9 (64-192 KiB) |
|
||||
| release big8 | 40; and at 1.5, 2, 3, 64 MiB | 20; and at 1.5 MiB | 17, the lowest from 64 KiB; none at 1-64 MiB |
|
||||
|
||||
With R at least the gap, the abort counts match the threads smaller than the frames before the first check
|
||||
(D10's second row plus the harness) in every shape and profile, big16 and big32 included (segments 4·R); with
|
||||
R below the gap, threads well above those frames abort mid-decode.
|
||||
|
||||
D12. **Checking at the top of the door** (`jobP4`). `decode_payload` patched so its whole body runs inside
|
||||
`stacker::maybe_grow(R, segment, ..)` before the walk and the wrapper, R = 512 KiB, segment 2 MiB. Every
|
||||
thread from 24 KiB to 1 MiB in 8 KiB steps, then 1.5, 2, 3, 4, 8 and 64 MiB:
|
||||
|
||||
| Profile | wide (8 KiB), 1 level | wide, 255 levels | lim (15,880 B), 1 level | lim, 254 levels |
|
||||
|---|---|---|---|---|
|
||||
| debug: smallest thread that decodes | 40 KiB | 40 KiB | 72 KiB | 72 KiB |
|
||||
| release: smallest thread that decodes | 24 KiB | 24 KiB | 40 KiB | 40 KiB |
|
||||
|
||||
No thread at or above the smallest one aborts, in any of the four columns, in either profile. lim's gap is
|
||||
220 KiB in debug and 93 KiB in release (D10's lines give 220 and 93), so R = 512 KiB is 2.3 times the debug
|
||||
gap of a record at 16 KiB. What a thread needs is now the caller's own frames, which hold the returned value
|
||||
and do not depend on the payload's depth.
|
||||
|
||||
D13. **No red zone or placement removes the bound.** Every frame that holds a value of type T sits between the
|
||||
check before T's container and the check inside it, and serde's own impls add frames that hold T before T's
|
||||
first check: `Box<T>`'s `Deserialize` holds the `Result<T>` it boxes, and the `Option` check before it sized
|
||||
nothing larger than a pointer. A per-type red zone (a forked wrapper sized by `size_of::<V::Value>()`, or a
|
||||
grow in the derive sized by `size_of::<Self>()`) still leaves that frame of about one to three T outside any
|
||||
sized check. So the stack a decode needs between checks is bounded only by bounding the inline size of the
|
||||
records it builds. The derive is the one place that sees every record type it produces: a `const` assertion on
|
||||
`size_of::<Self>()` refuses an oversized record at compile time. A field of a type the derive did not produce
|
||||
(a fixed array of arrays, a large tuple, a plain serde struct) behind a `Box` or `Vec` is not reached by it.
|
||||
|
||||
D14. **Build closure and wasm, measured by Soul (`soul-decode-stack-s1` jobD, `cargo tree -e normal,build`
|
||||
at `2505c0c4` against `ae6ef5a8`).** Added on `x86_64-unknown-linux-gnu`: serde_stacker 0.1.14, stacker 0.1.25,
|
||||
psm 0.1.32, cc 1.8.0, find-msvc-tools 0.1.14, shlex 2.0.1, object 0.39.1 (`Apache-2.0 OR MIT`), all
|
||||
`MIT OR Apache-2.0` except ar_archive_writer 0.5.3 (`Apache-2.0 WITH LLVM-exception`, build-only, via psm).
|
||||
On `x86_64-pc-windows-msvc` the same plus libc 0.2.190 and windows-sys 0.61.2 (`MIT OR Apache-2.0`, beside the
|
||||
crate's own windows-sys 0.59). `fs2 0.4` (8 call sites in `lib.rs`, the store's file locks) has no wasi
|
||||
backend, so cultcache-rs does not build for `wasm32-wasip1`; D8 measured serde_stacker in a separate crate.
|
||||
|
||||
### Authority, and why
|
||||
|
||||
The door owns stack safety as it owns the bound: `decode_payload` runs the bound walk, then rmp-serde through
|
||||
`serde_stacker::Deserializer` with two private constants. The ruling's "every container level the derive and the
|
||||
door descend through" is met there, because every derived level is entered through the wrapper (D3); a
|
||||
`maybe_grow` in the derive would be a second owner of the same check, needs a red zone of 128 KiB in release
|
||||
and 256 KiB in debug for fat where the wrapper needs 32 and 144 KiB (D4), and still misses foreign recursive types. The derive is unchanged.
|
||||
|
||||
Revision 2 (D10-D13): the door's first act is a check, so no frame holding T precedes it, and the red zone is
|
||||
set against the gap of the largest record the derive may produce. That size is a public constant the derive
|
||||
asserts at compile time, because nothing at decode time can bound a frame that holds a T before T's own check
|
||||
(D13). The thread's size then decides nothing beyond the caller's own frames, which hold the returned value
|
||||
whatever the payload's depth (D12). Envelope and store decodes enter through the same grown decoder, without
|
||||
the payload walk, so what they accept is unchanged. rmp-serde's 1024 counter stays a backstop that never decides. Callers that decode a payload with
|
||||
`rmp_serde::from_slice` directly bypass the door, the bound and the grow alike; cultnet-rs re-decodes
|
||||
door-checked `rmpv::Value`s that way (follow-up `rs-redecode-outside-door-r2`).
|
||||
|
||||
Rejected inside the ruling: the derive placement (above), and writing the wrapper by hand (D9: established
|
||||
tooling, one owner upstream).
|
||||
|
||||
## E. Expect under compatible schema drift (cut `expect-persisted-schema`)
|
||||
|
||||
Pinned base: `origin/main` at `b549d731` (2026-10-21). Imagination pass `imagination-expect`, session
|
||||
`self-2026-10-19-ag7`. Probe `probe1` (`ProbeExpectDrift.cs`, written into the clone by the job command, never
|
||||
committed) ran on Yggdrasil, dotnet sdk 10.0, against `b549d731`. Fixture: a record written under
|
||||
`DriftWide` (`tests.variant_drift.v1`) and read by a cache whose registry holds only `DriftNarrow` (v2) loads
|
||||
as CompatibleDrift.
|
||||
|
||||
### Body facts
|
||||
|
||||
- **E1. The defect.** `CultCommitRequest.ConditionsHold` (`CultCache.cs:1677-1696`) compares the durable
|
||||
record's `SchemaId` with the observed entry's `Descriptor.SchemaId` (`Expect` captures it at `:1772`;
|
||||
`ExpectUnchanged` reads it at `:1694`). `ToStoredDocument` (`:3212-3268`) resolves a persisted record to the
|
||||
local descriptor and drops `record.SchemaId`. So the observed side holds the local id and the durable side
|
||||
the persisted id. Probe: unchanged drifted record, `Expect` + `Upsert` commits **False** on the single-file
|
||||
store (P1) and on the directory store (P7). Same code at Aetheria's pin `45c2f40` (`CultCache.cs:1147-1207`).
|
||||
- **E2. What must still refuse, at base.** Another cache commits a changed value (new minted `storedAt`):
|
||||
Expect False (P4). Another cache reading under v3 (`DriftReused`) flushes the file, rewriting the record under
|
||||
v3 with the **same** `storedAt`: Expect False (P5).
|
||||
- **E3. A whole-file write rewrites every record under its local schema and keeps `storedAt`.**
|
||||
`ToPersistedRecord` (`:3194-3204`) writes `entry.Descriptor.SchemaId` with `entry.StoredAt`, and both the
|
||||
single-file `PushAll` (`:3361-3375`) and an unconditional or dirty `CommitBatchCore` (`:3393-3399`) write the
|
||||
whole view. So the contract sentence "every write to a key mints a later storedAt"
|
||||
(`Contracts/cultcache-store-composition.md:229-231`) is false for a flush: the schema id is the half of the
|
||||
identity that sees it. The directory store writes only staged keys, each freshly stamped
|
||||
(`DirectoryMessagePackBackingStore.cs:240-293`), so it never normalises a clean record.
|
||||
- **E4. Lifetime at base.** After the cache's own `PushAll`, Expect commits True (P2): disk and descriptor now
|
||||
agree. After *another* cache's normalising flush to the observer's own local schema, Expect commits True (P3);
|
||||
after a re-pull, True (P6).
|
||||
- **E5. Re-reads.** Both pull filters skip a record whose `storedAt` and local descriptor id are unchanged
|
||||
(`CultCache.cs:3323-3327`, `DirectoryMessagePackBackingStore.cs:110-114`). A record normalised P to L by
|
||||
another writer keeps `storedAt` and the local id, so with the persisted id on the entry the filter must
|
||||
compare the persisted id, or a re-read never refreshes it and Expect stays a Mismatch forever.
|
||||
- **E6. Other runtimes.** cultcache-rs `SingleFileMessagePackBackingStore::compare_exchange` and the snapshot
|
||||
form (`packages/cultcache-rs/src/lib.rs:1352-1393`, `:1427-1450`) compare whole raw envelopes as read from
|
||||
disk (`schema_id: Some(record.1)`, `:3189`); the expectation *is* the persisted envelope, and the crate has
|
||||
no compatible-drift resolution, so the split cannot occur. Unchanged. cultcache-ts and cultcache-py have no
|
||||
conditional commit (`cultcache-persistence-format.md:67-69`; grep finds none); nor do the cultnet and
|
||||
cultmesh packages or cultmesh-kotlin. Not added here.
|
||||
- **E7. Release path.** Aetheria pins `org.gamecult.cultlib#cultlib-unity-v1.0.60` and
|
||||
`org.gamecult.caching.unity#caching-unity-v1.4.0`, both locked at `45c2f400` (2026-09-17). The CultCache
|
||||
runtime ships as DLLs in `unity/org.gamecult.cultlib` (built by `scripts/build-unity-package.ps1`). Main is
|
||||
1363 commits past the pin with a `### Breaking` section under `[Unreleased]`
|
||||
(`unity/org.gamecult.cultlib/CHANGELOG.md`). `release/cultlib-unity-1.0.x` is the pin plus one commit
|
||||
(1.0.61, Win64 QUIC plugins).
|
||||
|
||||
### Authority, and why
|
||||
|
||||
The backing store that read or wrote a record's durable bytes owns the schema id it is persisted under. It is set
|
||||
when the store builds the entry from a persisted record (`ToStoredDocument`), defaults to the descriptor's id
|
||||
for an entry this cache stamps (it is about to be written under that id), and is reset to the descriptor's id,
|
||||
under the hold, after a whole-file write the store completed. The commit check, `Expect` and the pull filters
|
||||
only read it. `Descriptor.SchemaId` stays what the entry decodes as; it is no longer the record's durable identity.
|
||||
|
||||
Consequence, recorded rather than ruled: after another writer's whole-file flush rewrites a drifted record
|
||||
under a schema id this cache did not read it under, an `Expect` captured before is a Mismatch until the cache
|
||||
re-reads (E4's P3 flips). That is the ruling's "compares that persisted id" applied to a rewrite that kept
|
||||
`storedAt`; it is fail-closed, the standard retry (re-read, retry) recovers, and it is what refuses E2's P5.
|
||||
|
||||
## Observed, not owned by these cuts
|
||||
|
||||
- At the default tolerance the solver's answer on allocator-shaped problems depends on the start by up to the whole
|
||||
box (B6). That is documented and pinned (`AnAllocatorShapedProblemIsStartIndependentAtATightTolerance`); a
|
||||
consumer that needs one answer passes a tight tolerance, as Aetheria does.
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
# CultLib Gaps: Target
|
||||
|
||||
Date: 2026-10-09. Campaign `cultlib-gaps` in the Eureka mind.
|
||||
|
||||
## Why
|
||||
|
||||
Consumers of CultLib keep finding capabilities a reasonable consumer of a general
|
||||
library would expect and that CultLib lacks. Doctrine says a gap is filled in its
|
||||
owner, never worked around in the consumer. This campaign is the standing home for
|
||||
those gaps, so each one lands in CultLib as a small cut instead of a local helper in
|
||||
whichever project hit it.
|
||||
|
||||
Known gaps at opening (each a follow-up in the mind, owned by the consumer campaign
|
||||
that found it):
|
||||
|
||||
- CultMath `BoundedLeastSquares.Solve` has a fixed KKT tolerance (1e-6); Aetheria's
|
||||
thrust allocator needs a tighter one (`aetheria-release:follow_up:cultmath-bls-kkt-tolerance`).
|
||||
- `cultcache-rs` has no decimal type, its snapshot reads follow symlinks, and its
|
||||
single-file store stages files with the process umask and no mode option
|
||||
(`eureka-body:follow_up:cultcache-rs-decimal-and-nofollow`).
|
||||
|
||||
## Invariants
|
||||
|
||||
- `owner-fills-gap`: a capability a consumer needs from CultLib is added to CultLib, with the consumer adopting it; no consumer-local copy survives the cut that adds it.
|
||||
- `defaults-unchanged`: a new option defaults to today's behaviour; existing callers and stored bytes do not change.
|
||||
- `wire-parity`: a CultCache wire change keeps byte parity across runtimes against the C# reference, with a cross-runtime fixture.
|
||||
- `semver-measured`: a public API change bumps the package version by measured semver and ships as a tagged release consumers can pin.
|
||||
|
||||
## Not in scope
|
||||
|
||||
- Growing CultCache into a general database (memory `cultcache-scope-restraint`).
|
||||
- Any consumer's adoption code beyond the pin bump; that stays in the consumer's campaign.
|
||||
|
|
@ -1,201 +0,0 @@
|
|||
{
|
||||
"campaign": "cultmath-tapes",
|
||||
"cut": "glsl-lowering",
|
||||
"revision": 2,
|
||||
"title": "GLSL lowering r2: Phacelle as its own MPL-2.0 file so CultMath.glsl is MIT, notices corrected, iv_exp in the fixture, fixture regenerated, glslang and dxc run",
|
||||
"repo": "GameCult/CultLib",
|
||||
"branch": "hands/cultmath-glsl-lowering",
|
||||
"base": "3ac7250a",
|
||||
"depends_on": [
|
||||
"interval-ops"
|
||||
],
|
||||
"first": [
|
||||
"Follow-up on cut report cultmath-tapes:cut_report:cut-glsl-lowering.h1 (range c9bd003b..5ae207e8, no verdict yet; worktree F:\\Projects\\CultLib-glsl). Rulings in force: operator-split-phacelle, operator-compiler-downloads, self-exp-measure-in-site. Map: docs/cultmath-interval-ground-cut.md, 'Pass 3'. First commit: MERGE 3ac7250a (interval-ops r3's head, which Soul s2 found holds for everything this cut lowers) into hands/cultmath-glsl-lowering. Do not rebase: the branch is pushed, and the git rules forbid force-pushing a shared branch. Probed by Imagination in a scratch clone: the merge conflicts only in packages/cultmath/README.md. Resolve it by keeping r3's Intervals bullet (iv_frustum_ball, its rounding bound, the envelope), then r1's GLSL bullet, so the GLSL bullet sits at :48-53. design.md and HlslSourceCompatibilityTests.cs merge clean.",
|
||||
"Second commit: regeneration alone (CULTMATH_WRITE_GLSL=1). Probed on the merge: CultMath.glsl gains 15 lines (cultmath_iv_frustum_ball with the r3 widening); the fixture does not change until this revision adds its families; GlslMirrorTests 4/4 and HlslSourceCompatibilityTests green (16 tests). Soul's range is 3ac7250a..HEAD, which carries r1's four commits through the merge, so the whole lowering gets its first verdict. Runs in parallel with interval-ops r4: r4 changes no src, HLSL or README, only NoiseBoundTests.cs and design.md's saving section (:475-512 at 3ac7250a, :533-570 after this merge), disjoint from this cut's :136-193. Do not merge r4's head into this branch.",
|
||||
"The split (operator-split-phacelle). Transformation step 1 becomes: each #include is inlined in place, except an include whose file is under its own licence, which is lowered on its own into its own output file. The list of such includes is one table in GlslLowering, SeparateFiles = { \"CultMath.Phacelle.hlsl\" -> \"CultMath.Phacelle.glsl\" }, with the reason beside the entry (file-level MPL-2.0). That is an enumerated rule of the lowering, documented as step 1 in design.md; it is not a per-function special case, and nothing else is keyed by name. Lower(root) returns the two texts; Lower(string) stays for the inlined body if that is simpler, but there is one resolver (ReadShaderSource, generalised to accept the include predicate), never a second.",
|
||||
"shaders/CultMath.glsl: the provenance line says it is generated from CultMath.hlsl and CultMath.Interval.hlsl, MIT, and that cultmath_phacelle is in CultMath.Phacelle.glsl, to be concatenated after this file by consumers that call it. The dropped include line leaves nothing behind. shaders/CultMath.Phacelle.glsl: its own provenance line (generated from CultMath.Phacelle.hlsl, MPL-2.0, requires CultMath.glsl before it), the MPL-2.0 header and attribution exactly as the HLSL carries them, the CultPhasor struct and cultmath_phacelle, guard CULTMATH_PHACELLE_GLSL. Both files pinned by CommittedGlslEqualsLowering; CULTMATH_WRITE_GLSL=1 writes both.",
|
||||
"Pin tests over both files: NoHlslOnlyTokensSurvive runs on each; EveryPublicFunctionIsLowered takes the union of both files' definitions against the HLSL (minus texture samplers) and additionally asserts cultmath_phacelle and CultPhasor appear only in the Phacelle file; a new PhacelleFileCompilesOnlyAfterTheLibrary is not needed, the compile check below proves it.",
|
||||
"The fixture (self-exp-measure-in-site). The site measures WebGL2 exp against the fixture, so iv_exp joins it; its C# bits come from the platform exp and differ by OS, so its entry carries tolerance \"ulp-bounded, platform exp\" and names the OS that generated it. GoldenFixtureMatchesCSharp parses the committed JSON (System.Text.Json, BCL) and compares family by family: text-equal for every family except those whose tolerance names the platform, which compare case by case within 1 ulp and print the maximum observed. One fixture, one writer, one tolerance field already in the format; no second fixture. The phacelle family stays in the fixture; the site's parity harness concatenates both GLSL files for that run (the site ships only CultMath.glsl), say so in design.md.",
|
||||
"Notices: packages/cultmath/THIRD-PARTY-NOTICES.md :35-43 lists the MPL-2.0 files as math.Phacelle.cs, CultPhasor.cs, CultMath.Phacelle.hlsl with its Unity copy, and shaders/CultMath.Phacelle.glsl; CultMath.glsl is MIT and says so in its header. The package licence expressions (MIT AND MPL-2.0) do not change: the NuGet still ships the HLSL include. README :36-37 and :48-53 (after the merge) name both GLSL files and when a consumer needs the second. design.md :136-192: step 1 rewritten, the second file, the wrapper runs, the fixture's platform tolerance. Unity ships neither GLSL file.",
|
||||
"Compile checks on Starfire (operator-compiler-downloads), one job each, detached with a log, polled in the foreground: tools/get-glslang.ps1 (16.6.0, the pinned SHA already in the script; if the published digest differs, stop and report, do not edit the pin to match); tools/compile-glsl.ps1 gains a -Phacelle switch and runs twice: the MIT-only wrapper over CultMath.glsl calling every function in it, and the full wrapper over both files calling every function including cultmath_phacelle. Negative controls, both must fail: -Body 'float x = lerp(0.0, 1.0, 0.5);' and the MIT-only wrapper with -Body calling cultmath_phacelle (proves Phacelle is not in CultMath.glsl). Then tools/get-dxc.ps1 and dxc -T lib_6_3 -HV 2021 on both HLSL copies, exit 0. The browser-pane WebGL2 probe of r1 is repeated on the two-file wrapper.",
|
||||
"Carried from r1 and still out of scope, name them in the report: the fixture's ulp bounds on WebGL2 are measured by the site cut; Stryker is not re-run unless packages/cultmath/src changes in 3ac7250a..HEAD (it should not; the lowering lives in tests; interval-ops r3's Stryker evidence, Soul s2, stands). Deviations r1 reported that stand: the anchored token regex; the iv_exp absence is now resolved by this revision; the HLSL edits (damp splat, pcg3d order) stay."
|
||||
],
|
||||
"deletes": [
|
||||
{
|
||||
"path": "packages/cultmath/shaders/CultMath.glsl",
|
||||
"lines": 100,
|
||||
"note": "the inlined Phacelle block (:510-612 at 5ae207e8: MPL header, CultPhasor, cultmath_phacelle) leaves this file; it is regenerated, not edited."
|
||||
},
|
||||
{
|
||||
"path": "packages/cultmath/THIRD-PARTY-NOTICES.md",
|
||||
"lines": 3,
|
||||
"note": ":39-41 the sentence making CultMath.glsl MPL-2.0 as a whole."
|
||||
}
|
||||
],
|
||||
"keeps_moves": [
|
||||
"Transformation steps 2-9 and their order: unchanged; the cast scanner, saturate lowering, the guard and provenance mechanism stay.",
|
||||
"Both tracked CultMath.hlsl copies stay one blob; no HLSL edit is expected in this revision (a dialect gap found by the two-file compile is fixed in the HLSL in the common subset, bit-identical, listed in the report).",
|
||||
"HlslSourceCompatibilityTests' mirror and FindCultMathRoot: the resolver generalises in place; no second include resolver.",
|
||||
"tests/CultMath.Tests/fixtures/glsl-parity.json: the same file, regenerated; families keep their order so the diff shows only the two additions."
|
||||
],
|
||||
"adds": [
|
||||
"packages/cultmath/shaders/CultMath.Phacelle.glsl: generated, MPL-2.0, guard CULTMATH_PHACELLE_GLSL.",
|
||||
"GlslLowering: the SeparateFiles table and the two-text Lower; GlslMirrorTests: the second file in CommittedGlslEqualsLowering, NoHlslOnlyTokensSurvive and EveryPublicFunctionIsLowered; GoldenFixtureMatchesCSharp's per-family comparison with the platform tolerance.",
|
||||
"fixture families: cultmath_iv_frustum_ball (exact) and cultmath_iv_exp (ulp-bounded, platform exp).",
|
||||
"tools/compile-glsl.ps1: -Phacelle switch (appends CultMath.Phacelle.glsl after the library and includes cultmath_phacelle in the calls).",
|
||||
"design.md and README: the two files, the concatenation order, the fixture tolerance."
|
||||
],
|
||||
"file_changes": [
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/GlslLowering.cs",
|
||||
"line": 24
|
||||
},
|
||||
"change": "Lower takes the root (or the resolver's two texts) and returns both outputs; SeparateFiles table with the MPL reason; ProvenanceLine per file."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs",
|
||||
"line": 320
|
||||
},
|
||||
"change": "ReadShaderSource accepts a predicate (or returns the separated include's text beside the inlined body); the C# mirror keeps inlining everything."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/GlslMirrorTests.cs",
|
||||
"line": 30
|
||||
},
|
||||
"change": "CommittedGlslEqualsLowering over both files; :45 NoHlslOnlyTokensSurvive over both; :54 EveryPublicFunctionIsLowered union plus the phacelle-only-in-its-file assertion; :64 GoldenFixtureMatchesCSharp per family with the platform tolerance."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tools/compile-glsl.ps1",
|
||||
"line": 1
|
||||
},
|
||||
"change": "-Phacelle switch; the library text is CultMath.glsl, plus CultMath.Phacelle.glsl when set; the call list covers whichever functions are present."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/THIRD-PARTY-NOTICES.md",
|
||||
"line": 35
|
||||
},
|
||||
"change": "MPL-2.0 file list: the two C# files, the HLSL include and its Unity copy, shaders/CultMath.Phacelle.glsl. CultMath.glsl is MIT."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/README.md",
|
||||
"line": 48
|
||||
},
|
||||
"change": "both GLSL files; a consumer concatenates CultMath.Phacelle.glsl after CultMath.glsl only when it calls cultmath_phacelle."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/docs/design.md",
|
||||
"line": 150
|
||||
},
|
||||
"change": "step 1 rewritten for the separated include; :182-192 the two wrapper runs and the fixture's platform tolerance; the site's parity run concatenates both files."
|
||||
}
|
||||
],
|
||||
"authority_map": {
|
||||
"owner": "GlslLowering owns the HLSL -> GLSL transformation and now emits two derived files, one per licence; both are pinned by GlslMirrorTests and never edited. The fixture stays owned by GoldenFixtureMatchesCSharp with one tolerance field per family. THIRD-PARTY-NOTICES.md states which files are MPL-2.0 and is the one place that does.",
|
||||
"inputs": [
|
||||
"shaders/CultMath.hlsl, CultMath.Interval.hlsl and CultMath.Phacelle.hlsl text at the merged head (3ac7250a merged into the branch)",
|
||||
"C# math for the fixture values, including the platform exp for iv_exp"
|
||||
],
|
||||
"outputs": [
|
||||
"shaders/CultMath.glsl (MIT) and shaders/CultMath.Phacelle.glsl (MPL-2.0)",
|
||||
"tests/CultMath.Tests/fixtures/glsl-parity.json with cultmath_iv_frustum_ball and cultmath_iv_exp families"
|
||||
],
|
||||
"derived_state": [
|
||||
"both GLSL files are derived; a hand edit to either fails CommittedGlslEqualsLowering",
|
||||
"the fixture is derived from C#; the iv_exp family is derived from the generating platform's exp and compared within 1 ulp, every other family bit for bit"
|
||||
],
|
||||
"forbidden_writers": [
|
||||
"no per-function special case in the lowering; the only name-keyed rule is the SeparateFiles table, one entry, licence-justified",
|
||||
"no second include resolver, no PowerShell or JS copy of the transformation",
|
||||
"no GLSL in the Unity package; no precision statement in either library file",
|
||||
"no edit to the pinned glslang or dxc hashes to make a download pass"
|
||||
],
|
||||
"shared_paths": [
|
||||
"the same dotnet test run pins the C# mirror, both GLSL files and the fixture",
|
||||
"compile-glsl.ps1 is the one wrapper writer for the MIT-only run, the two-file run and both negative controls"
|
||||
],
|
||||
"deletion_line": "The Phacelle block leaves CultMath.glsl by regeneration and the MPL sentence leaves the notices before the new file is added. Net: one more generated file and one switch; what it buys is an MIT CultMath.glsl the site can vendor without carrying MPL text, and the compile checks r1 could not run."
|
||||
},
|
||||
"verification": {
|
||||
"builds": [
|
||||
"Yggdrasil: dotnet test packages/cultmath/tests/CultMath.Tests green on the merged branch; paste counts; GlslMirrorTests 4/4 with the per-family fixture line printed",
|
||||
"Starfire: tools/get-glslang.ps1 installs 16.6.0 with the pinned SHA matching; tools/compile-glsl.ps1 exits 0 on the MIT-only wrapper and on -Phacelle; both negative controls fail with the error quoted",
|
||||
"Starfire: tools/get-dxc.ps1 then dxc -T lib_6_3 -HV 2021 on both HLSL copies, exit 0; FXC cs_5_0 still green",
|
||||
"Browser pane WebGL2: the two-file wrapper compiles with an empty log"
|
||||
],
|
||||
"tests": [
|
||||
{
|
||||
"name": "GlslMirrorTests.CommittedGlslEqualsLowering",
|
||||
"pins": "CultMath.glsl and CultMath.Phacelle.glsl are exactly the lowering of the committed HLSL; the regeneration path is the same code."
|
||||
},
|
||||
{
|
||||
"name": "GlslMirrorTests.EveryPublicFunctionIsLowered",
|
||||
"pins": "the union of both files equals the HLSL's cultmath_* set minus texture samplers; cultmath_phacelle and CultPhasor live only in the Phacelle file."
|
||||
},
|
||||
{
|
||||
"name": "GlslMirrorTests.NoHlslOnlyTokensSurvive",
|
||||
"pins": "no HLSL-only token in either file."
|
||||
},
|
||||
{
|
||||
"name": "GlslMirrorTests.GoldenFixtureMatchesCSharp",
|
||||
"pins": "every family is C#'s current output bit for bit, except iv_exp within 1 ulp of the platform exp; iv_frustum_ball is present and exact."
|
||||
},
|
||||
{
|
||||
"name": "HlslSourceCompatibilityTests.EveryMirrorFunctionMatchesCSharpMath (existing)",
|
||||
"pins": "no HLSL edit in this range changed a bit."
|
||||
}
|
||||
],
|
||||
"negative": [
|
||||
{
|
||||
"pattern": "Mozilla|MPL|Johansen|CultPhasor|cultmath_phacelle\\(",
|
||||
"scope": "packages/cultmath/shaders/CultMath.glsl: 0 matches; the header's file-name pointer to CultMath.Phacelle.glsl is the only mention of Phacelle."
|
||||
},
|
||||
{
|
||||
"pattern": "^CultPhasor cultmath_phacelle\\(",
|
||||
"scope": "packages/cultmath/shaders/CultMath.Phacelle.glsl: exactly 1 match."
|
||||
},
|
||||
{
|
||||
"pattern": "CultMath\\.glsl|Phacelle\\.glsl",
|
||||
"scope": "packages/cultmath/unity/**: 0 matches."
|
||||
},
|
||||
{
|
||||
"pattern": "\\b(float|u?int|bool)[234]\\b|\\blerp\\(|\\bfrac\\(|\\bsaturate\\(|\\brsqrt\\(|\\basuint\\(|\\basfloat\\(|static const|#include|precision (high|medium|low)p",
|
||||
"scope": "both GLSL files: 0 matches (the anchored regex r1 settled on)."
|
||||
},
|
||||
{
|
||||
"pattern": "CultMath\\.glsl",
|
||||
"scope": "packages/cultmath/THIRD-PARTY-NOTICES.md: 0 matches in the MPL-2.0 file list; CultMath.Phacelle.glsl appears there once."
|
||||
},
|
||||
{
|
||||
"pattern": "git diff 3ac7250a..HEAD -- packages/cultmath/src",
|
||||
"scope": "empty; the lowering and the fixture live in tests, so interval-ops r3's Stryker evidence (verdict s2) stands for this range."
|
||||
}
|
||||
],
|
||||
"operator": []
|
||||
},
|
||||
"estimate": {
|
||||
"lines_added": 320,
|
||||
"lines_removed": 130,
|
||||
"dependencies_added": [],
|
||||
"dependencies_removed": [],
|
||||
"formats_added": [
|
||||
"GLSL ES 3.00 library file (generated, MPL-2.0): shaders/CultMath.Phacelle.glsl"
|
||||
],
|
||||
"formats_removed": [],
|
||||
"targets_added": [],
|
||||
"targets_removed": []
|
||||
},
|
||||
"rulings": [
|
||||
"cultmath-tapes:ruling:operator-split-phacelle",
|
||||
"cultmath-tapes:ruling:operator-compiler-downloads",
|
||||
"cultmath-tapes:ruling:self-exp-measure-in-site",
|
||||
"cultmath-tapes:ruling:operator-campaign-home"
|
||||
],
|
||||
"questions": []
|
||||
}
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
{
|
||||
"campaign": "cultmath-tapes",
|
||||
"cut": "glsl-lowering",
|
||||
"revision": 1,
|
||||
"title": "GLSL lowering: shaders/CultMath.glsl is generated from CultMath.hlsl by one enumerated textual transformation, pinned by a test, compiled by glslang, with a committed golden fixture for WebGL2 parity",
|
||||
"repo": "GameCult/CultLib",
|
||||
"branch": "hands/cultmath-glsl-lowering",
|
||||
"base": "c49c76ee",
|
||||
"depends_on": [],
|
||||
"first": [
|
||||
"Map: docs/cultmath-interval-ground-cut.md, sections 'Where the interval math lives' (route C) and 'The authority map'. Model: the HLSL -> C# mirror in tests/CultMath.Tests/HlslSourceCompatibilityTests.cs (wrapper at :348) and its complete transformation list in packages/cultmath/docs/design.md:92-134. The GLSL lowering follows the same discipline: a complete, enumerated list; anything outside it fails the test rather than being patched around.",
|
||||
"Read how HlslSourceCompatibilityTests resolves #include \"CultMath.Phacelle.hlsl\" (CultMath.hlsl:506); the GLSL lowering inlines the included file at that line, because GLSL has no #include.",
|
||||
"Target dialect: GLSL ES 3.00 (#version 300 es), WebGL2. No implicit int/uint -> float conversion; step(vec4, float) and max(vec4, float) differ (max(genType, float) exists, step(genType, float) does not); C-style casts do not exist; lerp/frac/saturate/rsqrt/asuint are mix/fract/clamp(x,0.0,1.0)/inversesqrt/floatBitsToUint; floatN/intN are vecN/ivecN; static const is const. Mixed-signedness shifts are allowed.",
|
||||
"Where the lowered text does not compile, fix the HLSL into the common subset with a legal, bit-identical HLSL edit (e.g. CultMath.hlsl:114 and :200 step(h, 0.0) -> step(h, float4(0.0, 0.0, 0.0, 0.0)); :471-473 and :502 (float)((uint)hash.x >> 8) before the float multiply). The HLSL -> C# mirror test and the FXC/dxc compiles keep those edits honest. Never special-case the lowering for one function.",
|
||||
"Casts need a balanced-paren scanner, not a regex: (int)((state >> 28) + 4) at CultMath.hlsl:396 is the case that breaks a regex.",
|
||||
"Dropped, as the C# mirror drops them: include guards (replaced by CULTMATH_GLSL guards), and the Texture2D/SamplerState functions at CultMath.hlsl:535-573. The GLSL declares no precision; the host shader does (precision highp float; precision highp int; the pcg hashes need 32-bit int).",
|
||||
"Regeneration: the pin test writes shaders/CultMath.glsl when the environment variable CULTMATH_WRITE_GLSL=1 is set, then asserts equality. No new project, no PowerShell copy of the transformation.",
|
||||
"Golden fixture: tests export 256 seeded points per function family (snoise float3/float2, snoise_grad, fbm_grad 4 octaves, ridged_grad, cellular, pcg3d/pcg4d, iv_* if present on main by then) with outputs as float32 bit patterns in hex, to tests/CultMath.Tests/fixtures/glsl-parity.json. JSON is tolerated here: the consumer is a browser-pane WebGL2 readback, a xenos boundary. The site cut evaluates it.",
|
||||
"glslang: tools/get-glslang.ps1 fetches a pinned glslangValidator release the way tools/get-dxc.ps1 does; tools/compile-glsl.ps1 compiles a #version 300 es fragment wrapper that includes shaders/CultMath.glsl and calls every public function once. Run on Starfire, one job, detached with a log, polled in the foreground.",
|
||||
"Verification on Yggdrasil: dotnet test from packages/cultmath/tests/CultMath.Tests; Stryker --since:c49c76ee from a plain clone (not a linked worktree: Stryker resolves --since paths against the main checkout). Never run verification as a background shell task."
|
||||
],
|
||||
"deletes": [],
|
||||
"keeps_moves": [
|
||||
"Both tracked CultMath.hlsl copies (packages/cultmath/shaders and unity/org.gamecult.cultmath/Shaders) stay one blob; the common-subset edits land in both.",
|
||||
"The C# mirror test, the FXC probe and dxc -T lib_6_3 -HV 2021 all still pass on the edited HLSL; the Unity package does not ship the GLSL file.",
|
||||
"design.md's HLSL -> C# transformation list (92-134) is unchanged; the GLSL list is a new sibling section."
|
||||
],
|
||||
"adds": [
|
||||
"tests/CultMath.Tests/GlslLowering.cs: the HLSL -> GLSL ES 3.00 transformation, one static function string -> string, with the list as named steps in order: inline includes; drop guards and texture functions; type names; static const; casts via the balanced scanner; intrinsic renames (lerp, frac, saturate, rsqrt, asuint/asfloat); float literal passthrough; guards CULTMATH_GLSL added; a provenance header line naming the source file and that the file is generated.",
|
||||
"tests/CultMath.Tests/GlslMirrorTests.cs: CommittedGlslEqualsLowering (writes when CULTMATH_WRITE_GLSL=1, prints a unified diff on failure); NoHlslOnlyTokensSurvive (regex over the output for float[234]\\(|int[234]\\(|lerp\\(|frac\\(|saturate\\(|static const|\\((float|int|uint)\\)|Texture2D|SamplerState -> 0 matches); EveryPublicFunctionIsLowered (the set of cultmath_* function names in the HLSL minus the texture ones equals the set in the GLSL); GoldenFixtureMatchesCSharp (regenerates the fixture in memory from C# and asserts equality with the committed file).",
|
||||
"shaders/CultMath.glsl: the committed generated artifact.",
|
||||
"tests/CultMath.Tests/fixtures/glsl-parity.json: the golden fixture (seed, points, per-function expected bit patterns, tolerance class per function: exact for integer-hash paths, ulp-bounded for transcendental).",
|
||||
"tools/get-glslang.ps1 and tools/compile-glsl.ps1, mirroring get-dxc.ps1 / compile-hlsl-spirv.ps1 in shape.",
|
||||
"packages/cultmath/docs/design.md: a section 'GLSL Target: Source Transformations' after :134 with the complete list, the common-subset rule for HLSL, the regeneration command, and that the GLSL is a derived file never edited by hand.",
|
||||
"packages/cultmath/README.md: one paragraph that CultMath.glsl exists, what dialect it is, and how a WebGL consumer includes it (string concatenation before its own #version-led source; declare highp)."
|
||||
],
|
||||
"file_changes": [
|
||||
{
|
||||
"location": { "path": "packages/cultmath/shaders/CultMath.hlsl", "line": 114 },
|
||||
"change": "step(h, 0.0) -> step(h, float4(0.0, 0.0, 0.0, 0.0)); same at :200. Bit-identical in HLSL and C#."
|
||||
},
|
||||
{
|
||||
"location": { "path": "packages/cultmath/shaders/CultMath.hlsl", "line": 470 },
|
||||
"change": "jitter components and the id at :502: an explicit (float) cast around the shifted uint before the float multiply, so the lowered text has no uint*float."
|
||||
},
|
||||
{
|
||||
"location": { "path": "packages/cultmath/shaders/CultMath.Phacelle.hlsl", "line": 1 },
|
||||
"change": "whatever common-subset edits the glslang compile demands; each one bit-identical, each one listed in the cut report."
|
||||
},
|
||||
{
|
||||
"location": { "path": "packages/cultmath/docs/design.md", "line": 134 },
|
||||
"change": "new section: GLSL Target: Source Transformations."
|
||||
}
|
||||
],
|
||||
"authority_map": {
|
||||
"owner": "GlslLowering (tests/CultMath.Tests) owns the HLSL -> GLSL transformation; shaders/CultMath.glsl is its derived output, pinned by GlslMirrorTests. CultMath.hlsl stays the one hand-written shader source.",
|
||||
"inputs": [
|
||||
"shaders/CultMath.hlsl and shaders/CultMath.Phacelle.hlsl text",
|
||||
"C# math for the golden fixture values"
|
||||
],
|
||||
"outputs": [
|
||||
"shaders/CultMath.glsl (GLSL ES 3.00 library, every public cultmath_* function except texture samplers)",
|
||||
"tests/CultMath.Tests/fixtures/glsl-parity.json"
|
||||
],
|
||||
"derived_state": [
|
||||
"CultMath.glsl is derived; a hand edit fails CommittedGlslEqualsLowering",
|
||||
"the fixture is derived from C#; a stale fixture fails GoldenFixtureMatchesCSharp"
|
||||
],
|
||||
"forbidden_writers": [
|
||||
"no per-function special case in the lowering; a dialect gap is fixed in the HLSL in the common subset",
|
||||
"no second transformation implementation (no PowerShell or JS copy of the rules)",
|
||||
"no GLSL in the Unity package; no precision statement in the library file"
|
||||
],
|
||||
"shared_paths": [
|
||||
"both tracked CultMath.hlsl copies receive the same edits; the existing one-blob rule covers them",
|
||||
"the same dotnet test run pins the C# mirror, the GLSL pin and the fixture"
|
||||
],
|
||||
"deletion_line": "Nothing is deleted in CultLib. The liability bought is one generated file, one test file, one fixture and two tool scripts; what it retires is every future hand copy of CultMath noise in a GLSL consumer, starting with the site."
|
||||
},
|
||||
"verification": {
|
||||
"builds": [
|
||||
"Yggdrasil: dotnet test packages/cultmath/tests/CultMath.Tests green; paste counts",
|
||||
"Yggdrasil: dotnet stryker --since:c49c76ee from a plain clone; triage every survivor by name",
|
||||
"Starfire: tools/compile-glsl.ps1 exits 0 on the wrapper over shaders/CultMath.glsl; a negative-control wrapper with one lerp( call must fail (records that the compile check sees errors)",
|
||||
"Starfire: FXC cs_5_0 and dxc -T lib_6_3 -HV 2021 still compile both HLSL copies after the common-subset edits"
|
||||
],
|
||||
"tests": [
|
||||
{
|
||||
"name": "GlslMirrorTests.CommittedGlslEqualsLowering",
|
||||
"pins": "the committed GLSL is exactly the lowering of the committed HLSL; the regeneration path is the same code."
|
||||
},
|
||||
{
|
||||
"name": "GlslMirrorTests.NoHlslOnlyTokensSurvive",
|
||||
"pins": "the list is complete: no HLSL-only token survives in the output."
|
||||
},
|
||||
{
|
||||
"name": "GlslMirrorTests.EveryPublicFunctionIsLowered",
|
||||
"pins": "no function is silently dropped except the texture samplers."
|
||||
},
|
||||
{
|
||||
"name": "GlslMirrorTests.GoldenFixtureMatchesCSharp",
|
||||
"pins": "the fixture the site will read on WebGL2 is C#'s current truth."
|
||||
},
|
||||
{
|
||||
"name": "HlslSourceCompatibilityTests.EveryMirrorFunctionMatchesCSharpMath (existing)",
|
||||
"pins": "the common-subset edits changed no bit of any HLSL function."
|
||||
}
|
||||
],
|
||||
"negative": [
|
||||
{
|
||||
"pattern": "lerp\\(|frac\\(|saturate\\(|float[234]\\(|int[234]\\(|static const|\\((float|int|uint)\\)|Texture2D|SamplerState|#include",
|
||||
"scope": "packages/cultmath/shaders/CultMath.glsl: 0 matches."
|
||||
},
|
||||
{
|
||||
"pattern": "precision (high|medium|low)p",
|
||||
"scope": "packages/cultmath/shaders/CultMath.glsl: 0 matches (the host declares precision)."
|
||||
},
|
||||
{
|
||||
"pattern": "CultMath\\.glsl",
|
||||
"scope": "packages/cultmath/unity/**: 0 matches; the Unity package does not ship the GLSL."
|
||||
},
|
||||
{
|
||||
"pattern": "git diff c49c76ee..HEAD -- packages/cultmath/shaders/CultMath.hlsl | grep '^[-+]' | grep -v '^[-+][-+]'",
|
||||
"scope": "every changed HLSL line is listed in the cut report with the reason (dialect gap it closes); none changes a value, proven by the mirror test and the existing goldens."
|
||||
}
|
||||
],
|
||||
"operator": []
|
||||
},
|
||||
"estimate": {
|
||||
"lines_added": 1100,
|
||||
"lines_removed": 8,
|
||||
"dependencies_added": [],
|
||||
"dependencies_removed": [],
|
||||
"formats_added": ["GLSL ES 3.00 library file (generated)", "glsl-parity.json fixture"],
|
||||
"formats_removed": [],
|
||||
"targets_added": [],
|
||||
"targets_removed": []
|
||||
},
|
||||
"rulings": [],
|
||||
"questions": []
|
||||
}
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
{
|
||||
"campaign": "cultmath-tapes",
|
||||
"cut": "interval-ops",
|
||||
"revision": 2,
|
||||
"title": "Interval ops r2: envelope-then-noise fields, scenarios with authored empty space including the void, the tile march on cell ranges, iv_frustum_ball, wasted probes removed, dxc run",
|
||||
"repo": "GameCult/CultLib",
|
||||
"branch": "hands/cultmath-interval-ops",
|
||||
"base": "c9bd003b",
|
||||
"depends_on": [],
|
||||
"first": [
|
||||
"Follow-up on cut report cultmath-tapes:cut_report:cut-interval-ops.h1 (head c9bd003b, worktree F:\\Projects\\CultLib-iv). Rulings in force: operator-tile-amortized-march (the bound stays; one probe per screen tile), operator-compiler-downloads. The operator's challenge, verbatim in the map's Pass 3 section: r1 measured a uniform noise slab with no empty space to skip, the worst case for intervals, and the density that matters is an analytic envelope (a pre-distortion SDF) modulated by noise, where the envelope's interval is tight and proves whole ray lengths empty in one probe. Map: docs/cultmath-interval-ground-cut.md, 'Pass 3'. r1's ops and bounds stay; this revision touches math.Interval.cs, CultMath.Interval.hlsl (both tracked copies), NoiseBoundTests.cs, design.md and README only.",
|
||||
"The field, from Aetheria (Volumetric.cginc:165-221 and :91-102; Zone.cs:369-397 GetHeight, :426 PowerPulse; Settings.asset). SDF s(p) = p.y + h(p.xz), h >= 0 the well map: h = PowerPulse(|p.xz| / (2R), 2) * 64 (the zone bowl) + sum over wells of PowerPulse(|p.xz - c_b| / r_b, 16) * d_b, with PowerPulse(x, e) = (1 - 4x^2)^e for x in [0, 1/2] and 0 beyond, r_b = 500 M^0.25, d_b = 30 M^0.175 (Aetheria's GravityRadius/GravityDepth curves). Noise only where s < S: fade(s) = 1 - smoothstep(0.75 S, S, s); displaced height s' = s + A fade(s) n(p) with n the warped two-phase coverage noise plus half the detail octave (r1's field shape); density = max(0, (F - s') / B) (Aetheria's floor: FloorOffset F = -20, FloorBlend B = 10; the patch band and the 1e-9 fill are left out). Above the safety distance density is exactly 0, and so is the noise cost: Aetheria's dense march gates the noise pointwise (`if (dist < _SafetyDistance)`); the test's dense march does the same.",
|
||||
"The composed bound over a tile's frustum slice [z0, z1], Envelope over the slice's exact box, not the ball: with slopes m in [m_c - s, m_c + s] per axis and z in [z0, z1], y = iv_mul([z0, z1], m_y) and x = iv_mul([z0, z1], m_x) (plus the tile's translation o); h's interval over the xz box is the sum over bowls of [pulse(far), pulse(near)], near and far the least and greatest distance from the box to the bowl's centre (pulse is monotone decreasing in distance, so each term is exact; the only slack is the sum). iv_s = iv_add(y, iv_h); iv_fade = the ordered endpoints of fade over iv_s (monotone); iv_n = iv_snoise_ball(ball.xyz * F0, ball.w * F0) with ball = iv_frustum_ball(..., warp = D); iv_s' = iv_add(iv_s, iv_scale(iv_mul(iv_fade, iv_n), A)); the slice is provably empty iff iv_s'.lo >= F. When iv_fade.hi == 0 the noise term is identically zero and iv_snoise_ball is not evaluated: the envelope alone proves the slice empty at no snoise cost. That short-circuit is what tiles compound.",
|
||||
"Where the warp lives in the bound. Aetheria warps only the noise argument (triNoise3d(pos + fl * ...)); the surface height is read at the unwarped xz and the displacement is applied to y afterwards. So the envelope is evaluated on the pre-distortion SDF over the unwarped slice (no D), and only the noise ball is enlarged by D (iv_frustum_ball's warp argument). A warp applied to the envelope's argument would be unsound without enlarging the box by D; the field is defined so that it is not. The test EnvelopeBoundEnclosesDensity pins this: 2,000 seeded slices x 64 warped points, the composed bound encloses density(p) with no tolerance (every op is monotone or already widened in r1; if a seeded case fails at an endpoint, widen in the implementation, never the test).",
|
||||
"iv_frustum_ball(float2 centreSlope, float z0, float z1, float footprintPerDepth, float warp) -> float4(centre, radius), in C# and both HLSL copies, is the one new CultMath function: the noise ball of a tile over a depth segment. Camera: pinhole at the origin looking +z, focal length f pixels; a pixel's ray is p(z') = (m z', z'); a tile's slopes lie in m_c + [-N/(2f), N/(2f)]^2 (full footprint, so sub-pixel jitter is covered), s = N / (sqrt(2) f). Centre c = (m_c z_m, z_m), z_m = (z0 + z1) / 2; radius r = ((z1 - z0) / 2) |(m_c, 1)| + z1 s + warp. Proof: a point of slope m_c + e (|e| <= s) at depth z' is c + (z' - z_m)(m_c, 1) + z'(e, 0), the terms bounded by the two radius terms, and the warp moves it by at most D (WarpedPointsStayEnclosed). The slice is a convex polytope, so every point between rays is enclosed too. The two vectors are never parallel, so the inequality is strict and float32 cannot cross it.",
|
||||
"(Tile ball, continued.) Test TileBallEnclosesEveryRaySegment: 2,000 seeded tiles (N in {1, 4, 8, 16}, f in [500, 3000], |m_c| <= 1.1, segments across each scenario's z range), 64 points each with sub-pixel offsets, both depth ends and a flow of length <= warp; no tolerance. Hand mutations that must die, listed in the file header: z0 for z1 in the lateral term; |m_c| for |(m_c, 1)|; the warp dropped. A moving camera needs nothing more: every frame's pre-pass evaluates iv_frustum_ball from that frame's camera and never carries a ball, a mask or a probe result across frames; the only cross-frame state is the TAA history, which is colour, not a soundness input. The test says so by drawing each tile's camera independently.",
|
||||
"Implementation review of r1's march (NoiseBoundTests.cs:344-376), each item fixed here: (1) every probe was charged one snoise per ray; the tile shares it (ruling). (2) Coarse probes were attempted where the ball could never prove emptiness: iv_snoise_ball's hi is n(c) + L r clamped to 1, so a probe with L r F0 >= 1 + cutoff is a guaranteed failure; at r1's StepMax = 2 that is L r F0 = 3.7, so every coarse probe was a wasted evaluation (the 0.74x row). r2 attempts a noise probe only when L * radius * F0 < 1 - (the cutoff the probe needs), otherwise the level is 'maybe' at no cost. (3) Growth was capped at StepMax = 8 Step0, so a ray through clear space paid Depth / StepMax probes; r2 grows to the remaining range (the hierarchy is over cell-index ranges, halving on maybe, doubling on skip, capped only by the grid's end).",
|
||||
"(Review, continued.) (4) The dense sample evaluated the detail octave even where occupancy was 0; r2's dense sample evaluates coverage first and the detail only when the envelope and coverage leave density > 0, as Aetheria's `if (dist < _SafetyDistance)` does; the dense baseline gets cheaper and the comparison honest. (5) The finest-level probe before integrating a cell stays: amortized over the tile it costs 1/N^2 and can skip 3 snoise per ray. (6) The step never regrew after a dense cell until a skip; with (3) it regrows from the next skip, which is enough.",
|
||||
"The march, on cell ranges. The dense grid is a scenario-owned list of cell boundaries z_0 < ... < z_J (uniform for the slab scenarios; Aetheria's quadratic spacing z_j = near + (j/J)^2 (far - near), J = 256, for the Aetheria-like ones, from CloudShader.shader:116). MarchTile takes a range [j0, j1): the envelope probe over the slice (z_j0, z_j1); if empty, skip and double the next range; else if the noise probe is affordable (item 2) and proves empty, skip; else if the range is more than one cell, halve; else the cell is dense. It runs to the grid's end once per tile (a pre-pass does not know any ray's transmittance) and yields the dense cells; each of the N^2 rays integrates exactly those cells in order with the dense march's own Integrate and early-out at 0.02. Counted separately: snoise evaluations and envelope evaluations (pointwise in the dense march; interval probes in the tile march), per ray with probes divided by N^2.",
|
||||
"The cost model, printed and reasoned about in snoise units: a pointwise envelope evaluation or an envelope probe costs e = 0.2 (a handful of pulses or a height-map fetch against ~150 flops of snoise; stated as an estimate, both counts printed so any weight can be applied). Per scenario the test prints: dense snoise and envelope per ray, tile snoise and envelope probes per ray, the combined cost ratio, and the probe-free ceiling (dense cost over the cost of the dense cells alone). The 2x contract is on the combined cost at N = 8 for scenarios (a) and (c); (b) and (d) are printed as the worst cases and not asserted.",
|
||||
"Scenarios (IV-REPORT lines, each with 64 tiles at N in {1, 4, 8, 16}, warp in {0, D}): (a) height fog: h = 0, camera at y = S + 30, rays with slope m_y in [0, 0.5] (level and up), Aetheria's grid, expected near-total skip (0 snoise either way; the envelope count carries the ratio). (b) inside the fog: h = 0, camera at y = F - 10, rays level, expected near 1x: every ray saturates within a few cells and the pre-pass still probes the whole grid. (c) Aetheria-like: the zone bowl R = 2000 and four wells of mass {100, 1000, 10000, 1000} at seeded positions inside R, camera at a seeded xz in the bowl at y = max(0, S + 10 - h(camera)), rays gazing across with m_y in [-0.25, 0.1], Aetheria's units (noise scale 414 so F0 = 1/414, D = 15 * 8 / 2 = 60, A = 20 with snoise in [-1, 1]), Aetheria's grid. (d) the uniform slab: r1's field and parameters, kept as the reported worst case. The headline is (e) at N = 8, 200 tiles, printed with both its numbers; (a) and (c) are asserted.",
|
||||
"(e) the void, the shipped scene (rulings operator-ground-void-brush, operator-void-true-cavity; site-ground r3): a cloud volume everywhere, carved by one negative pseudo-Gaussian sphere, parametrised by the hollow radius Rh (density exactly 0 inside) and the ramp width S (density rises to full over [Rh, Rh + S]); the brush (1 - (d / Rc)^2)^e with CARVE gives Rh = Rc sqrt(1 - CARVE^(-1/e)) and S from the exponent, so (Rh, S) are the authored knobs and (Rc, e) derived. Noise displaces the wall only: fade(d) = smoothstep(Rh - S, Rh, d); d' = d + A fade(d) n(warp(p)), A = 20, F0 = 0.01 (wavelengths 100 and 25), D = 10; K = 1/30 (optical depth 1 per 30 units of full density), termination at T < 0.02. Shipped point: Rh = 198, S = 50, the sun on the hollow's bottom, the camera 120 behind it along the orbit tangent and 45 above it (186 from the centre, 'low'). Rays over the whole frustum (vertical FOV 60, f = 935, so theta_px = 1/f). Every ray crosses empty interior and ends in a wall.",
|
||||
"Footprint LOD (the operator's challenge, map 'Footprint LOD'): theta_px = 2 tan(fov/2) / H; the footprint at distance z is z theta_px. Step: Delta(z) = max(k z theta_px, min(S / 4, lambda_fine(z) / 2)), k = 4: never finer than four footprints, otherwise the finer of the two content limits (four samples across the ramp, two per wavelength of the finest live octave). Octaves: octave i of wavelength lambda_i carries weight w_i(z) = 1 - smoothstep(lambda_i / (2m), lambda_i / m, z theta_px), m = 4: one smooth blend per octave, no popping; TAA hides per-frame sparsity. The weights are part of the field, so the bound encloses the truncated field exactly: the noise interval over a slice is the sum over octaves of iv_mul([w_i(z1), w_i(z0)], iv_snoise_ball(c F_i, r F_i)), w_i monotone in z. At the shipped parameters (wavelengths 100 and 25, z <= 2 Rc) every weight is 1 in the frustum and the content limits set the step; the footprint bites when S < 4 k z theta_px.",
|
||||
"(e) runs three marches per tile and prints them: the fixed-step reference (Delta_fixed = min(10, S / 4) everywhere, the pointwise envelope gate, the baseline a march that does not know where the wall is must pay); the masked fixed-step march (the same cells, so transmittance agrees exactly: the soundness pin, as (a)-(d)); and the masked LOD march, the shipped one (steps from Delta(z) inside the unmasked range only, octave weights applied), whose transmittance must be within 0.02 of the reference (printed; an approximation by design, not a soundness claim). Printed per configuration: steps per pixel and snoise per pixel for all three, the combined cost ratio reference / LOD, probes per tile. The grid for (e): Rh in {100, 200, 400, 1000} x camera offset in {centred, mid (0.5 Rh), low (0.94 Rh, the shipped)} x S in {10, 50, 150}, 32 tiles each at N = 8; the shipped point with 200 tiles.",
|
||||
"Predictions to confirm or refute, N = 8, combined cost: (a) above 50x. (b) 0.8-1.0x. (c) 2-4x: clear cells collapse to a few probes per tile, fog cells cost the same on both sides; the noise ball proves nothing at Aetheria's scale (L r F0 >= 1.6), so item 2 keeps it unevaluated and the saving is the envelope's. (d) 1.3-1.5x: the ceiling at L = 10.099261. (e), from Imagination's model (scratchpad cavity_model.py): the LOD march needs about 6 steps and 12 snoise per pixel at every grid point with S <= 50 (the content limits S / 4 and the detail half-wavelength 12.5 set the step, not the footprint); S = 150 needs 14 steps and 36 snoise. Against the fixed-step reference at the shipped point: steps 35 to 6 (5-8x fewer), combined cost 1.7x, snoise alone 1.25x; cost 3.2x at S = 10, 1.4x at S = 150, 2.5x at Rh = 1000; camera offset barely matters. (e)'s contract is steps per pixel (Q8 retired). If (a) or (c) misses 2x, skip naming saving-2x-scenarios. Never tune the fields, the grids or e.",
|
||||
"Stryker on Yggdrasil from a plain clone: --since:c9bd003b -m math.Interval.cs; report timeouts separately from kills (a timeout is not a demonstrated kill); if the saving test is what times mutants out, exclude it from Stryker's test selection (a measurement, not a pin) rather than raising the timeout. Compile checks on Starfire, one job, detached with a log, polled in the foreground: FXC cs_5_0 over every cultmath_iv_* including the new function, both tracked copies; then tools/get-dxc.ps1 (pinned, ruling operator-compiler-downloads) and dxc -T lib_6_3 -HV 2021 on both copies. Carried from r1 and still out of scope, name them in the report: the Unity CultMath.dll is not rebuilt; the Unity HLSL copies stay one blob with packages/cultmath/shaders; public asfloat(uint) stays. Branch from c9bd003b; if main moved by docs only, say so as r1 did."
|
||||
],
|
||||
"deletes": [
|
||||
{
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"lines": 33,
|
||||
"note": ":344-376 MarchIntervals, the per-ray adaptive march with its StepMax cap and unconditional probes, replaced by MarchTile on cell ranges."
|
||||
},
|
||||
{
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"lines": 10,
|
||||
"note": ":401-408 the doc comment naming the r1 fork and :424-425 the Assert.Skip naming it; the fork is answered. A skip survives only on a shortfall in (a) or (c), naming saving-2x-scenarios."
|
||||
},
|
||||
{
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"lines": 12,
|
||||
"note": ":319-330 Density: the unconditional three-snoise sample is replaced by the envelope-gated sample (coverage first, detail only when density can be nonzero)."
|
||||
},
|
||||
{
|
||||
"path": "packages/cultmath/docs/design.md",
|
||||
"lines": 15,
|
||||
"note": ":423-437 the saving paragraph with r1's numbers (0.93x, 1.83x, 0.74x), replaced by the scenario paragraph with r2's printed numbers."
|
||||
}
|
||||
],
|
||||
"keeps_moves": [
|
||||
"Every iv_* op, both ball bounds, SNOISE_LIPSCHITZ and its provenance tests: untouched. The ruling keeps the bound; only the field model, the march and its test change.",
|
||||
"SavingField's noise shape (F0 0.35 in the slab, the warped two-phase coverage, the detail octave, period 4) and (d)'s parameters: kept, as the worst case; scenario (d) must reproduce r1's dense count where the detail gating does not bite (print it).",
|
||||
"MarchDense and Integrate: unchanged except that Density is envelope-gated; the dense grid becomes the scenario's boundary list.",
|
||||
"IntervalTests, WarpedPointsStayEnclosed, TightnessReport, MeasureLipschitz, LipschitzConstantPinsSampledGradients: unchanged.",
|
||||
"HlslSourceCompatibilityTests: no change expected; the new function must find its C# twin through the existing mirror."
|
||||
],
|
||||
"adds": [
|
||||
"packages/cultmath/src/CultMath/math.Interval.cs: iv_frustum_ball after iv_fbm_ball, doc comment carrying the derivation and that the caller scales by F0 and adds its translation.",
|
||||
"packages/cultmath/shaders/CultMath.Interval.hlsl: cultmath_iv_frustum_ball, same text in both tracked copies.",
|
||||
"tests/CultMath.Tests/NoiseBoundTests.cs: Scenario (grid, wells, camera, ray slopes, units), SavingField with the envelope (PowerPulse wells, fade, floor), EnvelopeBound (the composed interval, test-side; iv_* ops only), MarchTile, MarchLod (footprint step and octave weights, the shipped march), the per-tile Run; TileBallEnclosesEveryRaySegment; EnvelopeBoundEnclosesDensity; IntervalSkipHalvesEvaluations prints the scenario x N x warp grid and asserts (a) and (c) at N = 8.",
|
||||
"packages/cultmath/docs/design.md, 'Intervals': the frustum-ball paragraph after :401; the envelope-then-noise paragraph (how a consumer composes the bound, the short-circuit, where the warp lives); the rewritten saving paragraph with the four scenarios and the headline.",
|
||||
"packages/cultmath/README.md :38-43: iv_frustum_ball named; one sentence that the bounds compose with an analytic envelope."
|
||||
],
|
||||
"file_changes": [
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/src/CultMath/math.Interval.cs",
|
||||
"line": 152
|
||||
},
|
||||
"change": "after iv_fbm_ball: public static float4 iv_frustum_ball(float2 centreSlope, float z0, float z1, float footprintPerDepth, float warp)."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/shaders/CultMath.Interval.hlsl",
|
||||
"line": 91
|
||||
},
|
||||
"change": "after cultmath_iv_fbm_ball: float4 cultmath_iv_frustum_ball(...); both tracked copies."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 294
|
||||
},
|
||||
"change": "SavingField becomes envelope-then-noise over a Scenario; Density is envelope-gated; MarchTile replaces MarchIntervals; Run draws tiles and N^2 rays each, counting snoise and envelope evaluations separately."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 409
|
||||
},
|
||||
"change": "IntervalSkipHalvesEvaluations: scenarios (a)-(e), N in {1, 4, 8, 16}, warp in {0, D}; the (e) grid Rh x offset x S with three marches; assert >= 2x combined cost for (a) and (c) and >= 2x fewer steps per pixel for (e) at the shipped point, else Skip naming saving-2x-scenarios."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 89
|
||||
},
|
||||
"change": "TileBallEnclosesEveryRaySegment and EnvelopeBoundEnclosesDensity beside WarpedPointsStayEnclosed."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/docs/design.md",
|
||||
"line": 401
|
||||
},
|
||||
"change": "the frustum-ball and envelope-composition paragraphs; :423-437 the saving paragraph rewritten with r2's printed numbers only."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/README.md",
|
||||
"line": 38
|
||||
},
|
||||
"change": "iv_frustum_ball named; the envelope composition in one sentence."
|
||||
}
|
||||
],
|
||||
"authority_map": {
|
||||
"owner": "CultMath owns the interval ops, the noise bounds and now the frustum ball; a consumer (the test's SavingField, the site's pre-pass, Aetheria's march) owns its envelope and composes the bound from iv_* ops. The saving test owns the proving-ground numbers per scenario; the site's browser measurement owns the shipped gain. The bound (SNOISE_LIPSCHITZ, iv_snoise_ball) is unchanged.",
|
||||
"inputs": [
|
||||
"the r1 interval surface, unchanged",
|
||||
"a tile's geometry (centre slope, depth segment, footprint per depth, warp bound) and a scenario's grid, wells, camera and units; Aetheria's shapes and settings as read from its checkout, cited by file:line in the test"
|
||||
],
|
||||
"outputs": [
|
||||
"iv_frustum_ball / cultmath_iv_frustum_ball: a ball enclosing every point of every ray of the tile over the segment, warped",
|
||||
"printed numbers per scenario: dense and tile snoise and envelope counts per ray, combined cost ratio, probe-free ceiling, at N in {1, 4, 8, 16}"
|
||||
],
|
||||
"derived_state": [
|
||||
"the tile ball and the envelope box are derived from the tile's geometry; no radius is read from a texture, a uniform or a tuned constant",
|
||||
"whether a noise probe is attempted is derived from L, the radius and the cutoff (item 2), never from a mode flag",
|
||||
"the probe-free ceiling is derived from the same run; it states what no amortization can beat"
|
||||
],
|
||||
"forbidden_writers": [
|
||||
"no change to SNOISE_LIPSCHITZ, iv_snoise_ball, the noise shape or e; the scenarios may not be tuned toward 2x",
|
||||
"no per-ray probe in the tile march; no early-out of the pre-pass on any ray's transmittance",
|
||||
"no warp on the envelope's argument; the warp enlarges the noise ball only",
|
||||
"no new CultMath function for the envelope: PowerPulse, the box and the composition are consumer-side, built from iv_* ops",
|
||||
"no tape, prune, affine or choice token in the interval files; no number in design.md or a comment that the committed test did not print in this run"
|
||||
],
|
||||
"shared_paths": [
|
||||
"the dense march and the tile march integrate the same cells of the same grid with the same Integrate; the transmittance check is the proof that nothing provably empty was there",
|
||||
"the one composed bound (EnvelopeBound) serves the enclosure test and the march; no second composition",
|
||||
"the existing mirror test covers the new HLSL function; the Unity copy stays one blob"
|
||||
],
|
||||
"deletion_line": "r1's per-ray march, its unconditional probes, its growth cap and the unconditional three-snoise dense sample go first; the tile march on cell ranges and the gated sample are written in their place. Nothing in src is deleted. The liability added is one geometry function and two tests plus the scenario model; it buys the tile-march soundness invariant in three runtimes and a saving number measured where there is empty space to find."
|
||||
},
|
||||
"verification": {
|
||||
"builds": [
|
||||
"Yggdrasil: dotnet test packages/cultmath/tests/CultMath.Tests green (MeasureLipschitz/MeasureMonotonicity excluded by trait); paste counts and every IV-REPORT line",
|
||||
"Yggdrasil: dotnet stryker --since:c9bd003b -m math.Interval.cs from a plain clone; 0 survivors; timeouts listed separately from kills",
|
||||
"Starfire: FXC cs_5_0 kernel over every cultmath_iv_* including cultmath_iv_frustum_ball, both HLSL copies",
|
||||
"Starfire: tools/get-dxc.ps1 then dxc -T lib_6_3 -HV 2021 on both HLSL copies, exit 0"
|
||||
],
|
||||
"tests": [
|
||||
{
|
||||
"name": "NoiseBoundTests.TileBallEnclosesEveryRaySegment",
|
||||
"pins": "every sample point of every ray of an N x N tile over a depth segment, sub-pixel jitter and warp included, lies inside iv_frustum_ball."
|
||||
},
|
||||
{
|
||||
"name": "NoiseBoundTests.EnvelopeBoundEnclosesDensity",
|
||||
"pins": "the composed bound (envelope box, fade, noise ball enlarged by the warp) encloses density at every warped point of 2,000 slices, no tolerance; intervals-enclose holds for the composition the site and Aetheria will use."
|
||||
},
|
||||
{
|
||||
"name": "NoiseBoundTests.IntervalSkipHalvesEvaluations",
|
||||
"pins": "scenarios (a) and (c) at N = 8 cost at most half the dense combined cost with transmittance within 1e-3; (e) at the shipped point: the masked LOD march takes at most half the fixed-step reference's steps per pixel, the masked fixed-step march agrees exactly, the LOD march within 0.02; the (e) grid and every scenario, N and warp printed with both counts and the ceiling."
|
||||
},
|
||||
{
|
||||
"name": "IntervalTests.EveryOpEnclosesItsPoints (existing)",
|
||||
"pins": "r1's enclosure for every op still holds."
|
||||
},
|
||||
{
|
||||
"name": "HlslSourceCompatibilityTests.EveryMirrorFunctionMatchesCSharpMath (existing)",
|
||||
"pins": "cultmath_iv_frustum_ball matches iv_frustum_ball bit for bit."
|
||||
}
|
||||
],
|
||||
"negative": [
|
||||
{
|
||||
"pattern": "tape|prune|affine|choice",
|
||||
"scope": "math.Interval.cs and CultMath.Interval.hlsl: 0 matches (case-insensitive)."
|
||||
},
|
||||
{
|
||||
"pattern": "snoise_grad|fbm_grad",
|
||||
"scope": "math.Interval.cs and CultMath.Interval.hlsl: 0 matches."
|
||||
},
|
||||
{
|
||||
"pattern": "PowerPulse|pulse|envelope|Scenario",
|
||||
"scope": "math.Interval.cs and CultMath.Interval.hlsl: 0 matches; the envelope is consumer-side."
|
||||
},
|
||||
{
|
||||
"pattern": "Assert\\.Skip",
|
||||
"scope": "NoiseBoundTests.cs: at most one, in IntervalSkipHalvesEvaluations, naming saving-2x-scenarios; 0 if (a), (c) and (e) meet their contracts."
|
||||
},
|
||||
{
|
||||
"pattern": "10\\.099261|0\\.35f",
|
||||
"scope": "git diff c9bd003b..HEAD -- packages/cultmath/src: the constant is not touched; the slab's F0 stays in (d)."
|
||||
},
|
||||
{
|
||||
"pattern": "git diff --name-only c9bd003b..HEAD",
|
||||
"scope": "only packages/cultmath/**; both tracked CultMath.Interval.hlsl copies change together."
|
||||
}
|
||||
],
|
||||
"operator": []
|
||||
},
|
||||
"estimate": {
|
||||
"lines_added": 420,
|
||||
"lines_removed": 80,
|
||||
"dependencies_added": [],
|
||||
"dependencies_removed": [],
|
||||
"formats_added": [],
|
||||
"formats_removed": [],
|
||||
"targets_added": [],
|
||||
"targets_removed": []
|
||||
},
|
||||
"rulings": [
|
||||
"cultmath-tapes:ruling:operator-tile-amortized-march",
|
||||
"cultmath-tapes:ruling:operator-ground-void-brush",
|
||||
"cultmath-tapes:ruling:operator-void-true-cavity",
|
||||
"cultmath-tapes:ruling:operator-compiler-downloads",
|
||||
"cultmath-tapes:ruling:operator-campaign-home"
|
||||
],
|
||||
"questions": []
|
||||
}
|
||||
|
|
@ -1,268 +0,0 @@
|
|||
{
|
||||
"campaign": "cultmath-tapes",
|
||||
"cut": "interval-ops",
|
||||
"revision": 3,
|
||||
"title": "Interval ops r3: the frustum ball widened by its derived float32 bound, the analytic constant-density body, one shipped void, (c) held to its ceiling, LOD weights exercised, Stryker kills demonstrated",
|
||||
"repo": "GameCult/CultLib",
|
||||
"branch": "hands/cultmath-interval-ops",
|
||||
"base": "43183276",
|
||||
"depends_on": [],
|
||||
"first": [
|
||||
"Follow-up on cut report cultmath-tapes:cut_report:cut-interval-ops.h2 (head 43183276, worktree F:\\Projects\\CultLib-iv) and Soul's verdict cultmath-tapes:verdict:cut-interval-ops.s1 with its five findings, each resolved below in order. New rulings in force: operator-wells-contract-vs-ceiling ((c)'s contract is at least 90% of the probe-free ceiling, not 2x) and operator-analytic-wall-body (where the envelope interval proves the density constant, one closed-form Beer-Lambert step). Map: docs/cultmath-interval-ground-cut.md, 'Pass 4'. r2's ops, field models, tile march and scenarios stand; this revision touches math.Interval.cs, both tracked CultMath.Interval.hlsl copies, NoiseBoundTests.cs, SnoiseBall tests in the same file, design.md and README only. Nothing new in src beyond the radius widening.",
|
||||
"Finding 1, frustum-ball-float-crossing (Medium). The ball is widened, in C# and both HLSL copies: radius += (|c.x| + |c.y| + |c.z| + radius) * 2^-20 (the literal 9.5367431640625e-7, exact in float32), after the r2 radius. Derivation, u = 2^-24: the centre c = (m_c z_m, z_m) carries at most two roundings per component (z_m = fl(z0 + z1) * 0.5, then the product), so |c_float - c_exact| <= 2u |c|_1; the radius carries at most seven first-order roundings (z1 - z0; the axis sqrt at 3u; the product; z1 * footprint; the sum; + warp), so r_exact <= r_float (1 + 7u); the widening's own addition rounds once more. 2^-20 = 16u covers 8u (|c|_1 + r) twice over. fma contraction removes roundings and only tightens, so dxc's HLSL and any driver's GLSL stay covered. The sentence 'the two vectors are never parallel, so the inequality is strict and float32 cannot cross it' leaves math.Interval.cs:179-180, the HLSL and design.md:412; the rule and its derivation take its place.",
|
||||
"(Finding 1, continued.) TileBallEnclosesEveryRaySegment gains Soul's extreme families beside the r2 domain, 2,000 cases each, 8 footprint corners at both depth ends with the flow pointed outward along the corner's direction from the centre, the distance computed in double from the float ball and the float ray (point (m z, z) formed in double from the float m and z), no tolerance: z0 == z1; thin (z1 - z0 = 1e-6 z1); |m_c| up to 1000; far (z up to 1e7); z0 = 0 with z1 down to 1e-3; f down to 1; warp up to 1e6. The derivation pin becomes two-sided: r_exact <= ball.w <= r_exact + 2^-19 (r_exact + |c|_1), r_exact in double, so a ball that forgets the widening fails and a ball that grows past the rule fails. The hand mutations of r2 stay; a fourth, the widening dropped, must fail the degenerate family alone. WarpedPointsStayEnclosed is unchanged.",
|
||||
"Finding 2, aetheria-far-plane-miscited (Low). FogField's grid becomes Quadratic(0.3, 2048, 256), cited as the Main Camera (Aetheria d3075eb0 Assets/Scenes/ARPG.unity:31777 m_Name Main Camera, :31830 far clip plane 2048; VolumeCloudRenderer.cs:36 RequireComponent(Camera)); the comment at NoiseBoundTests.cs:594 no longer names the Tint camera's 1000. Soul re-measured (c) at far 2048: 1.39-1.51x at warp D, 2.46-2.74x at warp 0, ceiling within 1-2% of the ratio; the verdict on (c) does not move. Print every (a)-(c) row again at 2048.",
|
||||
"Finding 3, shipped-void-two-scenes (Medium). ONE shipped void, authored as (Rh, S) per the map's own rule, everything else derived; the map, the site-ground r3 spec and the test agree on it: Rh = 198, S = 50, so Rc = Rh + S = 248 and e = -ln CARVE / ln(1 - (Rh / Rc)^2) = 0.40 (CARVE 1.5); fade band [Rh - S, Rh], A = 20, F0 = 0.01, D = 10, K = 1/30. This is the scene r2 measured (2.21x) and the Footprint-LOD rule's; the map's site section (Rc 640, e 4, a 442-wide ramp) was the other scene and is superseded. The honest choice changes the look (a 50-wide noisy wall with a kinked outer edge, not a soft pseudo-Gaussian wall), so it is Q9 in the map, a fork for the operator with (a) recommended. Hands codes (a); another ruling moves only the shipped constants and the site section. VoidField's doc comment derives (Rc, e) from (Rh, S) and names it the one shipped void.",
|
||||
"Finding 4, lod-weights-never-exercised (Low). VoidField takes a focal length. One configuration reaches the weights: '(e) LOD far' Rh = 4000, S = 50, camera low, f = 467 (1080 rows at a 98 degree vertical FOV): theta = 1/467, so the fine weight (wavelength 25) is below 1 past z = 1460 and 0 past 2920, the coarse weight (100) below 1 past 5840, and the grid (|camera - c_v| + Rc + 6/K, about 7990) reaches both; 4 z theta exceeds 12.5 past z = 1460, so LodStep is footprint-set there too. It joins EnvelopeBoundEnclosesDensity's field list (the truncated check is no longer vacuous) and the saving test prints it, not asserted. Hand mutation that must fail the truncated check on it: w(z0) for w(z1) in WeightBound.",
|
||||
"(Finding 4, continued: the deviation.) Recorded in the test's doc and design.md: the bound takes each octave weight over [w(z1), 1], not the spec's [w(z1), w(z0)], because one Bound serves the full field (w = 1) and the truncated one (w in [w(z1), w(z0)], w decreasing in z). [w(z1), 1] contains both, iv_mul is monotone in its interval argument, so the composed interval is a superset of either field's and encloses both. The slack is only in far tiles whose step the footprint already sets.",
|
||||
"Finding 5, stryker-timeout-survivor (Low). Mutant 2525 (iv_sqr a.x >= 0 to a.x > 0) is equivalent: at a.x == 0 both branches return the same ends ((0, h) either way, (0, 0) when a.y == 0); the report says so by its diff, since numbers are per run. New enclosure cases kill the mirror-only survivors semantically: SnoiseBallEnclosesPoints and FbmBallEnclosesPoints draw radii LogUniform(1e-3, 1e3), so L / r in iv_snoise_ball (too tight once r > 1: at r = 100 the mutant's bound is 0.2 wide while snoise over the ball spans [-1, 1]) dies to an enclosure test, not only to the HLSL mirror. The mutants Soul listed as mirror-only (iv_scale s >= 0 to s > 0; the two iv_abs mutants) are hand-run and each either named equivalent with the one-line argument (signed zero or a boundary both branches map alike) or killed by one added point case in IntervalTests.",
|
||||
"(Finding 5, continued.) Stryker runs again as r2 did, --since:43183276; every timeout in math.Interval.cs is hand-run (patch from the mutation report, dotnet test minus IntervalSkipHalvesEvaluations) and the report lists killed, survived and equivalent by diff. The report claims only demonstrated kills; a timeout is not a kill.",
|
||||
"The analytic body (ruling operator-analytic-wall-body), consumer-side in the test and the site, no new CultMath function. When it applies: the Scenario's Bound over the remaining range [z, z_end] of the grid, gated, is a point interval with positive value: the envelope with the free noise interval [-1.5, 1.5] (coverage in [-1, 1] plus half the detail) proves fade = 1 and d_lo + A (fade n)_lo >= Rc, so every point of every ray of the tile over the rest of the grid has density exactly K for any noise value; or, generically, lo == hi > 0 from whichever path the bound took. Only then does the march integrate T *= exp(-K ext (z_end - z) |dir|) in one step and terminate (the grid end is past opacity by construction). For (a)-(d) no Bound is a positive point (the fog's density varies with s'), so the step never applies there, and the test asserts bodyStart = -1 for every (a)-(d) tile.",
|
||||
"(Analytic body, continued.) MarchTile returns (dense cells, bodyStart, bodyDensity). As r2 on cell ranges; when a single cell is found not provably empty and its own gated bound is a positive point (the cell is provably constant), one more probe over [grid[j], grid[^1]]: if that is a positive point too, bodyStart = j, bodyDensity = its value, and every remaining cell is appended dense without probing (adding cells is always sound; it also drops the body's probes). The mask's meaning is unchanged, so the masked fixed-step march's exact agreement with the reference stands as the soundness pin. MarchLod: inside unmasked runs as r2; on reaching a cell >= bodyStart it takes the one analytic step over [z, grid end], counts one step, and stops.",
|
||||
"(Analytic body, the grid end.) Every void configuration's grid ends at |camera - c_v| + Rc + 6/K (was 5/K): the body starts at d >= Rc + A 1.5 = Rc + 30 plus box slack, so 6/K leaves at least 4.9 of optical depth, T <= 0.0075 < 0.02, and 'unfinished rays 0' still prints. This is the grid end, not the field; the reference stops by opacity and its count does not move.",
|
||||
"(Analytic body, the pin.) New test AnalyticBodyMatchesFineMarch over the (e) grid, the shipped void and the LOD-far configuration, 2,000 seeded tiles at N in {1, 4, 8, 16}: (1) wherever MarchTile reports bodyStart b: the Bound over [grid[b], grid end] is a positive point; the full and the truncated Density at 64 random (pixel, sub-pixel, cell >= b, depth in cell) points equal bodyDensity exactly, no tolerance; (2) the analytic factor exp(-bodyDensity ext L) over the body equals the fixed-step product of the body cells' Integrate factors (no early-out, same cells) within 1e-4 relative; (3) for every (a)-(d) tile bodyStart = -1. Hand mutation that must fail (1): in VoidField.Compose, CarveRadius for HollowRadius in the lo test (the body claimed from the ramp). The site's mirror of this (site-ground r3): the pre-pass writes the body-start cell in the texel's fourth word, the main pass takes the same one step; its browser check is the dense-reference agreement already in that spec.",
|
||||
"Contracts after r3, asserted at N = 8 in IntervalSkipHalvesEvaluations, else one Assert.Skip naming saving-2x-scenarios with every shortfall: (a) at least 2x combined cost (the lower of warp 0 and D), measured 16384x. (c) at least 0.90 of its probe-free ceiling (Ratio / Ceiling, the lower of warp 0 and D), measured 0.99 at warp D and 0.96 at warp 0 in r2; the ruling's reading is that the ceiling is what intervals control and the fog band's cost is the field's. (e) at least 2x fewer LOD steps per pixel than the fixed-step reference at the ONE shipped void (200 tiles), the masked fixed-step march agreeing exactly and the LOD march within 0.02 of the reference. (b), (d) and the (e) grid incl. LOD-far: printed, not asserted. Nothing is tuned toward any contract: the fields, the grids (beyond the stated 6/K end), e = 0.2 and the seeds stay.",
|
||||
"Predictions to confirm or refute (Imagination's rule from r2's printed rows: the LOD steps that evaluated noise are about snoise / 2.8 plus two pre-band steps; the rest were constant-body steps, which the analytic step makes one). Shipped void: steps per pixel 36.34 (ref) to about 12.5 (was 16.45), 2.6-3.3x fewer; snoise per pixel about 26.6, unchanged; combined cost about 1.38x (was 1.34x); envelope probes per tile about 50 (was 59.1). Grid, low camera, ref/LOD steps: S = 10 about 5-6x at Rh 100-400 and 11x at Rh 1000 (was 2.1-3.4x and 6.8x); S = 50 about 2.5x, 2.8x, 3.5x, 5.2x for Rh 100, 200, 400, 1000 (was 1.8, 2.2, 2.9, 4.5); S = 150 unchanged (rays end inside the 150-wide ramp before the body is proven). Rh 1000 centred S 10: about 30x (was 12.4x). LOD-far: printed, none predicted. If the shipped point lands under 2.6x, say which assumption failed: the pre-band's 0-snoise steps, or a body start later than Rc + 30 by box slack.",
|
||||
"Compile and mutation checks as r2: Yggdrasil dotnet test (counts and every IV-REPORT line pasted); Stryker --since:43183276 -m math.Interval.cs from a plain clone with every timeout hand-run as in Finding 5; Starfire FXC cs_5_0 over every cultmath_iv_* on both HLSL copies and tools/get-dxc.ps1 then dxc -T lib_6_3 -HV 2021 on both, exit 0 (Soul could not re-run these for r2; they are re-run here because the HLSL changes). Carried and still out of scope, named in the report: the Unity CultMath.dll is not rebuilt; the Unity HLSL copies stay one blob with packages/cultmath/shaders; public asfloat(uint) stays. Main moved by docs only since 43183276's base (Imagination passes 3-3d and 4); say so. glsl-lowering r2 rebases onto this revision's head, not r2's (its fixture family for cultmath_iv_frustum_ball changes with the widening); its spec now says so."
|
||||
],
|
||||
"deletes": [
|
||||
{
|
||||
"path": "packages/cultmath/src/CultMath/math.Interval.cs",
|
||||
"lines": 2,
|
||||
"note": ":179-180 the sentence 'The two vectors are never parallel, so the triangle inequality is strict' and its float32 claim; replaced by the widening rule and its derivation."
|
||||
},
|
||||
{
|
||||
"path": "packages/cultmath/docs/design.md",
|
||||
"lines": 1,
|
||||
"note": ":412 'the two vectors are never parallel, so the bound is strict'; replaced by the rule."
|
||||
},
|
||||
{
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"lines": 1,
|
||||
"note": ":594 the Tint camera citation (ARPG.unity:635-636, far 1000); replaced by the Main Camera's :31830, far 2048."
|
||||
},
|
||||
{
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"lines": 3,
|
||||
"note": ":1181-1194 the 2x test on ratios[c] and the skip message's wording; replaced by the 0.90-of-ceiling test and a message naming each shortfall."
|
||||
},
|
||||
{
|
||||
"path": "packages/cultmath/docs/design.md",
|
||||
"lines": 12,
|
||||
"note": ":461-497 the r2 numbers (1.41x, 16.45, 2.21x, the (c) shortfall paragraph); replaced by r3's printed numbers and the three contracts."
|
||||
}
|
||||
],
|
||||
"keeps_moves": [
|
||||
"Every iv_* op, iv_snoise_ball, iv_fbm_ball, SNOISE_LIPSCHITZ and its provenance tests: untouched. iv_frustum_ball's centre and the r2 radius terms: untouched; only the widening term is appended.",
|
||||
"FogField, SlabField, WarpedNoise, Compose, the gated probe (BestBall), MarchTile's range hierarchy, the masked fixed-step march and Run: kept; MarchTile gains the body return and MarchLod the one analytic step.",
|
||||
"VoidField's field, Density, Bound, Weight, WeightBound and LodStep: kept; the constructor gains the focal length and the grid end moves to 6/K; the shipped point stays Shipped() = (198, 50, shipped camera).",
|
||||
"TileBallEnclosesEveryRaySegment's r2 domain, EnvelopeBoundEnclosesDensity, IntervalTests, TightnessReport, MeasureLipschitz, LipschitzConstantPinsSampledGradients: kept; the first two gain cases.",
|
||||
"HlslSourceCompatibilityTests: the mirror count stays 53; cultmath_iv_frustum_ball's new text must match the C# bit for bit."
|
||||
],
|
||||
"adds": [
|
||||
"math.Interval.cs and both CultMath.Interval.hlsl: the widening line in iv_frustum_ball / cultmath_iv_frustum_ball, and the rule in the doc comment.",
|
||||
"NoiseBoundTests.cs: the extreme families and the two-sided derivation pin in TileBallEnclosesEveryRaySegment; VoidField focal parameter and the LOD-far configuration; MarchTile's (bodyStart, bodyDensity); MarchLod's analytic step; AnalyticBodyMatchesFineMarch; the (c) ceiling contract; radii to 1e3 in the two ball tests; the WeightBound deviation in the doc comment; the shipped-void derivation in VoidField's doc comment.",
|
||||
"IntervalTests.cs: at most three point cases for the mirror-only mutants that are not equivalent (Finding 5).",
|
||||
"design.md 'Intervals': the widening rule and derivation; the analytic-body paragraph (condition, pre-pass word, one step, the pin); the WeightBound deviation; r3's printed numbers under the three contracts; the one shipped void with (Rc, e) derived.",
|
||||
"README.md :41: one clause that iv_frustum_ball's radius carries its float32 rounding bound."
|
||||
],
|
||||
"file_changes": [
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/src/CultMath/math.Interval.cs",
|
||||
"line": 183
|
||||
},
|
||||
"change": "iv_frustum_ball: after the r2 radius, radius += (abs(cx) + abs(cy) + abs(zm) + radius) * 9.5367431640625e-7f; doc comment :168-182 states the rule and derivation in place of the strictness sentence."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/shaders/CultMath.Interval.hlsl",
|
||||
"line": 110
|
||||
},
|
||||
"change": "cultmath_iv_frustum_ball: the same widening line; both tracked copies (packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.Interval.hlsl identical blob)."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 128
|
||||
},
|
||||
"change": "TileBallEnclosesEveryRaySegment: the seven extreme families, outward flow at corners, double distance, the two-sided pin; the file header lists the fourth hand mutation (widening dropped) and the WeightBound and Compose mutations."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 58
|
||||
},
|
||||
"change": "SnoiseBallEnclosesPoints and FbmBallEnclosesPoints: radius LogUniform(1e-3, 1e3)."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 594
|
||||
},
|
||||
"change": "FogField: Grid = Quadratic(0.3, 2048, 256), Main Camera citation."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 829
|
||||
},
|
||||
"change": "VoidField: focal parameter (default 935); LodFar() = (4000, 50, low, f 467); grid end reach + Rc + 6/K; doc comment with the (Rh, S) -> (Rc, e) derivation, the one shipped void, the [w(z1), 1] deviation."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 1009
|
||||
},
|
||||
"change": "MarchTile returns (cells, bodyStart, bodyDensity) with the one confirming probe; MarchLod takes the analytic step at bodyStart; Run and RunStats carry it; ReportVoid prints body steps."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 1160
|
||||
},
|
||||
"change": "IntervalSkipHalvesEvaluations: (c) asserted as Ratio >= 0.90 Ceiling; the LOD-far row printed; the (e) grid printed with the analytic body; AnalyticBodyMatchesFineMarch added beside it."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/docs/design.md",
|
||||
"line": 403
|
||||
},
|
||||
"change": ":403-430 the frustum-ball paragraph gains the widening rule; :412 strictness sentence deleted; :461-497 the saving section rewritten with r3's printed numbers, the three contracts, the analytic body and the shipped void."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/README.md",
|
||||
"line": 41
|
||||
},
|
||||
"change": "iv_frustum_ball: 'its radius carries its own float32 rounding bound'."
|
||||
}
|
||||
],
|
||||
"authority_map": {
|
||||
"owner": "CultMath owns the interval ops, the noise bounds and the frustum ball, whose enclosure promise now covers its own float32 rounding (the widening is part of the function, not of any consumer). The consumer (the test's Scenario, the site's pre-pass) owns the envelope, the composed bound, the body proof and the Beer-Lambert step. The saving test owns the proving-ground numbers; the operator owns the shipped void (Q9) and the contracts (the two rulings).",
|
||||
"inputs": [
|
||||
"the r2 surface unchanged; a tile's geometry and warp; a scenario's grid, wells, camera, focal length and units, Aetheria's Main Camera far plane 2048 cited by line",
|
||||
"the one shipped void as (Rh, S) = (198, 50); every other void constant derived from it"
|
||||
],
|
||||
"outputs": [
|
||||
"iv_frustum_ball / cultmath_iv_frustum_ball: a float32 ball that encloses every exact point of every ray of the tile over the segment, warped, under float32 evaluation of the ball itself",
|
||||
"per tile: the dense cells and, when the envelope proves the rest constant, the body-start cell and its density; printed numbers per scenario including the body steps"
|
||||
],
|
||||
"derived_state": [
|
||||
"the widening is derived from the computed centre and radius (2^-20 of |c|_1 + r), never a tuned epsilon; a consumer adds nothing for the ball's own rounding and remains responsible for its world transform",
|
||||
"(Rc, e) are derived from (Rh, S): Rc = Rh + S, e = -ln CARVE / ln(1 - (Rh/Rc)^2); no void constant is authored twice",
|
||||
"the body start is derived from the same Bound the mask is derived from, by one more probe; the main pass reads it, never decides it",
|
||||
"the (c) contract value is derived from the run (0.90 of its own ceiling); no absolute target is written for (c)"
|
||||
],
|
||||
"forbidden_writers": [
|
||||
"no change to SNOISE_LIPSCHITZ, iv_snoise_ball, iv_fbm_ball, the noise shape, e = 0.2, the seeds or any field toward a contract; the grid end moves to 6/K for termination only",
|
||||
"no analytic step from a pointwise density or from the noise ball alone: only a point-interval Bound over the whole remaining range proves the body",
|
||||
"no early-out of the pre-pass on transmittance; no probe in the main pass (the body-start cell is read, its constant is one pointwise envelope value)",
|
||||
"no second shipped void anywhere: the map's site section, the site-ground spec and VoidField.Shipped() name (198, 50) and derive the rest; no number in a doc or comment that the committed test did not print in this run",
|
||||
"no new CultMath function; PowerPulse, the box, the composition and the Beer-Lambert step stay consumer-side; no tape, prune, affine or choice token in the interval files"
|
||||
],
|
||||
"shared_paths": [
|
||||
"the masked fixed-step march still integrates exactly the dense march's cells (the soundness pin); only MarchLod takes the analytic step, and AnalyticBodyMatchesFineMarch ties that step to the fixed-step product over the same cells",
|
||||
"one Bound serves the enclosure tests, the mask, the body proof and the gated probe; no second composition and no second constancy test",
|
||||
"the widening lives once in C# and is mirrored bit for bit into both HLSL copies by the existing mirror test; GLSL follows by the lowering (glsl-lowering r2 rebases onto this head)"
|
||||
],
|
||||
"deletion_line": "The false strictness claim leaves the C# doc, the HLSL and design.md before the widening is written; the Tint camera citation leaves before the Main Camera's is written; the (c) 2x assertion and the second shipped void leave before the ceiling contract and the one void are written. Net in src: one line and a doc comment. In tests: one new pin test, seven case families, one configuration, the body return; what they buy is intervals-enclose holding for the ball's own float32 evaluation, the analytic body the ruling asked for with its soundness pinned, LOD weights actually exercised, and a mutation report that claims only what it demonstrated."
|
||||
},
|
||||
"verification": {
|
||||
"builds": [
|
||||
"Yggdrasil: dotnet test packages/cultmath/tests/CultMath.Tests green (MeasureLipschitz/MeasureMonotonicity excluded by trait); paste counts and every IV-REPORT line, (a)-(d) at far 2048, the (e) grid with body steps, LOD-far, the shipped headline",
|
||||
"Yggdrasil: dotnet stryker --since:43183276 -m math.Interval.cs from a plain clone; every timeout hand-run; killed, survived and equivalent listed by diff; 0 undemonstrated",
|
||||
"Starfire: FXC cs_5_0 kernel over every cultmath_iv_* including cultmath_iv_frustum_ball, both HLSL copies, exit 0",
|
||||
"Starfire: tools/get-dxc.ps1 (pinned) then dxc -T lib_6_3 -HV 2021 on both HLSL copies, exit 0, no diagnostics"
|
||||
],
|
||||
"tests": [
|
||||
{
|
||||
"name": "NoiseBoundTests.TileBallEnclosesEveryRaySegment",
|
||||
"pins": "every exact point of every ray of the tile, corners at both depth ends with an outward flow, lies inside the float32 ball, in the r2 domain and in the seven extreme families, distance in double, no tolerance; the radius is the derivation plus at most 2^-19 (r + |c|_1) and at least the derivation."
|
||||
},
|
||||
{
|
||||
"name": "NoiseBoundTests.EnvelopeBoundEnclosesDensity",
|
||||
"pins": "the composed bound encloses the full and the footprint-truncated density at every warped point of 2,000 slices, including the LOD-far configuration where octave weights are strictly between 0 and 1 and at 0."
|
||||
},
|
||||
{
|
||||
"name": "NoiseBoundTests.AnalyticBodyMatchesFineMarch",
|
||||
"pins": "a body start is reported only where the Bound over the whole remaining range is a positive point and every sampled density there equals it exactly; the analytic factor equals the fixed-step product within 1e-4 relative; no (a)-(d) tile reports a body."
|
||||
},
|
||||
{
|
||||
"name": "NoiseBoundTests.IntervalSkipHalvesEvaluations",
|
||||
"pins": "(a) at least 2x combined cost; (c) at least 0.90 of its probe-free ceiling; (e) at the one shipped void at least 2x fewer LOD steps per pixel, the masked fixed-step march exact, the LOD march within 0.02; every row printed including body steps; one Skip naming saving-2x-scenarios on any shortfall."
|
||||
},
|
||||
{
|
||||
"name": "NoiseBoundTests.SnoiseBallEnclosesPoints and FbmBallEnclosesPoints",
|
||||
"pins": "the ball bounds enclose their functions for radii up to 1e3, so a bound tightened for r > 1 (L / r) dies to an enclosure test."
|
||||
},
|
||||
{
|
||||
"name": "IntervalTests.EveryOpEnclosesItsPoints (existing, plus point cases)",
|
||||
"pins": "r1's enclosure for every op still holds; any non-equivalent mirror-only mutant from Finding 5 has a point case that kills it."
|
||||
},
|
||||
{
|
||||
"name": "HlslSourceCompatibilityTests.EveryMirrorFunctionMatchesCSharpMath (existing)",
|
||||
"pins": "the widened cultmath_iv_frustum_ball matches iv_frustum_ball bit for bit; 53 functions compared."
|
||||
}
|
||||
],
|
||||
"negative": [
|
||||
{
|
||||
"pattern": "never parallel|cannot cross|strict",
|
||||
"scope": "math.Interval.cs iv_frustum_ball doc comment, CultMath.Interval.hlsl, design.md 'Intervals' frustum-ball paragraph: 0 matches."
|
||||
},
|
||||
{
|
||||
"pattern": "635-636|far 1000|Tint",
|
||||
"scope": "NoiseBoundTests.cs: 0 matches; ':31830' and '2048' appear in FogField's citation."
|
||||
},
|
||||
{
|
||||
"pattern": "Rc = 640|e = 4\\b|Rc ~ 220|e ~ 0\\.6|about 220|about 0\\.6",
|
||||
"scope": "NoiseBoundTests.cs and design.md: 0 matches; the shipped void is derived from (198, 50) as Rc = 248, e = 0.40."
|
||||
},
|
||||
{
|
||||
"pattern": "tape|prune|affine|choice|PowerPulse|pulse|envelope|Scenario|body|Beer",
|
||||
"scope": "math.Interval.cs and CultMath.Interval.hlsl: 0 matches (case-insensitive); the analytic body is consumer-side."
|
||||
},
|
||||
{
|
||||
"pattern": "Assert\\.Skip",
|
||||
"scope": "NoiseBoundTests.cs: at most one, in IntervalSkipHalvesEvaluations, naming saving-2x-scenarios; 0 if (a), (c) and (e) meet their contracts."
|
||||
},
|
||||
{
|
||||
"pattern": "10\\.099261|0\\.35f",
|
||||
"scope": "git diff 43183276..HEAD -- packages/cultmath/src: the constant is not touched; the slab's F0 stays in (d)."
|
||||
},
|
||||
{
|
||||
"pattern": "git diff --name-only 43183276..HEAD",
|
||||
"scope": "only packages/cultmath/**; both tracked CultMath.Interval.hlsl copies change together and stay one blob."
|
||||
},
|
||||
{
|
||||
"pattern": "git diff 43183276..HEAD -- packages/cultmath/src | grep '^[-+]' | grep -v '^[-+][-+]' | grep -v '///' | wc -l",
|
||||
"scope": "at most 4 changed code lines in src: the widening line in iv_frustum_ball and nothing else."
|
||||
}
|
||||
],
|
||||
"operator": []
|
||||
},
|
||||
"estimate": {
|
||||
"lines_added": 260,
|
||||
"lines_removed": 40,
|
||||
"dependencies_added": [],
|
||||
"dependencies_removed": [],
|
||||
"formats_added": [],
|
||||
"formats_removed": [],
|
||||
"targets_added": [],
|
||||
"targets_removed": []
|
||||
},
|
||||
"rulings": [
|
||||
"cultmath-tapes:ruling:operator-wells-contract-vs-ceiling",
|
||||
"cultmath-tapes:ruling:operator-analytic-wall-body",
|
||||
"cultmath-tapes:ruling:operator-tile-amortized-march",
|
||||
"cultmath-tapes:ruling:operator-measure-with-empty-space",
|
||||
"cultmath-tapes:ruling:operator-footprint-lod-march",
|
||||
"cultmath-tapes:ruling:operator-ground-void-brush",
|
||||
"cultmath-tapes:ruling:operator-void-true-cavity",
|
||||
"cultmath-tapes:ruling:operator-compiler-downloads",
|
||||
"cultmath-tapes:ruling:operator-campaign-home"
|
||||
],
|
||||
"questions": [
|
||||
"Q9 in the map: the one shipped void. (a, recommended) Rh 198, S 50, derived Rc 248, e 0.40, the scene r2 measured; (b) the site section's Rc 640, e 4; (c) Rh 198, S 100. Hands codes (a)."
|
||||
]
|
||||
}
|
||||
|
|
@ -1,292 +0,0 @@
|
|||
{
|
||||
"campaign": "cultmath-tapes",
|
||||
"cut": "interval-ops",
|
||||
"revision": 4,
|
||||
"title": "Interval ops r4: LOD agreement against a converged march at the body start, BodyDepth pinned, (c) against an oracle ceiling, WeightBound pinned, design.md from r4's own run",
|
||||
"repo": "GameCult/CultLib",
|
||||
"branch": "hands/cultmath-interval-ops",
|
||||
"base": "3ac7250a",
|
||||
"depends_on": [],
|
||||
"first": [
|
||||
"Follow-up on verdict cultmath-tapes:verdict:cut-interval-ops.s2 (range 43183276..3ac7250a, worktree F:\\Projects\\CultLib-iv), four findings, one item each below. Touches packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs and packages/cultmath/docs/design.md only: no src, no HLSL, no README. Question cultmath-tapes:question:wells-ceiling-oracle decides only (c)'s assertion lines (item 9). Self dispatches with its ruling in the brief. Line numbers are at 3ac7250a. Every probe number quoted here is from Imagination's scratch probe at 3ac7250a, with the test's own seeds, tiles and N = 8. It is never committed; r4's run reprints its own numbers.",
|
||||
"Finding lod-agreement-blind-past-body (High), measured. Comparing with the 10-unit fixed-step march cannot pin LodStep. At the shipped void, the LOD march's T at the body start differs from it by up to 0.300 of max(T, 0.02). LodStep doubled differs by 0.309. Both are unconverged midpoint quadratures of a ramp with 25-unit detail. A converged march separates them: h = 0.5, which agrees with h = 1 to 0.0065. Relative optical-depth error per ray, max at the headline: fixed-step 0.061, LOD 0.086, LodStep x2 0.177, LodStep x0.5 0.036. With the pre-body integration dropped, T is wrong by 26.5x max(T, 0.02). So the LOD march is 1.4x less accurate than the dense march before the body. Report it; it is not tuned.",
|
||||
"The agreement rule. MarchLod gains `float cutoff`; Run's costed march passes 0.02, as now. It returns (Transmittance, AtBody). AtBody is T when z first reaches the body start, before the body step, or the final T when there is no body. New helper ConvergedDepth(field, tile, slope, stop, h, lod) returns the optical depth in double, by midpoint quadrature at step h. It runs over EVERY grid cell before `stop`, never the mask's, so it trusts no pre-pass, using the field's Density.",
|
||||
"(Agreement, continued.) In Run with lod, per ray, with stop = bodyStart, or the cell count (grid.Length - 1) when there is none:\n- tauConv = ConvergedDepth(..., stop, 0.5, lod: true).\n- tauLod = -ln(max(AtBody, 1e-30)) from a second MarchLod at cutoff 0.\n- tauRef = -ln of the product of Integrate over cells < stop, lod: false, with no early-out.\nRunStats keeps MaxLodDepthError and MaxRefDepthError, each max |tau - tauConv| / max(tauConv, 1e-3). The converged march and the cutoff-0 march count into a throwaway Counts and no steps, so cost rows do not move.",
|
||||
"The (e) agreement contract replaces 'LOD within 0.02 at the end': at the shipped headline, MaxLodDepthError <= 2 x MaxRefDepthError. The footprint march may be at most twice as wrong as the dense march it is measured against. This mirrors the 2x step contract and is Imagination's default; Self may put it to the operator. Measured 0.086 <= 0.122. LodStep x2 and the dropped pre-body integration fail it. The end-of-ray assertion :1455 is deleted. End-of-ray |dT| stays printed. Every (e) row and LOD-far prints both errors and their ratio, which is printed and not asserted. LOD-far's tauRef carries the deliberate octave truncation, so its ratio is not comparable.",
|
||||
"BodyFactor becomes BodyDepth (:1198-1201). It returns the optical depth Density x ext x (grid end - z) x |dir| as a float. MarchLod multiplies by exp(-BodyDepth(...)). AnalyticBodyMatchesFineMarch part (2) deletes its own tau and analytic (:1375-1377). It compares BodyDepth(field, tile, ray, grid[b], lod: true, counts) with the double sum over body cells of -ln(Integrate(cell, lod: true)), within 1e-4 relative. Probe: in the T domain the comparison underflows at LOD-far (float exp of about -256 is 0, relative error 1); in the depth domain it does not. With |dir| dropped, the depth error is |dir| - 1, at least 0.00145 at the shipped camera, so the mutant dies on every tile.",
|
||||
"Finding wells-ceiling-contract-vacuous (Medium), measured at N = 8. Warp 0: ratio 2.744x, mask ceiling 2.855x (0.961), oracle ceiling 7.263x (0.378), oracle-empty cells skipped 0.952, skip-nothing pre-pass 0.136. Warp D: 1.391x, 1.401x (0.993), 7.165x (0.194), 0.842, 0.139. At warp D about 80% of the masked cost lies in cells where every ray's sample is 0, so design.md's 'where the rays need them' is false. The ruling's reason needs a ceiling the pre-pass cannot move. Hence the question.",
|
||||
"Measurement, whatever the ruling. Run draws a tile's N^2 slopes first, then marches. The random stream reorders, so every IV-REPORT row reprints. The oracle mask is the cells whose Density at mid-depth is > 0 for at least one of those slopes, evaluated into a throwaway Counts. The oracle march runs each ray over the oracle cells only, with the same Integrate and early-out, costed into a new RunStats.Oracle. Assert its T equals the dense march's exactly (probe max |dT| 0). RunStats.Ceiling (:1259-1260) goes. In its place: OracleCeiling = Dense.Cost / Oracle.Cost; Efficiency = Oracle.Cost / (Masked.Cost + Probes.Cost); ProbeOverhead = Masked.Cost / (Masked.Cost + Probes.Cost); CullFraction = oracle-empty cells the mask skips / oracle-empty cells. Report prints all four.",
|
||||
"(c)'s assertion, at N = 8, the lower of warp 0 and D, per the ruling on wells-ceiling-oracle. :1437 records the chosen value. For the cull floor, Assert.True fails rather than skips.\n- oracle-target-cull-floor (recommended): hard CullFraction >= 0.5, plus Efficiency >= 0.90 in the shortfall list.\n- oracle-target-only: the shortfall alone.\n- cull-fraction: CullFraction >= 0.90 in the shortfall list.\n- overhead-plus-floor: ProbeOverhead >= 0.90 in the shortfall list, plus the hard floor.\nThe contracts line names the measured quantity. Note: under options 1 and 2 the test skips today, naming saving-2x-scenarios with (c)'s shortfall; (a) and (e) are still evaluated and listed.",
|
||||
"Finding weightbound-mutation-survives (Low). VoidField gains public (float2 Coarse, float2 Fine) WeightBounds(float z0, float z1) beside Weights(z) (:992). WeightBound (:1035) stays private and is what WeightBounds calls. New Fact WeightBoundEnclosesOneSignedNoise on LodFar, 2,000 cases: z0 ~ U(1460, 0.95 x grid end), z1 ~ U(z0, grid end), 16 depths in [z0, z1] including both ends. Each octave weight lies in its bound. For the one-signed noise interval n = [0.25, 1] and n_s in {0.25, U(0.25, 1), 1}, w(z) n_s lies in iv_mul(bound, n). Probe: the spec's mutant [w(z0), 1] misses w(z1) x 0.25 in 2000 of 2000 fine-octave cases on (1460, 2920). The header (:14-20) moves that mutant to this test.",
|
||||
"Finding design-saving-section-stale (Medium). Rewrite design.md :475-512 from r4's final run, where every figure appears in an IV-REPORT line the report pastes. Delete :422-423's '6e-4 at the shipped scales' sentence, which no test prints. Contents:\n- the tile march and its marches;\n- (a)-(d) at far 2048, with (c)'s oracle ceiling, efficiency, cull fraction and probe overhead;\n- (e) at the shipped void, Rh 198, S 50, Rc = Rh + S, e = -ln 1.5 / ln(1 - (Rh/Rc)^2): steps, snoise, cost, body tiles, the two depth errors;\n- the analytic body (condition, one probe, one step, the pin);\n- the WeightBound [w(z1), 1] deviation and why it encloses;\n- the contracts as asserted, (c) in the ruling's words.\n'The test skips' appears only if this run skips. No r2 or r3 number survives.",
|
||||
"Site-ground's dense-reference browser check has the same blind spot (its converged mean luminance is compared after the walls' bodies). It is follow-up cultmath-tapes:follow_up:site-ground-dense-check-blind-past-body, owned by the next site-ground spec. Not this cut.",
|
||||
"Mutation evidence, cheap by design. src is untouched, so Stryker has nothing in scope and is not run; the negative check proves the premise. Run ONE tools/mutation-table.sh job on Yggdrasil against the final head, test filter FullyQualifiedName~NoiseBoundTests, with these rows:\n- MarchLod `transmittance *= Integrate(field, tile, slope, z, next, lod: true, counts);` -> `transmittance *= 1.0f;` must fail IntervalSkipHalvesEvaluations;\n- LodStep's return wrapped in `2.0f * (...)`: same test;\n- BodyDepth without its sqrt factor: AnalyticBodyMatchesFineMarch;\n- WeightBound `).x, 1.0f)` -> `).y, 1.0f)`: WeightBoundEnclosesOneSignedNoise;\n- MarchTile `if (bound.y <= 0.0f)` -> `if (bound.y <= 0.0f && !field.Name.Contains(\"wells\"))`: IntervalSkipHalvesEvaluations, under the floor options; a survivor under oracle-target-only, by that option's design;\n- Compose `CarveRadius` -> `HollowRadius` in the lo test: AnalyticBodyMatchesFineMarch (carried).\nNo per-mutant hand loops, no Starfire jobs.",
|
||||
"Budget: under 200k Hands tokens. Read NoiseBoundTests.cs :480-1469 and design.md :344-515 once; no other file. Verification is one full CultMath.Tests run on Yggdrasil after the code commits (paste counts and every IV-REPORT line), the mutation-table job, and one fix round. If the headline agreement fails at r4's own numbers, do not move the factor or the step: report the measured errors as a fork."
|
||||
],
|
||||
"deletes": [
|
||||
{
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"lines": 1,
|
||||
"note": ":1455 the end-of-ray assertion 'the footprint-aware march differs from the reference by'; blind past the body (finding lod-agreement-blind-past-body)."
|
||||
},
|
||||
{
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"lines": 4,
|
||||
"note": ":1198-1201 BodyFactor and its comment; replaced by BodyDepth, the one owner of the body's optical depth."
|
||||
},
|
||||
{
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"lines": 3,
|
||||
"note": ":1375-1377 the test's own tau, its exp and the T-domain comparison; replaced by BodyDepth against the cells' summed depth."
|
||||
},
|
||||
{
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"lines": 2,
|
||||
"note": ":1259-1260 RunStats.Ceiling and 'What no amortization can beat'; the mask-derived ceiling stops being a contract owner."
|
||||
},
|
||||
{
|
||||
"path": "packages/cultmath/docs/design.md",
|
||||
"lines": 38,
|
||||
"note": ":475-512 the saving section with r2's rows (1.41x, 16.45, 2.21x, 1.34x) and '(c) does not, so the test skips'."
|
||||
},
|
||||
{
|
||||
"path": "packages/cultmath/docs/design.md",
|
||||
"lines": 2,
|
||||
"note": ":422-423 'At the shipped scales (|c|_1 near 600, r near 5) it adds 6e-4.'; printed by no test."
|
||||
}
|
||||
],
|
||||
"keeps_moves": [
|
||||
"packages/cultmath/src, both CultMath.Interval.hlsl copies, README.md and every other test file: untouched.",
|
||||
"Fields, grids (6/K end), seeds, e = 0.2, LodStep, Density, Bound, Compose, MarchTile, Integrate, the masked fixed-step march's exactness asserts, the (a) and (e) step contracts and the Skip mechanism: unchanged.",
|
||||
"TileBallEnclosesEveryRaySegment, EnvelopeBoundEnclosesDensity, the Lipschitz tests, TightnessReport, IntervalTests, HlslSourceCompatibilityTests: unchanged.",
|
||||
"AnalyticBodyMatchesFineMarch parts (1) and (3): unchanged; part (2) changes domain and owner only.",
|
||||
"design.md :344-474 (Intervals up to the saving section, with the r3 frustum-ball paragraph minus the 6e-4 sentence) and every other section: unchanged."
|
||||
],
|
||||
"adds": [
|
||||
"NoiseBoundTests.cs: BodyDepth; MarchLod's cutoff and (Transmittance, AtBody) return; ConvergedDepth; Run's slope-first draw, oracle mask, oracle march and the cutoff-0 agreement march; RunStats Oracle, OracleCeiling, Efficiency, ProbeOverhead, CullFraction, MaxOracleDifference, MaxLodDepthError, MaxRefDepthError; Report and ReportVoid fields; the (e) depth contract; (c)'s assertion per the ruling.",
|
||||
"NoiseBoundTests.cs: VoidField.WeightBounds(z0, z1); Fact WeightBoundEnclosesOneSignedNoise; the header's mutation list updated.",
|
||||
"design.md: the saving section rewritten from r4's run."
|
||||
],
|
||||
"file_changes": [
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 14,
|
||||
"end_line": 20
|
||||
},
|
||||
"change": "Header mutation list: w(z0) for w(z1) now fails WeightBoundEnclosesOneSignedNoise. Add: the dropped pre-body integration and LodStep x2 fail IntervalSkipHalvesEvaluations's depth agreement; BodyDepth without |dir| fails AnalyticBodyMatchesFineMarch."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 992
|
||||
},
|
||||
"change": "Beside Weights(z): public (float2 Coarse, float2 Fine) WeightBounds(float z0, float z1) => (WeightBound(CoarseWavelength, z0, z1), WeightBound(FineWavelength, z0, z1))."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 1198,
|
||||
"end_line": 1201
|
||||
},
|
||||
"change": "BodyFactor -> BodyDepth(Scenario field, Tile tile, float2 slope, float z, bool lod, Counts counts): the float optical depth Density x Extinction x (Grid[^1] - z) x sqrt(m.x^2 + m.y^2 + 1), with no exp."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 1205,
|
||||
"end_line": 1235
|
||||
},
|
||||
"change": "MarchLod(..., float cutoff, ref long steps, ref long bodySteps) returns (float Transmittance, float AtBody). Both loop guards use cutoff. At z >= body: AtBody = transmittance, then transmittance *= exp(-BodyDepth(...)), steps++, bodySteps++, return. Without a body, AtBody = final T."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 1236
|
||||
},
|
||||
"change": "New ConvergedDepth(Scenario field, Tile tile, float2 slope, int stop, float h, bool lod): sum over cells c < stop of ceil(len / h) midpoint samples, Density(field, tile, slope, z, lod, throwaway) x Extinction x dz x |dir| in double. Every cell, not the mask's."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 1237,
|
||||
"end_line": 1265
|
||||
},
|
||||
"change": "RunStats:\n- Counts Oracle; OracleEmpty and OracleEmptySkipped; MaxOracleDifference; MaxLodDepthError; MaxRefDepthError.\n- OracleCeiling, Efficiency, ProbeOverhead and CullFraction as the spec's 'first' defines them.\n- Ceiling deleted.\nRatio and LodRatio are unchanged."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 1270,
|
||||
"end_line": 1305
|
||||
},
|
||||
"change": "Run, per tile:\n- MarchTile.\n- Draw N^2 slopes.\n- Oracle mask over all cells (mid-depth Density > 0 for any slope; throwaway Counts); count oracle-empty cells and those the mask skips.\n- Per ray: dense, masked and oracle marches (oracle into stats.Oracle; MaxOracleDifference).\n- With lod: the costed MarchLod (cutoff 0.02), then a cutoff-0 MarchLod and ConvergedDepth at h 0.5 into throwaway Counts, and tauRef with no early-out; update both depth errors."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 1307,
|
||||
"end_line": 1318
|
||||
},
|
||||
"change": "Report prints oracle ceiling, efficiency, cull fraction (with counts), probe overhead and max oracle |dT| in place of 'ceiling'. ReportVoid adds the depth errors of LOD and fixed-step against the converged march and their ratio; the end-of-ray |dT| stays."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 1332
|
||||
},
|
||||
"change": "AnalyticBodyMatchesFineMarch part (2): depth = BodyDepth(field, tile, ray, grid[b], lod: true, counts); sum = the double sum over c in [b, last) of -Math.Log(Integrate(field, tile, ray, grid[c], grid[c + 1], lod: true, counts)); assert |depth - sum| <= 1e-4 x sum, naming field, b and both values. The doc comment says so."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 1430,
|
||||
"end_line": 1440
|
||||
},
|
||||
"change": "(a)-(d) loop: Assert MaxOracleDifference == 0 beside the existing <= 1e-3. :1437 records the ruling's (c) quantity (Efficiency, CullFraction or ProbeOverhead), the lower of warp 0 and D at N = 8, and the CullFraction floor value when the ruling has one."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 1453,
|
||||
"end_line": 1466
|
||||
},
|
||||
"change": "Headline:\n- MaxDifference == 0 stays.\n- :1455 is replaced by Assert MaxLodDepthError <= 2 x MaxRefDepthError, with both values in the message.\n- Under a floor option: Assert CullFraction >= 0.5 for (c) at both warps (hard).\n- Shortfalls: (c) per the ruling.\n- The contracts line names the quantity and prints the (e) depth errors."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 1402,
|
||||
"end_line": 1414
|
||||
},
|
||||
"change": "IntervalSkipHalvesEvaluations doc comment:\n- the (e) agreement is the converged depth, 2x the fixed-step's;\n- (c) per the ruling, citing it;\n- the oracle is defined;\n- 'within 0.02' leaves."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 304
|
||||
},
|
||||
"change": "After EnvelopeBoundEnclosesDensity: Fact WeightBoundEnclosesOneSignedNoise, as item 10 of 'first'. Its message names z0, z1, z, the octave, the weight and the bound."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/docs/design.md",
|
||||
"line": 475,
|
||||
"end_line": 512
|
||||
},
|
||||
"change": "The saving section is rewritten from r4's IV-REPORT lines (contents in item 11 of 'first'). The next paragraph, 'Consumers: ...' at :514, is kept."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/docs/design.md",
|
||||
"line": 422,
|
||||
"end_line": 423
|
||||
},
|
||||
"change": "Delete the sentence 'At the shipped scales (|c|_1 near 600, r near 5) it adds 6e-4.' Leave the rest of the paragraph."
|
||||
}
|
||||
],
|
||||
"authority_map": {
|
||||
"owner": "The saving test owns the proving-ground numbers and their oracles. The converged march (h = 0.5) is the accuracy oracle before the body. The oracle mask (cells some ray of the tile samples nonzero) is (c)'s ceiling oracle. BodyDepth is the one owner of the body's optical depth, in the march and in its pin. CultMath src is untouched. The operator owns the contracts: the rulings and the answer to wells-ceiling-oracle.",
|
||||
"inputs": [
|
||||
"the r3 fields, grids, seeds, tiles and marches at 3ac7250a",
|
||||
"the ruling answering cultmath-tapes:question:wells-ceiling-oracle"
|
||||
],
|
||||
"outputs": [
|
||||
"IV-REPORT rows: the LOD and fixed-step depth errors against the converged march; (c)'s oracle ceiling, efficiency, cull fraction and probe overhead",
|
||||
"design.md's saving section, every figure from those rows"
|
||||
],
|
||||
"derived_state": [
|
||||
"(c)'s ceiling is no longer derived from the pre-pass's own mask; it is derived from the oracle mask, which the pre-pass cannot move.",
|
||||
"Dense/Masked is no longer a contract owner; Masked/(Masked+Probes) is printed as probe overhead (an owner only under overhead-plus-floor).",
|
||||
"The fixed-step march is no longer the LOD march's accuracy oracle; it is a peer measured against the converged march, and its error sets the LOD's allowance (2x).",
|
||||
"End-of-ray |dT| is display-only.",
|
||||
"The body's optical depth is derived once, by BodyDepth; the test no longer re-derives it."
|
||||
],
|
||||
"forbidden_writers": [
|
||||
"no assertion compares the LOD march with the fixed-step march directly, or after the body",
|
||||
"AnalyticBodyMatchesFineMarch does not compute the body's depth itself; it calls BodyDepth",
|
||||
"neither the oracle mask nor ConvergedDepth reads MarchTile's output",
|
||||
"no change to src, the HLSL, the fields, grids, LodStep, seeds, the 0.2 weight or the 2x factor toward a contract",
|
||||
"no figure in design.md that this run's IV-REPORT lines do not print"
|
||||
],
|
||||
"shared_paths": [
|
||||
"MarchLod is one function for the costed march (cutoff 0.02) and the agreement march (cutoff 0)",
|
||||
"one Run computes dense, masked, oracle, LOD and converged for every scenario; the headline and the grid print the same fields",
|
||||
"the site's mirror of the march inherits the same check through follow-up site-ground-dense-check-blind-past-body"
|
||||
],
|
||||
"deletion_line": "The end-of-ray LOD assertion, BodyFactor, the test's own tau, RunStats.Ceiling, the (c) Ratio/Ceiling special case, design.md's r2 rows and the unprinted 6e-4 sentence all leave before their replacements are written. Net: about +170 / -60 in tests and docs, nothing in src. What that buys: (e) can now fail on a wrong pre-body march, the body depth can fail on a wrong |dir|, (c) can fail on a pre-pass that proves nothing, and the WeightBound mutant dies."
|
||||
},
|
||||
"verification": {
|
||||
"builds": [
|
||||
"Yggdrasil: dotnet test packages/cultmath/tests/CultMath.Tests -c Release green or skipped only by saving-2x-scenarios as the ruling implies (MeasureLipschitz/MeasureMonotonicity excluded by trait); paste counts and every IV-REPORT line",
|
||||
"Yggdrasil: one tools/mutation-table.sh job at the final head with the six rows in 'first' item 13; the table pasted; every row CAUGHT by the named test except the wells row under oracle-target-only",
|
||||
"No Stryker run and no Starfire job: no src or HLSL change in 3ac7250a..HEAD (negative check)"
|
||||
],
|
||||
"tests": [
|
||||
{
|
||||
"name": "NoiseBoundTests.IntervalSkipHalvesEvaluations",
|
||||
"pins": "At the shipped void the footprint march's optical depth at the body start is within 2x the fixed-step march's error of a converged march; the oracle march equals the dense march exactly; (c) is asserted per the ruling against a ceiling the pre-pass cannot move; (a) and (e) step contracts as before."
|
||||
},
|
||||
{
|
||||
"name": "NoiseBoundTests.AnalyticBodyMatchesFineMarch",
|
||||
"pins": "BodyDepth, the depth MarchLod uses, equals the summed fixed-step depth of the body cells within 1e-4 relative, in the depth domain (no underflow), so a body depth without |dir| fails on every tile; parts (1) and (3) unchanged."
|
||||
},
|
||||
{
|
||||
"name": "NoiseBoundTests.WeightBoundEnclosesOneSignedNoise",
|
||||
"pins": "Each octave weight at every sampled depth in [z0, z1] lies in WeightBounds(z0, z1), and w(z) n lies in iv_mul(bound, n) for a one-signed n, so a lower end above w(z1) fails."
|
||||
},
|
||||
{
|
||||
"name": "NoiseBoundTests (rest) and CultMath.Tests (rest)",
|
||||
"pins": "unchanged and green."
|
||||
}
|
||||
],
|
||||
"negative": [
|
||||
{
|
||||
"pattern": "git diff --name-only 3ac7250a..HEAD",
|
||||
"scope": "exactly NoiseBoundTests.cs and design.md under packages/cultmath."
|
||||
},
|
||||
{
|
||||
"pattern": "git diff 3ac7250a..HEAD -- packages/cultmath/src packages/cultmath/shaders packages/cultmath/unity",
|
||||
"scope": "empty."
|
||||
},
|
||||
{
|
||||
"pattern": "BodyFactor|Ceiling =>|\\.Ceiling\\b|MaxLodDifference <= ",
|
||||
"scope": "NoiseBoundTests.cs: 0 matches."
|
||||
},
|
||||
{
|
||||
"pattern": "Math\\.Exp\\(-\\(double\\)tau\\)|var tau = density",
|
||||
"scope": "NoiseBoundTests.cs: 0 matches (the test does not re-derive the body)."
|
||||
},
|
||||
{
|
||||
"pattern": "1\\.41x|16\\.45|2\\.21x|1\\.34x|6e-4|does not, so the test skips|where the rays need them",
|
||||
"scope": "packages/cultmath/docs/design.md: 0 matches."
|
||||
},
|
||||
{
|
||||
"pattern": "Assert\\.Skip",
|
||||
"scope": "NoiseBoundTests.cs: at most one, in IntervalSkipHalvesEvaluations, naming saving-2x-scenarios."
|
||||
}
|
||||
],
|
||||
"operator": []
|
||||
},
|
||||
"estimate": {
|
||||
"lines_added": 170,
|
||||
"lines_removed": 60,
|
||||
"dependencies_added": [],
|
||||
"dependencies_removed": [],
|
||||
"formats_added": [],
|
||||
"formats_removed": [],
|
||||
"targets_added": [],
|
||||
"targets_removed": []
|
||||
},
|
||||
"rulings": [
|
||||
"cultmath-tapes:ruling:operator-wells-contract-vs-ceiling",
|
||||
"cultmath-tapes:ruling:operator-analytic-wall-body",
|
||||
"cultmath-tapes:ruling:operator-footprint-lod-march",
|
||||
"cultmath-tapes:ruling:operator-measure-with-empty-space",
|
||||
"cultmath-tapes:ruling:operator-tile-amortized-march",
|
||||
"cultmath-tapes:ruling:operator-shipped-void"
|
||||
],
|
||||
"questions": [
|
||||
"cultmath-tapes:question:wells-ceiling-oracle"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
{
|
||||
"campaign": "cultmath-tapes",
|
||||
"cut": "interval-ops",
|
||||
"revision": 1,
|
||||
"title": "Interval ops: cultmath_iv_* over float2(lo, hi) in C# and HLSL with enclosure tests, a committed Lipschitz constant for snoise, and ball bounds for snoise and fbm",
|
||||
"repo": "GameCult/CultLib",
|
||||
"branch": "hands/cultmath-interval-ops",
|
||||
"base": "c49c76ee",
|
||||
"depends_on": [],
|
||||
"first": [
|
||||
"Map: docs/cultmath-interval-ground-cut.md, sections 'Ownership', 'Interval culling along the ray' and 'The proving-ground contract'. Parked target: docs/cultmath-tape-target.md, sketch step 1 (:59-64) and 'Open before unparking' (:106-115). This cut is step 1 and nothing after it: no tape, no pruning, no min/max choice tracking, no affine arithmetic (map D1).",
|
||||
"Common rules from the Asura cut map (Asura docs/cut-map.md, 'Common rules for every addition', :453-489): C# in packages/cultmath/src/CultMath as a partial of math (the Phacelle pattern: math.Phacelle.cs beside math.cs), HLSL in a new shaders/CultMath.Interval.hlsl included from CultMath.hlsl the way CultMath.Phacelle.hlsl is (:506), names prefixed cultmath_, both tracked HLSL copies one blob, bodies inside design.md's transformation list (:92-134), THIRD-PARTY-NOTICES untouched (nothing is ported; Fidget's ideas are used, not its code).",
|
||||
"Representation: an interval is float2(lo, hi), lo <= hi, both finite. The empty interval does not exist; a caller that needs 'provably empty' tests hi < cutoff. Point intervals are iv_point(x) = float2(x, x).",
|
||||
"Op set, each with the C# twin the mirror test demands: iv_point, iv_add, iv_sub, iv_neg, iv_mul, iv_scale(iv, float), iv_abs, iv_min, iv_max, iv_sqr, iv_sqrt (lo clamped at 0), iv_exp, iv_clamp(iv, float, float), iv_saturate, iv_smoothstep(float e0, float e1, iv) (monotone: endpoints), iv_lerp(iv, iv, float t). No others until a consumer names one.",
|
||||
"Noise bounds: CULTMATH_SNOISE_LIPSCHITZ (static const float, provenance in the comment: measured max |snoise_grad| over 1e6 seeded points plus 1e4 gradient-ascent refinements, times 1.10; the test that measures it is committed as a slow, explicitly-run test and the constant is pinned by a fast test). iv_snoise_ball(float3 c, float r) = [snoise(c) - L r, snoise(c) + L r] intersected with [-1, 1]. iv_fbm_ball(c, r, octaves, lacunarity, gain) = the octave sum of iv_snoise_ball(c * f_i, r * f_i) scaled by a_i, octaves clamped to [0, 16] like fbm_grad.",
|
||||
"Write the enclosure property first, as the shared harness: for each op, 10,000 seeded random intervals (widths from 1e-4 to 10, centres in [-20, 20]) and 16 points inside each; assert f(x) in op(I) with no tolerance (the bound is computed in the same float32 arithmetic; where rounding could exclude an endpoint, widen the op by one ulp in the implementation, not the test, and say so in design.md). For the ball bounds: 2,000 balls, r in [1e-3, 2], 64 points inside each.",
|
||||
"The saving test: a representative coverage field (one snoise octave at F0 = 0.35, cutoff 0.25) and 1,000 seeded rays through a slab of depth 12; march once at the dense step and once with the adaptive interval march from the map (Δ0, ΔMAX = 8 Δ0, ΔDENSE = Δ0/2, grow on skip, halve on maybe); count snoise evaluations; assert the integrated transmittance agrees within 1e-3 and the evaluation count is at least 2x lower. Print both counts and the ratio; the cut report quotes them.",
|
||||
"The warp enclosure test (the site's march rests on it): for 2,000 seeded (centre c, radius r, warp bound D) triples and random flow vectors with |flow| <= D, every warped point c' = x + flow (x in the ball of radius r) lies in the ball of radius r + D, and snoise(c') lies in iv_snoise_ball(c, r + D). This is a property of the existing bound, not a new function; it is written as a named test so the domain-warp use is pinned and documented in design.md.",
|
||||
"The saving test's field is domain-warped: coverage = sum over two phases of snoise(p + flow * (phase_k - 0.5) * PERIOD) with a bounded flow, matching the map's field shape, so the interval radius includes D.",
|
||||
"Tightness: a committed test prints the mean width ratio (interval width / true range over the 64 points) for iv_snoise_ball at r = 0.05, 0.25, 1.0 and for iv_fbm_ball at 4 octaves. No assertion beyond finiteness; the number is for the report and for a later affine cut to beat.",
|
||||
"Verification on Yggdrasil: dotnet test from packages/cultmath/tests/CultMath.Tests; Stryker --since:c49c76ee from a plain clone. Starfire: FXC cs_5_0 kernel calling every iv_* function and both ball bounds; dxc -T lib_6_3 -HV 2021. Detached with a log, polled in the foreground; never a background shell task. Numbers in comments or docs come from committed tests only.",
|
||||
"If glsl-lowering has merged to main before this branch does, rebase and regenerate shaders/CultMath.glsl (CULTMATH_WRITE_GLSL=1) so the pin test is green on the merge; if not, the lowering cut regenerates after this one. Either order works because the GLSL is derived."
|
||||
],
|
||||
"deletes": [],
|
||||
"keeps_moves": [
|
||||
"snoise, snoise_grad, fbm_grad, ridged_grad, cellular, phacelle: untouched. The bounds call snoise (value) only; snoise_grad is used by the Lipschitz measurement test, not by any bound (map D2).",
|
||||
"design.md's invariant 8 section (136-) is unchanged; intervals are not value-and-gradient primitives and do not claim the float4(grad, value) layout.",
|
||||
"HlslMirrorTests' ban on erosion kernels stands; nothing named interval touches it."
|
||||
],
|
||||
"adds": [
|
||||
"packages/cultmath/src/CultMath/math.Interval.cs: public static partial class math with iv_point, iv_add, iv_sub, iv_neg, iv_mul, iv_scale, iv_abs, iv_min, iv_max, iv_sqr, iv_sqrt, iv_exp, iv_clamp, iv_saturate, iv_smoothstep, iv_lerp, iv_snoise_ball, iv_fbm_ball, and the constant SNOISE_LIPSCHITZ; each with a doc comment stating the enclosure it guarantees and the ulp widening where one is needed.",
|
||||
"packages/cultmath/shaders/CultMath.Interval.hlsl: the HLSL mirrors with the same names prefixed cultmath_, and CULTMATH_SNOISE_LIPSCHITZ as static const; included from CultMath.hlsl after the Phacelle include (:506). Both tracked HLSL copies.",
|
||||
"tests/CultMath.Tests/IntervalTests.cs: the enclosure harness over every op; iv_mul sign cases (all four quadrant combinations and a zero-straddling factor); iv_sqrt with lo < 0; iv_abs straddling zero gives [0, max(|lo|,|hi|)]; iv_smoothstep monotonicity; iv_min/iv_max against the pointwise min/max; a mutant that shrinks any bound by one ulp dies (hand-mutation list in the file header for the float arithmetic Stryker cannot reach, as in 2a-ii-a).",
|
||||
"tests/CultMath.Tests/NoiseBoundTests.cs: iv_snoise_ball and iv_fbm_ball enclosure; LipschitzConstantPinsSampledGradients (fast, 1e5 points); MeasureLipschitz (slow, explicit trait, prints the max and the refined max); TightnessReport; the saving test (InvalSkipHalvesEvaluations) with its printed counts.",
|
||||
"packages/cultmath/docs/design.md: a section 'Intervals' after the invariant 8 section: representation, the op list, the ulp-widening rule, the Lipschitz provenance and margin, what the ball bounds guarantee and what they do not (nothing about tapes), and that the site's ground shader and Aetheria's nebula raymarch are the consumers.",
|
||||
"packages/cultmath/README.md: one paragraph under the noise entry naming the interval family and its purpose (empty-space culling with a proof)."
|
||||
],
|
||||
"file_changes": [
|
||||
{
|
||||
"location": { "path": "packages/cultmath/shaders/CultMath.hlsl", "line": 506 },
|
||||
"change": "add #include \"CultMath.Interval.hlsl\" after the Phacelle include; both tracked copies."
|
||||
},
|
||||
{
|
||||
"location": { "path": "packages/cultmath/src/CultMath/math.cs", "line": 602 },
|
||||
"change": "no change to snoise; a comment pointer from snoise to SNOISE_LIPSCHITZ in math.Interval.cs so the next kernel change knows the constant must be re-measured."
|
||||
},
|
||||
{
|
||||
"location": { "path": "packages/cultmath/docs/design.md", "line": 136 },
|
||||
"change": "new 'Intervals' section after invariant 8."
|
||||
},
|
||||
{
|
||||
"location": { "path": "packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs", "line": 348 },
|
||||
"change": "none expected; confirm the mirror resolves the second #include and that every cultmath_iv_* finds its C# twin (a mirror without a twin fails Assert.Contains MirrorOnly)."
|
||||
}
|
||||
],
|
||||
"authority_map": {
|
||||
"owner": "CultMath owns interval arithmetic and the noise bounds in C# and HLSL (and GLSL by derivation). The Lipschitz constant is owned by the committed measurement test; the kernel of snoise owns its value.",
|
||||
"inputs": [
|
||||
"the existing snoise(float3) value function; snoise_grad for the measurement test only",
|
||||
"seeded random points and intervals in tests"
|
||||
],
|
||||
"outputs": [
|
||||
"cultmath_iv_* and the two ball bounds, each enclosing its function over its input region",
|
||||
"three printed numbers for the report: the Lipschitz max, the tightness ratios, the evaluation saving"
|
||||
],
|
||||
"derived_state": [
|
||||
"CULTMATH_SNOISE_LIPSCHITZ is derived from the snoise kernel; a kernel change must re-run MeasureLipschitz and re-pin",
|
||||
"shaders/CultMath.glsl (if the lowering has landed) is derived and regenerated, never edited"
|
||||
],
|
||||
"forbidden_writers": [
|
||||
"no interval op without the enclosure test and a C# twin",
|
||||
"no bound that reads snoise_grad; no value-only/gradient twin of any iv_* function",
|
||||
"no tape, bytecode, pruning, choice flag or affine form in this cut; names containing tape, prune or affine do not appear",
|
||||
"no number in a comment or design.md that a committed test did not print"
|
||||
],
|
||||
"shared_paths": [
|
||||
"the same IntervalTests enclosure harness runs every op; a new op is one entry in its table",
|
||||
"the existing HLSL mirror test covers every HLSL interval function with no new comparison path"
|
||||
],
|
||||
"deletion_line": "Nothing is deleted. The liability is one C# file, one HLSL file, two test files and a doc section; it buys the invariant intervals-enclose for every GPU consumer, and it is step 1 of the parked tapes campaign landed in advance."
|
||||
},
|
||||
"verification": {
|
||||
"builds": [
|
||||
"Yggdrasil: dotnet test packages/cultmath/tests/CultMath.Tests green (MeasureLipschitz excluded by trait); paste counts",
|
||||
"Yggdrasil: dotnet stryker --since:c49c76ee from a plain clone; 0 survivors in math.Interval.cs except listed equivalents; hand mutations for float3 arithmetic listed and killed",
|
||||
"Starfire: FXC cs_5_0 kernel over every new function; dxc -T lib_6_3 -HV 2021 on both HLSL copies"
|
||||
],
|
||||
"tests": [
|
||||
{
|
||||
"name": "IntervalTests.EveryOpEnclosesItsPoints",
|
||||
"pins": "intervals-enclose for every op, no tolerance."
|
||||
},
|
||||
{
|
||||
"name": "NoiseBoundTests.SnoiseBallEnclosesPoints / FbmBallEnclosesPoints",
|
||||
"pins": "the ball bounds enclose snoise and fbm over 2,000 balls x 64 points."
|
||||
},
|
||||
{
|
||||
"name": "NoiseBoundTests.WarpedPointsStayEnclosed",
|
||||
"pins": "a domain warp bounded by D is enclosed by enlarging the ball radius by D; the site's interval march is sound."
|
||||
},
|
||||
{
|
||||
"name": "NoiseBoundTests.LipschitzConstantPinsSampledGradients",
|
||||
"pins": "no sampled |snoise_grad| exceeds the constant; a constant lowered by 1% fails."
|
||||
},
|
||||
{
|
||||
"name": "NoiseBoundTests.IntervalSkipHalvesEvaluations",
|
||||
"pins": "the adaptive march needs <= half the evaluations of the dense march for the same transmittance within 1e-3."
|
||||
},
|
||||
{
|
||||
"name": "HlslSourceCompatibilityTests.EveryMirrorFunctionMatchesCSharpMath (existing)",
|
||||
"pins": "every cultmath_iv_* and both bounds match C# bit for bit."
|
||||
}
|
||||
],
|
||||
"negative": [
|
||||
{
|
||||
"pattern": "tape|prune|affine|choice",
|
||||
"scope": "packages/cultmath/src/CultMath/math.Interval.cs and shaders/CultMath.Interval.hlsl: 0 matches (case-insensitive)."
|
||||
},
|
||||
{
|
||||
"pattern": "snoise_grad|fbm_grad",
|
||||
"scope": "math.Interval.cs and CultMath.Interval.hlsl: 0 matches; the bounds use values only."
|
||||
},
|
||||
{
|
||||
"pattern": "spherical_erosion|AdvancedErosionFilter",
|
||||
"scope": "packages/cultmath: 0 matches (HlslMirrorTests' standing rule)."
|
||||
},
|
||||
{
|
||||
"pattern": "git diff --name-only c49c76ee..HEAD",
|
||||
"scope": "only packages/cultmath/** (and shaders/CultMath.glsl if regenerated); no other package or src tree."
|
||||
}
|
||||
],
|
||||
"operator": []
|
||||
},
|
||||
"estimate": {
|
||||
"lines_added": 900,
|
||||
"lines_removed": 0,
|
||||
"dependencies_added": [],
|
||||
"dependencies_removed": [],
|
||||
"formats_added": [],
|
||||
"formats_removed": [],
|
||||
"targets_added": [],
|
||||
"targets_removed": []
|
||||
},
|
||||
"rulings": [],
|
||||
"questions": []
|
||||
}
|
||||
|
|
@ -1,246 +0,0 @@
|
|||
{
|
||||
"campaign": "cultmath-tapes",
|
||||
"cut": "site-ground",
|
||||
"revision": 3,
|
||||
"title": "Site ground r3: A cloud volume carved by one negative pseudo-Gaussian sphere, a sun at the hollow's bottom lights the walls and orbits, the camera follows low inside; pre-pass, camera-aware TAA",
|
||||
"repo": "GameCult/gamecult-site",
|
||||
"branch": "main",
|
||||
"base": "535d170",
|
||||
"depends_on": [
|
||||
"site-masthead:cut_spec:wash-home-only.r1",
|
||||
"cultmath-tapes:cut_spec:glsl-lowering.r2",
|
||||
"cultmath-tapes:cut_spec:interval-ops.r3"
|
||||
],
|
||||
"first": [
|
||||
"Map: F:/Projects/CultLib/docs/cultmath-interval-ground-cut.md, 'Pass 3' (the void scene, the composed bound, the pre-pass, the budget), then 'The shader design' for what pass 3 does not restate (sampled wash, palette input, pausing, contrast). Rulings in force: cultmath-tapes:ruling:operator-ground-void-brush (the scene), operator-tile-amortized-march (the march), site-masthead:ruling:operator-chromeless-column; wash-home-only's authority map. Re-pin base to main after wash-home-only lands. Stardust and DoF stay the next cut; leave the named composite slot. r2's token, readback, controller, vendoring, parity and fallback items stand unless this revision names them.",
|
||||
"The original, read once (Aetheria at e6181c11, 2021-03-13, Assets/Scenes/Main Menu.unity; Prefabs/RPG/Planets/Sun.prefab; Materials/Brushes/Gravity Well.mat, Main Menu Sun Fog Tint.mat, Main Menu Ambient Fog Tint.mat, Sector Boundary Fog.mat; Materials/Fog Marching Main Menu.mat; Shaders/Raymarching/VolumeRender.shader at that commit; Shaders/Brushes/PowerBrush.shader). What survives, in the original's units: the brush is PowerBrush (1 - x^2)^16 with depth 40 over radius 256 (half depth at r = 52, pseudo-Gaussian sigma about 45), the sun's own gravity-well child; a wide boundary bowl (depth 75, power 0.25, radius 1500) is flat across the scene and acts as a constant 75; floor offset -16.46, floor blend 39.6, so the fog top is at y = -91 outside the void and dips to -131 at the sun; the sun sphere (radius 12.5) sits at y = -130, the bottom of the void.",
|
||||
"What survives (continued): the sun is a child of 'Center', which rotates 5 degrees per second about y (72 s period), at orbit radius 175; the camera is a Cinemachine vcam following the sun (framing transposer, distance 150, damping 10, handheld noise amplitude 1 at frequency 0.25) and looking at 'Look Target' (0, -130, 0), the orbit centre at the sun's depth; the saved camera sits at y = -27, 64 above the fog top. The lighting was authored, not marched: the sun's 'Fog Tint' brush (1 - x^2)^3, depth 2, radius 128, colour (1, 0.4, 0.1) orange, painted into the tint map, times a scene-wide ambient tint (0.18, 0.59, 1) blue, depth 0.01. Stardust: 256^2 particles, spacing 2, floor -60, ceiling 0, height exponent 800 / (ceiling + wellDepth) so particles climb higher where the well is deeper (the void fills with stardust). Noise: strength 150, frequency 0.01, safety 50. Not surviving: the 2021 d() details, the tint map's runtime contents, the video.",
|
||||
"The field (ruling operator-void-true-cavity: a spherical hollow inside a cloud volume that surrounds it on every side, not the 2021 bowl). Units are the original's. Outer volume: an unbounded uniform fill, chosen over a shell because every ray then ends in a wall and the march terminates by opacity, never by a far plane; Z1 = |camera - c_v| + Rc + 6/K (614 at the shipped point) bounds the pre-pass range and is past opacity for every ray, so the analytic body step below always terminates. The carve is the pseudo-Gaussian brush g(d) = (1 - (d / Rc)^2)^e, d = |p - c_v|, density = K max(0, 1 - CARVE g(d')), authored by two knobs: the hollow radius Rh (density exactly 0 inside; Rh = Rc sqrt(1 - CARVE^(-1/e))) and the ramp width S (density full at Rh + S; set by the exponent: e = 4 gives S = 1.3 Rh, e = 0.25 gives S = 0.12 Rh).",
|
||||
"Shipped (Q9 in the map, option (a) pending the operator): Rh = 198, S = 50, so Rc = Rh + S = 248 and e = -ln CARVE / ln(1 - (Rh / Rc)^2) = 0.40; CARVE = 1.5, K = 1/30: a wall is opaque within about 150 units of full density. The ramp and the noise fade band are both S wide; nothing void-shaped is authored twice (interval-ops r3, finding shipped-void-two-scenes). Noise displaces the wall only: fade(d) = smoothstep(Rh - S, Rh, d), d' = d + A fade(d) n(warp(p)), A = 20, F0 = 0.01.",
|
||||
"Scene motion. The sun orbits the origin at radius 175 in xz with period 72 s (5 degrees per second, the original's Center pivot). The cavity centre is c_v = sun + (0, Rh - 12.5, 0): the sun sphere (radius 12.5) rests on the hollow's bottom and the hollow moves with it. The camera follows the sun 120 behind it along the orbit's tangent and 45 above it, so it sits 186 from c_v, 12 inside the hollow (the handheld drift is 2 units, two low-frequency snoise on time), low in the cavity, looking at the sun's position plus (0, 60, 0) so the lit lower wall and the dark upper wall share the frame. The 2021 follow distance 150 and height 25 do not fit a hollow a camera can stand in; 120 and 45 are the re-derivation, and the report says so. worldAt(t) in ground.js is the one owner of sun, cavity and camera, uploaded as uniforms; the shaders never integrate motion.",
|
||||
"The flow field, authored around the void: flow(xz) = SWIRL * tangent(xz - sun.xz) * (1 - (r / Rw)^2)^2 + GLOBAL * (snoise(xz Ff + o1), snoise(xz Ff + o2)) (the swirl is largest mid-slope and zero at the rim and the centre; the global term is r2's). The flow texture is a world-space window of side 4 Rw centred on the sun, quarter of the canvas resolution, re-rendered every frame; warp, hue, motion vectors and (next cut) the stardust read it, so D4 holds with a moving scene. D = FLOW_MAX * PERIOD / 2 as before, with FLOW_MAX the authored maximum of the swirl plus the global amplitude.",
|
||||
"The march (tile pre-pass, interval-ops r2's design). Pre-pass at W/8 x H/8: per tile, the loop over the slab's cell ranges (uniform cells, StepDense = min(10, S / 4) = 10, 62 cells to Z1 = 614 along +view depth; the mask has 96 bits): the envelope probe first (exact box: y and xz ranges by cultmath_iv_mul of the depth and slope intervals, d's interval is the box's least and greatest distance to c_v, exact; g, fade and the carve are monotone in it), then the noise ball (cultmath_iv_frustum_ball with D, cultmath_iv_snoise_ball) only when fade.hi > 0 and L r F0 leaves room to prove anything; writes the 96-bit mask of cells not proven empty to an RGBA32UI texel's first three words, and in the fourth word the body-start cell: the first cell whose own bound is a positive point and whose slice to Z1 is one too (the envelope with the free noise interval proves density exactly K for the rest of the range), 0xFFFFFFFF when none (interval-ops r3, ruling operator-analytic-wall-body).",
|
||||
"(March, continued.) Main pass: texelFetch the mask, then march the unmasked range with the footprint step Delta(z) and the octave weights (next item), the per-frame sample count capped by the budget S_budget and the start jittered so TAA completes what one frame does not; masked cells are skipped exactly; at the body-start cell take one analytic Beer-Lambert step, T *= exp(-K (Z1 - z) |dir|) with K the field's pointwise envelope value there (no probe in the main pass), and stop; integrate transmittance and the depth-weighted mean distance, apply the light. Every tile's interior cells retire in a few envelope probes and its wall cells are marked; there are no sky tiles. Behind a fully transmissive pixel the resolve shows --gamecult-nebula-base; by construction only an unfinished ray (budget exhausted before the wall, measured, must be under 1%) reaches it.",
|
||||
"Footprint LOD (the operator's challenge, map 'Footprint LOD'): theta_px = 2 tan(fov/2) / H; the footprint at distance z is z theta_px. Step: Delta(z) = max(k z theta_px, min(S / 4, lambda_fine(z) / 2)), k = 4: never finer than four footprints, otherwise the finer of the two content limits (four samples across the ramp, two per wavelength of the finest live octave). Octaves: octave i of wavelength lambda_i carries weight w_i(z) = 1 - smoothstep(lambda_i / (2m), lambda_i / m, z theta_px), m = 4: one smooth blend per octave, no popping; TAA hides per-frame sparsity. The weights are part of the field, so the bound encloses the truncated field exactly: the noise interval over a slice is the sum over octaves of iv_mul([w_i(z1), w_i(z0)], iv_snoise_ball(c F_i, r F_i)), w_i monotone in z. At the shipped parameters (wavelengths 100 and 25, z <= 2 Rc) every weight is 1 in the frustum and the content limits set the step; the footprint bites when S < 4 k z theta_px.",
|
||||
"Where the scene sits and what to turn (Imagination's model, scratchpad cavity_model.py; the test and the browser confirm): interval-ops r2 measured the shipped point at 16.45 LOD steps and 26.6 snoise per pixel against 36.3 fixed steps (2.21x); r3's analytic body is predicted to bring the steps to about 12.5 (2.6-3.3x), snoise unchanged; the wall's step is set by S / 4 and the detail half-wavelength, not by distance; the browser confirms or refutes. Cheaper without losing the mood, in order: (1) a lower per-frame S_budget, which LOD and the mask make nearly lossless (S_budget = 4 converges in about three frames at the walls); (2) a thinner ramp S (S = 10 is 3.2x on cost but reads as a surface; 25-50 keeps the fog); (3) a longer detail wavelength or no detail octave near the far wall (the 12.5-unit half-wavelength is the floor for S >= 50); (4) a larger hollow (Rh = 1000: 2.5x) costs nothing per pixel and changes the composition. Camera offset barely matters at these scales.",
|
||||
"Lighting, the cheapest version that keeps it moody (the original's own mechanism, made 3D): at each sample, light = SUN_COLOUR * (1 - (d / Rl)^2)^3 for d = |p - sun| < Rl, Rl = Rh = 198 (the original's 128 scaled with the hollow), plus AMBIENT * ambientTint; colour = density * (light + ambient) * hue(flow). No light march: zero extra snoise, one length and one pow per sample. The lower wall within Rl of the sun glows and the upper wall stays dark because the pulse is zero beyond Rl, which is the moody split; the sun inside the hollow is occluded by nothing, so the sun itself is a small additive disc drawn in the resolve at its projected position, occluded by the accumulated transmittance along its ray. Not in this cut, measured before it is ever added: one envelope-only transmittance sample toward the sun per primary sample (self-shadowing by the bowl's geometry), about +25% march cost; it is desktop-only if ever.",
|
||||
"TAA with a moving camera. ground.js uploads this frame's and the previous frame's view-projection. For each pixel the resolve reconstructs the world point at the march's depth-weighted mean distance (written by the main pass into a second history channel, R16F), moves it back by the field motion (p + flow(p) * dt: the pattern moves at -flow), projects it with the previous VP and fetches history at that uv. The same 3x3 min/max clamp, alpha = max(1/frames, 1/32), out-of-bounds alpha 1; history reset on resize, first frame and budget step. Transmittance, colour and the depth channel are all reprojected by the one motion vector. ?ground=debug-mv shows |mv| with the camera moving and the flow frozen, and with the flow moving and the camera frozen, so both terms are seen separately.",
|
||||
"Stardust distribution and the sun disc are the next cut (site-stardust), authored to the void: particles spawned in the bowl's volume with the original's height rule (more headroom where the well is deeper), lit by the same pulse, moving with the swirl. This cut leaves the composite slot and the depth history they need.",
|
||||
"Fit with the tokens. Inputs: --gamecult-nebula-0 (orange 255 138 42) is SUN_COLOUR; --gamecult-nebula-2 (sky 89 183 255) is the ambient tint; --gamecult-nebula-1 (violet 109 96 255) is the cloud hue's second pole; --gamecult-nebula-base the sky. Outputs from the 8x8 readback as r2, with one change: --gamecult-light-0-at is written from the sun's projected screen position (quantised to 1%, at most 2 Hz), so the CSS wash's main light follows the sun while the shader runs; --gamecult-light-0 and -a come from the texel there; lights 1 and 2 keep their seed positions and sample their colours. When the canvas is absent the seeds paint a still of the same composition (the seed light-0-at is the sun's mean screen position).",
|
||||
"Prove in the browser pane, in addition to r2's list: with ?ground=debug the pre-pass's mean probes per tile and the fraction of cells masked, and the mean snoise per pixel and the fraction of samples placed in the wall ramp against a dense-reference frame (?ground=dense: no pre-pass, S samples over the whole slab; the two converged frames' mean luminance agree within 1/255; the masked frame's per-frame variance at the walls is lower, printed); the camera reprojection test above; the orbit (after 36 s the sun's screen position has moved half an orbit and the TAA shows no smear at the walls); the contrast clamp with the lit cavity (max luminance <= 0.035 still holds; the sun disc is exempt only inside its own radius, which the clamp test masks and reports)."
|
||||
],
|
||||
"deletes": [
|
||||
{
|
||||
"path": "site/quartz/styles/custom.scss",
|
||||
"lines": 6,
|
||||
"note": "ritual body block 119-124: its own background goes; its seeds live under :root:has(body[data-slug]) (r2)."
|
||||
},
|
||||
{
|
||||
"path": "site/quartz/styles/custom.scss",
|
||||
"lines": 9,
|
||||
"note": "body 10-27: the literal colours and stops become token references (r2)."
|
||||
}
|
||||
],
|
||||
"keeps_moves": [
|
||||
"r2's tokens, sampled wash, controller, vendoring (now two files: the site vendors CultMath.glsl only, MIT; the parity run concatenates CultMath.Phacelle.glsl for the phacelle family), parity flags, fallbacks and the contrast clamp: unchanged except where this revision names them.",
|
||||
"--gamecult-wash and the index override from wash-home-only: untouched.",
|
||||
".page::before, the tour/Pitch VN ground, the Thing deck's .hero-bg, the masthead, the engine: untouched.",
|
||||
"The CSS wash stays the ground when the canvas is absent, not yet drawn, or removed by the controller."
|
||||
],
|
||||
"adds": [
|
||||
"site/quartz/components/GameCultGround.tsx: as r2.",
|
||||
"site/quartz/static/ground/ground.js (~500 lines): r2's passes plus worldAt(t) (sun, void, camera), the two view-projections, the pre-pass, the mask texture, the depth history, the sun-disc composite and the light-0-at writer.",
|
||||
"site/quartz/static/ground/flow.frag.glsl (world-space window, swirl plus global), prepass.frag.glsl (the tile loop, mask and body-start out), cloud.frag.glsl (masked sampling, light, depth out), taa.frag.glsl (camera plus flow reprojection), downsample.frag.glsl.",
|
||||
"site/quartz/static/ground/CultMath.glsl and glsl-parity.json, vendored from CultLib at interval-ops r3's head or later (cultmath_iv_frustum_ball with its float32 widening present).",
|
||||
"scripts/ground/check-cultmath-glsl.ps1: the vendoring drift check.",
|
||||
"custom.scss and docs/brand-design-language.md: as r2, with the void composition named in the brand doc's ground paragraph."
|
||||
],
|
||||
"file_changes": [
|
||||
{
|
||||
"location": {
|
||||
"path": "site/quartz/styles/custom.scss",
|
||||
"line": 4,
|
||||
"end_line": 8
|
||||
},
|
||||
"change": ":root block: the fifteen ground seeds and --gamecult-ground-mean; light-0-at seeded at the sun's mean screen position."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "site/quartz/styles/custom.scss",
|
||||
"line": 10,
|
||||
"end_line": 31
|
||||
},
|
||||
"change": "body: four nebula input tokens; radials and the linear gradient reference the ground tokens (r2)."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "site/quartz/styles/custom.scss",
|
||||
"line": 103,
|
||||
"end_line": 124
|
||||
},
|
||||
"change": "ritual block: background declaration deleted; :root:has(body[data-slug]) carries its seeds and nebula inputs (r2)."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "site/quartz.layout.ts",
|
||||
"line": 19
|
||||
},
|
||||
"change": "header: [GameCultThemeLock(), GameCultGround(), GameCultMasthead()]."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "docs/brand-design-language.md",
|
||||
"line": 69,
|
||||
"end_line": 84
|
||||
},
|
||||
"change": "wash section rewritten: the void scene, the sampled wash, the seeds, the nebula inputs and what each drives."
|
||||
}
|
||||
],
|
||||
"authority_map": {
|
||||
"owner": "The shader owns the ground. ground.js owns worldAt(t) (sun, void, camera), the flow texture, the pre-pass mask, the march, the TAA, the readback and the sampled tokens; custom.scss owns the four nebula inputs, --gamecult-wash and the seeds; CultLib owns every noise and interval function; the composed bound in prepass.frag.glsl is built from cultmath_iv_* only.",
|
||||
"inputs": [
|
||||
"the four nebula tokens and --gamecult-wash (computed on body)",
|
||||
"prefers-reduced-motion, visibilityState, canvas size, devicePixelRatio, elapsed time",
|
||||
"measured GPU frame time (timer query, fence, or rAF cadence)",
|
||||
"the shader's own 8x8 readback and the sun's projected position",
|
||||
"the vendored CultMath.glsl at its pinned CultLib commit"
|
||||
],
|
||||
"outputs": [
|
||||
"one opaque moving canvas under the page, luminance <= 0.035 outside the sun disc",
|
||||
"the fifteen ground tokens and --gamecult-ground-mean on :root inline, at most 2 Hz; light-0-at follows the sun",
|
||||
"debug, dense-reference and parity readbacks (console, flag only)"
|
||||
],
|
||||
"derived_state": [
|
||||
"sun, void and camera positions are derived from time by worldAt(t); nothing integrates motion frame to frame",
|
||||
"the tile mask is derived each frame from that frame's camera and the field; it is never carried across frames",
|
||||
"the flow texture is the one owner of motion: warp, hue, motion vectors and the stardust read it",
|
||||
"the TAA history (colour, transmittance, depth) is a cache reprojected by camera and flow; reset on resize, first frame and budget step",
|
||||
"the ground tokens are derived from the readback while the shader runs; the seeds are the fallback; the shader never reads them",
|
||||
"S and dpr are derived from measurement within the session; the sessionStorage token set is a cache"
|
||||
],
|
||||
"forbidden_writers": [
|
||||
"no colour, alpha, position or radius literal in ground.js or any GLSL file; the scene's dimensions (H0, Dw, Rw, F, S, Rl, orbit radius, period, camera offset) are named constants in one place in ground.js, uploaded as uniforms",
|
||||
"no sample placed in a masked cell; no probe in the main pass; no early-out of the pre-pass on transmittance",
|
||||
"no light march in this cut; the light is the analytic pulse",
|
||||
"no read of the ground output tokens by the shader or ground.js except the seed read before the first readback",
|
||||
"no userAgent, platform or maxTouchPoints; no class or attribute set on body or html; no second wash token; no second flow evaluation; no noise or interval function defined in the site's GLSL",
|
||||
"no synchronous readPixels to client memory; one requestAnimationFrame scheduler; localStorage never"
|
||||
],
|
||||
"shared_paths": [
|
||||
"every page paints one canvas and one token set; the ritual essays differ only in seeds and inputs",
|
||||
"slow device, hidden tab, reduced motion, missing WebGL2 and missing half-float all pass through the one controller",
|
||||
"the 8x8 readback and the sun projection are the only paths from the shader to CSS",
|
||||
"the pre-pass and the main pass read the same uniforms from the same worldAt(t); the dense-reference mode differs only by skipping the mask"
|
||||
],
|
||||
"deletion_line": "As r2: the ritual block's own background and the body literals go in the same commit the tokens are declared. Nothing else in the engine or content changes. Against pass 2's design this revision removes the static camera and the everywhere-clouds field; it adds one pre-pass, one mask texture, one depth history channel and the scene function."
|
||||
},
|
||||
"verification": {
|
||||
"builds": [
|
||||
"Deploy Quartz green on the landing commit: gh run list -R GameCult/gamecult-site -w \"Deploy Quartz\" -L 1",
|
||||
"scripts/ground/check-cultmath-glsl.ps1 exits 0 against a CultLib checkout at the pinned commit",
|
||||
"the built stylesheet contains rgb(var(--gamecult-light-0) / calc(var(--gamecult-light-0-a) * var(--gamecult-wash))) verbatim and the ritual :root:has rule"
|
||||
],
|
||||
"tests": [
|
||||
{
|
||||
"name": "browser pane, /?ground=debug: max relative luminance over the canvas outside the sun disc after 32 frames <= 0.035",
|
||||
"pins": "text contrast with the lit cavity."
|
||||
},
|
||||
{
|
||||
"name": "browser pane, /?ground=debug vs /?ground=dense: converged mean luminance within 1/255; steps and snoise per pixel printed for both, debug at most half of dense's steps; mask fraction printed",
|
||||
"pins": "the mask culls only provably empty cells; the shipped march takes at most half the fixed-step steps; the saving on the shipped scene is a number."
|
||||
},
|
||||
{
|
||||
"name": "browser pane, /?ground=debug-mv: |mv| non-zero with the camera moving and flow frozen, and with flow moving and camera frozen; zero under reduced motion",
|
||||
"pins": "both reprojection terms are live and separable."
|
||||
},
|
||||
{
|
||||
"name": "browser pane, / for 40 s: the sun's screen position moves about half an orbit; no history smear at the walls (3x3 clamp); light-0-at on :root follows within 2 s",
|
||||
"pins": "the moving scene converges and the CSS wash follows the sun."
|
||||
},
|
||||
{
|
||||
"name": "browser pane, /?ground=parity: every fixture family within its tolerance class on WebGL2, cultmath_iv_frustum_ball exact; numbers and GPU string in the report",
|
||||
"pins": "GLSL = C# on a device, including the tile ball."
|
||||
},
|
||||
{
|
||||
"name": "browser pane, /?ground=parity-wash and a ritual essay: canvas vs CSS seed wash within 2/255 at 16 points with density 0 and the sun disc off",
|
||||
"pins": "the seeds and the shader's base agree."
|
||||
},
|
||||
{
|
||||
"name": "reduced motion, hidden tab, WebGL2 disabled, sessionStorage cleared: as r2",
|
||||
"pins": "every fallback goes through the controller."
|
||||
},
|
||||
{
|
||||
"name": "git diff base..head --stat: custom.scss, quartz.layout.ts, docs/brand-design-language.md, the new component, static/ground/*, scripts/ground/* only",
|
||||
"pins": "no engine, content or masthead change."
|
||||
}
|
||||
],
|
||||
"negative": [
|
||||
{
|
||||
"pattern": "#[0-9a-fA-F]{6}\\b|rgba?\\(|\\b(255|138|109|183)\\b",
|
||||
"scope": "site/quartz/static/ground/*.js and *.glsl: 0 matches."
|
||||
},
|
||||
{
|
||||
"pattern": "gamecult-ground-|gamecult-light-",
|
||||
"scope": "site/quartz/static/ground/*.glsl: 0 matches; in ground.js only inside the token writer and the seed read."
|
||||
},
|
||||
{
|
||||
"pattern": "userAgent|navigator\\.platform|maxTouchPoints|localStorage|setInterval|classList|setAttribute\\(",
|
||||
"scope": "site/quartz/static/ground/ground.js: 0 matches."
|
||||
},
|
||||
{
|
||||
"pattern": "cultmath_iv_",
|
||||
"scope": "cloud.frag.glsl, taa.frag.glsl, flow.frag.glsl: 0 matches; the bound lives only in prepass.frag.glsl."
|
||||
},
|
||||
{
|
||||
"pattern": "float cultmath_|vec4 cultmath_|float snoise|hash\\(",
|
||||
"scope": "every site .frag.glsl: 0 matches; cultmath_* is defined only in the vendored CultMath.glsl."
|
||||
},
|
||||
{
|
||||
"pattern": "Mozilla|MPL|cultmath_phacelle",
|
||||
"scope": "site/quartz/static/ground/CultMath.glsl: 0 matches; the site vendors the MIT file."
|
||||
},
|
||||
{
|
||||
"pattern": "radial-gradient\\([^)]*rgba?\\([0-9]",
|
||||
"scope": "site/quartz/styles/custom.scss, body rule and ritual block: 0 matches."
|
||||
},
|
||||
{
|
||||
"pattern": "gamecult-titlebar|gamecult-nav-link|sidebar|composite|GameCult-Quartz",
|
||||
"scope": "git diff base..head changed lines: 0 matches."
|
||||
}
|
||||
],
|
||||
"operator": [
|
||||
"open / on the desktop and on the phone; say whether the hollow, the sun's orbit and the camera's height read as the old main menu's mood (uniforms: A, SWIRL, Rl, CARVE, the camera offset)",
|
||||
"on the phone with ?ground=debug, read the logged GPU ms per pass and the controller's final S/dpr; say whether it got warm after five minutes",
|
||||
"say whether the analytic light is moody enough or the self-shadowing sample (desktop only) should be tried",
|
||||
"navigate / -> /Blog -> back; say whether the ground is continuous (Q5)"
|
||||
]
|
||||
},
|
||||
"estimate": {
|
||||
"lines_added": 1250,
|
||||
"lines_removed": 15,
|
||||
"dependencies_added": [],
|
||||
"dependencies_removed": [],
|
||||
"formats_added": [
|
||||
"GLSL ES 3.00 shader files in static/ground",
|
||||
"vendored glsl-parity.json",
|
||||
"one sessionStorage key holding the last sampled token set",
|
||||
"an RGBA32UI tile-mask texture and an R16F depth history (GPU-side only)"
|
||||
],
|
||||
"formats_removed": [],
|
||||
"targets_added": [],
|
||||
"targets_removed": []
|
||||
},
|
||||
"rulings": [
|
||||
"cultmath-tapes:ruling:operator-ground-void-brush",
|
||||
"cultmath-tapes:ruling:operator-void-true-cavity",
|
||||
"cultmath-tapes:ruling:operator-tile-amortized-march",
|
||||
"cultmath-tapes:ruling:operator-analytic-wall-body",
|
||||
"cultmath-tapes:ruling:operator-split-phacelle",
|
||||
"site-masthead:ruling:operator-chromeless-column"
|
||||
],
|
||||
"questions": [
|
||||
"Q4 in the map: palette input tokens (a, recommended)",
|
||||
"Q5 in the map: carry the last sampled wash across page loads (a, recommended)",
|
||||
"Q9 in the map: the one shipped void (a, recommended: Rh 198, S 50, Rc 248, e 0.40); ruled in interval-ops r3's fork, this spec follows it"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,206 +0,0 @@
|
|||
{
|
||||
"campaign": "cultmath-tapes",
|
||||
"cut": "site-ground",
|
||||
"revision": 2,
|
||||
"title": "Site ground: a per-pixel WebGL2 nebula under every page, flow-warped, interval-culled, TAA-reprojected, budget-controlled by measurement; the CSS wash is sampled from the result, seeds fall back",
|
||||
"repo": "GameCult/gamecult-site",
|
||||
"branch": "main",
|
||||
"base": "535d170",
|
||||
"depends_on": [
|
||||
"site-masthead:cut_spec:wash-home-only.r1",
|
||||
"cultmath-tapes:cut_spec:glsl-lowering.r1",
|
||||
"cultmath-tapes:cut_spec:interval-ops.r1"
|
||||
],
|
||||
"first": [
|
||||
"Map: F:/Projects/CultLib/docs/cultmath-interval-ground-cut.md, 'The shader design' (field, culling with the warp, TAA motion-aware, sampled wash, palette input), 'Budget', D4-D8, Q4 and Q5. Rulings in force: site-masthead:ruling:operator-chromeless-column; wash-home-only's authority map (no second wash token, no class toggled by script, no page rule setting a radial alpha). Re-pin base to main after wash-home-only lands; line numbers below are from 535d170; re-read by selector. Stardust and DoF are the next cut: leave the resolve pass with a named, empty composite slot for them (a uniform-gated branch is not needed; a comment and a function stub are).",
|
||||
"Aetheria reference, read once, copy nothing: Volumetric.cginc:165-221 (two-phase flow-map warp, tri2 cross-fade), :130-135 (globalFlow); Raymarching/CloudShader.shader:83-104 (integration) and :261-314 (what not to do: gamma 0.5 clip, fixed 0.05 blend, packed depth, the density2 bug at :311).",
|
||||
"Engine facts to confirm before writing: how GameCult-Quartz bundles a component's afterDOMLoaded and whether a dynamic import() of /static/ground/ground.js is allowed, else emit a module script tag; site/quartz/static/** served at /static/**; enableSPA false (quartz.config.ts:15). Local build: scripts/quartz/quartz.ps1 dev.",
|
||||
"Tokens. Inputs (Q4 a), on body in custom.scss beside --gamecult-wash: --gamecult-nebula-0/1/2 (\"255 138 42\", \"109 96 255\", \"89 183 255\") and --gamecult-nebula-base (#07111a). Outputs with seeds, on :root: --gamecult-ground-0/1/2 (#03070d #07111a #09141f), and for N in 0..2 --gamecult-light-N (rgb triple), -a (0.18/0.16/0.14), -at (\"78% 14%\"/\"18% 10%\"/\"52% 0%\"), -r (18%/24%/28%), plus --gamecult-ground-mean (#07111a). The body background becomes radial-gradient(circle at var(--gamecult-light-0-at), rgb(var(--gamecult-light-0) / calc(var(--gamecult-light-0-a) * var(--gamecult-wash))), transparent var(--gamecult-light-0-r)) x3 over linear-gradient(180deg, var(--gamecult-ground-0) 0%, var(--gamecult-ground-1) 44%, var(--gamecult-ground-2) 100%).",
|
||||
"Ritual essays: their seeds go under :root:has(body[data-slug=\"Blog/...\"]) for the three slugs (yellow 255 178 30 .15 at 78% 8% 24rem; cobalt 49 88 255 .22 at 18% 4% 30rem; violet 91 54 184 .19 at 44% 20% 34rem; stops #030414 0%, #070b24 42%, #040514 100%; nebula inputs likewise overridden to yellow/cobalt/violet and base #050614). The ritual block's own background declaration is deleted; it inherits body's.",
|
||||
"ground.js owns: WebGL2 context on #gamecult-ground (alpha:false, antialias:false, powerPreference 'low-power'); capability check (WebGL2, EXT_color_buffer_half_float or float); input token read via getComputedStyle(document.body) -> uniforms; the flow texture pass (quarter res, RG16F, two snoise per texel, once per frame); the march pass; the TAA resolve with reprojection; the 8x8 downsample and the async PBO readback; the token writer on document.documentElement.style; the scheduler (30 fps cadence, visibilityState pause, reduced-motion freeze and stop at 32 frames); the budget controller; the debug flag (?ground=debug) and parity flags.",
|
||||
"Sampled wash: after the resolve, draw the final frame into an 8x8 RGBA8 target; gl.readPixels into a PIXEL_PACK_BUFFER; gl.fenceSync; on later frames poll getSyncParameter(SYNC_STATUS) and only when SIGNALED call getBufferSubData. Never readPixels to client memory directly (that stalls). At most 2 Hz. Compute the token values as the map states (row means for the stops; light colour and excess at the seed light's centre texel; the mean for --gamecult-ground-mean) and write them with setProperty on :root only when a value moved by more than 1/255. Q5 (a): keep the last written set in sessionStorage under one key and apply it on load before the first frame; it is a cache, and a missing or malformed entry means the seeds.",
|
||||
"Budget controller, one function, one state machine: GPU time per frame by EXT_disjoint_timer_query_webgl2 when present, else a fenceSync after the resolve polled to completion, else rAF cadence. Over a 60-frame window: if the median exceeds the budget (4.0 ms), step down: S 16 -> 8 -> 4, then dpr -> 1 (Q2), then remove the canvas (the seeds paint). Never step up within a session. A median rising 50% over 5 minutes also steps down. No storage of the verdict, no userAgent, no maxTouchPoints.",
|
||||
"cloud.frag.glsl: #version 300 es; precision highp float; precision highp int; the vendored CultMath.glsl is concatenated by ground.js before this file's body. The field, warp, interval march (ball radius F0 * (Δ/2 + D) with D = FLOW_MAX * PERIOD / 2) and integration exactly as the map's pseudocode; hue from the flow texture's channels mixed over the three nebula inputs; uniforms: resolution, time, dt, frame, jitter, S, intensity, nebula colours, base, Z0/Z1, F0, CUTOFF, K, PERIOD, FLOW_MAX. flow.frag.glsl: the two low-frequency snoise. taa.frag.glsl: motion vector from the flow texture (mv = project(-flow * dt)), history fetched at uv - mv, 3x3 min/max clamp, alpha = max(1/frames, 1/32), out-of-bounds alpha 1; writes new history and the screen (luminance clamp 0.035) via drawBuffers or two draws.",
|
||||
"Vendoring (D3): site/quartz/static/ground/CultMath.glsl byte-equal to CultLib shaders/CultMath.glsl at the commit named in its first line. scripts/ground/check-cultmath-glsl.ps1 diffs it against git show <sha>:packages/cultmath/shaders/CultMath.glsl from a CultLib checkout and exits non-zero on drift.",
|
||||
"Parity on the device: with ?ground=parity, ground.js evaluates the vendored fixture (CultLib tests/CultMath.Tests/fixtures/glsl-parity.json) one pixel per point, reads back float bit patterns, and reports per function the max ulp distance and the count outside its tolerance class. With ?ground=parity-wash, density and stardust are forced to 0 and the flow frozen; the canvas must equal the CSS seed wash within 2/255 at 16 points on / and on a ritual essay (this pins that the seed values and the shader's base agree at zero density, which is what the fallback promises).",
|
||||
"Prove in the browser pane: max relative luminance after 32 frames on / at intensity 1 (<= 0.035); the parity-wash probe; the debug readback (mean evals per pixel, unfinished-ray fraction < 1%); the token writer (after 2 s on /, :root's inline style carries all fifteen tokens and --gamecult-ground-mean, each within the expected range, and getComputedStyle(body).backgroundImage resolves to the sampled values); the motion test (with ?ground=debug-mv the resolve writes |mv| into red: non-zero where flow is non-zero, zero under reduced motion); GPU ms per pass with the timer query at the pane's desktop size and at the mobile preset's pixel count. Phone GPU time is operator verification."
|
||||
],
|
||||
"deletes": [
|
||||
{
|
||||
"path": "site/quartz/styles/custom.scss",
|
||||
"lines": 6,
|
||||
"note": "ritual body block 119-124: its own background (three radials and the linear gradient) goes; its seeds live under :root:has(body[data-slug]) and it inherits body's ground."
|
||||
},
|
||||
{
|
||||
"path": "site/quartz/styles/custom.scss",
|
||||
"lines": 9,
|
||||
"note": "body 10-27: the six literal colours and three literal stops leave as literals (rewritten as token references in the same lines; counted 9 removed, 9 added)."
|
||||
}
|
||||
],
|
||||
"keeps_moves": [
|
||||
"--gamecult-wash and the index override from wash-home-only: untouched; the shader reads it as intensity and the CSS wash still multiplies by it.",
|
||||
".page::before (the grid overlay), the tour/Pitch VN ground, the Thing deck's .hero-bg, the masthead, the engine: untouched.",
|
||||
"The CSS wash stays the ground when the canvas is absent, not yet drawn, or removed by the controller; it renders the sampled tokens when there are any and the seeds otherwise.",
|
||||
"docs/brand-design-language.md 'The ground is a wash, not a flat fill' (69-84): rewritten: the ground is the shader; the CSS wash is sampled from it; the seeds are today's literal wash; the four nebula inputs are where the palette is tuned."
|
||||
],
|
||||
"adds": [
|
||||
"site/quartz/components/GameCultGround.tsx: renders <canvas id=\"gamecult-ground\" aria-hidden=\"true\"></canvas>, mounts /static/ground/ground.js after DOM load; in sharedPageComponents.header after GameCultThemeLock (quartz.layout.ts:19). Its beforeDOMLoaded script applies the sessionStorage token cache to :root if present (Q5 a), so the first paint matches the last sample.",
|
||||
"site/quartz/static/ground/ground.js (~400 lines, no dependencies, no build step): context, tokens in and out, flow/march/resolve/downsample passes, PBO readback, scheduler, controller, debug and parity modes, a named composite slot for the stardust cut.",
|
||||
"site/quartz/static/ground/flow.frag.glsl, cloud.frag.glsl, taa.frag.glsl, downsample.frag.glsl; a shared 3-vertex full-screen vertex shader inline in ground.js.",
|
||||
"site/quartz/static/ground/CultMath.glsl and glsl-parity.json, vendored from CultLib with the pin in the header.",
|
||||
"scripts/ground/check-cultmath-glsl.ps1: the vendoring drift check.",
|
||||
"custom.scss: the four nebula input tokens on body; the fifteen ground seeds plus --gamecult-ground-mean on :root with the ritual overrides under :root:has(body[data-slug]); #gamecult-ground { position: fixed; inset: 0; width: 100%; height: 100%; z-index: -1; pointer-events: none; }.",
|
||||
"docs/brand-design-language.md: the wash section rewritten as above; one paragraph on the ground shader: what it reads (inputs), what it writes (outputs), what it may not hold, and the fallback."
|
||||
],
|
||||
"file_changes": [
|
||||
{
|
||||
"location": { "path": "site/quartz/styles/custom.scss", "line": 4, "end_line": 8 },
|
||||
"change": ":root block: the fifteen ground seeds and --gamecult-ground-mean declared beside color-scheme."
|
||||
},
|
||||
{
|
||||
"location": { "path": "site/quartz/styles/custom.scss", "line": 10, "end_line": 31 },
|
||||
"change": "body: four nebula input tokens declared; radials and the linear gradient reference the ground tokens; alphas keep the wash-home-only calc."
|
||||
},
|
||||
{
|
||||
"location": { "path": "site/quartz/styles/custom.scss", "line": 103, "end_line": 124 },
|
||||
"change": "ritual block: background declaration deleted; a :root:has(body[data-slug]) rule carries its seeds and nebula inputs."
|
||||
},
|
||||
{
|
||||
"location": { "path": "site/quartz.layout.ts", "line": 19 },
|
||||
"change": "header: [GameCultThemeLock(), GameCultGround(), GameCultMasthead()]."
|
||||
},
|
||||
{
|
||||
"location": { "path": "docs/brand-design-language.md", "line": 69, "end_line": 84 },
|
||||
"change": "wash section rewritten: shader-owned ground, sampled wash, seeds, nebula inputs."
|
||||
}
|
||||
],
|
||||
"authority_map": {
|
||||
"owner": "The shader owns the ground. custom.scss owns the four nebula inputs, --gamecult-wash and the seed values; ground.js owns the flow texture, the march, the TAA, the readback and the sampled ground tokens on :root; CultLib owns every noise and interval function the field calls.",
|
||||
"inputs": [
|
||||
"the four nebula tokens and --gamecult-wash (computed on body)",
|
||||
"prefers-reduced-motion, visibilityState, canvas size, devicePixelRatio, elapsed time",
|
||||
"measured GPU frame time (timer query, fence, or rAF cadence)",
|
||||
"the shader's own 8x8 readback",
|
||||
"the vendored CultMath.glsl at its pinned CultLib commit"
|
||||
],
|
||||
"outputs": [
|
||||
"one opaque moving canvas under the page, luminance <= 0.035",
|
||||
"the fifteen ground tokens and --gamecult-ground-mean on :root inline, at most 2 Hz",
|
||||
"debug and parity readbacks (console, flag only)"
|
||||
],
|
||||
"derived_state": [
|
||||
"the ground tokens are derived from the shader's result while it runs; the seeds are the fallback; the CSS wash renders whichever is in force; the shader never reads them",
|
||||
"the flow texture is the one owner of motion: warp, hue, motion vectors (and the stardust, next cut) read it",
|
||||
"the TAA history is a cache, reprojected by the flow; reset on resize, first frame and budget step",
|
||||
"S and dpr are derived from measurement within the session; never stored",
|
||||
"the sessionStorage token set is a cache of the last sample; absent or malformed means seeds",
|
||||
"CultMath.glsl in the site is derived from CultLib at the pinned commit"
|
||||
],
|
||||
"forbidden_writers": [
|
||||
"no colour, alpha, position or radius literal in ground.js or any GLSL file",
|
||||
"no read of --gamecult-ground-* or --gamecult-light-* by the shader or by ground.js, except the seed read before the first readback",
|
||||
"no navigator.userAgent, platform, maxTouchPoints, or any device-name branch",
|
||||
"no class or attribute set on body or html by ground.js; token writes are setProperty on :root only",
|
||||
"no second wash token; no page rule setting a radial alpha; no second flow evaluation outside flow.frag.glsl",
|
||||
"no noise or interval function defined in the site's GLSL; only cultmath_* calls",
|
||||
"no synchronous readPixels to client memory; no setInterval; one requestAnimationFrame scheduler; localStorage never"
|
||||
],
|
||||
"shared_paths": [
|
||||
"home, every content page and the ritual essays paint one canvas and one token set, in CSS and in the shader",
|
||||
"slow device, hidden tab, reduced motion, missing WebGL2 and missing half-float all pass through the one controller",
|
||||
"the 8x8 readback is the only path from the shader to CSS; the beforeDOMLoaded cache apply and the live writer set the same tokens the same way",
|
||||
"the same Deploy Quartz workflow; static files ship with the overlay"
|
||||
],
|
||||
"deletion_line": "The ritual block's own background and the nine body literals go in the same commit the tokens are declared. The canvas, four shaders, ground.js and the vendored library are the whole addition; nothing else in the engine or the content changes."
|
||||
},
|
||||
"verification": {
|
||||
"builds": [
|
||||
"Deploy Quartz green on the landing commit: gh run list -R GameCult/gamecult-site -w \"Deploy Quartz\" -L 1",
|
||||
"scripts/ground/check-cultmath-glsl.ps1 exits 0 against a CultLib checkout at the pinned commit",
|
||||
"the built stylesheet contains rgb(var(--gamecult-light-0) / calc(var(--gamecult-light-0-a) * var(--gamecult-wash))) verbatim and a :root:has(body[data-slug=\"Blog/small-ritual-of-reach\"]) rule"
|
||||
],
|
||||
"tests": [
|
||||
{
|
||||
"name": "browser pane, /?ground=debug: max relative luminance over the canvas after 32 frames <= 0.035",
|
||||
"pins": "text contrast: body text keeps >= 7:1 on every ground pixel."
|
||||
},
|
||||
{
|
||||
"name": "browser pane, /?ground=parity-wash and a ritual essay: canvas vs CSS seed wash within 2/255 at 16 points with density 0 and flow frozen",
|
||||
"pins": "the seeds and the shader's base agree; the fallback promise holds."
|
||||
},
|
||||
{
|
||||
"name": "browser pane, / after 2 s: :root inline style carries the sixteen tokens; body's computed backgroundImage resolves to the sampled values; a read 1 s later differs only where the frame changed",
|
||||
"pins": "the CSS wash is sampled from the shader, at most 2 Hz, without a stall (no readPixels-to-client in the profiler)."
|
||||
},
|
||||
{
|
||||
"name": "browser pane, /?ground=parity: every fixture function within its tolerance class on WebGL2; numbers and GPU string in the report",
|
||||
"pins": "GLSL = C# on a device; the proving ground's third runtime."
|
||||
},
|
||||
{
|
||||
"name": "browser pane, /?ground=debug at desktop and the mobile preset: mean evals per pixel, unfinished rays < 1%, GPU ms per pass; ?ground=debug-mv shows motion where flow is non-zero",
|
||||
"pins": "the step budget, the interval saving with the warp, and the reprojection on the shipped field."
|
||||
},
|
||||
{
|
||||
"name": "reduced motion emulated: frames stop at 32, mv is 0, time frozen; hidden tab: no frames; WebGL2 disabled: no canvas in the DOM and the seed wash paints; sessionStorage cleared: seeds paint first",
|
||||
"pins": "every fallback goes through the controller and lands on the same CSS ground."
|
||||
},
|
||||
{
|
||||
"name": "git diff base..head --stat: custom.scss, quartz.layout.ts, docs/brand-design-language.md, the new component, static/ground/*, scripts/ground/* only",
|
||||
"pins": "no engine, content or masthead change."
|
||||
}
|
||||
],
|
||||
"negative": [
|
||||
{
|
||||
"pattern": "#[0-9a-fA-F]{6}\\b|rgba?\\(|\\b(255|138|109|183)\\b",
|
||||
"scope": "site/quartz/static/ground/*.js and *.glsl: 0 matches."
|
||||
},
|
||||
{
|
||||
"pattern": "gamecult-ground-|gamecult-light-",
|
||||
"scope": "site/quartz/static/ground/*.glsl: 0 matches; in ground.js only inside the token writer and the seed read, never in a uniform upload."
|
||||
},
|
||||
{
|
||||
"pattern": "userAgent|navigator\\.platform|maxTouchPoints|localStorage|setInterval|classList|setAttribute\\(",
|
||||
"scope": "site/quartz/static/ground/ground.js: 0 matches."
|
||||
},
|
||||
{
|
||||
"pattern": "readPixels\\([^)]*(Uint8Array|Float32Array|Uint16Array)",
|
||||
"scope": "ground.js: 0 matches outside the parity and debug readbacks, which are flag-gated and named."
|
||||
},
|
||||
{
|
||||
"pattern": "float cultmath_|vec4 cultmath_|float snoise|hash\\(",
|
||||
"scope": "flow/cloud/taa/downsample.frag.glsl: 0 matches; cultmath_* is defined only in the vendored CultMath.glsl."
|
||||
},
|
||||
{
|
||||
"pattern": "radial-gradient\\([^)]*rgba?\\([0-9]",
|
||||
"scope": "site/quartz/styles/custom.scss, body rule and ritual block: 0 matches."
|
||||
},
|
||||
{
|
||||
"pattern": "gamecult-titlebar|gamecult-nav-link|sidebar|composite|GameCult-Quartz",
|
||||
"scope": "git diff base..head changed lines: 0 matches."
|
||||
}
|
||||
],
|
||||
"operator": [
|
||||
"open / on the desktop and on the phone; say whether the nebula's motion and intensity are right (uniforms: PERIOD, FLOW_MAX, intensity)",
|
||||
"on the phone with ?ground=debug, read the logged GPU ms and the controller's final S/dpr; say whether it got warm after five minutes",
|
||||
"navigate / -> /Blog -> back; say whether the ground is continuous or shows a seed flash (Q5)",
|
||||
"open a ritual essay and confirm its ground still reads as its own paper"
|
||||
]
|
||||
},
|
||||
"estimate": {
|
||||
"lines_added": 980,
|
||||
"lines_removed": 15,
|
||||
"dependencies_added": [],
|
||||
"dependencies_removed": [],
|
||||
"formats_added": ["GLSL ES 3.00 shader files in static/ground", "vendored glsl-parity.json", "one sessionStorage key holding the last sampled token set"],
|
||||
"formats_removed": [],
|
||||
"targets_added": [],
|
||||
"targets_removed": []
|
||||
},
|
||||
"rulings": [
|
||||
"site-masthead:ruling:operator-chromeless-column"
|
||||
],
|
||||
"questions": [
|
||||
"Q4 in the map: palette input tokens (a, recommended)",
|
||||
"Q5 in the map: carry the last sampled wash across page loads (a, recommended)"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
{
|
||||
"campaign": "cultmath-tapes",
|
||||
"cut": "site-stardust",
|
||||
"revision": 1,
|
||||
"title": "Site stardust: stateless particles advected by the ground's flow texture, rendered to a half-res layer, blurred by the clouds' accumulated opacity (raymarch-driven DoF), composited behind the nebula",
|
||||
"repo": "GameCult/gamecult-site",
|
||||
"branch": "main",
|
||||
"base": "535d170",
|
||||
"depends_on": [
|
||||
"cultmath-tapes:cut_spec:site-ground.r2"
|
||||
],
|
||||
"first": [
|
||||
"Map: F:/Projects/CultLib/docs/cultmath-interval-ground-cut.md, 'Stardust', 'Depth of field driven by the raymarch', 'Budget', D4, D9, D10. Re-pin base to main after site-ground lands. The composite slot site-ground left in the resolve is where this cut plugs in; do not restructure the resolve.",
|
||||
"Aetheria reference, read once, copy nothing: Compute/Stardust/Stardust.compute:118-147 (grid cell -> hashed spawn, lifetime = frac(time/period + hash), position -= flow * lifetime * period, size = parabola(lifetime, 2), colour from a hue ramp x nebula tint); Stardust.shader:49-86 (billboard from quadPoints by SV_VertexID/SV_InstanceID, powerPulse falloff, distance exponents); CustomDoF/DepthOfField.hlsl:29-32 (CoC read from a raymarch output), :150-201 (disk gather weighted by CoC), :217-242 (combine). The web version is far smaller: no distance exponents (the slab is thin), no shadow pass, no near/far split.",
|
||||
"Particles are stateless: no buffers are updated. One static 6-vertex quad VBO; drawArraysInstanced with count N; the vertex shader derives everything from gl_InstanceID, time and the flow texture (vertex texture fetch is standard in WebGL2): cell = (id mod G, id / G) over the slab mid-plane; hashes via cultmath_pcg3d (the vendored CultMath.glsl is concatenated before the vertex source too) for spawn jitter, lifetime offset, size and brightness; lifetime = fract(t / PERIOD_P + offset); position = spawn - flow(spawn uv) * lifetime * PERIOD_P; size = base * parabola(lifetime, 2); colour = hue at the screen position (from the flow texture's channels, the same mix as the clouds) * brightness. Reduced motion freezes t as site-ground does.",
|
||||
"Stardust layer: half-resolution RGBA16F target, cleared each frame, additive blend, powerPulse falloff in the fragment shader; N = 16384 desktop, 4096 phone; the controller scales N with S (N halves when S halves) and the layer follows dpr.",
|
||||
"DoF: in the resolve's composite slot, read opacity = 1 - T from the reprojected, clamped history (not the current frame) and gather the stardust layer with an 8-tap Poisson disk of radius RMAX * opacity in layer texels (RMAX ~ 12 px at dpr 1, scaled by dpr); weight taps by a soft disk; composite out = ground + clouds + stardust_blurred * T, then the luminance clamp. All stardust is behind the whole cloud layer (D9).",
|
||||
"Debug flags: ?ground=debug-stardust writes the stardust layer alone; ?ground=debug-coc writes opacity into red. Prove in the browser pane: the stardust and the clouds move together (with ?ground=debug-mv from site-ground and the particle velocity written in green under ?ground=debug-stardust-v, the two directions agree within 5 degrees at 64 sampled pixels where |flow| is above a threshold); luminance clamp still holds after the composite; GPU ms per pass (stardust layer, gather) with the timer query at desktop and at the mobile preset's pixel count; the full-stack total against the budget table; the unfinished-ray fraction unchanged by this cut.",
|
||||
"Budget contract (map, 'Budget'): the stardust layer plus the gather cost no more than a quarter of the march at the same S; if the measurement says otherwise, the gather drops to 4 taps before N drops, and the report says which. Stardust is never cut before the clouds' S on the ladder."
|
||||
],
|
||||
"deletes": [],
|
||||
"keeps_moves": [
|
||||
"site-ground's flow texture, march, TAA, readback and token writer: untouched; the stardust reads the flow texture and the hue mix the same way the clouds do.",
|
||||
"The sampled wash now includes the stardust in its 8x8 readback by construction (it samples the final frame); nothing changes in the writer.",
|
||||
"The budget controller gains one scaled quantity (N) and no new state; the ladder order is unchanged."
|
||||
],
|
||||
"adds": [
|
||||
"site/quartz/static/ground/stardust.vert.glsl and stardust.frag.glsl: the instanced billboard and the powerPulse sprite.",
|
||||
"ground.js: the stardust pass (one VBO, one program, drawArraysInstanced), the half-res layer, N in the controller, the gather in the resolve's composite slot, the two debug flags.",
|
||||
"taa.frag.glsl: the composite slot filled: opacity from history, the 8-tap gather, out = ground + clouds + stardust_blurred * T.",
|
||||
"docs/brand-design-language.md: two sentences under the ground shader paragraph: the stardust and the DoF, and that they read the same flow and hue as the nebula."
|
||||
],
|
||||
"file_changes": [
|
||||
{
|
||||
"location": { "path": "site/quartz/static/ground/ground.js", "line": 1 },
|
||||
"change": "stardust pass, layer, N scaling, gather hookup, debug flags; line numbers are site-ground's and are re-read by function name."
|
||||
},
|
||||
{
|
||||
"location": { "path": "site/quartz/static/ground/taa.frag.glsl", "line": 1 },
|
||||
"change": "the named composite slot becomes the opacity-weighted gather and the final composite."
|
||||
},
|
||||
{
|
||||
"location": { "path": "docs/brand-design-language.md", "line": 69 },
|
||||
"change": "two sentences on stardust and DoF under the ground shader paragraph (post site-ground numbering)."
|
||||
}
|
||||
],
|
||||
"authority_map": {
|
||||
"owner": "ground.js owns the stardust pass and the DoF gather; the flow texture (site-ground) owns the particles' motion; the TAA history owns the opacity the DoF reads; the nebula input tokens own the particles' hue through the same mix as the clouds.",
|
||||
"inputs": [
|
||||
"the flow texture and the hue mix (site-ground)",
|
||||
"the reprojected, clamped history's transmittance",
|
||||
"time, N and dpr from the controller; gl_InstanceID"
|
||||
],
|
||||
"outputs": [
|
||||
"a half-res stardust layer per frame",
|
||||
"the composite with stardust blurred by cloud opacity behind the nebula, still under the luminance clamp"
|
||||
],
|
||||
"derived_state": [
|
||||
"every particle's position, lifetime, size and colour are derived from gl_InstanceID, time and the flow texture each frame; nothing is stored or updated",
|
||||
"N is derived from S by the controller; the layer size from dpr",
|
||||
"the blur radius is derived from the accumulated opacity; there is no second CoC source"
|
||||
],
|
||||
"forbidden_writers": [
|
||||
"no particle buffer update, transform feedback or CPU-side particle state",
|
||||
"no second flow function or hue mix; the vertex shader samples the flow texture",
|
||||
"no colour literal; no per-depth transmittance; no second march",
|
||||
"no new controller state beyond N; the ladder order is not changed",
|
||||
"no change to the readback or the token writer"
|
||||
],
|
||||
"shared_paths": [
|
||||
"clouds and stardust read one flow texture and one hue mix, so they move and colour as one",
|
||||
"the one resolve pass composites ground, clouds and stardust and applies the one luminance clamp",
|
||||
"the one controller scales S, N and dpr together"
|
||||
],
|
||||
"deletion_line": "Nothing is deleted. The addition is two small shaders, one pass and one gather; it buys the stardust and the DoF the operator named, and it must prove it fits the same budget."
|
||||
},
|
||||
"verification": {
|
||||
"builds": [
|
||||
"Deploy Quartz green on the landing commit",
|
||||
"scripts/ground/check-cultmath-glsl.ps1 still exits 0"
|
||||
],
|
||||
"tests": [
|
||||
{
|
||||
"name": "browser pane, /?ground=debug-stardust-v with ?ground=debug-mv: particle velocity and cloud motion vectors agree within 5 degrees at 64 sampled pixels where |flow| exceeds the threshold",
|
||||
"pins": "one flow: the stardust and the nebula move as one."
|
||||
},
|
||||
{
|
||||
"name": "browser pane, /?ground=debug-coc: opacity is 0 where the clouds are empty and rises inside them; the stardust gather radius follows it (a visible blur under a dense cloud, sharp in a gap)",
|
||||
"pins": "the raymarch drives the DoF, as in Aetheria's hack."
|
||||
},
|
||||
{
|
||||
"name": "browser pane, /?ground=debug: max relative luminance after 32 frames <= 0.035 with stardust on",
|
||||
"pins": "the clamp holds after the composite."
|
||||
},
|
||||
{
|
||||
"name": "browser pane, timer query at desktop and at the mobile preset: stardust layer + gather <= 25% of the march at the same S; full-stack totals quoted against the budget table",
|
||||
"pins": "the stardust fits the same budget or the report says what gave."
|
||||
},
|
||||
{
|
||||
"name": "reduced motion: particles frozen, frames stop at 32; hidden tab: no frames",
|
||||
"pins": "the stardust obeys the one controller."
|
||||
}
|
||||
],
|
||||
"negative": [
|
||||
{
|
||||
"pattern": "bufferSubData|transformFeedback|TRANSFORM_FEEDBACK|new Float32Array\\((N|count|particles)",
|
||||
"scope": "ground.js stardust section: 0 matches; particles are stateless."
|
||||
},
|
||||
{
|
||||
"pattern": "#[0-9a-fA-F]{6}\\b|rgba?\\(",
|
||||
"scope": "stardust.vert.glsl, stardust.frag.glsl, ground.js: 0 matches."
|
||||
},
|
||||
{
|
||||
"pattern": "snoise\\(|float cultmath_",
|
||||
"scope": "stardust.vert.glsl and stardust.frag.glsl: 0 matches; the flow and hue come from the texture, not from a second evaluation."
|
||||
},
|
||||
{
|
||||
"pattern": "readPixels|setProperty",
|
||||
"scope": "git diff base..head changed lines in ground.js: 0 matches; the readback and the token writer are not touched."
|
||||
}
|
||||
],
|
||||
"operator": [
|
||||
"open / on the desktop and on the phone; say whether the stardust sells the motion and whether the blur under the clouds reads like the video (RMAX, N, PERIOD_P are the dials)",
|
||||
"on the phone with ?ground=debug, read the full-stack GPU ms and the final S/N/dpr; say whether it got warm"
|
||||
]
|
||||
},
|
||||
"estimate": {
|
||||
"lines_added": 320,
|
||||
"lines_removed": 4,
|
||||
"dependencies_added": [],
|
||||
"dependencies_removed": [],
|
||||
"formats_added": [],
|
||||
"formats_removed": [],
|
||||
"targets_added": [],
|
||||
"targets_removed": []
|
||||
},
|
||||
"rulings": [
|
||||
"site-masthead:ruling:operator-chromeless-column"
|
||||
],
|
||||
"questions": []
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,125 +0,0 @@
|
|||
# CultMath tapes: interval-pruned field evaluation on CPU and GPU
|
||||
|
||||
Status: steps 2 and 4 (graph, C# evaluator, HLSL interpreter) unparked on
|
||||
2026-10-14 (ruling tape-unparked-for-trophic-graphs, which supersedes
|
||||
tape-target-unparks-when-asura-stable); their model page and cut sequence are
|
||||
`docs/cultmath-interval-ground-cut.md`, "Pass 7". Steps 3 and 5 (pruning,
|
||||
meshing) stay parked. The affine-forms
|
||||
piece was unparked on 2026-10-03 (ruling affine-forms-unparked) and is on main
|
||||
(156782da), alongside step 1's intervals; its map and as-built record are in
|
||||
`docs/cultmath-interval-ground-cut.md`. This note records the direction for the
|
||||
parked steps so they can be picked up without re-deriving it.
|
||||
|
||||
## Intent
|
||||
|
||||
Evaluate large implicit fields, meaning deeply nested CSG trees of brushes with
|
||||
thousands of nodes, cheaply enough to mesh at many levels of detail and to
|
||||
raymarch. The operator's words (2026-10-02): "I would love to design a space
|
||||
station out of a grammar of nested brushes, and have the ability to
|
||||
automatically mesh it at multiple levels of detail so I can get arbitrarily
|
||||
close to its tiny details without melting my GPU."
|
||||
|
||||
GameCult has no deep CSG trees today because nothing could evaluate them
|
||||
cheaply. The capability comes first, and the content follows.
|
||||
|
||||
## Prior art
|
||||
|
||||
[Fidget](https://www.mattkeeter.com/projects/fidget/) by Matt Keeter
|
||||
(MPL-2.0, Rust, v0.5.1 on 2026-09-27; repo `mkeeter/fidget`).
|
||||
- It compiles an expression to a register-allocated bytecode tape.
|
||||
- It evaluates that tape as points, SIMD batches, intervals, and forward-mode
|
||||
gradients.
|
||||
- The central trick is interval evaluation over a region. When it proves that
|
||||
one operand of a `min` or `max` always wins, the tape for that region is
|
||||
rewritten without the other branch. In the project's example, the tape
|
||||
shrinks from 254 clauses to about 20 at pixel level.
|
||||
- `fidget-jit` targets x86-64 and AArch64.
|
||||
- `fidget-wgpu` has a WGSL tape interpreter, interval ops and tape
|
||||
simplification on the GPU (`interval_ops.wgsl`, `tape_interpreter.wgsl`,
|
||||
`tape_simplify.wgsl`).
|
||||
- `fidget-mesh` implements Manifold Dual Contouring. It is known to be weak on
|
||||
thin features.
|
||||
- Halfspace is the IDE codesigned with `fidget-wgpu` (Rust, egui and wgpu,
|
||||
MPL-2.0).
|
||||
|
||||
## Chosen shape (option c)
|
||||
|
||||
The ideas are ported into CultLib and owned here. Fidget is not a dependency.
|
||||
Unity renders HLSL on its own graphics backend, so `fidget-wgpu` cannot run
|
||||
inside it.
|
||||
|
||||
Three options were weighed on 2026-10-02:
|
||||
|
||||
| Option | What it is | Trade |
|
||||
|---|---|---|
|
||||
| a | Fidget as a Rust native plugin in Unity | Fastest demo; costs per-platform native builds, an FFI boundary, and an upstream that warns of breaking changes |
|
||||
| b | Offline bake with a Rust tool; ship meshes | Simplest; the baked depth caps how close a viewer can get |
|
||||
| c | CultLib owns tape, interval and pruning in C# and HLSL | Most coherent end state; the largest code liability we would own |
|
||||
|
||||
The operator parked (c). An (a) spike to validate authoring and LOD behaviour
|
||||
before owning code was suggested but not ruled.
|
||||
|
||||
Sketch, to be replaced by the target pass:
|
||||
|
||||
1. **CultMath `interval`** in C# and HLSL with parity tests:
|
||||
- arithmetic, `min`/`max`, `smin`, `sqrt`, `clamp` and `smoothstep`;
|
||||
- interval or Lipschitz bounds for every CultMath noise primitive.
|
||||
|
||||
This step needs no tape. It stands alone, and is the first cut to take even
|
||||
if the rest stays parked.
|
||||
2. **Tape**: an expression graph lowered to bytecode, with point, interval and
|
||||
gradient evaluators in C#. Gradients reuse CultMath's analytic-gradient
|
||||
primitives, which Asura cut 2a built.
|
||||
3. **Pruning**: per-region tape simplification driven by interval results.
|
||||
4. **HLSL interpreter** for the same bytecode, with interval ops and pruning.
|
||||
Fidget's MPL-2.0 WGSL is the reference. MPL code keeps its own MPL-headed
|
||||
files, as Phacelle does.
|
||||
5. **Meshing**: an octree of chunks with seams stitched between LOD levels.
|
||||
- The default is surface nets (GameCult.Geometry, Asura Cut 2) plus
|
||||
Asura's tile pass: Newton refinement onto `f = 0`, then crease snapping
|
||||
from field gradients (Asura Cut 6c).
|
||||
- The operator's lean (2026-10-02) is that gradient work beats dual
|
||||
contouring here. Thin authored bevels can be shaded by their analytic
|
||||
normals, and the refined positions can be iterated further.
|
||||
- Dual contouring is not planned. The tile pass on station brushes is its
|
||||
test.
|
||||
|
||||
## Truth and lowerings
|
||||
|
||||
The field is data: a typed CultCache document holding the expanded tree. A
|
||||
generator, such as a brush grammar, expands into that document, and everything
|
||||
downstream caches it. The CPU evaluator, the HLSL interpreter, the mesher and
|
||||
collision are all lowerings of that one document. This is how "one
|
||||
implementation of the field" survives having both CPU and GPU evaluators.
|
||||
Asura's invariant 2 had to forbid CPU evaluation because its field is HLSL
|
||||
source.
|
||||
|
||||
## Known consumers
|
||||
|
||||
- **Aetheria volumetric raymarching** (clouds, nebulae). Step 1 alone gives it
|
||||
empty-space skipping. Coverage and shape terms carry the culling, because
|
||||
detail octaves only bound to ±amplitude.
|
||||
- Prerequisite: Aetheria's shaders must use CultMath noise, not their own
|
||||
copies. That is already owed at Aetheria's CultMath pin bump (see the Asura
|
||||
cut map, snoise release follow-ups).
|
||||
- **Space stations from a brush grammar**, meshed in LOD chunks around the
|
||||
camera. This is a separate project with its own target. This campaign
|
||||
supplies its evaluator.
|
||||
- Asura does not need this campaign. Its field is noise-dominated, and its
|
||||
`A_max` shell and band-limiting already give the cheap culling.
|
||||
|
||||
## Decided since the unpark, and what stays open
|
||||
|
||||
- The spike-or-(c) choice was settled by the operator's unpark: the tape
|
||||
unparked for Aetheria's trophic species graphs (ruling
|
||||
`cultmath-tapes:ruling:tape-unparked-for-trophic-graphs`).
|
||||
- Interval representation: affine forms for noise and warp, unparked on
|
||||
2026-10-03 (ruling `cultmath-tapes:ruling:affine-forms-unparked`): one
|
||||
shared symbol, mapped in `docs/cultmath-interval-ground-cut.md`, "Pass 5".
|
||||
- Register model: the HLSL interpreter keeps its registers in groupshared
|
||||
memory, a slice per thread, linear-scan allocated at lowering (ruling
|
||||
`cultmath-tapes:ruling:tape-register-model-ruled`; research in
|
||||
`docs/research/tape-register-model.md`).
|
||||
- Open: whether the brush-grammar station project lives in Aetheria or in
|
||||
its own repo (follow-up `cultmath-tapes:follow_up:brush-grammar-station-repo`,
|
||||
raised as an operator question when a station cut reaches the frontier).
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
# CultMesh garden path: map
|
||||
|
||||
Campaign `cultmesh-garden-path`, stage 1 (server-authoritative garden path for the
|
||||
orbs .io game). Target: `docs/cultmesh-garden-path-target.md` (typed as
|
||||
`cultmesh-garden-path:target:r2`). Rulings, questions and follow-ups are documents
|
||||
in the mind; this page holds the model page, body facts and the stage 1 cut lines.
|
||||
|
||||
Written by Imagination (`imagination-gp-0b`, session `self-2026-10-27-eureka`)
|
||||
against CultLib `d1bf21b3`. Refreshed by `imagination-gp-stage1` (session
|
||||
`self-2026-10-27c-eureka`) against `51ca81a1`: every fork the page waited on is
|
||||
ruled, and the cells below carry the rulings.
|
||||
|
||||
## Model page
|
||||
|
||||
One row per kind. "Store" says where it lives: a CultCache document in the room's
|
||||
store, or transient per-tick data that never enters a store. Proposed values are
|
||||
marked as such; they are the design this page offers for the cuts, not landed
|
||||
behaviour. A cell that waits on an operator question names it.
|
||||
|
||||
The governing split, from `state-is-cultcache` and the CultCache scope restraint:
|
||||
every value that crosses a boundary is CultCache-encoded (one serialisation model,
|
||||
no JSON), but only state that outlives a tick is a record in a store. Inputs, ticks
|
||||
and snapshots are typed CultNet message payloads, not store records. The store
|
||||
receives exactly one write set per tick (ruled `tick-state-commit`: tick-batch).
|
||||
|
||||
| Kind | Store | Identity (what names it) | Lifecycle (what happens to it) | Authority (who decides; forbidden writers) |
|
||||
|---|---|---|---|---|
|
||||
| **Game definition** (documents, systems, authority declaration, tick rate) | Proposed: one CultCache document per server process (`gamecult.mesh.game_definition`), read-only to everyone but the server's startup. | Game id string from code (`"orbs"`), namespaced by Verse id. The id alone is not injective (two builds share it), so proposed identity is `(game id, definition hash)`, the hash derived from the lowered definition: document schema ids and versions, system ids, order and marks (`[Predicted]`, later `[Adjudicable]`), tick rate, authority declaration. Schema ids (`orbs.player`) are global strings; the game prefix is convention, not enforced (body fact F9). | Created at server startup from code; never revised at runtime; a new build supersedes it with a new hash. Clients carry their own compiled hash (C# in wasm, ruling `browser-sim-runtime`) and are refused at join on mismatch. A declaration a mode cannot honour is refused at startup (`modes-do-not-leak`). Never replayed. | The game's code, through the builder, at startup; the runtime validates. Forbidden: clients, any runtime mutation, Odin (Odin may read and index it, never write it). |
|
||||
| **Game document** (Player, Pellet, Shot) | CultCache documents in the room store. | `(schema id, record key)`. Today the key is a free string the caller picks; `[CultIndex] Id` in the illustration is an index, not the key. Proposed: the runtime mints keys. Player key derives from the session principal (one Player per principal per room). Spawned documents (Shot, Pellet) key on `(spawner key, tick, spawn ordinal within that tick)`, so a client's predicted spawn and the server's authoritative spawn derive the same key and reconcile instead of duplicating. That is injective as long as spawn order within a system step is deterministic for the predicting client, which the predicted system's own code gives under one runtime (C# both sides). The developer's `Id` field becomes redundant; whether to keep it as a mirror of the key is an API call for the cut, not an operator fork. | Created by a system during a tick, or by the join system (Player). Revised at most once per tick, in that tick's commit. Deleted by a system (pellet eaten) or by the game's leave system after a session ends. Across reconnect within the grace window the Player record is unchanged; only the session's connection binding moves. Across a server restart: gone (ruled `room-durability`: ephemeral; a restart is a new room epoch at tick 0). Replayed only on the client, during reconciliation; the server never replays in stage 1. | The server tick runner on the room's primary is the one writer of committed state, and systems write only through the tick's working set. Forbidden writers: client document puts through `CultNetDatabaseServer.HandlePutAsync` (today accepted from any peer, F1); `PutPredictedAsync` and `CultMeshGameSession.PredictAsync` on any networked store; game code calling database APIs; the simulation-fact committer (F7). |
|
||||
| **Player input** | Transient. A CultCache-encoded payload on a CultNet message; never a store record in stage 1. | `(session principal, input sequence)`, the sequence monotonic per session; each input also carries the tick it targets. Namespace: the room. Injective given the principal. The input type is a schema'd CultCache type, so the schema catalog publishes it like any document. | Sampled each client tick (devices read by the TS shell, handed to wasm); sent with the last few unacknowledged inputs repeated, so one loss is absorbed; buffered by the server per session against its target tick; consumed exactly once by the matching tick (proposed policy for late inputs: applied to the next tick and counted); acknowledged in the snapshot as "last processed sequence"; kept by the client until acknowledged, for replay; then discarded. Stage 4 changes this: inputs become signed, hash-chained and retained for a dispute window. | The client's principal authors the content; the tick runner decides which tick consumes it. Forbidden: server code altering input content; other clients; any input that arrives without a bound session. |
|
||||
| **Tick** | Transient counter; proposed: the last committed tick is written into the room record in each tick's batch, so a snapshot or checkpoint knows it. | `(room id, room epoch, tick number)`, the number starting at 0 when the room epoch starts. Injective with the epoch. | Advanced only by the server tick runner at the one declared rate (ruled `tick-model`: one-fixed-rate); never revised. The client estimates the server tick from snapshots and runs its predicted tick ahead by its round trip plus a buffer; on reconnect it re-syncs from the next full snapshot. | Server tick runner. Forbidden: client clocks, transport arrival time, wall-clock reads inside systems (systems receive the tick, not the time). Today no tick scheduler exists anywhere in the C# body (F6). |
|
||||
| **Predicted state and reconciliation** | Client-side only. In the browser they live in the C# wasm CultCache store; the TS renderer reads a per-frame view (ruled `browser-client-store`: wasm-owns-store). | Same `(schema id, record key)` as the authoritative document, plus the client tick it was predicted for. | Each client tick: predicted systems run over owned documents and the client's own spawns. On a snapshot for tick T acknowledging input sequence S: predicted documents are replaced by the authoritative ones at T, then inputs after S are replayed through the predicted systems up to the current client tick; predicted spawns are matched by derived key, and an unmatched one is removed once the acknowledgement passes its tick. Today `Reconciled` means "the authoritative put replaced the predicted copy" with no replay (F3); the replay loop does not exist. | Predicted state owns nothing outside the client. It is derived from the last authoritative snapshot plus unacknowledged inputs plus the predicted systems. The snapshot owns truth. Forbidden: sending predicted documents to the server; `CultNetClientAuthorityScope` in server mode (F4); the renderer writing. |
|
||||
| **Session and the player's identity** | Proposed: session membership as a small CultCache document in the room store (`gamecult.mesh.game_session`, low churn, readable by systems and present in snapshots). The connection handle is transient process state. | Principal: the fingerprint of a browser-held P-256 key, proved by signing a join challenge (ruled `player-identity`: browser-keypair). Connection: a server-minted id per transport connection (ephemeral). Logical session: `(principal, room)`, bound across reconnects. Today the client's identity is a self-asserted `runtimeId` string (browser client options; `SourceRuntimeId` on operation envelopes); CultMesh's session identity proves the server to the client and not the reverse (F5). The body still has no authenticated client principal (F5, F13); the session cut builds it. | Connect, then join (definition hash check, principal proof), then the session record is created and the join system spawns the Player. Disconnect suspends the session for a grace window; resume binds a new connection to the same session; expiry ends the session, systems see the leave, and the game's leave system removes the Player. On server restart sessions end (ruled `room-durability`). | The server's join and leave handler on the room primary decides membership; the principal proves itself by signing the join challenge. Forbidden: a client-asserted `runtimeId` as identity; transport peer address; peer catalogs. |
|
||||
| **Authority declaration and the lease it lowers to** | Declaration: inside the game definition. Lease: proposed as a CultCache document (`gamecult.mesh.authority_lease`); today a plain class in an in-memory dictionary (F2). | Declaration: per document type, `(writer, checker, arbiter)`. Lease: `LeaseId`, carrying Verse, peer, roles, shard ids, issuer, validity window, epoch and signature (`CultMeshAuthorityLease`). Namespace: the Verse. | Declaration fixed per build. At startup `Authority.Server` lowers to: the server holds a `ShardPrimary` lease on the room shard, and clients hold no write scope at all. Renewed before expiry; revocation or expiry stops the tick runner from committing; a new primary takes a new epoch. Proposed issuer in stage 1: the server's own deployment key, which clients pin through the browser trust policy, so an indie deployment needs no Odin. Odin-issued leases stay possible. | The game definition decides the declaration; the issuer issues leases; `CultMeshAuthorityResolver` decides whether a peer holds a role on a shard. Today the database's write gate (`CultNetShardDescriptor.IsPrimary`, `CultNetClientAuthorityScope`, both set from options) never consults the resolver: two authority structures (F2, F4). Proposed: the write gate is derived from lease resolution. First step (cut `remote-write-authority`): a peer's put or delete reaches the database only when `CultNetDatabaseServerOptions.RemoteWriteAuthority` grants it; none configured refuses every remote write (F1, GP1). The lease-backed grant needs an authenticated principal bound to the connection, so it follows the session cut. `CultNetClientAuthorityScope` is no longer an owner; it is derived from the declaration, and in stage 1 it is empty. |
|
||||
| **Shard / room** (stage 1: one) | `CultNetShardDescriptor` (configuration); the room's state is its CultCache store. | Room id `(Verse, game id, room name)`, for example `orbs/main`, carried as the shard id plus the shard epoch. | Created at server start (epoch 1; on restart a new epoch, ruled `room-durability`); never moved in stage 1; ended at shutdown. Join means "join the one room"; matchmaking is out of scope. Interest management is stage 5 per the target, so in stage 1 every client receives the whole room. | The runtime holding the `ShardPrimary` lease. Forbidden: a second primary; clients. |
|
||||
| **Snapshot sent to clients** | Transient. A CultNet message whose payload is CultCache-encoded documents; never stored. The server keeps per-client baselines in memory for deltas. | `(room, epoch, tick, recipient session, baseline tick)`. | Derived after a tick commit at the send rate; a delta against the recipient's last acknowledged snapshot, or a full snapshot on join and resume; carries the last processed input sequence for that session. Discarded once sent; a baseline is dropped when a newer one is acknowledged. Today the nearest things are per-record change pushes from `CultNetDatabaseSubscriptionServer` (no tick, no acknowledgement, no input sequence) and shard catch-up snapshots in `CultMeshSnapshots.cs` (F8). | The snapshot builder decides nothing; it is derived from the committed tick. Forbidden: the per-record subscription stream acting as a second game replication path. That stream stays for non-game observers. |
|
||||
| **Simulation fact / witness observation** (found) | `CultMeshSimulationFact` is a CultCache document; observations are hub memory. | `(shard, epoch, frame, subject, kind)`. | Stage 1: not used. The target replaces it at stage 4. | Today `CultMeshGameSession` serves observations by default (`ServeSimulationObservations = true`) and commits quorum facts through the primary (F7). In server mode there is no checker, so the observation server and the committer must not be wired. Forbidden writer in stage 1. |
|
||||
| **Shard mutation log entry** (found) | `CultNetDatabase` in-memory `SortedList` per shard, plus an optional durable store. | `(shard id, sequence)`. | Today every primary write appends one entry, and the in-memory list is never trimmed (F10). Per-record writes at tick rate would grow it without bound. In stage 1 the tick's write set either bypasses the per-record log or lands as one bounded entry per tick. Ruled: the tick's write set lands as one batch per tick (`tick-state-commit`), in memory (`room-durability`); the bounded per-tick entry is the tick-runner cut's to specify. | `CultNetDatabase` on the primary. Forbidden: per-system-write appends from the tick runner. |
|
||||
|
||||
### Cells filled by rulings (2026-10-27)
|
||||
|
||||
- Session principal: `player-identity` (browser-keypair). Not yet in the body (F5, F13).
|
||||
- Browser documents and the renderer's read: `browser-client-store` (wasm-owns-store).
|
||||
- Room state across restarts: `room-durability` (ephemeral).
|
||||
- Tick rate model: `tick-model` (one-fixed-rate).
|
||||
- How a tick reaches the store: `tick-state-commit` (tick-batch; the tick-runner cut times `CultCacheBatch` at tick rate first).
|
||||
- Stage 1 transport: `browser-transport` (WebSocket behind a port carrying only inputs and snapshots; a WebTransport server in parallel, `webtransport-server-layer`: managed C# over the MsQuic bridge).
|
||||
- Still unprobed: `CultCacheBatch` commit cost at tick rate for about 50 players plus pellets (wasm measuring probe and tick-runner cut).
|
||||
|
||||
## Body facts
|
||||
|
||||
All are source reads at CultLib `d1bf21b3` on 2026-10-27 by `imagination-gp-0b`. No
|
||||
runtime probe was run in this pass. Each fact names the read that would falsify it.
|
||||
|
||||
- **F1. The primary applies any peer's document put.** `CultNetDatabaseServer.HandlePutAsync(message, peer)` (`src/GameCult.Networking/CultNetDatabaseServer.cs:307`) calls `ApplyPutAsync(message)` (`CultNetDatabase.cs:1022`), which checks only that this runtime is primary for the shard and the epoch matches (`EnsurePrimary`, `:2003`). `peer` is used only to send errors. Any connected client can overwrite any game document on the server.
|
||||
- **F2. Leases are not typed state and do not gate writes.** `CultMeshAuthorityLease` is a plain class (no `[CultDocument]`); `CultMeshAuthorityLeaseCatalog` is a `Dictionary` (`CultMeshAuthority.cs:242`). The only `[CultDocument]` types in `GameCult.Mesh` are body publication, CDN chunk and manifest, content transfer state, discovery state, entity prefab package and simulation fact. `GameCult.Networking` (the database) references no lease type; the one Networking file naming `Lease` is `CultNetSchemaMessages.cs`.
|
||||
- **F3. Reconciliation is replacement, not replay.** `CultNetDatabase.cs:1247-1252`: a predicted put records a prediction key; the next non-predicting write to that key turns the change into `Reconciled`. No code re-applies inputs after an authoritative state.
|
||||
- **F4. Client write scope is a runtime-id string match from options.** `CultNetClientAuthorityScope.Matches` compares `OwnerRuntimeId` with the database's own `_runtimeId` (`CultNetDatabase.cs:111-153`, `EnsureClientAuthority` `:1934`). It gates local prediction only and has no relation to leases.
|
||||
- **F5. No authenticated client principal.** `CultMeshSessionIdentity.cs` verifies transports for `(verse, authorityRuntimeId, protocol, routeGeneration)`, which proves the server to the client. The browser client's `runtimeId` (`packages/cultmesh-browser/src/index.ts:182`) and the operation envelope's `SourceRuntimeId` (`CultNetOperationServer.cs:39`) are self-asserted.
|
||||
- **F6. No tick scheduler.** A grep of `GameCult.Networking` and `GameCult.Mesh` for tick or timestep names finds none. `Frame` exists only as a witness-asserted number in `CultNetSimulationConsensus` and `CultMeshSimulationFact`.
|
||||
- **F7. The game session serves the witness path by default.** `CultMeshGameSession` constructs `CultNetSimulationObservationServer` when `ServeSimulationObservations` (default `true`) and always constructs a `CultMeshSimulationFactCommitter` (`CultMeshGameSession.cs:70-104`). `PredictAsync` is `PutPredictedAsync` (`:161`).
|
||||
- **F8. The browser can watch and invoke, not put.** The `cultmesh-browser` README says it "does not expose direct document writes". The client offers `leaseRawDocument` (`index.ts:349`) and `invoke` of typed operations (`:370`), over one WebSocket. On C#: `CultNetDatabaseSubscriptionServer` and `CultNetOperationServer` take `ICultNetSchemaServer`, so they run over `CultNetWebSocketSchemaServer`; `CultNetDatabaseServer` (puts) takes the concrete UDP `Server` (`CultNetDatabaseServer.cs:36`). Operations are request and response with receipts and idempotency keys, which is a poor fit for an input stream at tick rate.
|
||||
- **F9. Schema-id namespacing is convention.** `[CultDocument("orbs.player", ...)]` ids are global strings; nothing ties a schema id to a game id.
|
||||
- **F10. The in-memory mutation log is never trimmed.** `RecordMutationLogEntry` (`CultNetDatabase.cs:1610`) inserts into a per-shard `SortedList`; no `Remove` or trim on that list exists in the file. Compaction applies to the durable store and to burned sequences only.
|
||||
- **F11. CultCache has a write-set batch; CultNetDatabase does not expose one.** `CultCacheBatch` with `Commit` and `TryCommit` (`GameCult.Caching/CultCache.cs:1796`, `:2212`) is the 2.0.0 write set (`variants:ruling:write-set-content-version-declared-ownership`). `CultNetDatabase` has no batch method: every write is a per-record door.
|
||||
- **F12. A reflection site on the mesh put path.** `CultMesh.cs:3502` `PutDatabaseDocumentAsync` picks `PutAsync` or `PutPredictedAsync` by `GetMethods` and `MakeGenericMethod`, which matters to the AOT prerequisite (`cultlib-gaps:follow_up:cs-aot-safe-registration`; probe in `research/cultmesh-garden-path/cultcache-aot-probe-2026-10-27.md`).
|
||||
|
||||
- **GP1 (runtime probe, 2026-10-28, `imagination-gp-stage1`).** Scratch NUnit test at `51ca81a1`, run on Yggdrasil (`ygg-verify.sh`, `mcr.microsoft.com/dotnet/sdk:10.0`, filter `ImagGpProbeTests`, never pushed to origin). The server writes `gp:player` = "server-owned" through `CultNetDatabase.PutAsync` on a primary shard. `CultNetDatabaseServer._putHandler`, invoked with an uninitialized `CultNetServerPeer` (no field set: no endpoint, no transport, no identity) and a put of "client-overwrote", leaves the record "client-overwrote", and `Server.CanProcessBeforeVerification(put)` returns `true`. Output: `GP1 after=client-overwrote canProcessBeforeVerification=True`, Passed 1. This confirms F1 at runtime: nothing about the peer is consulted, and puts and deletes pass before the legacy login verification (`Server.cs:562-575`).
|
||||
- **F13. A server peer carries no identity.** `CultNetServerPeer` (`Server.cs:829`) holds the LiteNetLib `NetPeer`, its endpoint and transport stats. The only principal on `Server` is the legacy username and password login (`PlayerData.PlayerId`, `IsVerified` `:697`), which the database door never reads. `ServerSecurityOptions` is one shared `ConnectionKey`: transport admission, not authority.
|
||||
- **F14. A new refusal code is a coordinated change.** The `cultnet.error.v0` `code` is a closed enum (`contracts/cultnet/cultnet.error.schema.json:28-31`, with a byte-identical copy under `packages/cultnet-rs/contracts/`). Rust `CultNetErrorCode` (`packages/cultnet-rs/src/contracts.rs:242-273`) refuses unknown codes, `packages/cultnet-rs/tests/error_message.rs` decodes captured C# bytes per code, and TypeScript imports the root schema (`packages/cultnet-ts/src/contracts.ts:10`). `unowned_schema` was added this way and is the pattern.
|
||||
- **F15. Forwarding re-sends as a new client.** `TryForwardPutAsync` (`CultNetDatabaseServer.cs:654`) forwards a `not_primary` put through `ICultNetShardWriteForwarder`, and `CultNetSchemaWriteForwarder` opens its own client connection (`CultNetShardWriteForwarding.cs:122-128`). The primary therefore sees the forwarding runtime, never the original peer.
|
||||
- **F16. Consumers of the remote write door.** No C# consumer outside CultLib constructs `CultNetDatabaseServer` (grep of the Aetheria, Epiphany, Ghostlight, huginn and gamecult-ops `*.cs` files finds only vendored CultLib copies). In CultLib, tests reach the put and delete handlers directly in `CultNetJournalTests.cs:738`, `:935-936` and `SoulP4cProbeTests.cs:57`. `CultNetDatabaseServer.ApplyPutAsync` and `ApplyDeleteAsync` (`:73`, `:81`) have no caller in `src` or `tests` besides the handlers. TypeScript puts (the `cultnet-ts` Idunn presence and signed health publishers) target Rust servers.
|
||||
- **F17. The Rust document server admits a put by size only.** In `packages/cultmesh-rs/src/rudp_document_server.rs:682-720`, a `DocumentPutRaw` is refused only when its served response would not fit; no authority is asked. This is F1's gap in the Rust runtime (a follow-up, not stage 1).
|
||||
|
||||
## Cut sequence (stage 1)
|
||||
|
||||
One line per cut not yet mapped. The model page has no empty cell as of 2026-10-28. Remote write authority (`cultmesh-garden-path:cut_spec:cut-remote-write-authority.r1`, mapped 2026-10-28, so no longer a line here) goes first: it waits on nothing, every later server cut stands on a closed write door, and GP1 shows the door open today. The wasm measuring probe is the sibling that may run beside it, since it touches no file this cut does; ruling `browser-sim-runtime` names it the first cut of the browser runtime track, not of stage 1 as a whole.
|
||||
|
||||
- **Wasm runtime measuring probe.** Download size, startup and 60 Hz tick cost of a stub orbs tick under Mono browser-wasm and NetWasm, including `CultCacheBatch` commit cost at tick rate. This is the first cut named by ruling `browser-sim-runtime`, which it can overturn. Its NetWasm and AOT variant waits on `cultlib-gaps:follow_up:cs-aot-safe-registration`.
|
||||
- **Witness services off in server mode.** `CultMeshGameSession` stops constructing the observation server and fact committer when the declaration has no checker (F7). Waits on nothing; can run beside remote write authority.
|
||||
- **Leases as typed state and the lease-derived grant.** `CultMeshAuthorityLease` becomes a CultCache document, the lease catalog a store view, and the Mesh host's `RemoteWriteAuthority` resolves the connection's principal through `CultMeshAuthorityResolver`. Waits on remote write authority and on session and principal.
|
||||
- **Demote `CultNetClientAuthorityScope`.** It becomes derived from the authority declaration (empty in server mode). Waits on game definition.
|
||||
- **Changelog entry for the remote write refusal.** `unity/org.gamecult.cultlib/CHANGELOG.md` is also written by `variants:cut_spec:cut-write-ownership-cs.r1`, so Self adds this cut's bullet at merge, after that cut lands, and the two specs share no file.
|
||||
- **Game definition and server tick runner.** Builder, document and system registration, definition document and hash, fixed tick, tick-boundary commit. Rests on rulings `tick-model`, `tick-state-commit` and `room-durability`; waits on nothing mapped.
|
||||
- **Session and principal.** Join, resume and leave; principal proof; session document. Rests on ruling `player-identity`; waits on game definition (the join checks the definition hash).
|
||||
- **Input and snapshot path.** Input messages with redundancy, a server input buffer, acknowledgements, delta snapshots. Rests on ruling `browser-transport`; waits on session and principal.
|
||||
- **Browser client runtime.** Wasm simulation host, TypeScript shell for transport and rendering, prediction with replay reconciliation. Waits on the measuring probe and `browser-client-store`.
|
||||
- **Orbs reference game and garden-path proof.** The game written with no networking code (`garden-path-needs-no-networking`), and a browser client that writes (`browser-is-first-class`). Waits on all of the above.
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
# CultMesh garden path: target
|
||||
|
||||
Status: draft target for campaign `cultmesh-garden-path`, 2026-10-27. It holds the
|
||||
ends, not the means. The code below is illustrative: it shows what a developer
|
||||
should have to write, and no name in it is decided.
|
||||
|
||||
## The problem
|
||||
|
||||
Operator, 2026-10-27: "the value is in solving the multiplayer scaling problem
|
||||
for indie developers in an ergonomic fashion. Having one authoritative server is
|
||||
fine, up to a point, but there's no real open source playbook on how to ship an
|
||||
MMO, and existing solutions like SpacetimeDB have a huge ergonomics gap. I want
|
||||
CultMesh to feel like magic: as a garden path, you annotate your domain POCOs and
|
||||
simulation code and declare your authority structure ... and it'll all just work
|
||||
without the developer needing to understand networking deeply."
|
||||
|
||||
Prior art: `research/cultmesh-garden-path/prior-art-2026-10-27.md` (frameworks,
|
||||
ergonomics, scale-out) and `research/witness-authoritative-networking/` (consensus
|
||||
and P2P anti-cheat). What the field shows, in short:
|
||||
|
||||
- One API over several authority modes ships today (Photon Fusion: Server, Host,
|
||||
Shared; Unity NGO: client-server and distributed authority), and in both the
|
||||
modes leak into the developer's code (`IsServer` against `HasAuthority`,
|
||||
features missing in one mode).
|
||||
- SpacetimeDB gives one machine per database. Scaling past it, client
|
||||
prediction, and the reducer-shaped rewrite of game code are left to the
|
||||
developer.
|
||||
- Determinism, where it is required at all, is required of the whole simulation
|
||||
(Croquet/Multisynq, Photon Quantum), and that tax is the main ergonomic cost.
|
||||
- Shipped world-scale designs treat a spatial unit as indivisible and move
|
||||
players between units (EVE, Pax Dei, Folia); nobody publishes an open,
|
||||
end-to-end playbook (Astron, the nearest, is discontinued).
|
||||
|
||||
## What a developer writes (illustrative)
|
||||
|
||||
The reference game is a small browser .io game (operator, 2026-10-27: "the .io
|
||||
game, Aetheria later"): players steer orbs, eat pellets, and shoot.
|
||||
|
||||
```csharp
|
||||
[CultDocument("orbs.player", "orbs.player.v1")]
|
||||
public sealed class Player
|
||||
{
|
||||
[CultIndex] public string Id { get; set; } = "";
|
||||
public Fix2 Position { get; set; }
|
||||
public Fix2 Velocity { get; set; }
|
||||
public int Mass { get; set; }
|
||||
}
|
||||
|
||||
[CultDocument("orbs.shot", "orbs.shot.v1")]
|
||||
public sealed class Shot
|
||||
{
|
||||
[CultIndex] public string Id { get; set; } = "";
|
||||
public string Shooter { get; set; } = "";
|
||||
public Fix2 Origin { get; set; }
|
||||
public Fix2 Direction { get; set; }
|
||||
}
|
||||
|
||||
// Runs every tick. Predicted on the owning client, corrected by authority.
|
||||
public sealed class Movement : CultSystem
|
||||
{
|
||||
[Predicted]
|
||||
public void Step(Player player, PlayerInput input, Tick tick) { /* ... */ }
|
||||
}
|
||||
|
||||
// A non-owner may re-run this to settle a dispute, so it must be
|
||||
// deterministic. The analyzer enforces that on this system only.
|
||||
[Adjudicable]
|
||||
public sealed class Hits : CultSystem
|
||||
{
|
||||
public HitResult Resolve(Shot shot, Rewind<Player> target) { /* ... */ }
|
||||
}
|
||||
|
||||
var game = CultMesh.Game("orbs")
|
||||
.Documents<Player, Pellet, Shot>()
|
||||
.Systems<Movement, Eating, Hits>()
|
||||
.Authority(Authority.Server);
|
||||
// .Authority(Authority.TrustedCoop);
|
||||
// .Authority(Authority.Witnessed(w => w.Challenger(Party.Victim).Quorum(3).Arbiter(Party.Server)));
|
||||
// .Authority(Authority.Regions(r => r.Grid(512).Handoff())); // later: past one server
|
||||
```
|
||||
|
||||
Changing the authority line is the whole migration between modes, unless the new
|
||||
mode re-executes a system on a non-owner and that system is not yet
|
||||
deterministic. Then the analyzer names exactly those systems.
|
||||
|
||||
## Invariants
|
||||
|
||||
- `one-model`: server-authoritative, trusted co-op, witnessed and regioned
|
||||
authority are configurations of one authority declaration, answering per
|
||||
document: who writes, who checks, who arbitrates. No mode has its own API for
|
||||
state, systems or queries.
|
||||
- `modes-do-not-leak`: game code does not branch on the authority mode or ask
|
||||
"am I the server"; a system states what it does and the runtime decides where
|
||||
it runs. Where a mode cannot offer a capability, the declaration is refused at
|
||||
startup with a reason, never degraded silently at runtime.
|
||||
- `determinism-is-per-system`: only code a non-owner may re-execute must be
|
||||
deterministic. That requirement is marked on the system and checked by an
|
||||
analyzer at build time, not by documentation or by desync in production.
|
||||
- `state-is-cultcache`: game state is ordinary CultCache documents; the
|
||||
framework adds no second state model and no JSON on the load-bearing path.
|
||||
- `authority-is-typed-state`: who holds authority over what is CultMesh state
|
||||
(leases, shard primaries), inspectable and revocable, never inferred from
|
||||
transport success or peer catalogs.
|
||||
- `garden-path-needs-no-networking`: the reference game is written without the
|
||||
developer touching sockets, serialisation, tick scheduling, prediction or
|
||||
reconciliation code.
|
||||
- `honest-guarantees`: each mode states its fault model (trusted server, trusted
|
||||
peers, honest-majority sampled witnesses with server arbitration) and its
|
||||
documentation and tests claim nothing past it.
|
||||
- `browser-is-first-class`: the reference game's client runs in a browser and can
|
||||
write (submit inputs, own predicted state), not only watch.
|
||||
- `built-for-load`: what the garden path ships that is database-shaped (the
|
||||
tick commit, the batch door, logs, subscriptions) is architected for
|
||||
unreasonable load from the start (operator 2026-10-27,
|
||||
`cultlib-gaps:ruling:database-shaped-built-for-load`): "if what we're shipping
|
||||
is database shaped, people will use it as such".
|
||||
|
||||
## Sequence (each a campaign or stage of its own; one foundation at a time)
|
||||
|
||||
1. Server-authoritative garden path for the .io game: documents, systems,
|
||||
ticked scheduling, client prediction and reconciliation, browser client that
|
||||
writes. This is the proof of ergonomics.
|
||||
2. Trusted co-op: authority moves to the owning peer; same game code.
|
||||
3. Deterministic dispute kernel (CultMath fixed-point hit tests, cross-runtime
|
||||
vectors) and the `[Adjudicable]` analyzer.
|
||||
4. Witnessed authority: signed, hash-chained inputs; victim challenge; sampled
|
||||
network-near witnesses; server arbitration; browser-to-browser channels.
|
||||
Demonstration: a cheating client caught and corrected.
|
||||
5. Regions: spatial authority handoff and interest management past one server.
|
||||
6. Aetheria on the same declaration.
|
||||
|
||||
## Not in scope
|
||||
|
||||
- Aetheria until stage 6. Eve and Gjallar (deprecated).
|
||||
- A new CultCache storage engine (the paged single-file store stays postponed,
|
||||
`variants:ruling:page-tree-store-postponed`). The garden path fills gaps in
|
||||
their owners, built for load.
|
||||
- Asynchronous Byzantine total ordering, zero-knowledge proofs, and chains of
|
||||
any kind.
|
||||
- The existing witness aggregator (`CultNetSimulationConsensus`, unsigned and
|
||||
weighted by observed votes) as a base for stage 4: it is the trusted co-op
|
||||
first stage by design and will be replaced, not extended.
|
||||
|
||||
## Open forks (to the Fork Desk, each with prior art first)
|
||||
|
||||
1. Where simulation code runs in the browser: C# compiled to WebAssembly (one
|
||||
implementation everywhere, which also shrinks the determinism problem to
|
||||
fixed-point maths, at the cost of runtime download size), C# on the server
|
||||
with TypeScript systems in the browser (two implementations of each predicted
|
||||
system), or another arrangement.
|
||||
2. Server transport for the browser: WebSocket as today, or WebTransport
|
||||
(QUIC datagrams) on the provider side.
|
||||
3. Tick model: fixed tick with a declared rate, or per-system rates.
|
||||
|
|
@ -1,286 +0,0 @@
|
|||
# TypeScript CultMesh Content Plane: Map
|
||||
|
||||
Campaign `cultmesh-ts-content`. Imagination wrote this on 2026-10-19 at CultLib
|
||||
`8990bd46` (origin/main `a88c629e` plus the target doc), and refreshed it the
|
||||
same day at `2de07065` for C2 and C3, reading C1 from `b8b825e1`. The target
|
||||
is `docs/cultmesh-ts-content-target.md`. The HTTP prior art is
|
||||
`docs/research/2026-10-19-content-addressed-http-answers.md` (cited below as
|
||||
**R**, with its section number). This page holds the body facts, the model
|
||||
page, the rationale and the cut sequence. Specs and questions live in the
|
||||
typed mind, not here.
|
||||
|
||||
## 1. Body facts
|
||||
|
||||
F1-F18 were probed by running code or read from source at `8990bd46`. F19-F27
|
||||
were probed or read at `b8b825e1` (C1's head) and `2de07065`. F28-F33 were
|
||||
probed on 2026-10-19 over the C2+C3 merge tree (`7adbf3cb` merged with C3's
|
||||
head, extracted by `git archive`), on Windows and in a Linux container. Scratch probes
|
||||
ran under the session scratchpad (`imagination/`), not in the repo.
|
||||
|
||||
| # | Fact | Evidence |
|
||||
|---|---|---|
|
||||
| F1 | A C#-packed manifest is a MessagePack **fixarray of 10** (`0x9a`): `[artifactId, kind, version, contentHash, sizeBytes, mimeType, createdAtUtc, chunks[], tags[], metadata{}]`. Each chunk ref is a 4-array `[chunkHash, offset, sizeBytes, recordKey]`. The fixture manifest is 1,111 bytes: 6 chunks (5 x 262,144 + 4,831), content hash `f4ae2a7c…2af1`. The empty body packs to 315 bytes with one zero-size chunk whose hash is `e3b0c442…b855` (the SHA-256 of nothing). | probe: `@msgpack/msgpack` 3.1.3 decode of `contracts/cultmesh/content-vectors.cs-written.json`, kind `manifest` |
|
||||
| F2 | `@msgpack/msgpack` 3.1.3 re-encodes all four C# manifest vectors **byte-identically**, metadata included (`zeta, alpha, mid` keep insertion order). Integers come out minimal (`4831` is `cd12df`, `262144` is `ce00040000`), as MessagePack-CSharp writes them. | same probe, `encode(decode(bytes))` equals the input for all four |
|
||||
| F3 | Three decoder hazards for a TS manifest reader. (a) JS objects put integer-like keys first (`{zeta,"2",alpha,"1"}` iterates `1,2,zeta,alpha`), so metadata must be ordered entries, not an object, or a `{"10":…,"2":…}` manifest re-encodes in another order. (b) Plain `decode` takes the last of duplicate map keys silently (`{"a":"2"}`); C# `Dictionary` and Rust `ordered_map` refuse duplicates. `new Decoder({ mapKeyConverter })` can refuse them: probed. (c) Plain `decode` rounds int64 above 2^53 (`…993` reads as `…992`); `useBigInt64: true` keeps it exact. | probe (node) |
|
||||
| F4 | `@msgpack/msgpack`'s decoder is iterative: a 1,000,000-deep `0x91` input decodes in 160 ms but costs about 158 MB of heap. So nesting does not crash it but amplifies memory about 150 times. `checkPayloadDepth` (the owner of ruling `payload-depth-limit-256`) is not exported from any `@gamecult/cultcache-ts` entry: `document.ts:108` exports only the error and the constant. | probe (node); source `packages/cultcache-ts/src/document.ts:2,108,123` |
|
||||
| F5 | The C# HTTPS connector sends `GET <endpoint>?chunkHash=<hex>&recordKey=<escaped>`, **without** a `sha256:` prefix, because `BuildChunkUri` runs `NormalizeHash` first. Probed URIs: `https://ygg.example/content?chunkHash=5993ac…&recordKey=mesh%3Acdn%3Achunk%3A5993ac…`. An existing endpoint query is kept and joined with `&` (`?tenant=sp&chunkHash=…`). A null record key sends `recordKey=` (empty). Other characters use `Uri.EscapeDataString`, so on the wire `k with space/ü&=?#` becomes `k%20with%20space%2F%C3%BC%26%3D%3F%23`. | probe: `BuildChunkUri` lines copied into a net10.0 console (`CultMeshHttpsContentTransport.cs:114-122`) |
|
||||
| F6 | The connector calls `EnsureSuccessStatusCode` and fails on any status outside 2xx. When `Content-Length` is present and is not the chunk's `SizeBytes`, it throws before reading. With no `Content-Length` it copies the stream without checking the length. Verification belongs to the caller's transfer owner. `CanConnect` admits only `https`. The constructor takes a `Func<HttpMessageHandler>`, so a test can pin a self-signed certificate without modifying the connector. | source `CultMeshHttpsContentTransport.cs:18-29,92-104` |
|
||||
| F7 | `NormalizeHash` (C#) strips a case-insensitive `sha256:` prefix *before* trimming, then `Trim().ToLowerInvariant()`. It never checks for hex or a length of 64. So the record-key grammar accepts any non-blank string, and a provider must add its own hex check before a hash can become a file name. | source `CultMeshCdn.cs:549-560` |
|
||||
| F8 | `StableToken` (the token in a manifest record key) is a C# rule with no Rust mirror: `cultnet-rs/src/content.rs` builds no artifact record key. Its Unicode semantics differ from the naive JS spelling in four probed ways. .NET `ToLowerInvariant` leaves `U+0130` unchanged, while JS gives `i` plus `U+0307`. .NET `Trim` keeps `U+FEFF` (and trims `U+0085`, per `char.IsWhiteSpace`), while JS `trim` drops `U+FEFF` and keeps `U+0085`. JS whole-string lowercasing applies final-sigma (`ΑΣ` becomes `ας`), while per-unit lowercasing gives `ασ`. `char.IsLetterOrDigit` is false on a lone surrogate and on `U+00B2`, which JS `\p{L}\p{Nd}` agrees with only when it is tested per UTF-16 unit. | probe (net10.0 console and node); source `CultMeshCdn.cs:562-580` |
|
||||
| F9 | `CreatedAtUtc` defaults to `DateTimeOffset.UtcNow.ToString("O")`, which looks like `2026-10-19T12:07:25.5235579+00:00`: 7 fractional digits and `+00:00`, never `Z`. | probe (net10.0) |
|
||||
| F10 | `contracts/cultmesh/content-vectors.cs-written.json` and `.rs-written.json` are written by `tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs` and `packages/cultnet-rs/tests/content.rs`, under `CULTNET_WRITE_VECTORS=1`. Each side judges both files. Kinds are `request`, `response` and `manifest`. Rust panics on an unknown kind (`tests/content.rs:302`), and C# requires the Rust labels to equal its own (`CultMeshContentVectorTests.cs:54`). **So a new kind or label in the cs-written file breaks both siblings.** A TS file must be a separate file. | source |
|
||||
| F11 | **Neither content-vector suite runs in CI.** `cultmesh-portability.yml` filters `dotnet test` to `AuthorityProof`, `RealtimeWireProtocol` and a lease churn test. `cultnet-interop.yml` runs `cargo test … rudp` (a name filter) and the Networking `Rudp` filter. The C#/Rust content parity has only ever been run by hand. | grep of `.github/workflows/*.yml` |
|
||||
| F12 | TS→C# vector precedent: `scripts/write-cultmesh-realtime-frame-vectors.mjs` writes `realtime-frame-vectors.ts-written.json` from built `dist`, deliberately from **different inputs** than the C# file. `CultMeshRealtimeWireProtocolTests` judges it, and `cultmesh-portability.yml:145-149` runs it. | source |
|
||||
| F13 | Cross-process precedent for C# against a Node provider exists. `packages/cultnet-ts/test/interop/cultnet-interop.test.ts:536-560` spawns the TS QUIC provider, then runs `dotnet test --filter NativeConnectorReceivesFromExternalProvider` with the endpoint in `CULTMESH_NATIVE_EXTERNAL_ENDPOINT`. The C# test skips without the variable (`tests/GameCult.Mesh.Quic.Native.Tests`, line 21). | source |
|
||||
| F14 | `cultnet-ts` is CommonJS. Its `./wire`, `./contracts` and `./authority` subpaths import only `@msgpack/msgpack`, `ajv` and `cultcache-ts/document`, so they are browser-safe. Its root `index` pulls `node:` modules (`peer`, `rudp`, `transport`, `security` and others). `cultmesh-browser` (ESM) already imports `cultnet-ts/wire`, `/contracts` and `/authority`. `cultmesh-ts` loads `koffi` lazily, inside a function (`realtime-quic-native.ts:139`), so a new subpath of it pays no native load. The root has `esbuild` ^0.28.1 as a devDependency. | source, grep |
|
||||
| F15 | TS packages build with `tsc` and test with `node --test dist-test/test/*.test.js` per workspace. `scripts/run-typescript-workspaces.mjs` runs them in order `cultcache-ts, cultnet-ts, cultmesh-ts, cultmesh-browser`, after `check-typescript-tests-wired.mjs`. No StrykerJS config exists in CultLib (`docs/mutation-testing.md:156-159`). | source |
|
||||
| F16 | **No CultLib TS package except `cultcache-ts` has a publish job.** `publish-packages.yml` triggers on `cultcache-ts-v*`, `cultcache-py-v*`, `cultnet-py-v*` and `cultmesh-py-v*` only. Tags `cultnet-ts-v0.1.1` and `cultmesh-ts-v0.1.1` exist, but no workflow consumes them. `npm view` returns 404 anonymously for `cultnet-ts`, `cultmesh-ts`, `cultmesh-browser` and even `@gamecult/cultcache-ts` (`@gamecult/cultcache-ts` may be private; unverified). The doctrine's rule of at most three tags per push stands (SKILL.md:674). | source; `npm view` |
|
||||
| F17 | StreamPixels consumes CultLib as the git submodule `vendor/CultLib` (gitlink `30ee8b97`) with `file:` deps and `pnpm.overrides`. Its CI and Idunn recipes build the vendored workspaces (`npm ci --ignore-scripts`, then build `cultcache-ts`, `cultnet-ts` and `cultmesh-ts`). `apps/web` depends only on `cultnet-ts` today, not on `cultmesh-browser`. | StreamPixels `.gitmodules`, `apps/*/package.json`, CI workflow lines 62-95 |
|
||||
| F18 | The Rust reader sets two guards that the TS reader should match. A caller cap `max_bytes` is checked before any request. And a zero-size chunk is refused unless it is the only one, because a manifest of zero-size chunks would be a request flood. | source `cultnet-rs/src/content.rs:364-385` |
|
||||
| F19 | `BuildChunkUri` on more endpoint shapes, as `AbsoluteUri` (what `HttpClient` sends). `https://h` and `https://h/` both give `https://h/?chunkHash=…&recordKey=k`. `https://h:443/x` drops the default port. `https://H.Example/X/` lowercases the host and keeps the path's case. `https://h/x#frag` keeps the fragment after the query. `https://h/x?a=%20b+c` keeps the existing query verbatim and joins with `&`. **`https://h/x?` (an empty query) gives `https://h/x?&chunkHash=…`**, because `UriBuilder.Query` is `?`, which is not blank. `https://[::1]:8443/c` keeps the bracketed host. `Uri.EscapeDataString("!'()*~-._ +é𐐀")` is `%21%27%28%29%2A~-._%20%2B%C3%A9%F0%90%90%80`: unlike JS `encodeURIComponent`, it escapes `!'()*`. | probe: `dotnet fsi` on .NET 10, the three lines of `BuildChunkUri` (`CultMeshHttpsContentTransport.cs:114-122`) copied verbatim |
|
||||
| F20 | Node 24.15 global `fetch` (undici) against a raw-socket server. A gzip body (`Content-Encoding: gzip`, `Content-Length: 29`) arrives as 1,000 decoded bytes, while `headers.get("content-length")` still says `29`. A body shorter than its `Content-Length` (400 of 1,000) rejects while reading: `TypeError: terminated`, cause code `UND_ERR_RES_CONTENT_LENGTH_MISMATCH`. A body longer than its `Content-Length` (1,000 against 400) is cut silently to 400 by HTTP framing. With no `Content-Length`, all 1,000 bytes arrive. So a reader that trusts the header refuses valid gzipped content, and it learns nothing the received bytes do not tell it (R §6: Fetch decodes content codings, and its own note calls `Content-Length` unreliable). | probe: `imagination/fetchprobe.mjs` |
|
||||
| F21 | The C# TCP content server is the reference for what a provider does per request (`SendChunkAsync`, `CultMeshTcpContentTransport.cs:384-430`). It runs `NormalizeHash` on the requested hash, refuses a non-blank `RecordKey` that is not `CreateRecordKey(hash)` (ordinal compare, message "record key disagrees with its content hash"), resolves the chunk **by hash** (`_resolveChunk(hash)`), and runs `CultMeshCdn.ValidateChunkPayload` before it sends a byte. So **verify-before-serve on every request is the reference behaviour**. C# chunk storage is one record per chunk, keyed by the chunk record key (`CultMeshDatabaseContentProvider`, `CultMeshContentTransfer.cs:41-71`). Per-chunk storage is the reference layout. | source |
|
||||
| F22 | C1's exports at `b8b825e1` (`packages/cultnet-ts/src/content.ts`): `normalizeHash`, `parseSha256Hex`, `sha256Hex`, `chunkRecordKey`, `artifactRecordKey` (async), `validateManifest`, `encodeManifest`, `decodeManifest`, `packContent`, `CULTMESH_CDN_MANIFEST_SCHEMA`, `DEFAULT_CHUNK_SIZE_BYTES` (4,194,304), `EMPTY_SHA256`, and the types `CultMeshCdnChunkRef`, `CultMeshCdnArtifactManifest`, `CultMeshCdnPackOptions` and `CultMeshCdnPackedChunk`. `sha256Hex` throws a plain `Error` ("WebCrypto SHA-256 is unavailable.") when `crypto.subtle` is missing. `validateManifest` takes the whole manifest (it reads `metadata`). `cultnet-ts/authority` already exports `isLoopbackEndpoint` (`cultmesh-authority.ts:377`: `localhost`, `loopback`, `::1`, `::ffff:7f00:1`, `127.x.x.x`), and `cultmesh-browser` already imports it (`index.ts:23`). | `git show b8b825e1:…`; cut report `cut-c1-shapes.h1` landed names |
|
||||
| F23 | The consumer (StreamPixels `dfbe2a6`, `docs/web-character-customization-map.md`) stores one whole-artifact file per content hash at `/srv/streampixels/content/<hex>`, builds manifests at service boot, and hashes every referenced blob at boot (lines 149-159, 175, 317-325). Its artifacts are 10-125 KB (W10), well under the 4 MiB default chunk. It also builds manifests for `catalog.cc` and `character-artifacts.cc`, whose sizes the map does not give. It mounts the handler at `GET /api/content` on its existing server (line 320). Its sketch, `createHttpsContentHandler(store)` (line 293), takes `(chunkHash, recordKey)` and returns bytes or not-found. | StreamPixels `git show dfbe2a6:docs/web-character-customization-map.md` |
|
||||
| F24 | Build shapes for the two packages. `cultmesh-ts` is CommonJS (`module` and `moduleResolution` `Node16`), and its `package.json` exports only `.` (lines 7-13). `cultmesh-browser` is ESM (`"type": "module"`, `moduleResolution` `Bundler`) and is one file, `src/index.ts`, with no relative imports. A new relative import must therefore be spelled `./content.js`, or Node's ESM loader cannot find it at test time. Both packages' `test` scripts run the glob `dist-test/test/*.test.js`, which the wiring check accepts (`scripts/check-typescript-tests-wired.mjs`). CI runs both in `cultmesh-portability.yml`: the browser at lines 119-120, and `cultmesh-ts` at lines 121-129 under the step name "Verify the realtime frame codec with the TypeScript codec". `esbuild` resolves from the root `devDependencies`, as C1's browser-bundle test shows. | source at `b8b825e1` |
|
||||
| F25 | The existing TLS fixture `packages/cultmesh-ts/test/fixtures/quic-test.p12` is a self-signed P-256 certificate with subject `CN=cultmesh-quic-native-tests` and no subjectAltName (fixtures README). Node's TLS hostname check therefore refuses it for `127.0.0.1` even when it is trusted through `NODE_EXTRA_CA_CERTS`. A C# handler that pins by certificate hash does not care about the name. | source: fixtures README |
|
||||
| F26 | `CultMeshCdn.ValidateChunkPayload`, `HashBytes`, `ValidateManifest` and `NormalizeHash` are `internal`, and `GameCult.Mesh` declares no `InternalsVisibleTo`. A test in `tests/GameCult.Mesh.Tests` can therefore verify chunk bytes only with `SHA256.HashData`, or through a public consumer of the connector. The public surface is `CreateRecordKey`, `PackArtifact`, `PublishAsync` and `ReadArtifact(Async)`. | grep of `CultMeshCdn.cs`, `src/GameCult.Mesh` |
|
||||
| F27 | No CultLib workflow runs `cultmesh-ts` interop tests. `cultnet-interop.yml` (windows-latest, main only) runs `cultnet-ts`'s `test/interop`, and its QUIC native case spawns `dotnet test --filter NativeConnectorReceivesFromExternalProvider` through `runDotnetTestFilter` (`cultnet-interop.test.ts:550,2826`). `cultmesh-ts` has a `test/interop/` folder (a QUIC peer script) but no `test:interop` script. | grep of `.github/workflows`, source |
|
||||
| F28 | **The real provider meets the real reader across processes, on Windows and Linux.** C2's handler, store and `toNodeListener` served on `node:https` with a self-signed P-256 certificate (subjectAltName `IP:127.0.0.1, DNS:localhost`, `CA:FALSE`, `serverAuth`, 100-year validity). They served a 4-chunk artifact (3.5 x 64 KiB) and an empty one. C3's `fetchVerifiedContent` ran in a child `node` with `NODE_EXTRA_CA_CERTS` set to that certificate, through the real global `fetch`, and returned both bodies with the right hashes. Without the variable it rejected `network` (cause `DEPTH_ZERO_SELF_SIGNED_CERT`). Behind a test wrapper that flips byte 0 of every non-empty 200 body, it rejected `chunk-hash-mismatch`. Platforms: Windows (Node 24.15) and a `node:24-trixie` container (Node 24.21). | probe: `imagination/probe/{serve,read,drive}.mjs` over the C2+C3 merge tree `8d1482c8` |
|
||||
| F29 | **The C# consumer that verifies is `CultMeshContentTransferService`, and it runs against an HTTPS route without Odin.** The probed path: `CultMeshDiscoveryService` over a test `RouteSource(<https endpoint>)`, then `CultMeshSessionManager` with `LocalDevelopment` trust and the unmodified `CultMeshHttpsContentTransportConnector`, then `CultMeshSessionContentProvider`, then `FetchAsync(manifest)`. This is the shape of `CultMeshContentSessionTests.TcpContentSessionStreamsLargeBodyAndReusesCommittedBody` with the TCP connector swapped for the HTTPS one. It fetched both artifacts from the TS provider, decoding the **TS-encoded** manifest bytes with `CultDocumentMessagePackSerialization.DeserializeUntyped`. Trust was a `SocketsHttpHandler` whose `SslOptions.CertificateChainPolicy` is `CustomRootTrust` with only the fixture certificate in `CustomTrustStore` (`RevocationMode.NoCheck`). That is real chain and name validation, with no callback and no system roots. The default handler failed with `UntrustedRoot`. Against the tampering wrapper, `FetchAsync` threw `AggregateException` over `InvalidDataException` ("CDN artifact chunk hash does not match its manifest."), leaving only `.<hash>.partial`, never `<hash>.body`. The connector itself does not verify (F6). The C# verifier is the transfer service's `BoundedHashingWriteStream` per chunk and its whole-file hash. The results on the Linux container (self-contained linux-x64 publish) were identical. | probe: `imagination/csprobe/Program.cs` and `probe/drive-cs.mjs`; source `CultMeshContentTransfer.cs:115-330`, `CultMeshContentTransports.cs:138-160` |
|
||||
| F30 | The two consumers request the same URLs. C# sent five requests: four chunks, plus the empty chunk `e3b0…`, which the provider answers intrinsically. Each was `/api/content?chunkHash=<hex>&recordKey=mesh%3Acdn%3Achunk%3A<hex>`. The reader sent the same four chunk URLs, and nothing for the empty chunk, which it verifies locally. | same probes, the provider's request log |
|
||||
| F31 | **Build order decides where the cross-process test lives.** `scripts/run-typescript-workspaces.mjs` builds `cultcache-ts, cultnet-ts, cultmesh-ts, cultmesh-browser`, and each package's `test` compiles `test/**/*.ts`, `test/interop` included. A `cultmesh-ts` test that imports `cultmesh-browser` therefore fails to compile on a clean checkout, while a `cultmesh-browser` test that imports `cultmesh-ts/content` follows the order. Named ESM imports of the CommonJS `cultmesh-ts/content` work from an ESM file: all four provider names import as functions. At `7adbf3cb`, `cultmesh-portability.yml` runs the browser step (lines 119-120) **before** the `cultmesh-ts` step (129-130), which is the opposite of the script's order. The wiring check requires `test/interop/*.test.ts` to be run by a `test:interop` script. | probe (node, ESM import); source |
|
||||
| F32 | Node's `execFile("dotnet")` resolves `dotnet.exe` on Windows without an extension. The C3 merge into `7adbf3cb` is clean at C3 head `67ff3f64` (`git merge-tree` gives `666162a3`). It touches the workflow only at comment lines 105-107, and the reader's exports keep their names. The probe's body pattern `(i*31+7)&255` repeats every 256 bytes, so all of its 64 KiB chunks shared one hash. A parity fixture needs non-periodic bytes, or chunk order is invisible. | probe (node); `git merge-tree` |
|
||||
| F33 | `cultmesh-portability.yml` triggers on `push` only for `main` and `codex/**`. A `hands/**` branch runs it through `pull_request` or `workflow_dispatch`, and both triggers exist. The job `reactive-and-resource-lifetime` has .NET 10 and Node 24 on `windows-latest` and `ubuntu-latest`, and it already builds `tests/GameCult.Mesh.Tests` in earlier steps. | source at `7adbf3cb` |
|
||||
|
||||
## 2. Model page
|
||||
|
||||
There is one row per persistent or wire-visible kind. Identity is what names the
|
||||
kind, lifecycle is what happens to it over time, and authority is who decides.
|
||||
The HTTP answers are new design: no runtime has an HTTPS content server (target,
|
||||
"Why now"). They rest on R, which fetched the specs and sources on 2026-10-19
|
||||
and corrected this page's first draft (R §8). In short:
|
||||
|
||||
- **404 for absent** is universal (R §9). Nix also reads 410 and S3's 403 as
|
||||
absent; we send 404 only.
|
||||
- **400 for a malformed id** comes from the IPFS path gateway (generic client
|
||||
error, with a malformed CID as its example) and bazel-remote (a hash that is
|
||||
not 64 lowercase hex). The OCI blob-pull text defines no 400; its
|
||||
`DIGEST_INVALID` is an upload code, so the first draft was wrong to cite it.
|
||||
- **No system specifies a server answer for a stored object that fails its
|
||||
digest** (R §9). Every one puts verification on the client. bazel-remote
|
||||
streams without checking, so a failure after the headers have gone "cannot
|
||||
change the status" (R §4). That is the scar `verify-before-serve` avoids by
|
||||
buffering and hashing before the status line.
|
||||
- **Range** may be ignored (RFC 9110 §14.2). OCI says registries SHOULD
|
||||
support it, which the first draft called "optional". We ignore it.
|
||||
- **`Cache-Control: public, max-age=29030400, immutable`** is the IPFS path
|
||||
gateway's MUST for immutable content (R §1). The trustless gateway itself
|
||||
does not state it.
|
||||
- **The Fetch facts** decide the reader (R §6). Fetch decodes content codings
|
||||
before the page sees the body, so `Content-Length` describes the encoded
|
||||
body, which F20 reproduces in Node. `Content-Length` is CORS-safelisted, and
|
||||
`ETag` is not. `Access-Control-Allow-Origin: *` passes the CORS check for any
|
||||
request whose credentials mode is not `include`.
|
||||
|
||||
| Kind | Identity (what names it) | Lifecycle | Authority (who decides; forbidden writers) |
|
||||
|---|---|---|---|
|
||||
| **Manifest** `gamecult.mesh.cdn_artifact_manifest.v1` | The record key (row below), derived from `artifactId`, `version` and `contentHash`. On the wire it is the 10-array of F1. Its namespace is the schema id. Two manifests for one body under different ids or versions are distinct and legitimate. | Immutable once packed. A new body or version is a new manifest and a new key. Nothing revises one in place, and deletion is not in scope (no GC). `createdAtUtc` is set at pack, and a caller may pin it so that repacking is reproducible. | `packContent` in `cultnet-ts/content` (C1, `b8b825e1`) is the only TS writer. The encoder and decoder in the same module are the only TS byte owners. The reader takes a manifest value and never builds or edits one. **Forbidden:** a second TS manifest type or encoder in `cultmesh-ts`, `cultmesh-browser` or a consumer, and any URL or endpoint field. |
|
||||
| **Chunk ref** `CultMeshCdnChunkRef` | Its `chunkHash` (SHA-256 of the chunk bytes) within one manifest, plus `offset`. Two refs may share a hash: this is legal, and the reader fetches each ref. | Immutable inside its manifest. `recordKey` is derived and may be blank on read. A blank key goes on the wire as `recordKey=` (F5), and the provider accepts it. | The packer writes. `validateManifest` (C1) judges contiguity, non-negative sizes and the size sum. The reader and provider never edit one. |
|
||||
| **Chunk record key** `mesh:cdn:chunk:<hex>` | Fully derived from `normalizeHash(chunkHash)`. It is injective only over valid hex, because C# accepts any non-blank string (F7). | Derived on demand, never stored independently of a manifest. | `chunkRecordKey` in `cultnet-ts/content`. On the HTTP wire the provider **checks** a non-blank `recordKey` against it, as the C# TCP server does (F21), and never derives a file from it. |
|
||||
| **Artifact record key** `mesh:cdn:artifact:<token>:<versionToken\|unversioned>:<hex>` | Derived by `StableToken` (F8). It is not injective: `A/b` and `a b` both give `a-b`, and the content hash at the end disambiguates. | Derived on demand. | `artifactRecordKey` in `cultnet-ts/content` (async; C1 deviation). Truth comes from the C#-written `contracts/cultmesh/cdn-record-key-vectors.json`. Neither C2 nor C3 uses it. |
|
||||
| **Content hash / chunk hash** | Lowercase 64-hex SHA-256. On input, `normalizeHash` accepts `sha256:`, any case and surrounding whitespace, as C# does. The HTTP boundary and the reader also demand 64 hex through `parseSha256Hex`. | Immutable. It *is* the identity. | `sha256Hex` and `parseSha256Hex` (C1) are the only TS hasher and the only hex rule. The provider and reader call them and keep no copy. The negative greps pin this. |
|
||||
| **Stored chunk** (provider disk) | File `<root>/<hex>`, flat, one file per distinct chunk hash. This is the reference layout (F21: C# stores one record per chunk). For a single-chunk artifact the chunk hash equals the content hash, so StreamPixels' whole-artifact file `/srv/streampixels/content/<hex>` (F23) is already a stored chunk. A multi-chunk artifact's publisher writes `packContent(...).chunks`, one file each. | Written by the publisher outside the provider, and never written by the provider. Immutable. Deletion has no owner (GC is out of scope). A corrupt or oversized file stays on disk and is never served. | The publisher writes; **the provider is read-only**. The provider verifies every read against its name before serving (`verify-before-serve`, the C# per-request behaviour of F21). A symlink or a replaced file cannot leak anything, because only bytes that hash to the requested name are ever served. **Forbidden:** any path derived from `recordKey`, the URL path or an unvalidated hash. |
|
||||
| **Provider store index** | **None.** The file name is the index. The directory store maps hex to `<root>/<hex>` on each request, after its own `parseSha256Hex` equality check. No in-memory table exists, so nothing can go stale. | Not applicable: it is computed per request. | `ContentChunkStore` is the port: `readChunk(chunkHash, maxBytes) → Promise<Uint8Array \| undefined>`. `createDirectoryChunkStore(root)` stats before reading and rejects a file over `maxBytes` with `ContentChunkTooLargeError`, so an oversized file is never read into memory. A host may supply another store, for example one indexed from manifests, and the handler verifies whatever any store returns. |
|
||||
| **The empty chunk** `e3b0c442…b855` | The SHA-256 of zero bytes. | Intrinsic: it needs no file. | The provider answers it as 200 with `Content-Length: 0` without consulting the store, so empty artifacts work against any store (bazel-remote does the same, R §4). The reader verifies it locally and sends no request. |
|
||||
| **Wire request** | `GET` or `HEAD <mount>?chunkHash=<hash>&recordKey=<key>`. Extra query parameters are ignored, because an endpoint may carry its own (F5). The mount path belongs to the host, and the handler never reads it. | Stateless. | Built by the C# connector or the TS reader. The TS reader builds the URL **byte-identical** to `BuildChunkUri`, `Uri.EscapeDataString` escaping (F19) and the empty-query `?&` join included, so C# and browser clients share HTTP cache entries. |
|
||||
| **Content coding** | Identity, always. | Not applicable. | Provider: no `Content-Encoding`, and `Cache-Control` carries `no-transform`, which forbids an intermediary to transform the content (RFC 9111 §5.2.2.6). That keeps the C# connector's `Content-Length` check meaningful (F6). The reader does not rely on it: it judges the bytes it received (F20). |
|
||||
| **Answer 200** (GET) | The body is exactly the verified chunk bytes. | Immutable per URL. | Provider. Headers: `Content-Length: <size>`, `Content-Type: application/octet-stream`, `X-Content-Type-Options: nosniff`, `Cache-Control: public, max-age=31536000, immutable, no-transform` and `Access-Control-Allow-Origin: *`. Octet-stream with `nosniff` keeps a public chunk from rendering as HTML on the host's origin. The IPFS `Content-Disposition: attachment` adds nothing over that for a fetch client, so it is not sent. |
|
||||
| **Answer 200** (HEAD) | The same headers as GET, with no body. | Same as GET. | Provider. HEAD verifies exactly as GET does, so it can never advertise a corrupt chunk. RFC 9110 §8.6 requires HEAD's `Content-Length` to equal GET's. |
|
||||
| **No ETag, no 304** | Not applicable. | Not applicable. | Provider. `immutable` with a one-year max-age means browsers do not revalidate. `ETag` is not CORS-exposed (R §6), and the URL is already the content identity. An `ETag` without a 304 path is noise, and a 304 path is a second answer to test. The first draft had both, and both are cut. |
|
||||
| **Answer 400** | `chunkHash` missing, repeated, or not 64 hex after `normalizeHash`. Or `recordKey` repeated, or non-blank and not exactly `mesh:cdn:chunk:<hex>` (the C# server's "record key disagrees" rule, F21). | Stateless. | Provider. Body is a fixed `text/plain; charset=utf-8` reason with no echo of input (bazel-remote echoes the URL; we do not). Headers: `nosniff`, `Cache-Control: no-store`, `Access-Control-Allow-Origin: *`. |
|
||||
| **Answer 404** | A valid hash whose chunk the store does not hold. | Stateless; a later publish can turn it into 200. | Provider. Same error headers as 400. `no-store` keeps an absence from being cached as immutable. |
|
||||
| **Answer 405** | Any method but GET and HEAD, OPTIONS included. | Stateless. | Provider, with `Allow: GET, HEAD`. The reader's GET is a CORS simple request (no custom headers, credentials omitted) and never preflights. A client that adds headers would preflight, and its preflight is refused, which is correct because the provider honours no request header. The first draft's 204 preflight answer is cut. |
|
||||
| **Answer 500** | The store returned bytes whose SHA-256 is not the requested hash; the store holds a file over `maxChunkBytes`; or the store failed. | Persists until the operator replaces the file. | Provider. It sends a fixed reason and none of the chunk's bytes, then calls the host's `onServerFault({ chunkHash, kind, error })` with `kind` ∈ {`hash-mismatch`, `too-large`, `store-error`}, so the host can fail health. A throwing hook does not change the answer. It is 500 and not 404 because the IPFS trustless gateway reserves 500 for "genuine server errors" (R §1), and corruption is one the operator must see, while a 404 would hide it as absence. |
|
||||
| **Range** | Not supported. A `Range` header is ignored, and the answer is 200 with the full chunk (RFC 9110 §14.2). No `Accept-Ranges` header is sent. | Not applicable. | Provider. The chunk is the resumption unit, and resumable TS state is out of scope. |
|
||||
| **Provider memory** | At most `maxChunkBytes` buffered per in-flight request. | Per request. | Provider. Buffering is forced, because the status line cannot be sent before the hash is known (the bazel-remote scar above). `maxChunkBytes` defaults to `DEFAULT_CHUNK_SIZE_BYTES` (4 MiB) and must lie in 1..2147483647. The host bounds concurrency (server `maxConnections` or a reverse proxy), and the README says so. |
|
||||
| **Reader result / error** | A `Uint8Array` of the whole body, or one `CultMeshContentError` with `code` ∈ {`manifest-invalid`, `too-large`, `insecure-endpoint`, `webcrypto-unavailable`, `network`, `http-status`, `length-mismatch`, `chunk-hash-mismatch`, `content-hash-mismatch`}, plus `chunkHash` and `status` where they apply. A caller abort rejects with the signal's own `reason`, unwrapped. Bad options reject with `RangeError`. | Transient, held in memory only. No byte of a failed chunk or body is returned, in whole or in part (`verify-before-use`). | `fetchVerifiedContent` (and `fetchVerifiedChunk`) in `cultmesh-browser`. Concurrency defaults to 4 and is bounded 1..32 (the C# `CultMeshContentTransferOptions` bounds). `maxBytes` defaults to 256 MiB and is checked before any request. The scheme must be `https:`, or `http:` where `isLoopbackEndpoint` (F22) holds. Only status 200 is accepted. **`Content-Length` is not consulted** (F20). The body is read through a stream into the chunk's slot, a body that runs past the chunk's size is cancelled at the first excess byte, and one that ends short is refused. |
|
||||
| **Endpoint** | A URL the caller resolved through CultMesh. | Per call; never persisted in content. | The caller. **Forbidden:** any manifest, chunk-ref or record field carrying it (`no-urls-in-content`). Error messages never echo it, because an endpoint query may carry a tenant or a token. |
|
||||
| **Vector files** | `content-vectors.cs-written.json` (C#), `.rs-written.json` (Rust), `content-vectors.ts-written.json` (TS, manifests only, own inputs) and `cdn-record-key-vectors.json` (C#, record-key traps), all landed by C1. | Each is rewritten only by its writer under `CULTNET_WRITE_VECTORS=1`. CI never sets the variable: the committed files are the contract. | Each writer owns its file. C2 and C3 add no vector file. HTTP behaviour is pinned by tests and, in C4, by the C# transfer service driving the unmodified connector (F29). |
|
||||
|
||||
Two cells rest on reasoning rather than a probe: that the host, not the
|
||||
provider, bounds concurrency, and the cost of hashing on HEAD. Both are
|
||||
defaults a later cut can revise without changing any shape.
|
||||
|
||||
## 3. Rationale
|
||||
|
||||
**Where the CDN shapes live: `cultnet-ts/src/content.ts`, exported as
|
||||
`cultnet-ts/content`.** C1 landed this at `b8b825e1`. Both consumers already
|
||||
depend on `cultnet-ts`, and its subpaths are the repo's established
|
||||
browser-safe entry points (F14). The placement matches Rust, where
|
||||
`cultnet-rs/src/content.rs` owns the same rules.
|
||||
|
||||
**Hashing is WebCrypto in one place.** `crypto.subtle` exists in browsers
|
||||
(secure contexts) and in Node 20+, so one hasher serves the packer, the
|
||||
provider and the reader. WebCrypto has no incremental digest, so every hash
|
||||
needs its bytes in memory. That fits all three users: the reader holds the
|
||||
body (the target keeps artifacts in memory), the provider holds one chunk at a
|
||||
time, and the packer holds the body.
|
||||
|
||||
**Decoding follows the siblings.** C1's report records two places where its
|
||||
decoder is stricter than MessagePack-CSharp: an exact field count, and refusal
|
||||
of nil strings. Ruling `leaf-acceptance-follows-reference` may make that a
|
||||
finding. That belongs to C1's Soul pass, not to C2 or C3, which take manifest
|
||||
values and never decode bytes.
|
||||
|
||||
**No TS CultCache document definition for manifests yet.** This is still
|
||||
follow-up `ts-manifest-document-definition`.
|
||||
|
||||
**Provider: `cultmesh-ts/content`, a request-to-answer core, one store and one
|
||||
Node adapter, in one file.** `createContentChunkHandler(store, options)`
|
||||
returns `(request: { method, url }) => Promise<{ status, headers, body? }>`.
|
||||
The request carries no headers, because the provider honours none: Range is
|
||||
ignored, and conditional requests have no answer now that ETag is cut. The
|
||||
core reads only the query, never the path, which is what makes
|
||||
`chunk-only-route` structural rather than checked. `toNodeListener(handler)`
|
||||
is about 15 lines for `node:http(s)`, and Express, Fastify or Next mount the
|
||||
core the same way. The name says "chunk" because the handler answers chunks
|
||||
only. StreamPixels' sketch name, `createHttpsContentHandler` (F23), implied it
|
||||
does TLS, and it does not: the host terminates TLS. One file, not three,
|
||||
because the store and adapter are about 50 lines between them and have no
|
||||
consumer apart from the core. `cultmesh-ts` is already a Node package, so
|
||||
`node:fs` in the file costs nothing.
|
||||
|
||||
**Store layout: per-chunk flat files.** This is the reference layout (F21),
|
||||
and it needs no index that can go stale. It coincides with StreamPixels'
|
||||
whole-artifact files, because every StreamPixels artifact is a single chunk
|
||||
(F23). The alternative is a store indexed from manifests, mapping a chunk hash
|
||||
to (artifact file, offset, size), so that multi-chunk artifacts could be
|
||||
stored whole. It is not shipped, because it adds a derived index that must be
|
||||
rebuilt when manifests change, and no consumer needs it. A host that wants it
|
||||
writes about 30 lines against the port, and the handler still verifies every
|
||||
byte. **Consumer note for StreamPixels B3:** its `catalog.cc` and
|
||||
`character-artifacts.cc` manifests must stay single-chunk, so their files are
|
||||
the stored chunk. Either they are packed with `chunkSizeBytes` at least the
|
||||
file size and the provider's `maxChunkBytes` is raised to match, or B3
|
||||
publishes the chunk files.
|
||||
|
||||
**Verify on every request, not once at boot.** The invariant says "serves
|
||||
only bytes whose SHA-256 equals the requested chunk hash", and a boot check
|
||||
cannot see a file replaced after boot. C# verifies per request (F21). A
|
||||
"verified at boot" cache would be a cache pretending to be truth. StreamPixels'
|
||||
boot check is its own deploy gate (missing blob means failed health), and it
|
||||
stays useful beside per-request verification. The cost is one SHA-256 of at
|
||||
most 4 MiB per request, and the immutable one-year caching keeps repeats away
|
||||
from the origin.
|
||||
|
||||
**Reader: `fetchVerifiedContent(endpoint, manifest, options)` and
|
||||
`fetchVerifiedChunk(endpoint, chunkHash, sizeBytes, options)` in
|
||||
`cultmesh-browser`, over one internal per-chunk primitive.** The second is the
|
||||
"fetch by hash" a general consumer expects for a single-chunk artifact. It
|
||||
builds no manifest: it validates the hash and size, sends
|
||||
`recordKey=chunkRecordKey(hash)` so its URL equals a C#-packed manifest's, and
|
||||
runs the same primitive. The whole-body check is the chunk check, because for
|
||||
a single chunk the two hashes are the same. Before any request,
|
||||
`fetchVerifiedContent` runs `validateManifest`, `parseSha256Hex` on every
|
||||
hash, the zero-size rule (F18) and the `maxBytes` cap. It then fills a private
|
||||
buffer of `sizeBytes` through a sliding window of at most
|
||||
`maxConcurrentChunkRequests` requests. It uses `credentials: "omit"` and an
|
||||
internal `AbortController` chained to the caller's signal, so the first
|
||||
failure cancels every request still in flight. It hashes each chunk in place
|
||||
and returns the buffer only after the whole-body hash matches.
|
||||
|
||||
**Why the reader ignores `Content-Length`.** The target says the reader
|
||||
"refuses a response whose length differs from the advertised size before
|
||||
reading it all". The header cannot carry that duty. Fetch decodes content
|
||||
codings, and a proxy that gzips in spite of `no-transform` turns a correct
|
||||
1,000-byte chunk into a 29-byte `Content-Length` (F20, R §6). In a browser the
|
||||
reader cannot even see `Content-Encoding` cross-origin, because it is not
|
||||
safelisted, so it cannot tell that case apart. What the invariant protects is
|
||||
bounded reading and no acceptance of a wrong length. The streaming cap
|
||||
delivers both from the bytes actually received: the reader stops at the first
|
||||
byte past the chunk's size, and refuses a short body at its end. The target
|
||||
sentence should be reworded (reported to Self as a contradiction).
|
||||
|
||||
**C3 does not wait for C2.** C3's live test serves from a stub `node:http`
|
||||
loopback server written in the test (about 30 lines). The stub sends what the
|
||||
model page says, plus the misbehaviours: gzip, a short body, an endless body,
|
||||
and wrong bytes. The real provider meets the real reader in C4. This keeps
|
||||
the two cuts parallel, and it lets C3 test misbehaviours the real provider can
|
||||
never produce.
|
||||
|
||||
**C4: the proof lives in `cultmesh-browser/test/interop`, with the C# half
|
||||
in `GameCult.Mesh.Tests`.** The orchestrator is Node, following the QUIC
|
||||
precedent (F13): it starts the provider, runs both consumers and judges what
|
||||
the provider saw. It lives in `cultmesh-browser` and not `cultmesh-ts`,
|
||||
because the workspace build order compiles `cultmesh-ts` first (F31). The
|
||||
browser package can import `cultmesh-ts/content` with types. The reverse
|
||||
would need either an untyped script or a build-order exception. The provider
|
||||
runs in its own process rather than the orchestrator's, so each of the three
|
||||
parties is a separate OS process, and the orchestrator never shares an event
|
||||
loop with the server it judges. The C# half drives the connector through
|
||||
`CultMeshContentTransferService` (F29), because the connector alone does not
|
||||
verify (F6). A parity test that stopped at the connector would show that C#
|
||||
receives bytes, not that C# refuses bad ones. TLS trust is a committed
|
||||
fixture certificate, as the QUIC fixture is (F25). The C# side anchors it
|
||||
through `CustomRootTrust`, which keeps chain and name validation and adds no
|
||||
code path that can accept a different certificate. A hash-pinning
|
||||
`ServerCertificateCustomValidationCallback` would skip name validation and
|
||||
add a callback that has to be read to be trusted. Corruption comes in two
|
||||
kinds. On disk, the real provider refuses it with 500 (C2's
|
||||
verify-before-serve). In flight, a test wrapper flips bytes after the
|
||||
provider has verified them. The in-flight leg is what proves that both
|
||||
consumers verify.
|
||||
|
||||
**"Released"** is ruled: `release-shape-gitlink` (Defaulted by Self).
|
||||
|
||||
## 4. Cut sequence
|
||||
|
||||
C1 (`cut-c1-shapes.r1`, report `h1` under a Soul pass), C2
|
||||
(`cut-c2-provider.r1`) and C3 (`cut-c3-reader.r1`) are specified in the mind.
|
||||
C2 and C3 run in parallel once C1 merges, each in its own worktree from C1's
|
||||
merge. Both are based on `b8b825e1`, and they touch disjoint files except
|
||||
`cultmesh-portability.yml`, where their hunks lie 15 lines apart. What
|
||||
remains:
|
||||
|
||||
4. **C4 `c4-parity`** (`cut-c4-parity.r1`, branch
|
||||
`hands/cultmesh-ts-content-c4-parity` from `7adbf3cb` plus the C3 merge),
|
||||
waiting on C2 and C3. The spec carries the plan. In one paragraph: a
|
||||
node:test orchestrator in `cultmesh-browser/test/interop` packs three
|
||||
artifacts (multi-chunk, single and empty) and spawns the C2 provider as its
|
||||
own `node:https` process with three mounts: honest, tampered and corrupt
|
||||
store. It runs the C3 reader in a child with `NODE_EXTRA_CA_CERTS`, once
|
||||
more without it as a control, and then `dotnet test` over a new
|
||||
`CultMeshHttpsContentInteropTests`. That suite drives the unmodified
|
||||
connector through `CultMeshContentTransferService` and trusts the committed
|
||||
fixture through `CustomRootTrust`. The orchestrator then judges URL parity
|
||||
and the provider's fault log. CI gains the step on both OSes, and the
|
||||
browser and `cultmesh-ts` steps swap to follow the workspace order (F31).
|
||||
5. **C5 `c5-release`**, waiting on C4 and the Soul passes of C2 to C4. Ruling
|
||||
`release-shape-gitlink`: merged to CultLib `main` with version bumps,
|
||||
CHANGELOGs and git tags, and no npm publish. Line-level plan:
|
||||
- Bumps (minor; every change is additive): `cultnet-ts` 0.2.0 to 0.3.0
|
||||
(`./content`), `cultmesh-ts` 0.1.1 to 0.2.0 (`./content`), and `cultmesh-browser` 0.3.0 to 0.4.0 (the reader).
|
||||
`@gamecult/cultcache-ts` gains only the `checkPayloadDepth` re-export:
|
||||
a patch bump, 0.14.0 to 0.14.1 (its version at `b8b825e1`).
|
||||
- Dependency ranges: `cultmesh-ts` and `cultmesh-browser` move
|
||||
`cultnet-ts` to `^0.3.0`. `cultnet-ts`'s `^0.14.0` on
|
||||
`@gamecult/cultcache-ts` already admits 0.14.1 and stays. C4 gives
|
||||
`cultmesh-browser` a devDependency `cultmesh-ts: ^0.1.1`, which does not
|
||||
admit 0.2.0, so it moves to `^0.2.0` in the same commit. Otherwise npm
|
||||
looks for 0.2.0 on the registry and the install fails. `npm install
|
||||
--no-package-lock`, the four workspace tests and the browser's
|
||||
`test:interop` run after the bumps.
|
||||
- A `CHANGELOG.md` per bumped package. None exists in any package at
|
||||
`b8b825e1`, so C5 creates four, each naming the live surface and not the
|
||||
history (Keep a Changelog headings).
|
||||
- A Soul pass over the release diff, then four tags: `cultcache-ts-v…`,
|
||||
`cultnet-ts-v0.3.0`, `cultmesh-ts-v0.2.0` and `cultmesh-browser-v0.4.0`.
|
||||
These go in **two pushes** (at most three tags per push, SKILL.md):
|
||||
`cultcache-ts` first, because its tag triggers `publish-packages.yml`
|
||||
(F16), and the three plane tags second. Whether the `cultcache-ts` tag's
|
||||
npm publish job still runs, and to where, is checked before that push;
|
||||
follow-up `npm-publish-ts-packages` owns the rest.
|
||||
- The consumer's pickup (StreamPixels bumping `vendor/CultLib` to the
|
||||
tagged commit) is StreamPixels' B-cuts, not this campaign.
|
||||
- Size: about 60 lines of version and CHANGELOG text; a short Hands pass.
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
# TypeScript CultMesh Content Plane Target
|
||||
|
||||
Campaign `cultmesh-ts-content`. Self wrote this on 2026-10-19 for the
|
||||
StreamPixels M1 live character preview. That preview is a three.js page that
|
||||
loads glTF bodies and accessories by content hash from Yggdrasil.
|
||||
|
||||
## Why now
|
||||
|
||||
CultMesh moves large immutable artifacts as content-addressed chunks described
|
||||
by a `gamecult.mesh.cdn_artifact_manifest`. C# packs manifests, serves chunks
|
||||
over TCP (`CultMeshContentServer`), fetches them over HTTPS
|
||||
(`CultMeshHttpsContentTransportConnector`), and verifies each chunk and the
|
||||
whole body (`CultMeshContentTransferService`). Rust speaks the chunk messages
|
||||
through `cultnet-rs` `fetch_content` and `answer_content_chunk_request`.
|
||||
TypeScript has none of this: no manifest type, no packer, no provider and no
|
||||
reader. And no runtime has an HTTPS content *server*; the C# HTTPS connector
|
||||
has never had a peer.
|
||||
|
||||
Standing ruling (StreamPixels `state/map.yaml`, "Content plane", operator
|
||||
2026-09-15): HTTPS content transport serves both browser and overlay.
|
||||
Yggdrasil plus SHA-256 verification is the trust boundary for now.
|
||||
Distributed zero-trust distribution is future work. The catalog never stores
|
||||
URLs; clients resolve content through CultMesh.
|
||||
|
||||
## Target state
|
||||
|
||||
- **One TypeScript owner of the CDN document shapes.** It holds the manifest
|
||||
and chunk-ref types, their MessagePack wire form, `normalizeHash`, the
|
||||
record-key grammar (`mesh:cdn:chunk:<hex>` and
|
||||
`mesh:cdn:artifact:<token>:<version>:<hex>`), and the packer. A manifest
|
||||
that TypeScript packs is byte-identical to the one C# packs from the same
|
||||
bytes and options. The owner can be imported in a browser, because the
|
||||
reader needs it there.
|
||||
- **An HTTPS content provider in `cultmesh-ts`.** It answers
|
||||
`GET <endpoint>?chunkHash=<hex>&recordKey=<key>` with exactly the chunk's
|
||||
bytes and a `Content-Length` equal to the chunk's size: the request shape
|
||||
the C# connector sends (`CultMeshHttpsContentTransport.cs` `BuildChunkUri`).
|
||||
Its core is framework-neutral: a request maps to an answer, and a host can
|
||||
mount it on `node:https` or on any HTTP framework. It reads from a content
|
||||
store port. The store shipped with it serves content-addressed files from a
|
||||
directory outside git. The provider never serves a byte it has not
|
||||
verified against its hash, and it never serves anything but a chunk:
|
||||
there is no generic static-file route.
|
||||
- **A verifying reader in `cultmesh-browser`.** Given a resolved endpoint and
|
||||
a manifest, it fetches the manifest's chunks over HTTPS with the C# query
|
||||
shape. It checks each response's length, each chunk's SHA-256 and the whole
|
||||
body's SHA-256 with WebCrypto, and returns the bytes or throws a typed
|
||||
error. No byte that failed its hash reaches the caller, in whole or in part.
|
||||
It runs in browsers and in Node 20+.
|
||||
- **Endpoints come from CultMesh, not from content.** The reader takes the
|
||||
endpoint as an argument that the caller resolved through CultMesh. Manifests
|
||||
carry no URL.
|
||||
|
||||
## Invariants
|
||||
|
||||
- `wire-parity`: manifests, chunk refs and record keys are byte-identical
|
||||
across C#, Rust and TypeScript for the same input. The C# runtime is the
|
||||
reference. TypeScript writes and reads the shared vectors in
|
||||
`contracts/cultmesh/`.
|
||||
- `https-shape-parity`: the C# `CultMeshHttpsContentTransportConnector`
|
||||
fetches content from the TypeScript provider unmodified, in a cross-process
|
||||
test. The TypeScript reader fetches the same chunks from the same provider.
|
||||
- `verify-before-use`: the reader releases no byte that failed its chunk hash
|
||||
or the whole-body hash. It refuses a body longer than the advertised size at
|
||||
its first excess byte, and a shorter one at its end. It does not trust
|
||||
response length headers, because Fetch decodes content codings and a proxy
|
||||
can rewrite them (research 2026-10-19, section 6).
|
||||
- `verify-before-serve`: the provider serves only bytes whose SHA-256 equals
|
||||
the requested chunk hash.
|
||||
- `chunk-only-route`: the provider answers only chunk requests. A path, a
|
||||
filename or a hash outside the store can never select a file.
|
||||
- `no-urls-in-content`: no manifest, chunk ref or record carries an endpoint.
|
||||
- `one-owner-per-shape`: one TypeScript module owns the CDN shapes, and both
|
||||
packages consume it. Neither package keeps a private copy.
|
||||
- `general-purpose`: the plane is judged by what a reasonable consumer
|
||||
expects, not only by StreamPixels' slice (operator, 2026-09-30). Multi-chunk
|
||||
artifacts, empty artifacts, cancellation, bounded concurrency and browser
|
||||
cross-origin use all work.
|
||||
- `released`: the npm packages StreamPixels vendors are published at versions
|
||||
that contain this plane. The release is its own cut with a Soul pass before
|
||||
the tag.
|
||||
|
||||
## Not in this campaign
|
||||
|
||||
- Odin route verification for content. Single-host trust is ruled for now.
|
||||
- Resumable or durable transfer state in TypeScript. The browser holds
|
||||
artifacts in memory.
|
||||
- Distributed or zero-trust distribution, and peer serving.
|
||||
- A TypeScript TCP or RUDP content server, and an HTTPS content server in Rust
|
||||
or C#.
|
||||
- Garbage collection of stored blobs.
|
||||
- StreamPixels' own export, publish and service wiring (its B2 and B3 cuts).
|
||||
StreamPixels is the consumer, not part of this change.
|
||||
|
||||
## Consumer
|
||||
|
||||
StreamPixels `map/web-customize` (dfbe2a6),
|
||||
`docs/web-character-customization-map.md` section B1. Each option's artifact is
|
||||
10-125 KB, and a full avatar is 0.5-0.8 MB. The browser fetches them. The
|
||||
service serves them from `/srv/streampixels/content/` on Yggdrasil, outside
|
||||
git, and hashes every referenced blob at boot.
|
||||
|
|
@ -1,663 +0,0 @@
|
|||
# CultNet error contract: cut map
|
||||
|
||||
Status: cut map, not started. Imagination pass 1, 2026-09-30, against CultLib `main` at `8fc74c70`.
|
||||
This document owns the means. There is no separate target doc: section 1 holds the ends until the
|
||||
operator rules on section 3. After that, Self moves the rulings and invariants into
|
||||
`contracts/cultnet/cultnet-distributed-database.md` ("Error Codes") and the schema's own description.
|
||||
|
||||
Open: operator questions Q1-Q8 (section 3). No cut is dispatched until Q1, Q2, Q3 and Q8 are ruled. Q4-Q7
|
||||
change only single emit sites and can be ruled during Cut 1.
|
||||
|
||||
Depends on: `hands/cultmesh-put-serve-bound` merging first. It introduces `document_unservable` in Python
|
||||
(`fd394d54`), a codeless unservable refusal in cultmesh-ts (`0eb7434a`) and `CultMeshRudpRejectionReason` in
|
||||
cultmesh-rs (`c9cdad12`, `85288a5a`). The cuts below convert those sites. Their anchors are by commit, not by
|
||||
line, because the branch is not on main.
|
||||
|
||||
Found by the put-serve Hands (batch 3, `38000efa`, 2026-09-30); they belong to this map's cuts:
|
||||
- **Python codes outside the closed code list.** Python refuses with `document_unservable`,
|
||||
`malformed_document_put` and `snapshot_*_exceeded`. None of these are in the closed `cultnet.error.v0` code
|
||||
list, and Rust and TS peers refuse to decode an unknown code.
|
||||
- **Two error-map shapes.** TS sends a two-key error map that its own peers cannot parse. Rust sends the five-key
|
||||
contract shape. The two agree only at the message level.
|
||||
- **The session after a refusal.** TS and Python keep the session open after a refusal; Rust sends a goodbye and
|
||||
ends it.
|
||||
- **New Rust refusal frame.** The put-serve branch adds a Rust refusal frame, `cultnet.error.v0` carrying the
|
||||
reason text with no code and no details, sent before the goodbye and acknowledging nothing. Cut 1 gives it a
|
||||
code.
|
||||
- Found by the put-serve Soul (merge verdict, 2026-09-30):
|
||||
- A store `OSError(ENOSPC)` in Python is refused as `malformed_document_put`, with the text "Raw put document could
|
||||
not be stored." That is a server fault reported as the peer's fault.
|
||||
- Over RUDP, Python's `document_unservable` details add `fragmentCount` and `maxFragmentCount`, and send
|
||||
`maxSnapshotBytes: null` when it is unbounded. The map specifies `{responseBytes, maxSnapshotBytes}`.
|
||||
- `response_unqueueable` (Python, P3) is a new code outside the closed list.
|
||||
- A TS peer cannot tell "stored, receipt lost" from "refused" (put-serve Soul F4, pass 3).
|
||||
|
||||
Map branch: `main`.
|
||||
|
||||
---
|
||||
|
||||
## 0. What the Body does today (probed)
|
||||
|
||||
Every claim in this table was established by running code. The C#, Rust and TypeScript probes ran on
|
||||
Yggdrasil through `ygg-verify.sh` at `8fc74c70`. The Python bytes came from `msgpack.packb` on the exact
|
||||
dicts `cultmesh_py/server.py:573-589` and `cultmesh-ts/src/index.ts:5921-5926` build. The six probe inputs:
|
||||
|
||||
| Input | Bytes from |
|
||||
|---|---|
|
||||
| `cs_cursor_stale` | Rust `tests/error_message.rs` captured C# hex (`cursor_stale`, details `{asOf:1,current:2}`) |
|
||||
| `cs_routing` | C# `new CultNetErrorMessage { Error, RoutingHint = { Reason="not_primary", Shard=... } }` serialized with `CultNetSchemaMessageSerialization` |
|
||||
| `cs_codeless` | C# `new CultNetErrorMessage { Error = "boom" }` |
|
||||
| `cs_unknown_code` | C# with `Code = "document_unservable"`, empty `CultNetErrorDetails` |
|
||||
| `py_snapshot_byte_limit` | the Python `_error_response` shape: `messageId`, `code: "snapshot_byte_limit_exceeded"`, `details {responseBytes, maxSnapshotBytes}` |
|
||||
| `ts_two_key` | the cultmesh-ts shape `{schemaVersion, error}` |
|
||||
|
||||
Decoded by:
|
||||
|
||||
| Input | C# `CultNetSchemaMessageSerialization.Deserialize` | Rust `decode_cultnet_message_from_slice` | TS `parseCultNetMessage` |
|
||||
|---|---|---|---|
|
||||
| `cs_cursor_stale` | ok | ok, typed | ok |
|
||||
| `cs_routing` | ok | ok, **routingHint dropped** (`code: None, details: None`) | ok (untyped passthrough) |
|
||||
| `cs_codeless` | ok | ok | ok |
|
||||
| `cs_unknown_code` | ok (free string) | **Err: "not a recognized cultnet.error.v0 code"** | **throws: /code must be one of the allowed values** |
|
||||
| `py_snapshot_byte_limit` | ok, but **details silently become an all-null object** (`responseBytes`, `maxSnapshotBytes` lost), `messageId` dropped | **Err: not a recognized code** | **throws** (missing routingHint, extra messageId, code enum, details keys) |
|
||||
| `ts_two_key` | ok | ok | **throws: missing routingHint, code, details** — TypeScript cannot parse the errors TypeScript sends |
|
||||
|
||||
Captured C# bytes for the two shapes Rust's vector file does not hold (for Hands' reuse):
|
||||
|
||||
- `cs_codeless`: `85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72A4626F6F6DAB726F7574696E6748696E74C0A4636F6465C0A764657461696C73C0`
|
||||
- `cs_routing`: see the probe; the shard descriptor writes ten keys, nil included.
|
||||
|
||||
Other findings, from reading source at `8fc74c70`:
|
||||
|
||||
1. **The interop CI lane has been red for 40 consecutive `main` runs.** It fails at "Verify CultNet schema
|
||||
contracts stayed consolidated" (`.github/workflows/cultnet-interop.yml:67-90`). That step forbids every
|
||||
`*.schema.json` outside `contracts/cultnet/`, and R-AB deliberately vendors 22 copies into
|
||||
`packages/cultnet-rs/contracts/cultnet/`. Those copies are held byte-equal by
|
||||
`packages/cultnet-rs/tests/schema_discovery_contracts.rs:74-99`. Nothing after that step (every
|
||||
cross-runtime interop test) has run on main in that time. Run `36714768445` is the latest.
|
||||
2. **The schema contradicts itself.** `contracts/cultnet/cultnet.error.schema.json:29` says the code is
|
||||
"Additive: a v0 peer that does not know this field ignores it" and, in the same sentence, "a closed
|
||||
enum: Rust and TypeScript refuse". The closed-enum text came from Soul pass 4d (`3f942a57`). It
|
||||
described what the code did; no operator ruled it. `docs/` holds no ruling on unknown codes.
|
||||
3. **Emitters that break the schema:**
|
||||
- cultmesh-ts sends `{schemaVersion, error}` only, at `packages/cultmesh-ts/src/index.ts:5875-5878`,
|
||||
`5885-5888`, `5913-5916` and `5922-5925`, and on the branch at `0eb7434a`.
|
||||
- cultmesh-py `_error_response` (`packages/cultmesh-py/src/cultmesh_py/server.py:573-589`) adds a
|
||||
top-level `messageId`, omits `routingHint`, omits `code` and `details` rather than sending nil, and
|
||||
sends eight out-of-enum codes: `unsupported_schema_version` (`:161`), `malformed_document_put`
|
||||
(`:358`), `unregistered_document_put` (`:365`), `malformed_document_delete` (`:386`),
|
||||
`unregistered_document_delete` (`:393`), `snapshot_document_limit_exceeded` (`:558`),
|
||||
`snapshot_byte_limit_exceeded` (`:567`) and `simulation_observations_disabled` (`:596`). The branch
|
||||
adds a ninth, `document_unservable` (`fd394d54`).
|
||||
- `packages/cultnet-py/src/cultnet_py/interop_peer.py:409-417` also sends `unsupported_schema_version`.
|
||||
R-2 (`docs/cultnet-selection-cut.md:2046-2053`) required that code, but it never entered the schema
|
||||
enum.
|
||||
4. **A second schema authority.** `packages/cultnet-py/src/cultnet_py/schema_catalog.py:17`, `:272` and
|
||||
`:357-362` publish a hand-built `cultnet.error.v0` descriptor under the canonical `$id`. It has a
|
||||
different shape: `messageId`, `code` as any string, `details` open, and only `schemaVersion` and `error`
|
||||
required. The same file does this for every wire message (about 350 lines). This is recorded as
|
||||
follow-up F-1, not cut here.
|
||||
5. **Silent refusals.** cultmesh-rs's RUDP document server returns every rejection to the local caller only
|
||||
(`packages/cultmesh-rs/src/rudp_document_server.rs:470-590`, `CultMeshRudpApplicationRejection`). The
|
||||
peer gets nothing and times out. On the branch this includes the new `DocumentUnservable`.
|
||||
6. **Receivers drop the code.**
|
||||
- Every C# receiver turns an error into `new IOException(error.Error)` or
|
||||
`new InvalidOperationException(error.Error)`, so a caller cannot branch on the code. The sites:
|
||||
`src/GameCult.Mesh/CultMeshBodySessions.cs:310`, `CultMeshContentSessions.cs:178/256-260`,
|
||||
`CultMeshPeerExchange.cs:430/466`, `CultMeshSnapshots.cs:91/107/222-226/743` and
|
||||
`CultMeshVerseDiscovery.cs:273`, plus `src/GameCult.Networking/CultNetShardReplication.cs:440/574`.
|
||||
- Rust reads `Error { error, .. }` at `packages/cultmesh-rs/src/lib.rs:823`.
|
||||
- TS types `CultNetErrorMessage` as `{schemaVersion, error}` only (`packages/cultnet-ts/src/contracts.ts:167-170`).
|
||||
- Kotlin has no error type. A request that gets an error back fails a
|
||||
`require(response.schemaVersion == ...)` (`packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt:1278`,
|
||||
`:1334`), and the peer's text is lost.
|
||||
7. **No correlation.** Only Python echoes `messageId`.
|
||||
- `cultmesh-browser` rejects an error only when exactly one operation is pending
|
||||
(`packages/cultmesh-browser/src/index.ts:724-731`); otherwise the error is dropped and the operation
|
||||
times out.
|
||||
- C# `CultMeshContentSessions.OnError` (`:256-260`) fails every pending request on any error.
|
||||
8. **The reference is inconsistent with itself.** `src/GameCult.Networking/CultNetDatabaseServer.cs:560`
|
||||
refuses a variant change with a codeless error. The subscription server refuses the same condition with
|
||||
`ForVariantUnsupported` (`CultNetDatabaseSubscriptionServer.cs:211/333/366`).
|
||||
9. **routingHint is never consumed.** No receiver in any runtime reads it. C# builds it at
|
||||
`CultNetDatabaseServer.cs:638-650`, and Rust's decode discards it.
|
||||
10. **Consumer audit** (read-only, all of `F:\Projects` except CultLib; no git in Aetheria):
|
||||
- Outside CultLib, nothing sets `code`, `details` or `routingHint`, and nothing branches on a code.
|
||||
- Emitters:
|
||||
- Huginn: `crates/huginn-daemon/src/serve.rs:288/307/312`, Rust struct literals
|
||||
`Error { error, code: None, details: None }`, pinned at cultnet-rs `8fc74c70`.
|
||||
- AetheriaEve: `Aetheria.State.Daemon/Program.cs:1737/1778`, C# object initializers.
|
||||
- Decoders read `.error` only: Gjallar `Program.cs:574`, Mimir
|
||||
`MimirOdinMoveProofEvidenceRingProvider.cs:80`, Hermodr `hermodr-daemon.cjs:1380` (hand-rolled),
|
||||
AetheriaEve's smoke test (text match against its own emit text), and Huginn's tests (text match).
|
||||
- Stale vendored TS:
|
||||
- `StreamPixels/vendor/CultLib` carries the current closed schema, so any change here breaks its
|
||||
TS error decode until it is re-vendored.
|
||||
- `Heimdall/vendor/CultLib` carries a pre-R-X schema with `additionalProperties:false` and no
|
||||
`routingHint`, so it already refuses every error C# sends today.
|
||||
- Odin, Idunn, Muninn, Ghostlight and voidbot: zero hits.
|
||||
|
||||
## 1. Ends
|
||||
|
||||
- **One schema.** `contracts/cultnet/cultnet.error.schema.json` is the only authority on the shape and on the
|
||||
code registry. C# embeds it (`GameCult.Networking.csproj:19`), TS mirrors it at build time, and Rust
|
||||
vendors a copy held byte-equal by test. Nothing else publishes a `cultnet.error.v0` shape (F-1 covers
|
||||
Python).
|
||||
- **Every runtime emits only schema-valid errors.** This is proven per emit path against the schema, and
|
||||
byte-equal to the C# reference for every registry code.
|
||||
- **Every runtime decodes every error it can receive into a typed value.** A known code becomes its typed
|
||||
variant with its typed details. An unknown code, if Q1 says so, becomes a typed "other" that keeps the
|
||||
code string and the raw details. No well-formed error is ever refused, and no field is silently dropped
|
||||
(routingHint, messageId).
|
||||
- **Code and details have one owner.** The details a message carries are decided by its code. The types in
|
||||
every runtime make "cursor_stale with a field" unrepresentable. No free-standing code string plus a
|
||||
grab-bag details object.
|
||||
- **A refusal is an answer, not a silence** (R-2 precedent, and Q4). A server that refuses a peer's request,
|
||||
and can still send, sends a coded error that correlates to the request.
|
||||
- **The public surface is complete in every runtime**, per the consumer-expectation doctrine:
|
||||
- a typed error value;
|
||||
- a constructor per registry code;
|
||||
- a typed exception or error that request APIs raise when the peer refuses, carrying the typed value.
|
||||
|
||||
## 2. Identity, lifecycle and authority
|
||||
|
||||
| Kind | What names it | Lifecycle | Who decides |
|
||||
|---|---|---|---|
|
||||
| Error message | `schemaVersion: cultnet.error.v0`; correlated to its request by `messageId` (Q3) | Sent once, in reply to one request or subscription. Never persisted. | The server that refused. |
|
||||
| Error code | A snake_case string in the schema's registry (`$defs/knownCode`) | Added by one coordinated change: schema, C# constructor, a C#-written vector, and a typed variant in every runtime. Never renamed and never reused. A retired code stays in the registry marked retired, so an old reader still decodes it. | The operator rules meaning; Self keeps the registry. |
|
||||
| Details for a code | The code | Frozen with the code. Adding a key to an existing code's details is a new code (or a v1), never an in-place edit (see Q2). | Same as the code. |
|
||||
| Unknown code, at a reader | The raw string | Preserved as typed `Other`. The reader acts on the `error` text and the absence of a known code. | The reader (Q1). |
|
||||
| Vector file | `contracts/cultnet/interop/error-vectors.cs-written.json` | Regenerated only by the C# writer test under `CULTNET_WRITE_VECTORS=1` and committed. Every other runtime judges it read-only. | C#, the reference. |
|
||||
| routingHint | Shard authority refusal | Sent with a null code (unchanged). Typed in every decoder. | CultNetDatabaseServer's authority path. |
|
||||
|
||||
## 3. Operator questions
|
||||
|
||||
**Rulings (operator, 2026-09-30):** "Q1: B / Q2: B / Q3: A / Q8: A".
|
||||
- **Q1:** closed for emitters, open for readers. An unknown code decodes to a typed `Other`.
|
||||
- **Q2:** per-code details.
|
||||
- **Q3:** a nullable top-level `messageId`.
|
||||
- **Q8:** v0 is corrected in place, with the semver bumps the policy requires.
|
||||
- Q4-Q7 are still open.
|
||||
- Cut 1 waits for `hands/cultmesh-put-serve-bound` to merge.
|
||||
|
||||
### Q1. What does a reader do with an error code it does not know?
|
||||
|
||||
Today Rust and TypeScript refuse the whole message. The probes above show the result: the peer's error text
|
||||
is lost and the caller sees "not a recognized code". In cultmesh-rs's snapshot client the `?` at `lib.rs:812-815`
|
||||
even turns a peer refusal into a local decode failure. So under a closed enum every new code breaks every
|
||||
reader that has not upgraded. By `docs/semver-policy.md:86-91` that makes each new code a breaking wire
|
||||
change, and nine codes are already in flight.
|
||||
|
||||
- **A. Closed enum (today).** Readers refuse unknown codes. Every code addition is a coordinated,
|
||||
semver-major wire change across all runtimes and consumers.
|
||||
- **B. Closed for emitters, open for readers.** The schema keeps the registry, and every runtime's emit
|
||||
constructors can produce only registry codes (tests prove each emit path valid against the schema).
|
||||
Readers decode an unrecognized code (matching `^[a-z][a-z0-9_]*$`) into a typed `Other { code, details }`.
|
||||
A new code is an additive, minor change: old readers still surface the text and know "an error I cannot
|
||||
classify". This is how gRPC status codes and protobuf open enums behave.
|
||||
- **C. Fully open.** No registry; any string, emit and decode.
|
||||
|
||||
**Recommended: B.** A refusal must never fail to arrive because it is more specific than the reader
|
||||
expected. B keeps the registry as the single meaning authority. C loses the meaning authority, and A makes
|
||||
the error channel the most brittle message in CultNet.
|
||||
|
||||
Depends on this: schema `code` shape, every decoder, the doc text at `cultnet-distributed-database.md:107-115`.
|
||||
|
||||
### Q2. How are details shaped on the wire?
|
||||
|
||||
Today C# writes all four detail keys (`field`, `value`, `asOf`, `current`), nil included, because
|
||||
`CultNetErrorDetails` (`CultNetSchemaMessages.cs:592-603`) is a MessagePack map-mode object. The codes in
|
||||
flight need new keys: `responseBytes`, `maxSnapshotBytes`, `documentCount`, `maxSnapshotDocuments` and
|
||||
`schemaVersion`.
|
||||
|
||||
- **A. One shared details record; grow it.** Every detail key of every code sits on one record, written as
|
||||
nil when unused. Each new key changes the bytes of every existing error that has details, so all of the
|
||||
C# vectors must be recaptured on every addition. It also breaks every strict reader: TS Ajv, and
|
||||
StreamPixels' vendored schema, have `additionalProperties:false`. And the type cannot tie a key to its
|
||||
code.
|
||||
- **B. Per-code details.** `details` carries exactly the keys its code defines. A defined-but-optional key
|
||||
is present as nil; another code's keys are absent. Keys are named for their meaning, which retires the
|
||||
`field: "schemaId"` / `field: "subscriptionId"` indirection that exists only because the record is
|
||||
shared. Adding a code never changes an existing code's bytes. In every runtime, code and details are
|
||||
fused into one tagged type:
|
||||
- Rust: an enum with data.
|
||||
- C#: sealed subclasses, with a custom formatter for `CultNetErrorMessage`. There is precedent in
|
||||
`src/GameCult.Mesh/CultMeshRouteRecordFormatter.cs`.
|
||||
- TS: a discriminated union.
|
||||
- Kotlin: a sealed class.
|
||||
- Python: constructors per code over a frozen dataclass.
|
||||
|
||||
The churn is one time. The five existing C# cases with details change bytes, and all eight Rust hex
|
||||
cases move into the shared vector file anyway (Cut 1).
|
||||
- **C. Opaque string map.** Untyped; no runtime can offer a typed accessor.
|
||||
|
||||
**Recommended: B.** A is the churn the brief warned of, and it recurs on every future code. B pays it once,
|
||||
at the moment every vector is being recaptured regardless.
|
||||
|
||||
Depends on this: the schema's `details`, the C# model and formatter, `encode_error_details`
|
||||
(`packages/cultnet-rs/src/contracts.rs:1339-1366`), and every decoder.
|
||||
|
||||
### Q3. Does an error carry the `messageId` of the request it refuses?
|
||||
|
||||
Python already sends `messageId` (`server.py:583`); the schema forbids it. Without it:
|
||||
|
||||
- `cultmesh-browser` drops an error whenever two operations are pending (`index.ts:724-731`);
|
||||
- C# content sessions fail every pending request on any error (`CultMeshContentSessions.cs:256-260`).
|
||||
|
||||
Adding a top-level key changes the bytes of every C# error. That happens once, in the same recapture as Q2.
|
||||
|
||||
- **A.** Add a nullable top-level `messageId`. A reply to a request echoes the request's id, and an error
|
||||
with no request (a subscription push, an unsolicited refusal) sends nil. Receivers correlate by it and
|
||||
fall back to their current behavior when it is nil.
|
||||
- **B.** No correlation. Python deletes the field.
|
||||
|
||||
**Recommended: A.** Correlation is a capability any multiplexing client needs, and one runtime already
|
||||
depends on it.
|
||||
|
||||
### Q4. Do servers send `document_unservable` (and every other refusal they can) to the peer?
|
||||
|
||||
Self ruled that "unservable" must be distinguishable as a typed code in every runtime. On the branch:
|
||||
Python sends it coded, TypeScript sends it codeless, and Rust tells only its local caller, so the Rust
|
||||
peer times out. The same Rust silence covers every other document-server rejection: a sink refusal, a
|
||||
failed snapshot source, too many snapshot documents, and a snapshot response that is too large.
|
||||
|
||||
- **A.** Every refusal of a peer's request that the server can still send is answered with a coded error
|
||||
carrying the request's `messageId`, and is also reported locally (Rust's `ApplicationRejected` stays as
|
||||
the local report). The refusals that cannot be answered are the ones where the answer itself cannot be
|
||||
queued: Rust `PayloadBudgetFull` and `ResponseQueueFailed`. Those stay local-only, and the doc says so.
|
||||
- **B.** Local-only in Rust; peer replies only where a runtime already sends them.
|
||||
|
||||
**Recommended: A.** R-2 already ruled that a silence is worse than a missing feature
|
||||
(`docs/cultnet-selection-cut.md:2049-2053`), and the operator's ruling that CultMesh "refuses" puts only
|
||||
means something to the party that sent the put.
|
||||
|
||||
### Q5. Does Python's `unregistered_document_put` / `unregistered_document_delete` become `unowned_schema`?
|
||||
|
||||
Python sends these when the node has no registered document type for the record's schemaId
|
||||
(`cultmesh_py/node.py:653-666`). C# `unowned_schema` means "no shard owns this schema and key"
|
||||
(`CultNetSchemaMessages.cs:537-544`). To a peer both mean "this server will not take that schema here; route
|
||||
elsewhere". The put and delete variants duplicate what the peer already knows (it sent the message).
|
||||
|
||||
- **A.** Both collapse into `unowned_schema { schemaId }`. The registry text broadens to "this server
|
||||
stores no document for that schema (and key)".
|
||||
- **B.** Join as a separate `schema_unregistered { schemaId }`.
|
||||
- **C.** Join both codes as they are.
|
||||
|
||||
**Recommended: A.** It is one meaning at the peer, and there is one action to take.
|
||||
|
||||
### Q6. Does Python's `malformed_document_put` / `malformed_document_delete` become one `message_invalid { field }`?
|
||||
|
||||
These are sent when the request lacks a required field (`server.py:355-359`, `383-387`). C# has the same
|
||||
condition, codeless, at `CultNetDatabaseServer.cs:373/415` and `CultNetDatabaseSubscriptionServer.cs:148`
|
||||
("requires a subscriptionId or messageId").
|
||||
|
||||
- **A.** One `message_invalid { field }`, naming the missing or ill-typed field. It is used by Python's four
|
||||
sites and C#'s three.
|
||||
- **B.** Join both per-message codes and leave C# codeless.
|
||||
|
||||
**Recommended: A.** The per-message suffix repeats the request's own schemaVersion, and C# gains a code for
|
||||
free.
|
||||
|
||||
### Q7. Does `simulation_observations_disabled` become `unsupported_schema_version`?
|
||||
|
||||
Python sends it when the observation hub is off (`server.py:593-598`), with
|
||||
`details { schemaVersion: "cultnet.simulation_observation.v0" }`, which is exactly the
|
||||
`unsupported_schema_version` shape. cultmesh-ts's "no cache for snapshot requests" and "no operation
|
||||
handler" refusals (`index.ts:5875`, `5913`) mean the same thing: this server does not serve that message.
|
||||
|
||||
- **A.** Collapse. `unsupported_schema_version` means "this server does not serve messages of this
|
||||
schemaVersion: unknown, a newer version, or not enabled here". The three TS sites and Huginn's "answers
|
||||
only X, Y, Z" adopt it.
|
||||
- **B.** Keep a separate `feature_disabled`-style code.
|
||||
|
||||
**Recommended: A.** A peer cannot act differently on "not enabled" and "not known". Python's
|
||||
`verify.py:335` check follows the rename.
|
||||
|
||||
### Q8. Is this a correction of `cultnet.error.v0` in place, or a new `cultnet.error.v1`?
|
||||
|
||||
Under Q2-B and Q3-A, strict v0 readers (TS Ajv and vendored copies) refuse the new bytes. Tolerant readers
|
||||
(C#, Rust, Python) keep working, because the probes show they ignore unknown keys and treat a missing key
|
||||
as null.
|
||||
|
||||
- **A. In place.** The v0 contract has never been decodable across all five runtimes (section 0). No
|
||||
consumer outside CultLib reads code or details (finding 10). Every publishing package takes the semver
|
||||
bump its policy requires:
|
||||
- `org.gamecult.cultlib` major, because the public `CultNetErrorDetails` properties change;
|
||||
- `cultnet-py` and `cultmesh-py` major, because emitted codes change;
|
||||
- Rust pins move deliberately.
|
||||
|
||||
StreamPixels and Heimdall re-vendor.
|
||||
- **B. v1.** Add `cultnet.error.v1`, keep v0 emit for peers that did not advertise v1 in hello, and run
|
||||
two error encoders in every runtime until v0 retires.
|
||||
|
||||
**Recommended: A.** B builds a version-negotiation path for a message nobody outside CultLib parses beyond
|
||||
`.error`. The one thing every old reader does need, the `error` text, survives A in every tolerant runtime.
|
||||
|
||||
## 4. The registry (as recommended; rewrite on each ruling)
|
||||
|
||||
| Code | Meaning | Details (per Q2-B; `?` = present-as-nil when absent) | Emitted by after this map |
|
||||
|---|---|---|---|
|
||||
| `selection_invalid` | A selection field failed the door. | `{ field, value? }` | C#, Rust (selection) |
|
||||
| `cursor_stale` | The cursor predates the server's retained history. | `{ asOf, current }` | C#, Rust |
|
||||
| `cursor_invalid` | The cursor could not be parsed. | none (details nil) | C#, Rust |
|
||||
| `reference_outside_target` | A hop reference left the declared target. | none | C#, Rust |
|
||||
| `variant_unsupported` | The selection selects a document variant CultNet does not carry. | `{ subscriptionId? }` | C# (incl. `DatabaseServer.cs:560`) |
|
||||
| `unowned_schema` | This server stores no document for that schema (and key). | `{ schemaId }` | C#, Python (Q5) |
|
||||
| `unsupported_schema_version` | This server does not serve messages of this schemaVersion. | `{ schemaVersion }` | Python, TS, Huginn (Q7) |
|
||||
| `message_invalid` | The request lacks a required field or has an ill-typed one. | `{ field }` | C#, Python (Q6) |
|
||||
| `snapshot_document_limit_exceeded` | The snapshot answer would exceed the document bound. | `{ documentCount, maxSnapshotDocuments }` | Python, Rust (Q4) |
|
||||
| `snapshot_byte_limit_exceeded` | The snapshot answer would exceed the byte bound. | `{ responseBytes, maxSnapshotBytes }` | Python, Rust (Q4) |
|
||||
| `document_unservable` | A put was refused because no snapshot could ever return the stored document under the byte bound. | `{ responseBytes, maxSnapshotBytes }` | Python, TS, Rust (Q4) |
|
||||
|
||||
A null code is legal and means "unclassified". Examples: an exception message from a failed snapshot
|
||||
source, an authority refusal carrying `routingHint`, and "not authorized"
|
||||
(`CultNetDatabaseSubscriptionServer.cs:165`). This map does not invent codes for those.
|
||||
|
||||
`snapshot_byte_limit_exceeded` and `document_unservable` have identical details. That is deliberate, and it
|
||||
is the fixture trap named in the verification rules.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cuts
|
||||
|
||||
All cuts are made on one campaign branch, `hands/error-contract`, from `main` after
|
||||
`hands/cultmesh-put-serve-bound` merges. The branch merges to `main` once, after Cut 5's Soul pass. The
|
||||
reason: Cut 1 changes C# bytes, and Rust's static hex cases would stay green against the stale bytes
|
||||
(finding: `tests/error_message.rs` pins constants, not the reference). A partial merge would ship a
|
||||
wire split while every suite passed. Each cut gets its own worktree, created by Self. Heavy verification
|
||||
runs through `ygg-verify.sh`, never on Starfire.
|
||||
|
||||
### Cut 0. Unblock the interop lane
|
||||
|
||||
- **Repo/branch:** CultLib, `hands/interop-guard` from `main`, merged alone ahead of the campaign. It
|
||||
does not depend on the rulings.
|
||||
- **Deletes first:** nothing.
|
||||
- **Per-file changes:** `.github/workflows/cultnet-interop.yml:72`. The `$stray` filter also accepts
|
||||
`^packages/cultnet-rs/contracts/cultnet/`, with a comment naming R-AB and
|
||||
`packages/cultnet-rs/tests/schema_discovery_contracts.rs:74-99` as the equality guard. The namespace
|
||||
check is unchanged.
|
||||
- **Authority map:** no ownership change. The canonical owner stays `contracts/cultnet/`, and the Rust
|
||||
mirror is derived from it, with drift judged by the Rust test.
|
||||
- **Verification:**
|
||||
- Push the branch and run the workflow. Report every step that goes red after the guard. None of those
|
||||
steps has run on main for 40 runs, so expect more red, and treat each failure as its own finding;
|
||||
do not fix it in this cut.
|
||||
- Negative: edit one byte of a vendored copy locally and check that the Rust equality test fails.
|
||||
Reach it on Yggdrasil with `cd packages/cultnet-rs && cargo test --test schema_discovery_contracts`.
|
||||
- **Ledger:** +3 lines.
|
||||
|
||||
### Cut 1. The contract and the C# reference
|
||||
|
||||
- **Repo/branch:** CultLib `hands/error-contract`, worktree named by Self.
|
||||
- **First:** capture the current C# bytes for all eight factory cases, plus `cs_codeless` and `cs_routing`,
|
||||
into the Hands report. This is the before-image a Soul pass compares against.
|
||||
- **Deletes first:**
|
||||
- `CultNetErrorDetails` (`src/GameCult.Networking/CultNetSchemaMessages.cs:588-603`, 16 lines) as a
|
||||
shared record.
|
||||
- The `field`/`value`/`asOf`/`current` property block and the `[MessagePackObject]`/`[Key]` attributes
|
||||
on `CultNetErrorMessage` (`:497-521`). Its serialization moves to one formatter.
|
||||
- `SoulP4dProbeTests.Wire_UnownedSchemaBytes` (`tests/GameCult.Networking.Tests/SoulP4dProbeTests.cs:191-199`).
|
||||
It is an `[Explicit]` hex printer, superseded by the vector writer.
|
||||
- The closed-enum paragraph at `contracts/cultnet/cultnet-distributed-database.md:107-115` and
|
||||
the `code`/`details` descriptions in the schema (`:28-42`).
|
||||
- **Adds:**
|
||||
- **Schema** (`contracts/cultnet/cultnet.error.schema.json`, rewritten; the Rust mirror is re-copied
|
||||
byte-equal):
|
||||
- Top level: `schemaVersion`, `error` and `routingHint` as today, plus `messageId: string|null` (Q3)
|
||||
and `code`. All five are required, present-as-nil (R-X is unchanged at the top level).
|
||||
- `$defs/knownCode` is the registry enum from section 4.
|
||||
- `code` is `null`, or a string matching `^[a-z][a-z0-9_]*$` (Q1-B).
|
||||
- `details` is decided by a top-level `oneOf`, one branch per known code, each fixing `code` by
|
||||
`const` and `details` by exact `properties`/`required`/`additionalProperties:false`. Two more
|
||||
branches: a null code with null details, and an unknown code (`not: {$ref knownCode}`) with details
|
||||
`object|null`, open.
|
||||
- Use `oneOf`/`not`/`const` only. `tests/GameCult.Networking.Tests/MiniJsonSchemaValidator.cs:30-35`
|
||||
implements those and **not** `if`/`then`/`allOf`, and it refuses unimplemented keywords. TS
|
||||
compiles with Ajv2020 (`packages/cultnet-ts/src/contracts.ts:1,674`), which handles both.
|
||||
- **C# model** (`GameCult.Networking`):
|
||||
- `CultNetErrorMessage { SchemaVersion, Error, MessageId?, RoutingHint?, Refusal? }`, where
|
||||
`Code => Refusal?.Code` is derived and not settable.
|
||||
- `abstract CultNetRefusal { string Code }`, with one sealed subclass per registry code carrying its
|
||||
typed details (`CultNetCursorStaleRefusal(ulong AsOf, ulong Current)` and so on), plus
|
||||
`CultNetUnknownRefusal(string Code, IReadOnlyDictionary<string, object?>? Details)` for Q1-B.
|
||||
- `CultNetErrorCodes`: string constants, one per registry code.
|
||||
- `CultNetErrorMessageFormatter : IMessagePackFormatter<CultNetErrorMessage>`. It writes the five
|
||||
top-level keys in order (schemaVersion, error, messageId, routingHint, code, details), and details
|
||||
as exactly the refusal's keys. It reads by key name, tolerates unknown keys at both levels, and
|
||||
maps an unknown code to `CultNetUnknownRefusal`. Register it through `[MessagePackFormatter]` on
|
||||
the type, the way `CultMeshRouteRecordFormatter` is wired.
|
||||
- Factories: keep `ForSelectionInvalid`, `ForCursor`, `ForReferenceOutsideTarget`,
|
||||
`ForVariantUnsupported` and `ForUnownedSchema` as thin constructors over the refusal types, and add
|
||||
one per new code. Each takes an optional `messageId`.
|
||||
- `CultNetPeerErrorException : IOException`, carrying the `CultNetErrorMessage`.
|
||||
- **Vector writer:** `ErrorVectorTests.WriteErrorVectors`, gated on `CULTNET_WRITE_VECTORS=1` like
|
||||
`SelectionParityVectorTests.cs:460-552`. It writes `contracts/cultnet/interop/error-vectors.cs-written.json`:
|
||||
one entry per registry code (both optional-key states where a key is optional), `cs_codeless`,
|
||||
`cs_routing`, one unknown code with open details, and one error with `messageId` set. Each entry
|
||||
holds `{ name, messagePackBase64, expected: { error, messageId, code, details, routingHint } }`,
|
||||
where `expected` is a JSON projection every runtime compares its typed decode against.
|
||||
- **Per-file changes** (`8fc74c70`):
|
||||
- `CultNetDatabaseServer.cs:560`: `ForVariantUnsupported(refusal.Message, subscriptionId)`.
|
||||
- `CultNetDatabaseServer.cs:373`, `:415` and `CultNetDatabaseSubscriptionServer.cs:148` become
|
||||
`message_invalid { field: "subscriptionId" }` (Q6-A).
|
||||
- Every `SendCultNet(CultNetErrorMessage...)` that answers a request passes the request's `MessageId`.
|
||||
- Each receiver listed in finding 6 throws `new CultNetPeerErrorException(error)` instead of
|
||||
wrapping `error.Error`. `CultMeshContentSessions.OnError` (`:256-260`) fails only the pending entry
|
||||
whose id matches `error.MessageId`, and falls back to all entries only when the id is nil.
|
||||
- `CultNetSchemaContractTests.cs:165-214`: the per-factory tests are replaced by one test that validates
|
||||
every vector's decoded JSON against the schema with `MiniJsonSchemaValidator`, and one test that
|
||||
serializes each registry factory and asserts byte equality with its committed vector.
|
||||
- Rewrite `contracts/cultnet/cultnet-distributed-database.md` "Error Codes" to the registry, and state
|
||||
the Q1 and Q2 rules.
|
||||
- **Authority map:**
|
||||
- Owner: the schema's registry owns code meaning and detail shape. `CultNetErrorMessageFormatter` owns
|
||||
C# bytes. The refusal subclass owns the code-to-details binding.
|
||||
- Inputs: the refusal instance, messageId, routingHint.
|
||||
- Outputs: bytes, and `cs-written.json` for the other runtimes.
|
||||
- Derived state: `CultNetErrorMessage.Code` becomes derived; the refusal decides it.
|
||||
- Forbidden writers: any `Code = "..."` assignment (it cannot compile); `[Key]` attribute serialization
|
||||
of the message; hand-typed hex anywhere.
|
||||
- Shared paths: every server send uses the factories; every receiver uses `CultNetPeerErrorException`.
|
||||
- Deletion line: `CultNetErrorDetails` and the settable `Code`.
|
||||
- **Verification** (`dotnet` image):
|
||||
- Builds: `tests/GameCult.Networking.Tests`, `tests/GameCult.Mesh.Tests`.
|
||||
- `ErrorVectorTests` with `CULTNET_WRITE_VECTORS=1`, then without, and check that the committed file
|
||||
reproduces byte-for-byte.
|
||||
- Tests and the rules they pin:
|
||||
- vector round trip: bytes, decode, bytes are equal for every entry;
|
||||
- a registry factory is byte-equal to its vector;
|
||||
- every vector validates against the schema;
|
||||
- an unknown code decodes to `CultNetUnknownRefusal` with its raw details intact;
|
||||
- a Python-shaped error (`py_snapshot_byte_limit` above, with the Q2/Q3 shape) decodes typed with
|
||||
`responseBytes` and `maxSnapshotBytes` intact. Today's C# silently empties those.
|
||||
- content-session correlation: two pending requests, an error for the second, and the first still
|
||||
completes.
|
||||
- Mutation: Stryker.NET on the formatter and factories. The survivors that must die:
|
||||
- swapping two known codes whose details are identical (`snapshot_byte_limit_exceeded` and
|
||||
`document_unservable`);
|
||||
- padding a nil key into details;
|
||||
- dropping `messageId` on encode;
|
||||
- mapping an unknown code to null.
|
||||
- Negative greps (checked at HEAD, where they hit the sites being cut):
|
||||
- `git grep -nE 'new (IOException|InvalidOperationException)\(error\.Error\)' -- src` must return 0
|
||||
(9 hits at HEAD).
|
||||
- `git grep -nE 'Field = "(schemaId|subscriptionId)"' -- src` must return 0 (2 at HEAD).
|
||||
- Operator: none.
|
||||
- **Ledger:** about -60 C# (the details record, the settable code, the probe printer, and the per-factory
|
||||
schema tests), +250 (the formatter, the refusal types, the vector writer). The schema grows by about 90
|
||||
JSON lines. The net is positive, bought by Q1/Q2 typing in the reference.
|
||||
|
||||
### Cut 2. Rust: cultnet-rs and cultmesh-rs
|
||||
|
||||
- **Deletes first:**
|
||||
- `CultNetErrorCode` and `CultNetErrorDetails` as separate types (`packages/cultnet-rs/src/contracts.rs:252-310`,
|
||||
59 lines);
|
||||
- `require_error_details` (`:1121-1146`);
|
||||
- `encode_error_details` (`:1339-1366`);
|
||||
- the eight hex `Case`s and the header comment in `packages/cultnet-rs/tests/error_message.rs:1-160`.
|
||||
That file is rewritten to read the vector file.
|
||||
- Keep `require_legacy_optional_u64` (`:1149`). Check with `git grep` for other callers first; at HEAD,
|
||||
only the error details use it.
|
||||
- **Adds:**
|
||||
- `CultNetMessage::Error { error: String, message_id: Option<String>, routing_hint: Option<CultNetShardRoutingHint>, refusal: Option<CultNetRefusal> }`.
|
||||
- `enum CultNetRefusal`, with one variant per registry code and its typed fields, plus
|
||||
`Other { code: String, details: Option<rmpv::Value> }`.
|
||||
- `CultNetRefusal::code(&self) -> &str`.
|
||||
- A constructor `CultNetMessage::error(text)` and builder-style `with_refusal` / `with_message_id`, so
|
||||
consumers stop writing struct literals. Huginn has three.
|
||||
- Decode reads every top-level key by name and routes unknown codes to `Other` (Q1-B).
|
||||
- Encode writes the Cut 1 key order.
|
||||
- `CultNetShardRoutingHint` is typed, reusing the crate's existing shard descriptor type.
|
||||
- **Per-file changes:**
|
||||
- `packages/cultnet-rs/src/contracts.rs:361-368` (variant), `:1097-1115` (decode), `:1300-1336` (encode).
|
||||
- `packages/cultnet-rs/src/selection.rs:1210-1255`: the four refusals build `CultNetRefusal` variants.
|
||||
- `packages/cultnet-rs/src/content.rs:316`: uses `CultNetMessage::error`.
|
||||
- `packages/cultmesh-rs/src/lib.rs:812-827`: a peer error becomes a typed error carrying the refusal,
|
||||
instead of `anyhow!` text. Name it `CultMeshPeerError`, next to the crate's existing error types.
|
||||
- `packages/cultmesh-rs/src/rudp_document_server.rs` (Q4-A): each `CultMeshRudpRejectionReason` that
|
||||
can be answered sends the matching coded error, correlated by the request's message id, before
|
||||
returning `ApplicationRejected`. `PayloadBudgetFull` and `ResponseQueueFailed` stay local.
|
||||
- **Authority map:**
|
||||
- Owner: `CultNetRefusal` owns code and details. The schema owns the registry.
|
||||
- Forbidden writers: string codes (`from_wire_str` survives only inside decode); struct literals of
|
||||
details.
|
||||
- Shared paths: selection refusals, document-server refusals and content errors all go through the one
|
||||
encoder.
|
||||
- **Verification** (`rust` image, `cd packages/cultnet-rs` then `cd ../cultmesh-rs`):
|
||||
- `cargo test --workspace`.
|
||||
- `tests/error_message.rs` pins: every vector decodes to `expected`; encoding the decoded value
|
||||
reproduces the vector's bytes; the unknown-code vector decodes to `Other`; the routing vector keeps
|
||||
`routing_hint`.
|
||||
- cultmesh-rs: a put over the bound makes the peer receive `document_unservable` with the request's
|
||||
message id, and details equal to the computed size and the bound. Run it at two bounds so that a
|
||||
constant or an offset in `maxSnapshotBytes` dies.
|
||||
- A peer that sends a Python-shaped `snapshot_byte_limit_exceeded` produces a `CultMeshPeerError`, not
|
||||
a decode failure.
|
||||
- Mutation: `cargo mutants --in-diff` on the cut's diff. Triage every survivor by name.
|
||||
- Negative: `git grep -n 'CultNetErrorDetails' -- packages` must return 0.
|
||||
- **Consumer note:** Huginn's `serve.rs:288/307/312` and its tests stop compiling at the next pin bump.
|
||||
The migration is mechanical (`CultNetMessage::error(...)`), and `:312` adopts `unsupported_schema_version`
|
||||
(Q7). Self routes it as a Huginn change; it is not part of this branch.
|
||||
|
||||
### Cut 3. TypeScript: cultnet-ts, cultmesh-ts and cultmesh-browser
|
||||
|
||||
- **Deletes first:**
|
||||
- the two-field `CultNetErrorMessage` interface (`packages/cultnet-ts/src/contracts.ts:167-170`);
|
||||
- the four literal error constructions in `packages/cultmesh-ts/src/index.ts:5875-5878`, `5885-5888`,
|
||||
`5913-5916` and `5922-5925`, and the branch's codeless unservable site (`0eb7434a`);
|
||||
- `rejectCorrelated`'s single-pending heuristic (`packages/cultmesh-browser/src/index.ts:724-731`).
|
||||
- **Adds:**
|
||||
- `CultNetErrorMessage` with every field.
|
||||
- `type CultNetRefusal` as a discriminated union on `code`, with an `{ code: string; details: Record<string, unknown> | null; known: false }` arm.
|
||||
- `createCultNetError(text, { messageId, refusal })` as the only constructor.
|
||||
- `class CultNetPeerError extends Error { message: CultNetErrorMessage }`.
|
||||
- `parseCultNetMessage` keeps Ajv validation against the canonical schema; it now accepts every schema
|
||||
branch, including unknown codes.
|
||||
- **Per-file changes:**
|
||||
- cultmesh-ts: the three "does not serve this" sites (`5875`, `5913`, `5922`) send
|
||||
`unsupported_schema_version` (Q7-A); the snapshot catch (`5885`) sends a null code; the unservable site
|
||||
sends `document_unservable` with details.
|
||||
- cultmesh-ts `6527-6529` throws `CultNetPeerError`.
|
||||
- cultmesh-browser `163`, `584` and `690`: correlate by `messageId`, falling back to today's behavior only
|
||||
for a nil id.
|
||||
- `packages/cultnet-ts/src/idunn-odin-presence-publisher.ts:118-120` throws `CultNetPeerError`.
|
||||
- The legacy `gamecult.networking.v0` path (`contracts.ts:840-844`, `:899-900`) is unchanged. That is a
|
||||
separate legacy contract.
|
||||
- **Verification** (`kotlin` image, which carries Node 24; `npm install`, then build cultcache-ts before
|
||||
cultnet-ts):
|
||||
- A new `test/error-vectors.test.ts` pins that every vector parses and deep-equals `expected`, and that
|
||||
`encode(createCultNetError(...))` is byte-equal to each registry vector.
|
||||
- The cultmesh-ts document-server tests assert that each refusal parses with TypeScript's own
|
||||
`parseCultNetMessage`. That is the probe's `ts_two_key` failure, pinned.
|
||||
- Browser: two pending operations, an error for the second, and the first still resolves.
|
||||
- Mutation: StrykerJS on the diff.
|
||||
- Negative: `git grep -nE 'schemaVersion: "cultnet\.error\.v0",' -- packages/cultmesh-ts/src packages/cultmesh-browser/src`
|
||||
must return 0. Exclude `cultnet-ts/src/schema-discovery.ts:302` (a descriptor) and
|
||||
`contracts.ts:842` (legacy decode); both are legitimate hits for the unscoped pattern.
|
||||
|
||||
### Cut 4. Python: cultnet-py and cultmesh-py
|
||||
|
||||
- **Deletes first:**
|
||||
- `_error_response` (`packages/cultmesh-py/src/cultmesh_py/server.py:573-589`);
|
||||
- `unsupported_schema_version_error` (`packages/cultnet-py/src/cultnet_py/interop_peer.py:409-417`);
|
||||
- every string literal code in `server.py` (lines in finding 3).
|
||||
- **Adds:**
|
||||
- `cultnet_py/errors.py`, holding:
|
||||
- `CultNetErrorCode(StrEnum)`, the registry;
|
||||
- a frozen `CultNetError` dataclass (`error`, `message_id`, `routing_hint`, `code: CultNetErrorCode | str | None`,
|
||||
`details: Mapping | None`);
|
||||
- one constructor function per code, taking exactly that code's keys;
|
||||
- `to_wire()`, which writes the five top-level keys in the Cut 1 order, nil included;
|
||||
- `parse_cultnet_error(dict)`.
|
||||
- `CultNetPeerError` (`client.py:46-50`) keeps `.response` and gains `.error: CultNetError`.
|
||||
- **Per-file changes:**
|
||||
- Sites `server.py:158-163`, `355-394`, `555-569` and `593-598` and the branch's `document_unservable`
|
||||
site use the constructors, with the Q5-A/Q6-A/Q7-A codes.
|
||||
- `interop_peer.py` uses the constructor.
|
||||
- `verify.py:335` reads `error.error.code`.
|
||||
- Tests `packages/cultmesh-py/tests/test_cultmesh.py:2088`, `3791`, `4323` and `4363-4371`, and
|
||||
`packages/cultnet-py/tests/test_cultnet.py:2342`, follow the renames.
|
||||
- **Verification** (the image the campaign already uses for Python; state it in the report):
|
||||
- A vector test pins that every vector parses to `expected` and that each constructor's `msgpack.packb`
|
||||
is byte-equal to its vector.
|
||||
- Mutation: mutmut on `errors.py` and the changed server sites.
|
||||
- Negative: `git grep -nE '"schemaVersion": "cultnet\.error\.v0"' -- packages/cultnet-py/src packages/cultmesh-py/src`
|
||||
returns only `errors.py`.
|
||||
- **Not here:** F-1 (`schema_catalog.py` publishes its own shapes).
|
||||
|
||||
### Cut 5. Kotlin: cultmesh-kotlin
|
||||
|
||||
- **Adds:**
|
||||
- A `CultNetErrorMessage` data class and a `sealed class CultNetRefusal` (known codes plus `Other`),
|
||||
with a `CultNetMessage.asError(): CultNetErrorMessage?` view.
|
||||
- `class CultNetPeerException(val error: CultNetErrorMessage) : IOException`.
|
||||
- At `CultMesh.kt:1278` and `:1334`, and at every `receiveSchemaMessage` consumer that expects a
|
||||
response, an error response throws `CultNetPeerException` before the `require`.
|
||||
- **Verification** (`kotlin` image, `pwsh -File build.ps1`):
|
||||
- A vector test pins that every vector decodes to `expected`.
|
||||
- Kotlin emits no errors today, so encode parity applies only if a Kotlin server path is added. Say
|
||||
so in the report; do not invent one.
|
||||
- There is no mutation tool. Behavioural tests cover it: an error reply to a schema-catalog request
|
||||
raises `CultNetPeerException` carrying the code.
|
||||
|
||||
### Cut 6. Merge, release, consumers
|
||||
|
||||
- Soul runs over the whole branch before the merge:
|
||||
- rerun every vector suite;
|
||||
- decode each runtime's live emissions with every other runtime. The interop lane from Cut 0 is the
|
||||
harness; add an error round to `tests/GameCult.Networking.InteropPeer` and
|
||||
`packages/cultnet-ts/test/interop` if Soul finds a runtime pair unreached.
|
||||
- Release per `docs/semver-policy.md`, with a Soul pass before tags:
|
||||
- `org.gamecult.cultlib` major, and the tracked Unity DLLs rebuilt, since they embed the schema;
|
||||
- `cultnet-py` and `cultmesh-py` major.
|
||||
- Consumers, routed by Self as separate repo changes:
|
||||
- Huginn: pin bump plus Cut 2's note.
|
||||
- StreamPixels and Heimdall: re-vendor. Heimdall is already broken by R-X.
|
||||
- AetheriaEve, Gjallar and Mimir compile against the live CultLib path and read only `.Error`, so they
|
||||
need no change. That is inferred from the audit; the next build of each confirms it.
|
||||
- Hermodr: none.
|
||||
|
||||
## 6. Subtraction ledger
|
||||
|
||||
| Cut | Removed (estimate) | Added (estimate) | Formats / targets |
|
||||
|---|---|---|---|
|
||||
| 0 | 0 | 3 | none |
|
||||
| 1 | ~60 C#, schema text | ~250 C#, ~90 schema | + `error-vectors.cs-written.json`; - `[Explicit]` hex printer |
|
||||
| 2 | ~150 (types, two helpers, 160-line hex test) | ~200 | hex constants gone; vectors read from file |
|
||||
| 3 | ~40 | ~120 | none |
|
||||
| 4 | ~40 | ~120 (`errors.py`) | 8 ad-hoc codes → registry |
|
||||
| 5 | 0 | ~80 | none |
|
||||
|
||||
The net is positive, by roughly 500 lines, across five runtimes. It buys typed decode where there was
|
||||
none (Kotlin, TS and Python), a correlation capability, and one vector file in place of hand-copied hex.
|
||||
The pressure point: if Cut 1's formatter passes 150 lines, report it rather than trimming typing.
|
||||
|
||||
## 7. Follow-ups outside this map
|
||||
|
||||
- **F-1.** `packages/cultnet-py/src/cultnet_py/schema_catalog.py` publishes about 350 lines of hand-built
|
||||
schemas under canonical `$id`s. Python should package and serve `contracts/cultnet/*.schema.json`, the
|
||||
way Rust vendors them. It is a separate cut, because it covers every message, not errors. It can wait
|
||||
because the catalog is advisory: no runtime validates a peer's message against a published descriptor.
|
||||
- **F-2.** Codes for the remaining codeless refusals (authorization, failed snapshot sources, routing).
|
||||
Wait for a consumer that branches on them.
|
||||
- **F-3.** Whatever the Cut 0 lane reveals once its later steps run again.
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -271,15 +271,6 @@ Current progress:
|
|||
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.
|
||||
- The receive window is a contract, not a discovery. A receiver must hold 1,023
|
||||
sequences and 4 MiB of payload beyond its contiguous received watermark, and
|
||||
a conforming sender never exceeds either. The sender's flow window enforces
|
||||
it without a wire field: a reliable packet is admitted only while its
|
||||
sequence is at most 1,023 above the lowest unacked sequence and the payload
|
||||
above that sequence stays within 4 MiB. The first packet is always
|
||||
admissible, so a payload larger than 4 MiB goes out alone, and packets wait
|
||||
in the FIFO in order, so a small packet never overtakes a large one. C#,
|
||||
Rust, TypeScript, and Python enforce it; Kotlin does not yet.
|
||||
- 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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,272 +0,0 @@
|
|||
# Element-id sweep list (C2a)
|
||||
|
||||
Data for the per-consumer sweep in docs/document-variants-cut.md, C2. Every object type below sits in a list (`List<T>`, `T[]`, `IEnumerable<T>`, and the like) reachable from a registered document type, and needs one string member with `[Key(n)]` and `[CultElementId]` before that consumer can take a CultLib that requires ids. A union base takes the member on the base; its subclasses inherit it, so put the slot above the subclasses' highest key.
|
||||
|
||||
Method: a static scan of `F:\Projects\<consumer>` C# files for types marked `[CultDocument(`, walked through keyed members, base classes, dictionary values and `[Union]` subtypes. Read-only; no consumer was edited. Worktree copies (`Aetheria-catalog`, `CultLib-*`, and so on) are excluded. Struct elements are values and need no id. Non-C# consumers carry the id as an ordinary member and are not listed. The scan is text-based: a document registered without a literal `[CultDocument(` attribute, or a type whose members lack an integer `[Key]`, is missed, so the sweep pass should confirm each repo by registering its types once.
|
||||
|
||||
Lines: `Type file:line (via Owner.Member)`, paths relative to the consumer root.
|
||||
|
||||
|
||||
## Aetheria (13 element types)
|
||||
|
||||
- BehaviorData Assets/Scripts/ServerShared/Behaviors/Behaviors.cs:190 (via ConsumableItemData.Behaviors)
|
||||
- EntityPack Assets/Scripts/ServerShared/EntitySerializer.cs:171 (via ZonePack.Entities)
|
||||
- ProductRole Assets/Scripts/ServerShared/FactionProduct.cs:38 (via FactionProductData.Roles)
|
||||
- InputLayoutRow Assets/Scripts/ServerShared/InputLayout.cs:32 (via InputLayout.Rows)
|
||||
- InputLayoutColumn Assets/Scripts/ServerShared/InputLayout.cs:52 (via InputLayoutKeyRow.Columns)
|
||||
- ItemRole Assets/Scripts/ServerShared/ItemData.cs:325 (via CraftedItemData.Roles)
|
||||
- AudioStat Assets/Scripts/ServerShared/ItemData.cs:441 (via EquippableItemData.AudioStats)
|
||||
- HardpointData Assets/Scripts/ServerShared/ItemData.cs:553 (via HullData.Hardpoints)
|
||||
- StatTerm Assets/Scripts/ServerShared/ItemData.cs:630 (via PerformanceStat.Terms)
|
||||
- LoadoutSlot Assets/Scripts/ServerShared/Loadout.cs:23 (via Loadout.Slots)
|
||||
- RoleFill Assets/Scripts/ServerShared/Provenance.cs:84 (via Lot.Roles)
|
||||
- SavedActionBarBinding Assets/Scripts/ServerShared/SavedGame.cs:168 (via SavedGame.ActionBarBindings)
|
||||
- Asteroid Assets/Scripts/ServerShared/ZoneData.cs:133 (via AsteroidBeltData.Asteroids)
|
||||
|
||||
## AetheriaEve (115 element types)
|
||||
|
||||
- AetheriaCorporationAllegiance Aetheria.State/Documents/AetheriaCorporation.cs:67 (via AetheriaCorporation.Allegiances)
|
||||
- AetheriaShapeCell Aetheria.State/Documents/AetheriaItemDefinition.cs:178 (via AetheriaItemDefinition.ShapeCells)
|
||||
- AetheriaItemHardpoint Aetheria.State/Documents/AetheriaItemDefinition.cs:188 (via AetheriaItemDefinition.Hardpoints)
|
||||
- AetheriaCurveKey Aetheria.State/Documents/AetheriaItemDefinition.cs:222 (via AetheriaItemDefinition.ThermalPerformanceCurveKeys)
|
||||
- AetheriaItemAudioStat Aetheria.State/Documents/AetheriaItemDefinition.cs:238 (via AetheriaItemDefinition.AudioStats)
|
||||
- AetheriaBehaviorPayload Aetheria.State/Documents/AetheriaItemDefinition.cs:267 (via AetheriaItemDefinition.BehaviorPayloads)
|
||||
- AetheriaBehaviorField Aetheria.State/Documents/AetheriaItemDefinition.cs:286 (via AetheriaBehaviorPayload.Fields)
|
||||
- AetheriaBehaviorMapEntry Aetheria.State/Documents/AetheriaItemDefinition.cs:296 (via AetheriaBehaviorValue.MapEntries)
|
||||
- AetheriaBehaviorValue Aetheria.State/Documents/AetheriaItemDefinition.cs:306 (via AetheriaBehaviorValue.Children)
|
||||
- AetheriaLegacyCatalogFile Aetheria.State/Documents/AetheriaLegacyCatalogQuarantine.cs:41 (via AetheriaLegacyCatalogQuarantine.NameFiles)
|
||||
- AetheriaMigrationCount Aetheria.State/Documents/AetheriaMigrationLedger.cs:35 (via AetheriaMigrationLedger.Counts)
|
||||
- AetheriaLegacyCatalogEntrySummary Aetheria.State/Documents/AetheriaMigrationLedger.cs:45 (via AetheriaMigrationLedger.LegacyCatalogEntries)
|
||||
- AetheriaStoryFileHash Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:42 (via AetheriaPlayerSettings.StoryFileHashes)
|
||||
- AetheriaInputBindingOverride Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:85 (via AetheriaPlayerInputSettings.BindingOverrides)
|
||||
- AetheriaEntityLoadout Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:119 (via AetheriaEntityLoadout.Children)
|
||||
- AetheriaLoadoutItemSlot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:186 (via AetheriaEntityLoadout.Equipment)
|
||||
- AetheriaCargoBayLoadout Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:199 (via AetheriaEntityLoadout.CargoContents)
|
||||
- AetheriaFactionZoneState Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:268 (via AetheriaRunState.HomeZones)
|
||||
- AetheriaAgentTaskState Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:275 (via AetheriaRunState.AgentTasks)
|
||||
- AetheriaDroppedPickupSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:346 (via AetheriaZoneState.DroppedPickups)
|
||||
- AetheriaOrbitSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:362 (via AetheriaZoneState.Orbits)
|
||||
- AetheriaBodySnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:384 (via AetheriaZoneState.Bodies)
|
||||
- AetheriaBodyResource Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:449 (via AetheriaBodySnapshot.Resources)
|
||||
- AetheriaAsteroidSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:459 (via AetheriaBodySnapshot.Asteroids)
|
||||
- AetheriaAsteroidMiningAccumulatorSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:484 (via AetheriaAsteroidSnapshot.MiningAccumulators)
|
||||
- AetheriaColor Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:545 (via AetheriaGasGiantVisualState.Colors)
|
||||
- AetheriaFogFieldEmitterSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:691 (via AetheriaEntitySnapshot.FogFieldEmitters)
|
||||
- AetheriaLoadoutGenerationSelection Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:723 (via AetheriaLoadoutGenerationReceipt.Selections)
|
||||
- AetheriaEntityContactSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:734 (via AetheriaEntitySnapshot.Contacts)
|
||||
- AetheriaEntityItemSlot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:751 (via AetheriaEntitySnapshot.Equipment)
|
||||
- AetheriaWeaponGroupSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:782 (via AetheriaEntitySnapshot.WeaponGroups)
|
||||
- AetheriaEntityStatGrid Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:789 (via AetheriaEntitySnapshot.StatGrids)
|
||||
- AetheriaActiveConsumableSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:805 (via AetheriaEntitySnapshot.ActiveConsumables)
|
||||
- AetheriaConsumableBehaviorStateSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:828 (via AetheriaActiveConsumableSnapshot.BehaviorStates)
|
||||
- AetheriaBehaviorProgressSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:844 (via AetheriaEntitySnapshot.BehaviorProgress)
|
||||
- AetheriaWeaponStateSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:863 (via AetheriaEntitySnapshot.WeaponStates)
|
||||
- AetheriaBehaviorStateSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:924 (via AetheriaEntitySnapshot.BehaviorStates)
|
||||
- AetheriaFactionRelationshipState Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:1093 (via AetheriaRunState.FactionRelationships)
|
||||
- AetheriaItemRarityTier Aetheria.State/Documents/AetheriaTradeValuePolicy.cs:29 (via AetheriaTradeValuePolicy.Tiers)
|
||||
- AetheriaRuntimeAgentTaskCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeAgentTasks.cs:35 (via AetheriaRuntimeRunCheckpointCommit.AgentTasks)
|
||||
- AetheriaRuntimeAssetManifestEntry Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeAssetDocuments.cs:102 (via AetheriaRuntimeAssetManifestDocument.Assets)
|
||||
- AetheriaRuntimeCurveKey Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCatalogSnapshot.cs:446 (via AetheriaRuntimeShotReceiptCommit.GuidanceCurve)
|
||||
- AetheriaRuntimeCorporation Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCatalogSnapshot.cs:620 (via AetheriaRuntimeCatalogSnapshot.Corporations)
|
||||
- AetheriaRuntimeNameFile Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCatalogSnapshot.cs:669 (via AetheriaRuntimeCatalogSnapshot.NameFiles)
|
||||
- AetheriaRuntimeLoadoutTemplateSnapshot Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCatalogSnapshot.cs:796 (via AetheriaRuntimeLoadoutTemplatesDocument.Templates)
|
||||
- AetheriaRuntimeDaemonCommandBoundaryEntry Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonDocuments.cs:462 (via AetheriaRuntimeDaemonCommandBoundaryDocument.Commands)
|
||||
- AetheriaRuntimeDaemonSoaIdentityDocument Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSoaDocuments.cs:143 (via AetheriaRuntimeDaemonSoaViewDocument.Identities)
|
||||
- AetheriaRuntimeDaemonRenderGroupDocument Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSoaDocuments.cs:156 (via AetheriaRuntimeDaemonSoaViewDocument.RenderGroups)
|
||||
- AetheriaRuntimeDaemonSoaBufferDocument Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSoaDocuments.cs:222 (via AetheriaRuntimeDaemonSoaViewDocument.Buffers)
|
||||
- AetheriaRuntimeDaemonSoaColumnDocument Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSoaDocuments.cs:253 (via AetheriaRuntimeDaemonSoaViewDocument.Columns)
|
||||
- AetheriaRuntimeDaemonSoaDirtyRangeDocument Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSoaDocuments.cs:284 (via AetheriaRuntimeDaemonSoaViewDocument.DirtyRanges)
|
||||
- AetheriaRuntimeEveSurfaceAdvertisement Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEveSurfaceCatalog.cs:11 (via AetheriaRuntimeDaemonProviderAdvertisementDocument.EveSurfaces)
|
||||
- AetheriaRuntimeEvePluginRequirement Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEveSurfaceCatalog.cs:58 (via AetheriaRuntimeEveSurfaceAdvertisement.RequiresPlugins)
|
||||
- AetheriaRuntimeZoneTargetRow Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:410 (via AetheriaRuntimeZoneContactsDocument.Targets)
|
||||
- AetheriaRuntimeZoneContactRow Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:441 (via AetheriaRuntimeZoneContactsDocument.Contacts)
|
||||
- AetheriaRuntimeStationStockItem Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:551 (via AetheriaRuntimeStationRefitEntityOption.CargoItems)
|
||||
- AetheriaRuntimeStationRefitEntityOption Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:585 (via AetheriaRuntimeStationRefitDocument.AvailableEntities)
|
||||
- AetheriaRuntimeStationDockingBayRow Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:620 (via AetheriaRuntimeStationRefitDocument.DockingBays)
|
||||
- AetheriaRuntimeStationLoadoutRestoreOption Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:655 (via AetheriaRuntimeStationRefitDocument.LoadoutRestoreOptions)
|
||||
- AetheriaRuntimeStationCargoTargetRow Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:674 (via AetheriaRuntimeStationRefitDocument.CargoTargets)
|
||||
- AetheriaRuntimeSectorMapZone Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:765 (via AetheriaRuntimeSectorMapDocument.Zones)
|
||||
- AetheriaRuntimeSectorMapLink Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:802 (via AetheriaRuntimeSectorMapDocument.Links)
|
||||
- AetheriaRuntimeZoneRenderAdjacentZone Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:1197 (via AetheriaRuntimeZoneRenderDocument.AdjacentZones)
|
||||
- AetheriaRuntimeZoneRenderWormholeExit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:1210 (via AetheriaRuntimeZoneRenderDocument.WormholeExits)
|
||||
- AetheriaRuntimeZoneRenderBodyPose Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:1229 (via AetheriaRuntimeZoneRenderDocument.BodyPoses)
|
||||
- AetheriaRuntimeZoneRenderAsteroidBeltPose Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:1260 (via AetheriaRuntimeZoneRenderDocument.AsteroidBeltPoses)
|
||||
- AetheriaRuntimeZoneRenderAsteroidInstancePose Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:1286 (via AetheriaRuntimeZoneRenderAsteroidBeltPose.InstancePoses)
|
||||
- AetheriaRuntimeViewportObject Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:1382 (via AetheriaRuntimeGameViewportDocument.Objects)
|
||||
- AetheriaRuntimeInventoryItem Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:1458 (via AetheriaRuntimeViewportObject.Inventory)
|
||||
- AetheriaRuntimeGravityInfluence Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:1493 (via AetheriaRuntimeGameViewportDocument.GravityInfluences)
|
||||
- AetheriaRuntimeBodyView Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:1530 (via AetheriaRuntimeGameViewportDocument.Bodies)
|
||||
- AetheriaHangarShip Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeHangarDocuments.cs:119 (via AetheriaHangarState.Ships)
|
||||
- AetheriaHangarItemStack Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeHangarDocuments.cs:129 (via AetheriaHangarState.Inventory)
|
||||
- AetheriaHangarCurrency Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeHangarDocuments.cs:136 (via AetheriaHangarState.Currencies)
|
||||
- AetheriaDeploymentReceipt Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeHangarDocuments.cs:155 (via AetheriaHangarState.Deployments)
|
||||
- AetheriaProgressionVerseOption Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeProgressionSourceDocuments.cs:45 (via AetheriaProgressionSourceDocument.AvailableVerses)
|
||||
- AetheriaRuntimeStoryFileHashDocument Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSettingsDocuments.cs:87 (via AetheriaRuntimePlayerSettingsDocument.StoryFileHashes)
|
||||
- AetheriaRuntimeInputBindingOverrideDocument Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSettingsDocuments.cs:108 (via AetheriaRuntimePlayerSettingsDocument.BindingOverrides)
|
||||
- AetheriaRuntimeEntityLoadoutCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:78 (via AetheriaRuntimeEntityLoadoutCommit.Children)
|
||||
- AetheriaRuntimeLoadoutItemSlotCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:143 (via AetheriaRuntimeEntitySnapshotCommit.Equipment)
|
||||
- AetheriaRuntimeCargoBayLoadoutCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:159 (via AetheriaRuntimeEntitySnapshotCommit.CargoContents)
|
||||
- AetheriaRuntimeGameEventCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:490 (via AetheriaRuntimeRunCheckpointCommit.GameEvents)
|
||||
- AetheriaRuntimeFactionZoneCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:509 (via AetheriaRuntimeRunCheckpointCommit.HomeZones)
|
||||
- AetheriaRuntimeShotReceiptCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:539 (via AetheriaRuntimeRunCheckpointCommit.ShotReceipts)
|
||||
- AetheriaRuntimePickupContactReceiptCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:591 (via AetheriaRuntimeRunCheckpointCommit.PickupContactReceipts)
|
||||
- AetheriaRuntimeDamageCellCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:603 (via AetheriaRuntimeShotReceiptCommit.DamageCells)
|
||||
- AetheriaRuntimeCorporationSurveyCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:614 (via AetheriaRuntimeRunCheckpointCommit.CorporationSurveys)
|
||||
- AetheriaRuntimeFactionRelationshipCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:623 (via AetheriaRuntimeRunCheckpointCommit.FactionRelationships)
|
||||
- AetheriaRuntimeZoneSnapshotCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:636 (via AetheriaRuntimeRunCheckpointCommit.Zones)
|
||||
- AetheriaRuntimePhysicalPayloadCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:699 (via AetheriaRuntimeZoneSnapshotCommit.PhysicalPayloads)
|
||||
- AetheriaRuntimeDroppedPickupCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:771 (via AetheriaRuntimeZoneSnapshotCommit.DroppedPickups)
|
||||
- AetheriaRuntimeOrbitSnapshotCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:805 (via AetheriaRuntimeZoneSnapshotCommit.Orbits)
|
||||
- AetheriaRuntimeBodySnapshotCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:830 (via AetheriaRuntimeZoneSnapshotCommit.Bodies)
|
||||
- AetheriaRuntimeBodyResourceCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:901 (via AetheriaRuntimeBodySnapshotCommit.Resources)
|
||||
- AetheriaRuntimeAsteroidCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:911 (via AetheriaRuntimeBodySnapshotCommit.Asteroids)
|
||||
- AetheriaRuntimeAsteroidMiningAccumulatorCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:936 (via AetheriaRuntimeAsteroidCommit.MiningAccumulators)
|
||||
- AetheriaRuntimeColorCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1008 (via AetheriaRuntimeGasGiantVisualCommit.Colors)
|
||||
- AetheriaRuntimeEntitySnapshotCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1024 (via AetheriaRuntimeZoneSnapshotCommit.Entities)
|
||||
- AetheriaRuntimeFogFieldEmitterCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1198 (via AetheriaRuntimeEntitySnapshotCommit.FogFieldEmitters)
|
||||
- AetheriaRuntimeEquipmentStateCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1240 (via AetheriaRuntimeEntitySnapshotCommit.EquipmentStates)
|
||||
- AetheriaRuntimeLoadoutGenerationSelectionCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1264 (via AetheriaRuntimeLoadoutGenerationReceiptCommit.Selections)
|
||||
- AetheriaRuntimeEntityContactCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1275 (via AetheriaRuntimeEntitySnapshotCommit.Contacts)
|
||||
- AetheriaRuntimeEntityStatGridCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1291 (via AetheriaRuntimeEntitySnapshotCommit.StatGrids)
|
||||
- AetheriaRuntimeActiveConsumableCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1307 (via AetheriaRuntimeEntitySnapshotCommit.ActiveConsumables)
|
||||
- AetheriaRuntimeConsumableBehaviorStateCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1330 (via AetheriaRuntimeActiveConsumableCommit.BehaviorStates)
|
||||
- AetheriaRuntimeBehaviorProgressCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1355 (via AetheriaRuntimeEntitySnapshotCommit.BehaviorProgress)
|
||||
- AetheriaRuntimeWeaponStateCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1374 (via AetheriaRuntimeEntitySnapshotCommit.WeaponStates)
|
||||
- AetheriaRuntimeBehaviorStateCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1444 (via AetheriaRuntimeEntitySnapshotCommit.BehaviorStates)
|
||||
- AetheriaRuntimeStarbridgeStationStockItem Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeStarbridgeDocuments.cs:71 (via AetheriaRuntimeStarbridgeScenarioDocument.StationStock)
|
||||
- AetheriaRuntimeStarbridgeWaveDefinition Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeStarbridgeDocuments.cs:83 (via AetheriaRuntimeStarbridgeScenarioDocument.Waves)
|
||||
- AetheriaRuntimeStarbridgeWaveForecast Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeStarbridgeDocuments.cs:94 (via AetheriaRuntimeStarbridgeSessionSummaryDocument.WaveForecast)
|
||||
- AetheriaRuntimeStarbridgeRuntimeRole Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeStarbridgeDocuments.cs:104 (via AetheriaRuntimeStarbridgeScenarioDocument.RuntimeRoles)
|
||||
- AetheriaRuntimeAuthorityRuntimeRole Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseAuthorityPolicy.cs:194 (via AetheriaRuntimeVerseAuthorityPolicyDocument.RuntimeRoles)
|
||||
- AetheriaRuntimeAuthorityRule Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseAuthorityPolicy.cs:201 (via AetheriaRuntimeVerseAuthorityPolicyDocument.Rules)
|
||||
- AetheriaRuntimeArenaSeat Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseAuthorityPolicy.cs:255 (via AetheriaRuntimeArenaRosterDocument.Seats)
|
||||
|
||||
## AquaSynth (0 element types)
|
||||
|
||||
None.
|
||||
|
||||
|
||||
## Aquarium (1 element types)
|
||||
|
||||
- AquariumFaceVoiceEndpointState src/Aquarium.Epiphany/State/AquariumLiveState.cs:61 (via AquariumLiveState.FaceVoiceEndpoints)
|
||||
|
||||
## Brokkr (4 element types)
|
||||
|
||||
- BrokkrGameObjectSnapshot surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrHostSnapshot.cs:42 (via BrokkrHostSnapshot.sceneObjects)
|
||||
- BrokkrComponentSnapshot surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrHostSnapshot.cs:62 (via BrokkrGameObjectSnapshot.components)
|
||||
- BrokkrSerializedPropertySnapshot surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrHostSnapshot.cs:73 (via BrokkrComponentSnapshot.properties)
|
||||
- BrokkrAssetSnapshot surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrHostSnapshot.cs:84 (via BrokkrHostSnapshot.assets)
|
||||
|
||||
## Delvehold (0 element types)
|
||||
|
||||
None.
|
||||
|
||||
|
||||
## Eve (17 element types)
|
||||
|
||||
- EveAssetCatalogEntry packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs:52 (via EveAssetCatalogDocument.Assets)
|
||||
- EveAssetVariant packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs:89 (via EveAssetCatalogEntry.Variants)
|
||||
- EveEntitySoaBuffer packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs:33 (via EveEntitySoaViewDocument.Buffers)
|
||||
- EveEntitySoaColumn packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs:41 (via EveEntitySoaViewDocument.Columns)
|
||||
- EveEntitySoaDirtyRange packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs:55 (via EveEntitySoaViewDocument.DirtyRanges)
|
||||
- EveEntityIdentity packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs:64 (via EveEntitySoaViewDocument.Identities)
|
||||
- EveEntityRenderGroup packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs:77 (via EveEntitySoaViewDocument.RenderGroups)
|
||||
- EveInputActionDocument packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs:22 (via EveInputCapabilityDocument.Actions)
|
||||
- EveInputProfileDocument packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs:51 (via EveInputCapabilityDocument.DefaultProfiles)
|
||||
- EveInputBindingDocument packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs:59 (via EveInputProfileDocument.Bindings)
|
||||
- EveProviderWitness packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs:249 (via EveProviderAdvertisementDocument.Witnesses)
|
||||
- EveAdvertisedSurface packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs:265 (via EveProviderAdvertisementDocument.Surfaces)
|
||||
- EveAdvertisedCommand packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs:333 (via EveProviderAdvertisementDocument.Commands)
|
||||
- EveSurfaceComponent packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs:106 (via EveSurfaceComponent.Children)
|
||||
- EveEmbeddedDocumentSlot packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs:230 (via EveSurfaceComponent.EmbeddedDocuments)
|
||||
- EveStyleToken packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs:277 (via EveSurfaceTree.Styles)
|
||||
- EveCommandTemplate packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs:295 (via EveSurfaceDocument.Commands)
|
||||
|
||||
## EvePlugins (1 element types)
|
||||
|
||||
- EveFieldsSplatLayer plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields/Runtime/EveFieldsDocuments.cs:38 (via EveFieldsSplatsDocument.Layers)
|
||||
|
||||
## Ghostlight (17 element types)
|
||||
|
||||
These types are a vendored copy of Eve (`vendor/eve`); they change when Eve does and the pin bumps.
|
||||
|
||||
- EveAssetCatalogEntry vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs:52 (via EveAssetCatalogDocument.Assets)
|
||||
- EveAssetVariant vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs:89 (via EveAssetCatalogEntry.Variants)
|
||||
- EveEntitySoaBuffer vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs:33 (via EveEntitySoaViewDocument.Buffers)
|
||||
- EveEntitySoaColumn vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs:41 (via EveEntitySoaViewDocument.Columns)
|
||||
- EveEntitySoaDirtyRange vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs:55 (via EveEntitySoaViewDocument.DirtyRanges)
|
||||
- EveEntityIdentity vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs:64 (via EveEntitySoaViewDocument.Identities)
|
||||
- EveEntityRenderGroup vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs:77 (via EveEntitySoaViewDocument.RenderGroups)
|
||||
- EveInputActionDocument vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs:22 (via EveInputCapabilityDocument.Actions)
|
||||
- EveInputProfileDocument vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs:51 (via EveInputCapabilityDocument.DefaultProfiles)
|
||||
- EveInputBindingDocument vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs:59 (via EveInputProfileDocument.Bindings)
|
||||
- EveProviderWitness vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs:249 (via EveProviderAdvertisementDocument.Witnesses)
|
||||
- EveAdvertisedSurface vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs:265 (via EveProviderAdvertisementDocument.Surfaces)
|
||||
- EveAdvertisedCommand vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs:333 (via EveProviderAdvertisementDocument.Commands)
|
||||
- EveSurfaceComponent vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs:106 (via EveSurfaceComponent.Children)
|
||||
- EveEmbeddedDocumentSlot vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs:230 (via EveSurfaceComponent.EmbeddedDocuments)
|
||||
- EveStyleToken vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs:277 (via EveSurfaceTree.Styles)
|
||||
- EveCommandTemplate vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs:295 (via EveSurfaceDocument.Commands)
|
||||
|
||||
## Gjallar (3 element types)
|
||||
|
||||
- GjallarAdvertisedSchema src/Gjallar/VerseState.cs:462 (via GjallarProviderAdvertisementRecord.Schemas)
|
||||
- GjallarAdvertisedWitness src/Gjallar/VerseState.cs:470 (via GjallarProviderAdvertisementRecord.Witnesses)
|
||||
- GjallarAdvertisedSurface src/Gjallar/VerseState.cs:480 (via GjallarProviderAdvertisementRecord.Surfaces)
|
||||
|
||||
## Heimdall (12 element types)
|
||||
|
||||
These types are a vendored copy of CultLib (`vendor/CultLib`); C2a already adds the ids in CultLib, so they resolve when the pin bumps. The witness bundle types are the fork reported with C2a.
|
||||
|
||||
- CultGeometryDomainNode vendor/CultLib/src/GameCult.Geometry/CultGeometryDocuments.cs:61 (via CultGeometryDomainNode.Children)
|
||||
- CultGeometryFeatureClaim vendor/CultLib/src/GameCult.Geometry/CultGeometryDocuments.cs:106 (via CultGeometryDomainNode.Claims)
|
||||
- CultGeometryContributionRow vendor/CultLib/src/GameCult.Geometry/CultGeometryDocuments.cs:262 (via CultGeometrySelectedCutManifest.Diagnostics)
|
||||
- CultMeshBodyDescriptor vendor/CultLib/src/GameCult.Mesh/CultMeshBodies.cs:35 (via CultMeshBodyPublicationDocument.Representations)
|
||||
- CultMeshCdnChunkRef vendor/CultLib/src/GameCult.Mesh/CultMeshCdn.cs:73 (via CultMeshCdnArtifactManifest.Chunks)
|
||||
- CultMeshDiscoveryCandidateDocument vendor/CultLib/src/GameCult.Mesh/CultMeshDiscoveryService.cs:418 (via CultMeshDiscoveryStateDocument.Candidates)
|
||||
- CultMeshEntityPrefabComponent vendor/CultLib/src/GameCult.Mesh/CultMeshEntityPrefabs.cs:81 (via CultMeshEntityPrefabNode.Components)
|
||||
- CultMeshEntityPrefabAssetRef vendor/CultLib/src/GameCult.Mesh/CultMeshEntityPrefabs.cs:96 (via CultMeshEntityPrefabPackage.Assets)
|
||||
- CultMeshEntityPrefabNode vendor/CultLib/src/GameCult.Mesh/CultMeshEntityPrefabs.cs:127 (via CultMeshEntityPrefabPackage.Nodes)
|
||||
- CultWitnessContractPin vendor/CultLib/src/GameCult.Networking/CultWitnessArtifactBundle.cs:105 (via CultWitnessArtifactBundle.Contracts)
|
||||
- CultWitnessArtifactEntry vendor/CultLib/src/GameCult.Networking/CultWitnessArtifactBundle.cs:136 (via CultWitnessArtifactBundle.Artifacts)
|
||||
- CultWitnessTimingEntry vendor/CultLib/src/GameCult.Networking/CultWitnessArtifactBundle.cs:179 (via CultWitnessArtifactBundle.TimingWitnesses)
|
||||
|
||||
## Mimir (0 element types)
|
||||
|
||||
None.
|
||||
|
||||
|
||||
## StreamPixels (20 element types)
|
||||
|
||||
`CultGeometryDomainNode` is the vendored CultLib copy; C2a already adds its id.
|
||||
|
||||
- ActivationCondition apps/overlay-unity/Assets/Scripts/Assembly-CSharp/ActivationCondition.cs:18 (via CharacterConditions.Conditions)
|
||||
- BattleEffect apps/overlay-unity/Assets/Scripts/Assembly-CSharp/BattleEffect.cs:8 (via BattleAction.Effects)
|
||||
- GearStatEffect apps/overlay-unity/Assets/Scripts/Assembly-CSharp/GearStatEffect.cs:7 (via GearData.StatEffects)
|
||||
- OverlaySceneIntent apps/overlay-unity/Assets/Scripts/Assembly-CSharp/ServiceLink/OverlayFrameDocuments.cs:33 (via OverlaySceneIntentWindow.Intents)
|
||||
- PlayedIntentRecord apps/overlay-unity/Assets/Scripts/Assembly-CSharp/ServiceLink/PlayedIntentLedger.cs:25 (via OverlayPlayedIntents.Entries)
|
||||
- StatModifier apps/overlay-unity/Assets/Scripts/Assembly-CSharp/StatModifier.cs:7 (via GearStatEffect.Modifiers)
|
||||
- StatusEffect apps/overlay-unity/Assets/Scripts/Assembly-CSharp/StatusEffect.cs:10 (via StatusData.Effects)
|
||||
- WeaponDamageTypeMultiplier apps/overlay-unity/Assets/Scripts/Assembly-CSharp/WeaponDamageTypeMultiplier.cs:7 (via WeaponData.DamageMultipliers)
|
||||
- CultGeometryDomainNode vendor/CultLib/src/GameCult.Geometry/CultGeometryDocuments.cs:61 (via CultGeometryDomainNode.Children)
|
||||
- CultGeometryFeatureClaim vendor/CultLib/src/GameCult.Geometry/CultGeometryDocuments.cs:106 (via CultGeometryDomainNode.Claims)
|
||||
- CultGeometryContributionRow vendor/CultLib/src/GameCult.Geometry/CultGeometryDocuments.cs:262 (via CultGeometrySelectedCutManifest.Diagnostics)
|
||||
- CultMeshBodyDescriptor vendor/CultLib/src/GameCult.Mesh/CultMeshBodies.cs:35 (via CultMeshBodyPublicationDocument.Representations)
|
||||
- CultMeshCdnChunkRef vendor/CultLib/src/GameCult.Mesh/CultMeshCdn.cs:73 (via CultMeshCdnArtifactManifest.Chunks)
|
||||
- CultMeshDiscoveryCandidateDocument vendor/CultLib/src/GameCult.Mesh/CultMeshDiscoveryService.cs:418 (via CultMeshDiscoveryStateDocument.Candidates)
|
||||
- CultMeshEntityPrefabComponent vendor/CultLib/src/GameCult.Mesh/CultMeshEntityPrefabs.cs:81 (via CultMeshEntityPrefabNode.Components)
|
||||
- CultMeshEntityPrefabAssetRef vendor/CultLib/src/GameCult.Mesh/CultMeshEntityPrefabs.cs:96 (via CultMeshEntityPrefabPackage.Assets)
|
||||
- CultMeshEntityPrefabNode vendor/CultLib/src/GameCult.Mesh/CultMeshEntityPrefabs.cs:127 (via CultMeshEntityPrefabPackage.Nodes)
|
||||
- CultWitnessContractPin vendor/CultLib/src/GameCult.Networking/CultWitnessArtifactBundle.cs:105 (via CultWitnessArtifactBundle.Contracts)
|
||||
- CultWitnessArtifactEntry vendor/CultLib/src/GameCult.Networking/CultWitnessArtifactBundle.cs:136 (via CultWitnessArtifactBundle.Artifacts)
|
||||
- CultWitnessTimingEntry vendor/CultLib/src/GameCult.Networking/CultWitnessArtifactBundle.cs:179 (via CultWitnessArtifactBundle.TimingWitnesses)
|
||||
|
||||
## Ymir (1 element types)
|
||||
|
||||
- YmirTransportLoweringDocument src/Ymir.Core/YmirServicePublicationDocument.cs:63 (via YmirProviderAdvertisementDocument.CommandLowerings)
|
||||
|
||||
## CultLib itself
|
||||
|
||||
C2a adds ids to: `CultGeometryDomainNode`, `CultGeometryFeatureClaim`, `CultGeometryContributionRow` (`src/GameCult.Geometry/CultGeometryDocuments.cs`); `CultMeshBodyDescriptor` (`src/GameCult.Mesh/CultMeshBodies.cs:35`); `CultMeshDiscoveryCandidateDocument` (`CultMeshDiscoveryService.cs:418`); `CultMeshEntityPrefabAssetRef`, `CultMeshEntityPrefabComponent`, `CultMeshEntityPrefabNode` (`CultMeshEntityPrefabs.cs`); `CultWitnessContractPin`, `CultWitnessArtifactEntry`, `CultWitnessTimingEntry` (`src/GameCult.Networking/CultWitnessArtifactBundle.cs`).
|
||||
|
||||
`CultMeshCdnChunkRef` (`src/GameCult.Mesh/CultMeshCdn.cs:73`) has a derived id, `[CultElementId(nameof(Offset))]`: the decimal text of its offset, never random, so packing the same body encodes the same manifest bytes. `contracts/cultmesh/content-vectors.*.json` and the Rust runtime carry it. A consumer with content-addressed elements declares the same way.
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
# CultCache Document Variants: Target
|
||||
|
||||
Date: 2026-09-17
|
||||
|
||||
Status: target, before any substrate map or cut map. This document owns the ends.
|
||||
A cut map, written after an Imagination pass over the Body, will own the means.
|
||||
Branch: `codex/document-variants`.
|
||||
|
||||
## Why
|
||||
|
||||
A CultCache record today is always complete. To make a bigger laser from a
|
||||
laser, an author copies the record and re-specifies every stat. For a ship hull
|
||||
with several hardpoint layouts, every layout is a full copy of the hull. When
|
||||
the base changes, each copy has to be found and edited by hand, and copies drift
|
||||
apart silently.
|
||||
|
||||
The first consumer is Aetheria's catalog (`F:\Projects\Aetheria\GameData\Aetheria.cc`),
|
||||
edited in CultCache Studio:
|
||||
|
||||
- **Hull variants:** one hull with several hardpoint layout options. A variant
|
||||
replaces the whole `HullData.Hardpoints` member.
|
||||
- **Item variants:** a bigger laser. A variant changes stats nested inside the
|
||||
design's behavior list, for example one behavior's `PerformanceStat.Max`.
|
||||
|
||||
## Rulings (operator, 2026-09-17)
|
||||
|
||||
- **This is a CultCache feature, not an Aetheria mechanism.** Operator: "Yes, this
|
||||
is absolutely a CultCache feature, write it up in CultLib".
|
||||
- **A variant is stored as a delta from its base and resolved by the cache.** A
|
||||
record stores a reference to its base plus a sparse set of overrides. The
|
||||
cache resolves it; readers see complete documents. The operator chose this
|
||||
over two alternatives. Authoring-only variants in Studio over flattened stored
|
||||
copies leave a derived cache pretending to be data. Per-type variant records
|
||||
in each consumer rebuild the feature bespoke for every type.
|
||||
|
||||
## Ends
|
||||
|
||||
- **Readers see complete documents.** `Get`, `GetAll`, lookups by name and by
|
||||
index, watches and snapshots return resolved documents. No consumer outside
|
||||
the cache and its authoring tools handles a delta.
|
||||
- **A base change reaches its variants.** Editing a base changes every variant
|
||||
that does not override the edited value. Watches, indexes and name lookups
|
||||
observe that change on the variants too, in the same commit that changed the
|
||||
base.
|
||||
- **A variant is a record.** It has its own key and name, and references to it
|
||||
behave like references to any record. In Aetheria a lot's design and a
|
||||
product's design can name a variant directly.
|
||||
- **Studio edits the delta.** It shows which values a variant inherits and which
|
||||
it overrides, and it can clear an override so the base value applies again.
|
||||
- **Loading refuses a broken variant loudly and names the records.** Cases:
|
||||
- a cycle of bases;
|
||||
- a base that does not exist;
|
||||
- a base whose type the variant's type cannot inherit from;
|
||||
- ~~an override naming a member that does not exist on the type~~ **superseded 2026-09-29
|
||||
(operator, variants Q4 c): a dangling override soft-drifts like any dropped member, ignored
|
||||
with a warning; see `document-variants-cut.md` section 4.**
|
||||
|
||||
Deleting a base that still has variants is refused.
|
||||
|
||||
## Invariants that must survive
|
||||
|
||||
- **Wire parity** for the persisted format (`src/GameCult.Caching/Contracts/cultcache-persistence-format.md`,
|
||||
`docs/persisted-record-encoding.md`). Every runtime that reads a store holding
|
||||
variants resolves them identically, or refuses the store loudly. Which
|
||||
runtimes resolve is decided in the map, against
|
||||
`docs/runtime-parity-scope.md`; none may misread silently.
|
||||
- **One home store per type**, and atomic single-store commit
|
||||
(`cultcache-store-composition.md`). A base change and the resulting variant
|
||||
changes are one commit, not a repair that runs afterwards.
|
||||
- **Schema compatibility** (`cultcache-schema-compatibility.md`): overrides are
|
||||
subject to the same semantic identity, soft drift and hard rejection rules as
|
||||
full records.
|
||||
- **Compare-exchange** (`Expect`, `ExpectUnchanged`) keeps a single meaning for
|
||||
variants. The map settles whether it tests the stored delta or the resolved
|
||||
document; it must not mean both.
|
||||
|
||||
## Fork rulings (operator, 2026-09-17)
|
||||
|
||||
- **1, granularity: stable element identity, ids everywhere** (operator, 2026-09-17: "ids
|
||||
everywhere").
|
||||
- Every object element in a list carries a stable id, minted when the element is created.
|
||||
This is the approach of sequence CRDTs (RGA, YATA/Yjs, Automerge, Fugue).
|
||||
- Matching algorithms (Myers, diff3, GumTree) are heuristics. They fail on look-alike
|
||||
elements such as two `EnergyDraw` behaviors, which is why identity is minted, not
|
||||
inferred.
|
||||
- An override addresses an element id plus a member path in CultNet's grammar. An
|
||||
insertion is a new element anchored after an existing id; a removal names the
|
||||
removed id. Index addressing is out.
|
||||
- Rebasing is deterministic. An override whose element id exists in the new base
|
||||
re-applies. One whose element is missing is an explicit conflict that Studio shows,
|
||||
to keep, retarget or clear; it is never silently retargeted.
|
||||
- Existing stores get ids minted once, by a rewrite.
|
||||
- **2, where resolution lives: at load, kept in memory** (operator: "agreed"). Resolution
|
||||
computes a variant's complete document from its base chain and its overrides.
|
||||
- The cache holds both the stored delta and the resolved document.
|
||||
- A base edit re-resolves its variants inside the same commit, so watches and indexes
|
||||
observe the change atomically.
|
||||
- Compare-exchange (`Expect`) tests the stored delta. The resolved document is derived.
|
||||
- **3, cross-store bases: no.** A variant and its base live in the same store.
|
||||
*Future direction (operator, 2026-09-30, from Aetheria moddable ships):* "we'll want cross-store variant
|
||||
support eventually; modders should be able to add variants of existing items without modifying the
|
||||
original". Ruling 3 holds for this campaign. A mod store layering variants over a shipped catalog is the
|
||||
named future consumer that would reopen it. Until then, no consumer builds its own cross-store resolver:
|
||||
Aetheria's compose step stays out of resolution.
|
||||
- **4, CultNet: deltas.**
|
||||
- **5, runtimes: C# first.** TypeScript, Python, Rust and Kotlin refuse a store
|
||||
or snapshot containing variants, loudly, until each implements resolution.
|
||||
- **6, type change: the same concrete type only** (operator: "agreed").
|
||||
- An override can only name members the base type has, and rebasing requires the same
|
||||
type.
|
||||
- A variant whose type differs from its base's is refused loudly.
|
||||
- Subtype variants are parked until real content needs a template base. A record
|
||||
flagged as a template, not a real item, may be the smaller feature.
|
||||
- **7, clearing and rebasing.**
|
||||
- Clearing an override removes that member from the variant's stored delta. A member
|
||||
that is not overridden is not stored.
|
||||
- Rebasing (changing a variant's base) is supported. Operator: "rebasing is a nice
|
||||
tool to have, things can get annoying otherwise".
|
||||
|
||||
## Open forks, for the Imagination pass to map and the operator to rule
|
||||
|
||||
1. **Override granularity.**
|
||||
- Top-level members are enough for hardpoint layouts.
|
||||
- A bigger laser needs overrides inside list elements. Addressing list
|
||||
elements by index is fragile: an insertion into the base silently retargets
|
||||
every variant's overrides.
|
||||
- Candidates: stable element identity (for example a behavior's type within its
|
||||
design); replacing whole list elements only; or addressing paths.
|
||||
- CultNet's selection work already resolves member paths per leaf type,
|
||||
inherited members included (`docs/cultnet-selection-cut.md`). A variant's
|
||||
delta addressing should reuse that grammar, not invent a second one.
|
||||
2. **Where resolution lives:** at load in the cache core, lazily on read, or as a
|
||||
materialized view the cache maintains. This affects memory, watch cost, and
|
||||
the compare-exchange meaning.
|
||||
3. **Base and variant across stores.** Can a variant in one store name a base in
|
||||
another (a run-store variant of a catalog record)? This interacts with
|
||||
one-home-store routing and read-only stores.
|
||||
4. **CultNet.** Do variants travel as deltas, or resolved? A subscriber that
|
||||
receives only resolved documents needs no resolver; one that edits needs
|
||||
deltas.
|
||||
5. **Runtime scope.** Which of C#, TypeScript, Python, Rust and Kotlin resolve,
|
||||
and which refuse a store containing variants.
|
||||
6. **Type change.** May a variant's type be a subtype of its base's type,
|
||||
adding members, or must the two types match?
|
||||
7. **Clearing and reparenting.** What clearing an override writes, and whether
|
||||
a variant can change its base.
|
||||
|
||||
## Not in scope
|
||||
|
||||
- Scaling or formula variants ("every stat times 1.5"). An override states a
|
||||
value. Generated authoring can compute values.
|
||||
- Any Aetheria catalog content. Aetheria adopts the feature after a release, as
|
||||
its own cut.
|
||||
|
|
@ -1,159 +0,0 @@
|
|||
# Mutation Testing
|
||||
|
||||
CultLib measures its C# test suites with Stryker.NET, pinned as a repo-local
|
||||
tool in `dotnet-tools.json`. Coverage is added project by project; a project
|
||||
with no `stryker-config.json` next to its test project has not been scoped
|
||||
yet (see "Follow-ups" below for what's outstanding).
|
||||
|
||||
## Running it
|
||||
|
||||
Restore the tool once per checkout:
|
||||
|
||||
```powershell
|
||||
dotnet tool restore
|
||||
```
|
||||
|
||||
Run a full mutation pass from a covered test project's directory, for
|
||||
example CultMath:
|
||||
|
||||
```powershell
|
||||
cd packages/cultmath/tests/CultMath.Tests
|
||||
dotnet stryker -t mtp
|
||||
```
|
||||
|
||||
`-t mtp` selects Stryker's Microsoft.Testing.Platform runner. It is required
|
||||
for test projects on `xunit.v3` (CultMath.Tests is one): Stryker's default
|
||||
`vstest` runner's coverage capture does not work against xunit.v3's native
|
||||
test host and the run stalls retrying test sessions indefinitely. Projects
|
||||
still on `xunit` v2 with `xunit.runner.visualstudio` can use the default
|
||||
`vstest` runner.
|
||||
|
||||
For a single cut, scope the run to what changed instead of the whole
|
||||
project:
|
||||
|
||||
```powershell
|
||||
dotnet stryker -t mtp --since:<base-commit-or-branch>
|
||||
```
|
||||
|
||||
Use a full commit SHA for `--since`, not an abbreviation: `git rev-parse
|
||||
<short-sha>` first, then pass the full result. Stryker resolves `--since`
|
||||
through the same git plumbing a short SHA can resolve ambiguously (or fail to
|
||||
resolve at all) depending on the checkout's object database, and a
|
||||
misresolved base silently changes what "since" means instead of erroring.
|
||||
|
||||
In a container, also set `git config --global --add safe.directory '*'`
|
||||
before running Stryker (or any other command that shells out to `git`
|
||||
against this checkout) - git refuses to operate on a repository owned by a
|
||||
different user by default, which is the common case for a bind-mounted
|
||||
checkout in a container.
|
||||
|
||||
Reports land in `StrykerOutput/` next to the test project (git-ignored).
|
||||
Open the `reports/mutation-report.html` file for a browsable view, or read
|
||||
`reports/mutation-report.json` for a scriptable one.
|
||||
|
||||
## Triage rules
|
||||
|
||||
The mutation score is not a gate (`break` threshold is 0). Every surviving
|
||||
mutant is a finding about the tests, not a build failure, and gets triaged
|
||||
into one of:
|
||||
|
||||
- **missing behavioural test** — name the test that should exist and what
|
||||
branch, edge case, or component it would exercise.
|
||||
- **degenerate fixture** — the code path is exercised, but the fixture's
|
||||
values make two different mutants produce the same observable result (a
|
||||
zero component that erases a `+`/`-` distinction, an already-normalized
|
||||
input that erases a `*`/`/` distinction, all-equal or all-same-sign inputs
|
||||
that erase a boundary or `&&`/`||` distinction). Fix the fixture, don't
|
||||
invent a new one that only kills that one mutant.
|
||||
- **equivalent** — the mutant cannot change behaviour. Float-threshold
|
||||
boundary flips (`>` to `>=`) are equivalent by default. A reasoned
|
||||
equivalence claim (for example, `>>` and `>>>` on an already-unsigned
|
||||
operand) needs its one-line reason recorded, not assumed.
|
||||
|
||||
Do not write a hand-rolled mutation suite or anchor test as a substitute.
|
||||
Where the tool can't reach (native/shader/editor code), the defence is a
|
||||
behavioural test at the layer the rule is decided, not a committed mutation
|
||||
harness.
|
||||
|
||||
## Known tool caveat
|
||||
|
||||
Stryker 5.0.0's `mtp` runner is documented as preview. Its per-test coverage
|
||||
capture was found to report false "Survived" verdicts specifically for
|
||||
mutations inside `static readonly` field initializers that a test does
|
||||
cover (confirmed by hand-mutating `bool3.@false` and `bool4.@true` and
|
||||
running the full suite directly with `dotnet test`, both of which fail
|
||||
against the mutation that Stryker reported as surviving). Treat a survivor
|
||||
inside a `static readonly` field initializer as unproven until confirmed
|
||||
with a direct `dotnet test` run against the same mutation; do not triage it
|
||||
as a missing test without that check.
|
||||
|
||||
A survivor that looks like it must be covered is unproven until confirmed
|
||||
with a direct `dotnet test` run against the same hand-applied mutation - but
|
||||
confirm what the mutant actually is before drawing a tooling conclusion from
|
||||
it. An earlier pass here reported three survivors in
|
||||
`CultGeometrySurfaceNets.TryGetOtherEndpoint`'s per-axis `if (x + 1 >=
|
||||
sizeX) return false;` guards and initially triaged them as a `vstest`
|
||||
coverage false-survivor (the same shape as the `mtp`/`static readonly`
|
||||
caveat above). That triage was wrong: the actual mutant Stryker reports
|
||||
there is `return false` -> `return true` inside the guard, which is
|
||||
equivalent, not a coverage miss. Returning `true` from
|
||||
`TryGetOtherEndpoint` still leaves the out `other` coordinate equal to the
|
||||
current coordinate (see the method: `other` is cloned from `coord` and only
|
||||
one component is incremented when the method returns `true`), so the caller
|
||||
compares a sample against itself, the inside/outside flags always match,
|
||||
and the crossing is skipped exactly as it would have been had the method
|
||||
correctly returned `false`. No test can distinguish the two branches because
|
||||
there is nothing to distinguish - it is a legitimate equivalent mutant, not
|
||||
evidence of a `vstest` false survivor.
|
||||
|
||||
`CultGeometrySurfaceNets.Extract`'s `if (inside == (OrientationSign[axis] <
|
||||
0))` (the sole authority for quad winding after the geometric cross-product
|
||||
fallback was deleted) has three mutants on that line: `==` -> `!=`, and `< 0`
|
||||
negated to `> 0` or widened to `<= 0`. The first two, `inside != (OrientationSign[axis]
|
||||
< 0)` and `OrientationSign[axis] > 0`, are both reported `Killed`. The third,
|
||||
`OrientationSign[axis] <= 0`, survives, but it is an equivalent mutant, not a
|
||||
coverage gap: `OrientationSign` is the fixed table `{ 1, -1, 1 }` and never
|
||||
holds `0`, so `< 0` and `<= 0` agree on every value the table can actually
|
||||
produce. There is nothing for a test to distinguish, the same shape as the
|
||||
`TryGetOtherEndpoint` equivalent mutant above.
|
||||
|
||||
No `vstest` coverage-based false survivor has actually been confirmed for
|
||||
GameCult.Geometry. Every survivor triaged here so far has turned out to be a
|
||||
genuine equivalent mutant once hand-mutated and checked directly against
|
||||
`dotnet test`; treat any survivor that looks like it must obviously be
|
||||
covered as unproven until confirmed the same way, but do not cite this
|
||||
project as precedent for the `vstest` caveat itself - that caveat is
|
||||
`mtp`-runner-specific and documented above for `static readonly` field
|
||||
initializers, and GameCult.Geometry has never actually hit it.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
CultMath.Tests and GameCult.Geometry.Tests are the only projects currently
|
||||
scoped. GameCult.Geometry's `stryker-config.json` has no `test-runner`
|
||||
entry: the project is NUnit 4 with `NUnit3TestAdapter` and
|
||||
`Microsoft.NET.Test.Sdk`, so Stryker's default `vstest` runner applies
|
||||
directly (no `mtp` workaround needed, unlike CultMath.Tests's xunit.v3
|
||||
suite).
|
||||
|
||||
GameCult.Geometry has only ever been run `--since:`-scoped to the cut that
|
||||
added surface nets; a full-project baseline (running mutation against all of
|
||||
`CultGeometryIsoSurface`, `CultGeometryDocuments`, and
|
||||
`CultGeometryPrimitives`, not just the diff) is a recorded follow-up, not
|
||||
something either cut did.
|
||||
|
||||
That first `--since` pass did not actually stay diff-scoped: adding
|
||||
`stryker-config.json` itself in the same cut that first used it made Stryker
|
||||
log "391 mutants will be tested because: Non-CSharp files in test project
|
||||
were changed" and mutate the whole `mutate` glob (all four existing
|
||||
GameCult.Geometry source files), not just the new surface-nets files. A cut
|
||||
that changes a non-C# file inside the test project (a fixture, this config
|
||||
file, anything under `Fixtures/`) loses `--since` line-level scoping for that
|
||||
run; a future cut whose diff is C#-only, against a base commit that already
|
||||
has `stryker-config.json`, should scope correctly.
|
||||
|
||||
`GameCult.Caching`, `GameCult.Mesh` (+ `.Quic`, `.Quic.Native`),
|
||||
`GameCult.Networking` (+ `.WebSockets`), and the non-.NET runtimes
|
||||
(`cultcache-rs/py/ts`, `cultmesh-*`, `cultnet-*`) each still need their own
|
||||
`stryker-config.json` (or ecosystem-equivalent tool: `cargo-mutants` for
|
||||
Rust, `StrykerJS` for TypeScript, `mutmut` for Python) and a baseline pass.
|
||||
None of that is wired into CI yet.
|
||||
|
|
@ -1,8 +1,5 @@
|
|||
# NuGet Packaging
|
||||
|
||||
See `docs/semver-policy.md` for what a version bump on these packages is
|
||||
required to mean and how a release is checked against that before it ships.
|
||||
|
||||
CultLib publishes the managed dependency graph as separate packages. The leaf
|
||||
package is `GameCult.Mesh`; its NuGet dependencies preserve the CultMesh,
|
||||
CultNet, and CultCache ownership boundaries.
|
||||
|
|
@ -11,12 +8,9 @@ CultNet, and CultCache ownership boundaries.
|
|||
powershell -ExecutionPolicy Bypass -File .\scripts\pack-nuget.ps1
|
||||
```
|
||||
|
||||
The command packs `GameCult.Mesh`, `GameCult.Networking.WebSockets`, and every
|
||||
project they reach through `ProjectReference` (including `GameCult.Math`, which
|
||||
carries its own version) into a local feed under `artifacts/nuget`. It verifies
|
||||
that each of those packages exists at the identity MSBuild reports for it, then
|
||||
restores and runs a clean .NET consumer using only
|
||||
`PackageReference Include="GameCult.Mesh"`.
|
||||
The command creates a local feed under `artifacts/nuget`, verifies that every
|
||||
internal dependency package exists at the same version, then restores and runs
|
||||
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
|
||||
|
|
@ -25,5 +19,5 @@ 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-v2.0.0
|
||||
https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.46
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,170 +0,0 @@
|
|||
# Content-addressed HTTP answers: what the specs and sources say
|
||||
|
||||
Fork served: **cultmesh-ts-content C2 provider HTTP answers** (session label
|
||||
self-2026-10-19-cm-content). Eyes pass, 2026-10-19. Facts with evidence pointers
|
||||
only; no recommendation. Line numbers refer to the raw files fetched on
|
||||
2026-10-19 (master/main at that date), so they drift.
|
||||
|
||||
Source shorthands:
|
||||
|
||||
- **TG** = IPFS Trustless Gateway spec, https://specs.ipfs.tech/http-gateways/trustless-gateway/ (source: https://raw.githubusercontent.com/ipfs/specs/main/src/http-gateways/trustless-gateway.md)
|
||||
- **PG** = IPFS Path Gateway spec, https://specs.ipfs.tech/http-gateways/path-gateway/ (source: https://raw.githubusercontent.com/ipfs/specs/main/src/http-gateways/path-gateway.md)
|
||||
- **OCI** = OCI Distribution Spec, https://raw.githubusercontent.com/opencontainers/distribution-spec/main/spec.md
|
||||
- **Fetch** = WHATWG Fetch Standard, https://fetch.spec.whatwg.org/
|
||||
- **RFC 9110/9111/9112**, https://www.rfc-editor.org/rfc/rfc9110.txt (and 9111, 9112, 8246)
|
||||
|
||||
Two things the draft model page said "from memory" are checked at the end
|
||||
(section 8).
|
||||
|
||||
## 1. IPFS Trustless Gateway (`application/vnd.ipld.raw`)
|
||||
|
||||
| Case | What the spec says | Pointer |
|
||||
|---|---|---|
|
||||
| Found | Body is "An opaque bytes matching the requested block CID"; "The Body hash MUST match the Multihash from the requested CID." | TG "Block Responses (application/vnd.ipld.raw)" (src l.341-346) |
|
||||
| Unknown/absent hash | `404 Not Found` MUST be returned when the root block is not in the gateway's storage; applies to HEAD for any CID and GET of raw blocks. Body not specified. For non-recursive gateways it "definitively signals" the content is not in the dataset. | TG "Response Status Codes / 404 Not Found" (src l.274-283); "Recursion" (l.606-612) |
|
||||
| Malformed request | TG defines no status for a malformed CID. It says servers SHOULD "validate CID format and encoding before processing requests". PG says `400 Bad Request` is a "generic client error", example "malformed CID". TG gives 400 for missing `Accept` in strict trustless mode (l.108) and for an out-of-range `entity-bytes` (l.210). | TG "Security Considerations / HTTP Servers" (src l.616-628); PG "Response Status Codes" |
|
||||
| Stored object failing its digest | **Not specified for servers.** The only requirement is client-side: clients MUST verify block digest against the CID before processing or storing. TG `500` is "only for genuine server errors, not for content unavailability" (storage failure, resource exhaustion, unexpected internal errors); corruption is not named. | TG "HTTP Clients / Block Validation" (src l.629-631); "500 Internal Server Error" (l.294-296) |
|
||||
| Range | TG defines no Range support for raw blocks. PG says Range is "for requesting specific byte range of UnixFS files and raw blocks"; gateways SHOULD support single-range, multi-range optional; success is `206` with `Content-Range`; `Accept-Ranges` is optional, `none` is the example for non-deterministic CAR order. | PG "Range (request header)" (src l.184-193), "Content-Range" (l.626), "Accept-Ranges" (l.632-640) |
|
||||
| HEAD | `HEAD /ipfs/{cid}` is GET without payload; missing root block is 404 for HEAD too. PG: gateways SHOULD do minimal work to produce headers such as `Content-Length`. For the probe path, all HEAD headers are optional. | TG "HEAD /ipfs/{cid}" (src l.68-72), "404" (l.274-283), "Dedicated Probe Paths" (l.525-548); PG HEAD text |
|
||||
| CORS | **TG defines none.** PG only says gateways serving cross-origin traffic SHOULD list `Ipfs-Uri` (and `X-Ipfs-Path`) in `Access-Control-Expose-Headers`. Kubo's config doc says its default `Gateway.HTTPHeaders` is `{}` "+ implicit CORS headers from `boxo/gateway#AddAccessControlHeaders` and ipfs/specs#423"; the values themselves are not in that file. | PG header sections; https://raw.githubusercontent.com/ipfs/kubo/master/docs/config.md l.1484-1488 |
|
||||
| Cache-Control | Response value is in PG, not TG: `Cache-Control: public, max-age=29030400, immutable` "MUST be returned for every immutable resource under `/ipfs/` namespace". (Request-side `Cache-Control: only-if-cached` returns `412` when the root block is not immediately available.) | PG "Cache-Control (response header)" (src l.488-500); TG "Cache-Control: only-if-cached" (l.120-126) |
|
||||
| ETag | TG: "MUST be returned and follow the recommendations in" PG. PG default is the requested CID in quotes: `Etag: "bafy…foo"`; format-specific variants add a suffix (`"bafy…foo.raw"`); non-deterministic streams use `W/`. PG: gateway MUST compare `If-None-Match` against its `Etag` (answer 304). TG: clients SHOULD NOT assume ETags are portable across gateways. | TG "Etag (response header)" (src l.324-338); PG l.130-136, l.453-486 |
|
||||
| Content-Type | "MUST be returned"; for raw blocks the type is `application/vnd.ipld.raw` (IANA registered). No charset parameter is specified. | TG "Content-Type (response header)" (src l.300-308); IANA entry linked at l.343 |
|
||||
| Other headers | `Content-Disposition` MUST be `attachment` with `filename="<cid>.bin"` for raw blocks, "to ensure requested bytes are not rendered by a web browser". PG: `X-Content-Type-Options: nosniff` should be returned with `application/vnd.ipld.raw` and `.car` (from the PG fetch summary; line not re-grepped). | TG "Content-Disposition" (src l.310-316); PG "X-Content-Type-Options" |
|
||||
| Content-Encoding / Length | PG: `Content-Length` "Represents the length of returned HTTP payload", and warns "the value may differ from the real size of requested data if compression or chunked `Transfer-Encoding` are used", citing ipfs/specs#461 (an open TODO in the spec). | PG "Content-Length (response header)" (src l.613-624) |
|
||||
| Size cap | Clients SHOULD cap block size; the spec names 2 MiB as the p2p block limit (see Block Limits). | TG "Block Limits" (src l.558-573) and "HTTP Clients" (l.629-640) |
|
||||
|
||||
## 2. OCI Distribution Spec, blob `GET`/`HEAD /v2/<name>/blobs/<digest>`
|
||||
|
||||
| Case | What the spec says | Pointer |
|
||||
|---|---|---|
|
||||
| Found (GET) | "MUST provide the expected blob, with a response code that MUST be `200 OK`." Response MUST contain `Docker-Content-Digest`; "If present, the value of this header MUST be a digest matching that of the response body." Clients MAY ignore it, but if used MUST verify it; "Clients SHOULD verify that the response body matches the requested digest." | OCI "Pulling blobs" (src l.180-195) |
|
||||
| Unknown | `404 Not Found` MUST be returned (GET and HEAD). The endpoint table lists only `200` success and `404` failure. Error code names `BLOB_UNKNOWN` (code-1) exist in the error table; this section does not require a JSON body. | OCI l.194, l.209-216; Endpoints table l.846 (end-2); Error Codes table l.891 |
|
||||
| Malformed digest | **Not specified for blob pull.** The table lists only 404. (Other endpoints: PUT blob 400 on digest problems; DELETE blob 404/400/405.) `DIGEST_INVALID` is defined as "provided digest did not match uploaded content" (upload semantics). | OCI table l.846-858; code-4 l.894 |
|
||||
| Stored object failing its digest | **Not specified for servers.** Client-side verification only (above). | OCI l.188-193 |
|
||||
| Range | "A registry SHOULD support the `Range` request header in accordance with RFC 9110 (section 14)." No `206`/`416`/`Accept-Ranges` text for blob pull (416 appears only in the upload flow, l.392, 421). | OCI l.196 |
|
||||
| HEAD | HEAD on an existing blob MUST return `200`; MUST contain `Docker-Content-Digest` and `Content-Length` ("the size in bytes"); not found is `404`. Note: older registries may omit the digest header; clients "can reasonably assume the digest algorithm used is sha256". | OCI "Checking if content exists" (src l.198-216) |
|
||||
| CORS | **Not in spec.** | whole OCI spec (no `Access-Control` occurrence found in the `grep` of the file) |
|
||||
| Cache-Control/ETag | **Not in spec.** | OCI spec |
|
||||
| Content-Type | **Not specified for blob GET**; `application/octet-stream` appears only for upload requests. | OCI upload sections |
|
||||
| Redirects | Registries may answer blob GET with a redirect; clients must not forward `Authorization` across hosts. | OCI pull text (from the WebFetch of the same file; not re-grepped) |
|
||||
|
||||
Known failures near this area (NVD records, fetched 2026-10-19):
|
||||
|
||||
- CVE-2024-3727 (containers/image, Red Hat): "integrity-check flaw" allowing unexpected authenticated registry access, resource exhaustion, local path traversal. It affects the client library, not a registry server. https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=CVE-2024-3727
|
||||
- CVE-2026-85747 (distribution/distribution, fixed 3.1.2): the client's manifest `Get()` does not hash the body against the requested digest, so a malicious registry can substitute content. https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=CVE-2026-85747 (status "Awaiting Analysis")
|
||||
- CVE-2026-93317 (Docker build): a registry or OCI-layout blob source could supply content not matching the claimed digest and have it cached under that digest. https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=CVE-2026-93317 (status "Awaiting Analysis")
|
||||
- Searched for a path traversal CVE in a registry server's digest-to-path mapping: none surfaced.
|
||||
|
||||
## 3. Nix binary cache (HTTP)
|
||||
|
||||
The manual's Binary Cache page defines layout only (a `nix-cache-info` file, a
|
||||
`.narinfo` per object with "a (usually relative) URL to the corresponding
|
||||
compressed NAR", the NAR). It states no status codes, headers, CORS, Range or
|
||||
Cache-Control. https://nix.dev/manual/nix/2.35/protocols/binary-cache/ (page head).
|
||||
The behavior below is from the Nix client/writer source.
|
||||
|
||||
| Case | What the source shows | Pointer |
|
||||
|---|---|---|
|
||||
| Found | Writer stores NARs at `nar/<FileHash nix32>.nar<ext>`, i.e. named by the hash of the compressed file; narinfo records `FileHash`, `FileSize`, plus `NarHash`. | https://raw.githubusercontent.com/NixOS/nix/master/src/libstore/binary-cache-store.cc l.156-181 |
|
||||
| Absent | Client maps HTTP `404` and `410` to `NotFound` ("The file is definitely not there"); `403` to `Forbidden`; `408`/`429` to `Transient`. `fileExists` issues a **HEAD** and treats `NotFound` **and `Forbidden`** as absent, "S3 buckets return 403 if a file doesn't exist and the bucket is unlistable". | https://raw.githubusercontent.com/NixOS/nix/master/src/libstore/filetransfer.cc l.826-838; .../http-binary-cache-store.cc l.184-202 |
|
||||
| Malformed request | Not specified. | n/a |
|
||||
| Failing digest | Client side only; the writer's `FileHash`/`NarHash` are checked by the consumer. No server answer defined. | binary-cache-store.cc l.175-181, l.313 |
|
||||
| Range | Client records `acceptRanges` for retries (client-side use of Range). No server rule. | filetransfer.cc l.219 |
|
||||
| HEAD | Used for existence probing (above). | http-binary-cache-store.cc l.190 |
|
||||
| CORS / Cache-Control / ETag | Not in spec or the files read. | n/a |
|
||||
| Content-Type | Writer uploads `text/x-nix-narinfo` (narinfo), `text/x-nix-cache-info`, and `application/x-nix-nar` (NAR). | binary-cache-store.cc l.57, l.135, l.270 |
|
||||
| Content-Encoding | Writer can upload with a `Content-Encoding` header naming the compression (http-binary-cache-store.cc l.232). Client sets `CURLOPT_ACCEPT_ENCODING ""` (accept any supported) and tracks a `hasContentEncoding` flag. The NAR itself is stored already compressed (named by compressed-file hash), so transport encoding and stored compression are separate. | filetransfer.cc l.223-224, l.616 |
|
||||
|
||||
## 4. Bazel remote cache, HTTP
|
||||
|
||||
| Case | What the source/spec says | Pointer |
|
||||
|---|---|---|
|
||||
| Protocol | Bazel docs: "Binary data (BLOB) is uploaded via PUT requests and downloaded via GET requests"; action results under `/ac/`, files under `/cas/`; any "HTTP/1.1 server that supports PUT and GET" can serve. The doc page states **no** status codes, no digest verification on PUT, no Content-Type, no compression, no Range. HEAD is not mentioned there. | https://bazel.build/remote/caching "HTTP caching protocol" |
|
||||
| bazel-remote README | CAS key is "the lowercase SHA256 hash of the entry"; `/cas/<key>` or `/<instance>/cas/<key>` (instance "optional but ignored"); "HEAD requests can be used to confirm whether a key exists"; the empty blob is "always available, even if the cache is empty" (HEAD example: `200` with `Content-Length: 0`); GET with `Accept-Encoding: zstd` "may" return zstd data; PUT of zstd requires `Content-Encoding: zstd` and `X-Digest-SizeBytes`. | https://raw.githubusercontent.com/buchgr/bazel-remote/master/README.md (HTTP section) |
|
||||
| Found | `200`, `Content-Type: application/octet-stream`, `Content-Length` set to the stored size (uncompressed path). | https://raw.githubusercontent.com/buchgr/bazel-remote/master/server/http.go l.275-282 |
|
||||
| Absent | `404`, body "Not found" via `http.Error`. | http.go l.268-271 |
|
||||
| Malformed | Path must match `^/?(.*/)?(ac/\|cas/)([a-f0-9]{64})$` (lowercase 64-hex); otherwise `400` with the escaped URL echoed in the message (`html.EscapeString`). | http.go l.33, l.101-112, l.216-223 |
|
||||
| Failing digest (read) | No digest check on the GET path in this handler (reader returned by `cache.Get`, `io.Copy` to the client). A failure after headers are sent is logged and cannot change the status ("we've already started writing data"). | http.go l.284-297 |
|
||||
| Digest check (write) | PUT checks size match (`400` "sizes don't match") and `Content-Encoding` is `identity`/`zstd`/absent (else `400`); the remaining digest verification is in code not read here. | http.go l.306-345, l.372-388 |
|
||||
| Compression | When zstd is chosen the handler sets `Content-Encoding: zstd` and **omits `Content-Length`** ("TODO: calculate Content-Length for compressed blobs too"). | http.go l.246-250, l.275-281 |
|
||||
| HEAD | `200` with `Content-Length` of the size, or `404`. | http.go l.468-491 |
|
||||
| Range / CORS / Cache-Control / ETag | Not present in the handler excerpts read. | n/a |
|
||||
|
||||
## 5. Git over HTTP
|
||||
|
||||
Smart HTTP does not address single objects. Dumb HTTP does: URL
|
||||
`$GIT_URL/objects/XX/YYYY…` for a loose object.
|
||||
https://git-scm.com/docs/http-protocol (introduction example).
|
||||
|
||||
| Case | What the spec/source says | Pointer |
|
||||
|---|---|---|
|
||||
| Absent | Server "MUST NOT respond with `200 OK`"; "SHOULD respond with `404 Not Found`, `410 Gone`, or any other suitable HTTP status code which does not imply the resource exists". Access denied MUST be `403`. | git http-protocol "General Request Processing" |
|
||||
| Caching | Clients MAY reuse a cached entity per `Cache-Control`/`Expires` (follow RFC 2616); servers MAY return `304`; clients treat `304` as `200`. Smart services must prevent caching. The page states no Cache-Control value for object files. | same section |
|
||||
| Content-Type | Specified only for `info/refs` (`text/plain; charset=utf-8` SHOULD, clients MUST NOT validate); nothing for object files. | same page |
|
||||
| Failing digest | Client side: the dumb walker compares the received object's computed id with the requested id and errors `File %s has bad hash`. | https://raw.githubusercontent.com/git/git/master/http-walker.c l.540-541 |
|
||||
| Missing target | The client treats a missing-target result as `HTTP_MISSING_TARGET` (http.c l.1944). | https://raw.githubusercontent.com/git/git/master/http.c |
|
||||
| Content-Encoding | Client sets `CURLOPT_ENCODING ""` at http.c l.2284 (accept any encoding curl supports, curl decodes transparently) and sends `Pragma: no-cache` (l.1452). The http-protocol page says nothing about gzip for objects. | http.c |
|
||||
| Range, CORS, ETag | Not in the page. | n/a |
|
||||
|
||||
## 6. Browser fetch facts (verifying reader)
|
||||
|
||||
| Fact | Evidence |
|
||||
|---|---|
|
||||
| CORS-safelisted response-header names are exactly `Cache-Control`, `Content-Language`, `Content-Length`, `Content-Type`, `Expires`, `Last-Modified`, `Pragma`. So `Content-Length` is readable cross-origin from a CORS response without `Access-Control-Expose-Headers`. `ETag` is **not** on the list; it needs `Access-Control-Expose-Headers` to be visible. | Fetch 2.2.2 "Headers", CORS-safelisted response-header name (https://fetch.spec.whatwg.org/#cors-safelisted-response-header-name) |
|
||||
| The CORS-exposed header-name list comes from `Access-Control-Expose-Headers`; a CORS filtered response exposes safelisted names plus that list. | Fetch 2.2.6 "Responses" (https://fetch.spec.whatwg.org/#concept-response-cors-exposed-header-name-list); 3.3.3 |
|
||||
| CORS check: failure if `Access-Control-Allow-Origin` is absent; "If request's credentials mode is not "include" and origin is `*`, then return success." With credentials mode `include`, `*` is not enough (needs exact origin plus `Access-Control-Allow-Credentials: true`). | Fetch 4.10 "CORS check"; 3.3.5 |
|
||||
| A CORS-preflight happens for non-safelisted methods/headers (`GET`/`HEAD` with no custom headers are simple requests). | Fetch 3.3 (general); not re-quoted here |
|
||||
| Fetch decodes content codings before exposing the body. In HTTP-network fetch: it records encoded size from transmitted bytes, sets `bytes` to "the result of handling content codings", then increases decoded size by the decoded length. Unsupported codings: bytes are returned as-is; decoding error: failure. | Fetch 4.7 "HTTP-network fetch"; 2.2.4 "handle content codings" (https://fetch.spec.whatwg.org/#handle-content-codings) |
|
||||
| The spec itself notes: "This makes the `Content-Length` header unreliable to the extent that it was reliable to begin with." (the header describes the encoded body; the body the page reads is decoded.) | Fetch 4.7 (fetch_t.txt l.9670) |
|
||||
| When a request has a `Range` header, the browser appends `Accept-Encoding: identity`, "This avoids a failure when handling content codings with a part of an encoded response." | Fetch 4.6 "HTTP-network-or-cache fetch" (l.8760-8767) |
|
||||
| HTTP framing: with a valid `Content-Length`, a response is incomplete if fewer octets arrive than `Content-Length`; a client MUST record it as incomplete. `Content-Length` "refers specifically to the amount of data enclosed" (encoded form on the wire). | RFC 9112 section 8 "Handling Incomplete Messages"; RFC 9110 section 8.6 |
|
||||
| `Cache-Control: no-transform`: an intermediary "MUST NOT transform the content". | RFC 9111 section 5.2.2.6 |
|
||||
| `immutable` extension: identifies responses that will not be updated during freshness lifetime so clients need not revalidate. | RFC 8246 section 2 |
|
||||
|
||||
## 7. HTTP core facts that apply to all five systems
|
||||
|
||||
| Fact | Evidence |
|
||||
|---|---|
|
||||
| Range: "A server MAY ignore the Range header field"; a server MUST ignore Range for methods without defined range handling (GET is the only one). | RFC 9110 section 14.2 "Range" (l.6476-6484) |
|
||||
| HEAD: "server SHOULD send the same header fields" as for GET; a `Content-Length` on HEAD MUST equal the GET length if sent. | RFC 9110 section 9.3.2 (l.3993), section 8.6 (l.3225) |
|
||||
| `304`: a `Content-Length` in 304 MUST equal what a 200 would carry, if present. | RFC 9110 section 8.6 |
|
||||
| `Content-Length` MUST NOT be sent on `204`. | RFC 9110 section 8.6 |
|
||||
|
||||
## 8. Cross-check against the draft model page's from-memory claims
|
||||
|
||||
| Draft claim (map l.44-49) | Finding |
|
||||
|---|---|
|
||||
| IPFS trustless gateway answers raw blocks with `Cache-Control: public, max-age=29030400, immutable` | In **PG**, not TG: the `/ipfs/` immutable value is a MUST there; TG itself does not state the response value. |
|
||||
| IPFS uses the CID as `ETag` | PG: default `Etag: "<cid>"` quoted; variants add suffixes (raw: `"<cid>.raw"`). |
|
||||
| IPFS returns 400 for an unparseable CID | PG lists `400` as "generic client error" with malformed-CID as the example. TG itself lists no malformed-CID rule. |
|
||||
| OCI `GET`/`HEAD` returns 200 with the digest header | Confirmed (`Docker-Content-Digest` MUST on GET and HEAD; `Content-Length` MUST on HEAD). |
|
||||
| OCI 404 `BLOB_UNKNOWN` | 404 confirmed; `BLOB_UNKNOWN` exists as a code name; the blob-pull text does not mandate a JSON body. |
|
||||
| OCI 400 `DIGEST_INVALID` on a bad digest | **Not in the blob-pull text.** Endpoint table lists only 404 for blob GET/HEAD; `DIGEST_INVALID` means "digest did not match uploaded content". |
|
||||
| OCI Range (206/416) optional | Spec says "SHOULD support" Range per RFC 9110; no 206/416/Accept-Ranges text for pull. |
|
||||
| Nix `<hash>.narinfo` 200 or 404, probed with HEAD | Client source: HEAD for existence; 404 and 410 both map to NotFound; 403 also treated as absent in `fileExists`. |
|
||||
| Map's own claims (open `*` CORS, 500 on corrupt, Range ignored, Cache-Control immutable) | `*` with non-`include` credentials passes the CORS check (Fetch 4.10). No system surveyed specifies a status for a corrupt stored object. Ignoring Range is permitted (RFC 9110 14.2). |
|
||||
|
||||
## 9. Where the systems agree and differ (matrix of what was found)
|
||||
|
||||
| Item | IPFS TG/PG | OCI | Nix | Bazel (bazel-remote) | Git dumb |
|
||||
|---|---|---|---|---|---|
|
||||
| Absent | 404 (MUST, TG) | 404 (MUST) | 404/410 -> NotFound; 403 also "absent" in HEAD probe | 404 "Not found" | 404/410/other non-200 |
|
||||
| Malformed id | PG 400 generic; TG silent | silent for blob pull | silent | 400 with message | silent |
|
||||
| Corrupt stored object (server) | silent; client MUST verify | silent; client SHOULD verify | silent; client verifies hashes | no read-path check in handler | silent; client errors "bad hash" |
|
||||
| Range | PG: 206 supported (raw blocks); TG silent | SHOULD support | client-side retry only | not in handler | not in spec |
|
||||
| HEAD | same as GET minus body; 404 | 200 + digest + Content-Length MUST | used to probe | 200 + Content-Length or 404 | not in spec |
|
||||
| CORS | none in TG/PG (Kubo adds implicit headers) | none | none | none | none |
|
||||
| Cache-Control on content | PG: `public, max-age=29030400, immutable` MUST | none | none | none | defers to RFC 2616 |
|
||||
| ETag | PG: quoted CID | none | none | none | 304 allowed |
|
||||
| Content-Type | `application/vnd.ipld.raw` | unspecified (pull) | `application/x-nix-nar` etc. | `application/octet-stream` | unspecified (objects) |
|
||||
| Content-Length vs encoding | PG warns they may differ | n/a | transport `Content-Encoding` separate from stored compression | zstd path omits Content-Length | client auto-decodes |
|
||||
| nosniff / attachment | TG: Content-Disposition attachment MUST; PG: nosniff | none | none | none | none |
|
||||
|
||||
## 10. Limits of this pass
|
||||
|
||||
- Fetch spec 4.7 was read from the HTML text; the exact algorithm line that fails the fetch on a body shorter than Content-Length was not found in the Fetch text (framing is RFC 9112 section 8, which says the client records the message incomplete).
|
||||
- bazel-remote digest verification on PUT was not read to the end; the read-path (GET) has no digest check in the handler.
|
||||
- Kubo's actual default CORS header values were not retrieved (only the pointer to boxo and ipfs/specs#423).
|
||||
- git CVEs were not searched; Nix and Bazel CVEs were not searched.
|
||||
- OCI "CORS: not in spec" is from a search of the spec file, not from a registry implementation (distribution/distribution's own headers were not read).
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
# Consumer pinning at a breaking land: prior art and internal state
|
||||
|
||||
Serves fork `variants:question:unpinned-consumers-at-land` (campaign `variants`, session `self-2026-10-22-eureka-c2a`).
|
||||
Eyes pass, 2026-10-22. Facts and evidence pointers only; no recommendation.
|
||||
|
||||
Question as admitted: Eve.Surface and Brokkr build against an unpinned sibling `..\CultLib` source checkout. C2a (refuse element types with no id) lands on main. Options: pin-then-land, sweep-then-land, land-and-break.
|
||||
|
||||
## Sources
|
||||
|
||||
| # | Source | URL | Status |
|
||||
|---|---|---|---|
|
||||
| S1 | Abseil, About / philosophy | https://abseil.io/about/philosophy | fetched |
|
||||
| S2 | Abseil, compatibility guidelines | https://abseil.io/about/compatibility | fetched |
|
||||
| S3 | Software Engineering at Google, ch. 21 Dependency Management | https://abseil.io/resources/swe-book/html/ch21.html | fetched |
|
||||
| S4 | Software Engineering at Google, ch. 16 Version Control and Branch Management | https://abseil.io/resources/swe-book/html/ch16.html | fetched (does not cover live at head) |
|
||||
| S5 | Russ Cox, Minimal Version Selection | https://research.swtch.com/vgo-mvs | fetched |
|
||||
| S6 | Cargo book, Cargo.toml vs Cargo.lock | https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html | fetched |
|
||||
| S7 | npm docs, package-lock.json | https://docs.npmjs.com/cli/v10/configuring-npm/package-lock-json | fetched |
|
||||
| S8 | Chromium src, dependencies doc (DEPS, gclient, roll-dep) | https://chromium.googlesource.com/chromium/src/+/main/docs/dependencies.md | fetched |
|
||||
| S9 | Pro Git, Submodules | https://git-scm.com/book/en/v2/Git-Tools-Submodules | fetched |
|
||||
| S10 | chromium infra dependencies.md | https://chromium.googlesource.com/infra/infra/+/main/docs/dependencies.md | 404, not retrieved |
|
||||
| L1 | `F:\Projects\Aetheria\Directory.Build.targets`, `Directory.Build.props`, git history | local files | read |
|
||||
| L2 | `F:\Projects\Aetheria\docs\cultcache-migration-cut.md` (Q6 table, lines ~565-580; pin guard notes ~1900-1925) | local file | read |
|
||||
| L3 | `F:\Projects\Eve` and `F:\Projects\Brokkr` csprojs and git file lists | local files | read |
|
||||
| L4 | voidbot `search_history` (two queries), `search_sources` (one query) | MCP | queried |
|
||||
|
||||
Anything not in a "fetched" row above is not claimed from the web.
|
||||
|
||||
## 1. Established practice: who pays for a breaking upstream change, and when
|
||||
|
||||
- **Abseil / live at head (S1, S2).** Recommends users track the latest code. Says it will provide a tool to perform an API refactor for well-behaved code (S1). Requires of downstream: show how you call it, stay within the compatibility guidelines; otherwise fork or use periodic supported branches (about every 6 months, patched 2 years) (S1). Says every change is potentially breaking for someone; breaks are announced in commit messages and LTS notes, and users are asked to report undetected breaks on GitHub (S2). Users should be able to temporarily pin to an earlier commit if a newer change is a problem (S2). S1/S2 do not mention Rosie, the Beyonce rule, or caller-fixing; the fetched pages describe tooling and a feedback channel only.
|
||||
- **Live at Head as described in SWE at Google ch. 21 (S3).** Providers are expected to test changes against downstream dependents before committing; breaking changes wait until dependents are updated or an automated migration tool exists. Consumers must keep meaningful tests. Stated preconditions: unit tests across dependencies run in CI, a visible reverse-dependency graph, substantial compute, no pinning of dependencies, provider acceptance of the downstream-testing duty. Called "costly but effective" inside Google and "largely unproven at scale." Notes that under SemVer a major bump costs maintainers little and many indirect users a lot. Ch. 16 (S4) does not address live at head; it states large-scale changes across trunk are costly and tooling-supported.
|
||||
- **Go MVS (S5).** Selects the oldest version satisfying all declared minimums. Publishing a new version changes nobody's build; a consumer gets it only when a requirement names it, directly or through another module. Upgrades are explicit edits to `go.mod` by the top-level module. Compatibility rule: newer versions inside one major version are expected to work where older did; a major version is a different module identity. Cost of a breaking change falls on whoever opts into the new version, at the time they opt in.
|
||||
- **Cargo (S6).** Git dependency without `rev` takes the default-branch tip at first build and fixes that commit in `Cargo.lock`; changes arrive only on `cargo update`. A `rev` in `Cargo.toml` pins explicitly; the Cargo book calls hand-managed SHAs "tedious and error prone" and prefers the lockfile. Consumer pays at `cargo update` time.
|
||||
- **npm (S7).** `package-lock.json` records the exact tree; installs stay on it until the lockfile changes and is committed. It is not published with a package. The page gives no timeline for when updates reach a consumer.
|
||||
- **Chromium DEPS (S8).** `DEPS` stores dependency URLs and commit hashes (often in `vars`). Developers roll with `gclient setdep`, `roll-dep`, or manual `git update-index`; some projects (Skia) are autorolled. The page says manual rolling after an upstream change is good practice to confirm the roll compiles and tests pass. It does not describe revert procedure. Cost is paid by the roller at roll time.
|
||||
- **Git submodules (S9).** The superproject records one commit SHA (gitlink, mode 160000). Upstream commits never move the pin; it moves when someone updates the checkout and commits the new SHA in the superproject. Collaborators receive the pin via pull and must run `git submodule update`.
|
||||
|
||||
## 2. Internal precedent
|
||||
|
||||
- **Aetheria pin (L1).** `Directory.Build.props` holds `CultLibRevision` (currently `45c2f4006f5f7dab357426cabd3197e8d0a17f66`) and `CultMathRevision` (`9c1b06cd703bfa44352050e819b0d0659cf5978a`). `Directory.Build.targets` target `VerifyCultLibRevision` (BeforeTargets `ResolveProjectReferences`) errors if the sibling checkout's `git rev-parse HEAD` differs from the pin, or if `git status --porcelain` is non-empty; escape hatch `SkipCultLibRevisionCheck=true`. File is 39 lines; the pin is one pair of properties per pinned package.
|
||||
- **Why added (L1 git).** Commit `4f4beb0f` (2026-09-14, "Pin CultLib for headless builds"): "Directory.Build.targets carries Delvehold's VerifyCultLibRevision guard, checking for CultMath.csproj: HEAD must equal the pin and the worktree must be clean." Commit `e91ba369` (2026-09-24, "Pin CultLib per package, as Unity's manifest does"): headless build must compile the revisions Unity consumes; a single pin would have compiled six commits of newer CultCache under headless tests while Unity stays on its tag. The Aetheria cut doc (L2, ~line 1905) calls the guard "the only working pin for a sibling checkout." Aetheria's `Packages/manifest.json` pins Unity packages by git tag.
|
||||
- **Pin guard already in Delvehold (L4/search_sources hit, `Delvehold:Directory.Build.targets`)** and a variant in AetheriaEve (archived, `Aetheria.State.Dependencies.targets`, also checks a Unity package tree hash). Delvehold's guard checks `GameCult.Mesh.csproj` exists, HEAD equals `CultLibRevision`, tree clean. Date of Delvehold's first guard: not determined.
|
||||
- **Consumer table (L2, Q6 in Aetheria `docs/cultcache-migration-cut.md`).** Aquarium, Ymir, AquaSynth, Mimir, Gjallar: ".NET sibling, unpinned", compile against main at once. Brokkr, Eve, EveUnity, EvePlugins: "Unity package / sibling", pin "various". Delvehold: sibling with revision guard at `334e60f`, 52 behind, "Not until it re-pins." Heimdall: submodule `b6b1d9c`. Rust consumers (Idunn, Epiphany, Odin, Ghostlight, Muninn, Ratatoskr, CodexConnector): Cargo by commit.
|
||||
- **Operator discussion (L4).** voidbot `search_history` ("pin CultLib revision vs track main for consumers"; "Brokkr Eve sibling CultLib checkout build pin") returned no relevant operator statement on pinning versus tracking; hits were unrelated Discord chatter. Nearest operator words found in the Discord index: Metacrat, 2026-05-09, "Oh, anyone using CultLib will still be paying the blood price, you can count on that" (message 1502671320406687894; context not fetched, so its subject is not confirmed). No operator quote on pinning versus tracking was found in memory files grepped (`C:\Users\Meta\.claude\projects\F--Projects\memory`, pattern pin/track main/unpinned/sibling) beyond a retired Idunn pin line and Odin/Muninn pin-branch coordination notes.
|
||||
- Aetheria cut doc operator mentions near pins are about content (Schematic-shape drawer, 2026-09-14; Studio grouping, 2026-09-17), not pinning policy.
|
||||
|
||||
## 3. Current state of the two consumers (L3)
|
||||
|
||||
- **Eve.** `packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj:16` `CultLibRoot` defaults to `$(MSBuildThisFileDirectory)..\..\..\CultLib`; lines 20-21 ProjectReference `$(CultLibRoot)\src\GameCult.Caching\...` and `...\GameCult.Mesh\...`. `tests/GameCult.Eve.Surface.Serialization.Tests/*.csproj:19` references `$(CultLibRoot)\src\GameCult.Mesh\...` (and Eve.Surface by relative path). `git ls-files` shows only `packages/org.gamecult.eve.surface/Directory.Build.props` (sets only BaseOutputPath/BaseIntermediateOutputPath/MSBuildProjectExtensionsPath, no revision). No `Directory.Build.targets`, no `global.json`, no `.gitmodules`, no revision property or check. Eve also holds Cargo.toml/Cargo.lock (Rust side, not examined).
|
||||
- **Brokkr.** `brokkr-command/Brokkr.Command.csproj:7`, `brokkr-contracts.Tests/Brokkr.Contracts.Tests.csproj:7`, `brokkr-contracts/Brokkr.Contracts.csproj:8`: `CultLibRoot` defaults to `..\..\CultLib`. ProjectReferences: Caching, Caching.MessagePack, Mesh, Networking (by project). No `Directory.Build.*`, no `.gitmodules` in `git ls-files`; no revision property or check.
|
||||
- Neither repo contains `CultLibRevision`, a `VerifyCultLibRevision` target, or any HEAD check. Both build whatever `F:\Projects\CultLib` currently holds (including dirty or other-branch states).
|
||||
- Fork text claims (not re-verified here): Eve.Surface has 0 `[CultElementId]`; Brokkr `[CultDocument]` files show no object lists by grep.
|
||||
|
||||
## Gaps
|
||||
|
||||
- S10 (Chromium infra) 404; Chromium autoroller behavior beyond S8 not fetched.
|
||||
- Rosie / Beyonce rule not found in fetched pages; not cited from memory.
|
||||
- No operator statement on pin-versus-track found in voidbot or the memory grep.
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
Serves the C2a fork on finding variants:finding:cut-land-c2a-gate.s1.reflist-record-read-only-at-head (campaign variants), 2026-10-23.
|
||||
|
||||
# Prior art: read with an old writer schema, then write back
|
||||
|
||||
Facts only. Pages were fetched 2026-10-23 through a summarising fetcher; "not stated" means the fetched page did not say it, not that no source does. Items marked (memory) are my background knowledge, not verified from a fetched page.
|
||||
|
||||
## 1. Apache Avro
|
||||
- Source: https://avro.apache.org/docs/current/specification/ (Schema Resolution; summarised via apache/avro doc source), Java `DataFileWriter` javadoc (1.11.1).
|
||||
- Model: the writer schema travels with the data. The reader resolves it against its own reader schema. Resolution is a read-time projection; the spec defines no write-back.
|
||||
- Data loss on projection: "the writer's value for that field is ignored" when the reader schema lacks the field. A reader-only field takes its default, or the read errors if it has none.
|
||||
- Write-back: the spec states no rule. Object container files store one schema in the header ("Each datum in a file is of the same schema"). `appendTo` is documented only as "Open a writer appending to an existing file"; it does not say which schema the append uses. (memory: Java `appendTo` reuses the file's header schema, so an append never restamps.)
|
||||
- Stated reason for the read-time design: readers and writers evolve separately (fields added or removed in a different software version).
|
||||
|
||||
## 2. Protocol Buffers (proto3)
|
||||
- Source: https://protobuf.dev/programming-guides/proto3/ (Unknown Fields).
|
||||
- On write: unknown fields are "included during parsing and in the serialized output" (matches proto2). Round-trip keeps fields the current type does not know.
|
||||
- Loss cases the page lists: serializing to JSON, and copying a message field by field into a new message (it recommends `CopyFrom`/`MergeFrom`).
|
||||
- No schema id or version is written. The wire format carries only field numbers, so "restamp" has no meaning here.
|
||||
- Not stated: behaviour in versions before 3.5 (memory: proto3 dropped unknown fields before 3.5, then reversed). The Java reference page says nothing on preservation.
|
||||
- Stated reason: preserve data across a mixed-version fleet.
|
||||
|
||||
## 3. Confluent Schema Registry
|
||||
- Source: https://docs.confluent.io/platform/current/schema-registry/fundamentals/schema-evolution.html
|
||||
- BACKWARD (default): consumers on the new schema read data written with the new or the previous version. FORWARD: data written with the new schema is readable by consumers on the old one. FULL: both.
|
||||
- Write: "Each schema version gets a unique ID and an incremented version number." A producer writes under the schema it holds (its own current version). The id is stamped per message at serialisation, from the producer's schema.
|
||||
- Upgrade order is how the rules prevent loss. BACKWARD means upgrade consumers before producers. FORWARD means upgrade producers first, and keep old-schema data away from consumers until they are upgraded.
|
||||
- Not stated on the page: what a consume-modify-produce app writes. (memory: it re-serialises under its own latest schema, so fields it does not model are lost unless a compatibility rule kept them.)
|
||||
- The registry gates registration. It does not gate individual rewrites.
|
||||
|
||||
## 4. Document DBs and ORMs with stored versions
|
||||
### MongoDB schema-versioning pattern
|
||||
- Sources: https://www.mongodb.com/blog/post/building-with-patterns-the-schema-versioning-pattern ; https://www.mongodb.com/docs/manual/data-modeling/design-patterns/data-versioning/schema-versioning/
|
||||
- Documents carry `schema_version` (absent means 1). The app keeps a handler per version and writes new documents at the current version.
|
||||
- Migration strategy is the app's choice: migrate all, migrate "when accessed" (lazy), or never.
|
||||
- Not stated: whether an update of an old document restamps it. The manual's update example branches on `schemaVersion` and writes into each version's own shape. It notes its `else: null` branch adds stray null fields to the other shape.
|
||||
- Stated reasons: no downtime, failed migrations easier to handle, timing flexibility. Cost: handler code per live version.
|
||||
|
||||
### Realm (Atlas Device SDKs, now deprecated)
|
||||
- Source: https://www.mongodb.com/docs/atlas/device-sdks/sdk/swift/model-data/change-an-object-model.md
|
||||
- Per-file schema version. A changed schema needs an incremented `schemaVersion`, plus a `migrationBlock` for non-trivial changes. The file is migrated whole at open, before any read or write, and Realm "will throw an error" if the version is lower or the same but the schema differs.
|
||||
- No lazy per-object restamp. Writes happen only on a migrated file.
|
||||
- Data loss: the manual says "Deleting a property will not impact existing objects", i.e. removal is automatic and needs no developer step. Required new properties and renames need a manual block. (memory: removed-property values are dropped from the file.)
|
||||
|
||||
### Core Data
|
||||
- Source: web search results only (Apple forum threads 745114 and 785427, WWDC22-10120, "staged migrations" doc). The Apple docs URL I tried returned 404.
|
||||
- A store whose model hash differs from the current model fails to open with an incompatible-version error. Opening migrates the whole store when `NSMigratePersistentStoresAutomaticallyOption` and `NSInferMappingModelAutomaticallyOption` are set. Lightweight migration covers only obvious patterns (add entity, remove relationship, required to optional, rename). Anything else needs a staged or custom migration.
|
||||
- Migration is store-wide at open. It does not happen per record on write.
|
||||
|
||||
### EF Core
|
||||
- Not fetched. (memory: no per-row schema stamp. Migrations are explicit, ordered DDL, tracked in a `__EFMigrationsHistory` table.)
|
||||
|
||||
## 5. Old-schema data held read-only until explicit migration
|
||||
### Elasticsearch archive indices
|
||||
- Sources: https://www.elastic.co/docs/deploy-manage/upgrade/deployment-or-cluster/reading-indices-from-older-elasticsearch-versions ; https://www.elastic.co/guide/en/elasticsearch/reference/current/archive-indices.html ; 8.0 migration notes (7.0+ indices readable, 6.x or earlier must be reindexed).
|
||||
- Indices from an older major are readable through search and field-caps. "They do not support the Get API or any write APIs."
|
||||
- The route to a writable copy is reindex into a new index that is "fully compatible with the current Elasticsearch version". `_source` is readable, so no data is lost on reindex.
|
||||
- Stated reason: the page does not say why they are read-only. It describes archive access as "slower read-only access", with limited query capability. Some legacy mappings cannot be imported (the original is kept in `_meta`), and that is the one place the page shows a lossy translation.
|
||||
- Third-party (Search Guard blog, unverified against Elastic): in 9.x a plain `index.blocks.write` does not satisfy the boot check; `index.verified_read_only` is needed.
|
||||
- This is the closest match to option (b) in the fetched set: an old format, readable but not writable, left until the owner reindexes it.
|
||||
|
||||
## Where restamp-on-write loses data (from the sources above)
|
||||
- Avro: a field present in the writer schema and absent from the reader schema is dropped at read, "ignored". Any rewrite from the resolved value would omit it.
|
||||
- Protobuf: unknown fields survive a binary round trip. They are lost via JSON, or when copied field by field.
|
||||
- Realm: removed properties are deleted by the automatic migration.
|
||||
- MongoDB: a handler that writes only the current shape can lose or relocate old-shape fields (the manual's example adds nulls).
|
||||
- Confluent: loss is prevented by compatibility rules at registration time, not at each write.
|
||||
|
||||
## Pattern across the set
|
||||
- No fetched system restamps a single record on write when the record's writer schema differs from the application's type, on an app-visible, per-record basis.
|
||||
- Avro, protobuf and Confluent carry the schema per file or message.
|
||||
- Realm, Core Data and Elasticsearch move or block the whole store or index at open.
|
||||
- MongoDB leaves it to the app.
|
||||
- Whole-store migration at open is the usual explicit step (Realm, Core Data). Elasticsearch is the one case of read-only-until-reindex.
|
||||
- Not found: a system that declares per-schema-id compatibility, as in option (b). Avro schema resolution and Confluent compatibility rules are the nearest analogues. Both are read-side or registration-side declarations.
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
Serves question variants:question:store-hold-user-code (campaign variants), 2026-10-23.
|
||||
|
||||
# Prior-art check: user code under a lock
|
||||
|
||||
Facts and pointers only. Each source was fetched or searched this session; what was not reachable is marked.
|
||||
|
||||
## 1. Goetz et al., Java Concurrency in Practice (Addison-Wesley, 2006), ch. 10 "Avoiding Liveness Hazards"
|
||||
Status: EXISTS, SECTION NUMBER CORRECTED.
|
||||
- "Open Calls" is **10.1.4**, not 10.1.3. 10.1.3 is "Deadlocks Between Cooperating Objects"; 10.1.2 is "Dynamic Lock Order Deadlocks"; 10.1.5 is "Resource Deadlocks". Source: text extracted from a PDF copy of the book (its TOC lists Listing 10.6 "Using Open Calls to Avoiding Deadlock Between Cooperating Objects", p. 133). The copy is an unofficial one at library.megu.edu.ua; a publisher link was not checked.
|
||||
- (a) 10.1.3 gives the warning sign: an "alien method" (defined p. 40 as a method whose behavior the calling class does not fully specify) called while holding a lock. Paraphrase: such a call may take other locks (deadlock risk) or block a long time, stalling threads that need the lock you hold. 10.1.4 defines an open call as a call made with no locks held (credited to Lea, CPJ 2.4.1.3) and says programs restricted to open calls are far easier to analyze for deadlock-freedom than ones that allow alien calls under lock.
|
||||
- Cost it names: moving a call out of a synchronized block makes an atomic operation non-atomic (its getImage example stops being a snapshot). Where atomicity is needed it suggests structuring so only one thread can run the code path after the open call (service shutdown example).
|
||||
- (b), (c): not addressed in this section.
|
||||
- Pointer: JCIP 10.1.3 and 10.1.4, pp. ~132-134 (chapter 10 starts p. 128).
|
||||
|
||||
## 2. Sutter, "Effective Concurrency: Avoid Calling Unknown Code While Inside a Critical Section"
|
||||
Status: VERIFIED, with date caveat.
|
||||
- Dr. Dobb's column in the "Effective Concurrency" series. Sutter's blog teaser is dated 2007-11-06: https://herbsutter.com/2007/11/06/effective-concurrency-avoid-calling-unknown-code-while-inside-a-critical-section/ . Original link ddj.com/architect/202802983; Dr. Dobb's mirror https://drdobbs.com/cpp/avoid-calling-unknown-code-while-inside/202802983 (4 pages). The Dr. Dobb's issue date was not shown in anything fetched; the blog date is the only date confirmed. www.drdobbs.com did not resolve from this machine, so the column body comes from search-result summaries and the blog teaser, not a full first-hand read.
|
||||
- (a) Core claim: locks do not compose; two separately correct modules can deadlock when combined. Trigger: caller inside a critical section, callee enters another critical section or blocks, another thread can take the same two in the opposite order. "Unknown code" listed as: library functions, plug-ins, callbacks/function pointers, virtual methods, generic types.
|
||||
- Mitigations listed: move the call outside the critical section; pass copies instead of shared data; narrow the critical section (finer locks, reader-writer); require callees not to block (documentation/discipline only).
|
||||
- (b), (c): not addressed.
|
||||
|
||||
## 3. Linux lockdep
|
||||
Status: VERIFIED, with a nuance.
|
||||
- Source: kernel docs "Runtime locking correctness validator", https://docs.kernel.org/locking/lockdep-design.html .
|
||||
- (b) Lockdep tracks lock classes, not instances, and records dependencies between classes as sequences are observed. It reports a circular order as a potential deadlock even if the deadlock never happened. Its "Proof of 100% correctness" section says that once each simple single-task locking sequence has occurred at least once, no timing of them can deadlock. Each new lock chain is validated once and hashed; repeats skip validation.
|
||||
- Nuance: it flags the dependency edge that closes a cycle, so both orders must have been exercised at some point (possibly by different, non-concurrent runs). A lone nesting is flagged only if it breaks a single-lock rule (IRQ-safe vs unsafe, same-class recursion). "Refuses on first sight" fits the closing edge or a single-lock rule violation only. It warns; it does not block the acquisition.
|
||||
|
||||
## 4. ConcurrentHashMap re-check after locking
|
||||
Status: VERIFIED.
|
||||
- Source: OpenJDK java/util/concurrent/ConcurrentHashMap.java, `putVal` and `computeIfAbsent`: head node `f` is read unlocked, `synchronized (f)` is taken, then `tabAt(tab, i) == f` is re-checked; on failure nothing is done and the loop retries. The overview comment gives the reason: the locked node can be replaced, so first-in-bin must be confirmed after locking. (Only the first 100k of 269k chars were read; both methods were reported found there.) Empty-bin `computeIfAbsent` uses a ReservationNode plus CAS instead.
|
||||
- (c) This is the pattern: choose the lock from an unlocked read, validate the read after acquiring.
|
||||
|
||||
## 5. LMDB
|
||||
Status: VERIFIED (paraphrase). Not about alien code or lock order.
|
||||
- Source: lmdb.h, `mdb_txn_begin` and `mdb_env_open` (https://github.com/LMDB/lmdb, libraries/liblmdb/lmdb.h). A thread may hold one transaction at a time (plus children); a transaction and its cursors belong to one thread; one write transaction at a time, so writers cannot deadlock each other; MDB_NOTLS ties reader slots to the txn object instead of the thread, and the application must then serialize writers itself because write locking is unaware of user threads.
|
||||
- Relevance: "one hold per thread" as a deliberate rule, parallel to CultCache's one-cache-gate-per-thread.
|
||||
|
||||
## 6. SQLite btmutex.c
|
||||
Status: VERIFIED (summary of trunk source).
|
||||
- Source: https://github.com/sqlite/sqlite/blob/master/src/btmutex.c (read via raw.githubusercontent.com; sqlite.org served a bot check).
|
||||
- (b) Prevention by ordering, not detection: sharable B-trees on a connection sit in a list sorted by BtShared address and are always locked ascending (`sqlite3BtreeEnterAll`). `sqlite3BtreeEnter` try-locks first; on failure it releases later-ordered locks, waits, then reacquires in order. `wantToLock` is a count that makes a non-recursive mutex behave recursively.
|
||||
- No alien-code or callback discussion in these comments.
|
||||
|
||||
## Counter-positions and how they stay safe
|
||||
- Callbacks under lock, documented: Java `ConcurrentHashMap.compute`/`computeIfAbsent`. Javadoc (Java 21): the function should be short and simple because other threads' updates may block meanwhile, and must not modify the map. The javadoc does not say "internal lock"; that it runs under the bin lock is inferred from the source in item 4. Safety rests on documented restrictions, not enforcement.
|
||||
- Opposite choice: .NET `ConcurrentDictionary.GetOrAdd(key, valueFactory)`. Writes use fine-grained locks but valueFactory runs outside them, "to avoid the problems that can arise from executing unknown code under a lock" (doc remarks). Price: not atomic; the factory may run more than once; a recheck decides the winner (another thread's insert wins). https://learn.microsoft.com/en-us/dotnet/api/system.collections.concurrent.concurrentdictionary-2.getoradd . Same shape as option `encode-first` (user code outside the lock, then recheck and commit) and as item 4.
|
||||
- JCIP and Sutter both name the cost of leaving the lock: lost atomicity. Sutter also offers "pass copies" and "callee must not block" as alternatives to leaving.
|
||||
- Not researched (no source fetched): Linux notifier chains, Java `synchronized` event listeners, other systems that call back under lock by design. No verified citation for them.
|
||||
|
||||
## Unverified / not done
|
||||
- JCIP: section numbers and text come from one PDF copy, not the publisher edition.
|
||||
- Sutter: full column body not read first-hand.
|
||||
- No Discord or voidbot content read.
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
# Prior art: a process-global default registry deciding write ownership
|
||||
|
||||
Serves `variants:question:registryless-write-reads-shared` (campaign `variants`). Eyes pass, session `self-2026-10-27b-eureka`, 2026-10-27. Facts only; items marked "summary" rest on search summaries not opened.
|
||||
|
||||
# Prior art: process-global type registries and write/overwrite ownership
|
||||
|
||||
Fork: `variants:question:registryless-write-reads-shared` (viewed in full via eureka-state, status InForce, options `shared-decides` / `written-type-only`, recommended `shared-decides` at ~65%).
|
||||
Facts only. No conclusion. Date 2026-10-27.
|
||||
|
||||
Method note: web pages were read through WebFetch (a small model summarises the page) and WebSearch summaries. Quotes below are what those tools returned; where only a summary was available it is marked "summary". Nothing was run.
|
||||
|
||||
## A. Local mechanics (CultLib, read-only, `C:\cl-wt\c2a-land\src\GameCult.Caching\CultCache.cs`)
|
||||
|
||||
- `CultDocumentRegistry.Shared` is `Lazy<CultDocumentRegistry>` (line 639-645). Its public constructor calls `Refresh()` (647-650), which scans `ReflectionExtensions.GetAttributedDocumentTypes()` (688; defined in `ReflectionExtensions.cs:10`) and rebuilds the indexes under `_registrationGate`. So Shared's initial content is whatever `[CultDocument]` types are in loaded assemblies at first touch of `Shared`.
|
||||
- `GetRequired(Type)` (697-711) registers an unregistered `[CultDocument]` type on demand. So Shared also grows whenever any caller asks for a type. `Refresh()` is public and rebuilds the index from currently loaded assemblies.
|
||||
- `ForTypes(IEnumerable<Type>)` (658-669) builds a registry with `discoverLoadedDocuments:false`, containing only the given types (plus those `GetRequired` pulls in).
|
||||
- `CultCache(registry = null, ...)` sets `_registry = registry ?? CultDocumentRegistry.Shared` (2202-2205).
|
||||
- `WriteDocument<TDocument>(key, doc, storedAt, registry = null)` (4515-4545): `resolvedRegistry = registry ?? Shared`; `catalogEntry = resolvedRegistry.GetRequired<TDocument>().ToCatalogEntry()`; then `Cacheless(resolvedRegistry, ...)` lends that registry to the store for the call. XML doc (4510) says Shared "can only refuse more".
|
||||
- `WriteRecord(..., registry = null)` (4491-4497): XML doc (4489) says that with registry omitted "it decides nothing for copied records and the one entry is the only claimant".
|
||||
- `OwnsOnDisk(upserts)` (4741-4759): when the store is cacheless and upserts are given, ownership of an on-disk record is decided by `CultDocumentRegistry.Owns(id, catalog, declared, new CatalogEntryLookup(writes, AttachedRegistry), ..., claimant => writes.Any(entry => entry.SchemaId == claimant.SchemaId))`. The comment (4731-4735, 4738-4740) says written entries shadow the lent registry's type for their ids, the registry adds other claimants, and the record is owned only when the claimant is a written entry, "a registry can only refuse more". `CatalogEntryLookup` ctor at 604 takes `(entries, CultDocumentRegistry? others)`.
|
||||
- Net: with `registry == null` the lent registry's claimant set is Shared's current contents, which depend on assemblies loaded and on every prior `GetRequired` call in the process.
|
||||
|
||||
## B. Q1. Process-global, lazily filled registries in established libraries
|
||||
|
||||
### MongoDB C# driver, `BsonClassMap`
|
||||
- Global: yes. `BsonClassMap.RegisterClassMap` is process-wide static; unregistered types are auto-mapped on first use. Docs: "You must register a class map _before_ it's needed in your code." and "We recommend registering class maps before initializing a connection with MongoDB." https://www.mongodb.com/docs/drivers/csharp/current/serialization/class-mapping/ (WebFetch; the page itself says nothing on freezing or late-registration errors).
|
||||
- Behaviour depends on what has been touched: EF Core provider issue EF-88 reports "Unknown discriminator value 'List`1'" when the first operation after startup is a read; reporter links it to a class map that is created only on write. Status Closed/Fixed in 8.0.0; page has no maintainer comment. https://jira.mongodb.org/browse/EF-88 (WebFetch). Reporter says explicit `RegisterClassMap` at startup did not fix it for them.
|
||||
- A MongoDB community answer (via search summary, not opened): discriminator mappings persist once registered, so a read after an insert works; fix offered is `[BsonKnownTypes]`. https://mongodb.com/community/forums/t/c-driver-why-is-bsonknowntypes-attribute-needed-for-polymorphism/2183 (summary only).
|
||||
- Register-once contract: CSHARP-4424 states the three `RegisterClassMap` methods are "meant to only be called once for a given type"; requested because several libraries share data models and do not control init order. Closed Done, fix version 2.19.0; the page does not say which API shipped. https://jira.mongodb.org/browse/CSHARP-4424 (WebFetch). Community workaround named in search summary: `BsonClassMap.IsClassMapRegistered`.
|
||||
- CSHARP-1879 (search summary): `RegisterClassMap` accepted an unfrozen map and failed later on use; fixed 2.4.2. https://jira.mongodb.com/browse/CSHARP-1879 (not opened).
|
||||
- CSHARP-5597 (search summary): `BsonSerializer.RegisterDiscriminator` has no effect; discriminator dictionary populated at class-map registration. https://jira.mongodb.org/browse/CSHARP-5597 (not opened).
|
||||
- Polymorphic docs: "You must specify hierarchical discriminators in root-to-leaf order." and the relationship of all classes in a hierarchy must be documented before deserialization. https://www.mongodb.com/docs/drivers/csharp/current/serialization/polymorphic-objects/ (WebFetch).
|
||||
- Particular docs report an order-dependent GuidSerializer failure ("incorrect order of class mappings"). https://docs.particular.net/persistence/upgrades/mongodb-4to5 (search summary, not opened).
|
||||
- Did they move away: not found. Class maps remain global static; mitigation is documentation plus the IfNotRegistered API.
|
||||
|
||||
### protobuf-net `RuntimeTypeModel.Default`
|
||||
- Global: yes. README: "The Serializer.* methods are basically just shortcuts to RuntimeTypeModel.Default.*". https://cdn.jsdelivr.net/gh/protobuf-net/protobuf-net@main/README.md (WebFetch).
|
||||
- Lazy fill, freezing, order-dependent bugs: NOT verified. README is silent; a direct docs fetch (docs.protobuf-net.dev/runtime) was redirected and not followed. No issue found.
|
||||
|
||||
### MessagePack-CSharp
|
||||
- Global mutable default exists: `MessagePackSerializer.DefaultOptions`. README (WebFetch): labelled "Global state; Not recommended when writing library code." Safe if you control all MessagePack code; libraries should pass options explicitly because others "may change this static property." Libraries "must not use StaticCompositeResolver - as it is global state". https://github.com/MessagePack-CSharp/MessagePack-CSharp/blob/master/README.md
|
||||
- Resolvers are an ordered list (`CompositeResolver`): order matters by construction.
|
||||
- Whether it moved away from the global: not found.
|
||||
|
||||
### System.Text.Json
|
||||
- `JsonSerializerOptions.Default`: "Gets a read-only, singleton instance"; remarks give a performance reason: shared instance avoids "redundant recomputation of converters". https://learn.microsoft.com/dotnet/api/system.text.json.jsonserializeroptions.default (WebFetch). The page states no rationale about cross-component interference; any such reading is inference. Design-discussion issue in dotnet/runtime: searched, not found. Whether Default was ever mutable: not verified.
|
||||
- Type-info resolvers: not researched beyond the above.
|
||||
|
||||
### Json.NET
|
||||
- `TypeNameHandling` is a security concern, not an order concern: CA2326 says "An attacker could modify the serialized data to include unexpected types"; mitigation is a custom `ISerializationBinder` restricting types. https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca2326 (WebFetch).
|
||||
- `JsonConvert.DefaultSettings` global behaviour/guidance: the Newtonsoft page fetched says nothing about global effects. Not verified.
|
||||
|
||||
### Orleans
|
||||
- Serializer discovery is build-time source generation (`[GenerateSerializer]`, `[Id]`), not a runtime scan; the docs' older code-generation section lists scan of assemblies referencing the core library plus `KnownType`/`KnownAssembly` attributes. Aliases: "Type aliases are globally scoped, and you cannot have two aliases with the same value in an application." https://learn.microsoft.com/en-us/dotnet/orleans/host/configuration-guide/serialization (saved locally: `C:\Users\Meta\.claude\projects\F--Projects-CultLib\0840c7c7-0e31-410e-9012-afb79c3c0742\tool-results\toolu_0148m7no1AbrJzWmNLicn6N9.txt`, lines 134, 839-841).
|
||||
- Type manifest / codec registry runtime behaviour, issues: not researched.
|
||||
|
||||
### EF Core
|
||||
- Model is built per `DbContext` type and cached (IMemoryCache; "the built model has a cache size of 100"); initialised at first operation: "just creating a DbContext instance does not cause the EF model to be initialized." https://learn.microsoft.com/en-us/ef/core/performance/advanced-performance-topics
|
||||
- Assembly scan: `ApplyConfigurationsFromAssembly` — "The order in which the configurations will be applied is undefined, therefore this method should only be used when the order doesn't matter." https://learn.microsoft.com/en-us/ef/core/modeling/ ; and "`EntityTypeConfigurationAttribute` types will not be automatically discovered in an assembly."
|
||||
- So EF's registry is per context type, not process-global; assembly scan is opt-in and documented as order-undefined.
|
||||
|
||||
### Marten
|
||||
- Storage is a table and upsert function per document type, alias `mt_doc_<alias>`; alias collisions are resolved with `DocumentAlias`. https://martendb.io/schema/storage (WebFetch). Hierarchies share one table via `AddSubClass<T>()`; subclass docs are "stored as the parent type" in the identity map. https://www.martendb.io/documents/hierarchies (WebFetch). Schema is configured in `StoreOptions` per store, not a process-global.
|
||||
|
||||
### Java ServiceLoader
|
||||
- "Service providers are loaded and instantiated lazily, that is, on demand." Providers on the class path load in the order `getResources` finds config files; "The ordering of modules in a module layer is not defined." "Instances of this class are not safe for use by multiple concurrent threads." Caches cleared by `reload`. https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/ServiceLoader.html (WebFetch). Visibility is class-loader dependent ("must be visible to the class loader").
|
||||
|
||||
## C. Q2. Write/overwrite decisions against an existing record of another type
|
||||
|
||||
- RavenDB: REST put docs, request body section: updates must "include its collection name in the metadata" and "A document's collection cannot be modified." Server error code for mismatch: the first fetch summary said HTTP 500; not confirmed on a second fetch. https://docs.ravendb.net/7.1/client-api/rest-api/document-commands/put-documents (WebFetch twice). Ayende, RavenDB 4.0 storage: document key namespace is global, same key cannot exist in two collections (summary). https://ayende.com/blog/173954/the-design-of-ravendb-4-0-physically-segregating-collections The check is against the collection named in the write (derived from the writer's type by the client), not a registry; the "client maps entity type to collection" part is inference from the search summary, not a quoted source. `PutDocumentCommand` page: a missing collection means `@empty`; concurrency via `changeVector`, null "to skip the check". https://docs.ravendb.net/client-api/commands/documents/put
|
||||
- Marten: per-type tables, so the same id under two types are two rows in two tables; no cross-type check. Same id across subclasses of one hierarchy table: not covered by docs fetched; unverified. Marten docs: upsert is used so users "don't have to check whether a document is new or existing" (search summary of https://www.martendb.io/documents/storing, not opened).
|
||||
- MongoDB: the server has no type; `_id` unique per collection; the C# driver writes a `_t` discriminator from the written object's class map. Whether the driver checks an existing document's `_t` on replace: not researched; nothing found stating it does.
|
||||
- Couchbase: key unique within a collection; upsert replaces; no server-side type field enforcement; guidance is one type per collection. https://docs.couchbase.com/server/7.0/learn/data/scopes-and-collections.html (search summary, not opened).
|
||||
- Orleans: grain identity is type plus key ("type/key"), so same key under two grain classes is two identities, not a conflict. https://learn.microsoft.com/en-ca/%20dotnet/orleans/grains/grain-identity (search summary, not opened). How storage providers key state: not found.
|
||||
- Result of this pass: no store found in which a process-global type registry decides whether a write may replace an existing record of another type. Found: collection/type fixed by metadata of the writer (RavenDB), type as part of the key (Orleans, Marten tables), or no check (Couchbase upsert). This is a negative search result, not proof of absence. Not searched: Cosmos DB, DynamoDB, EF Core TPH update of an existing row with another discriminator, Realm, LiteDB, Akka.Persistence.
|
||||
|
||||
## D. Q3. Guidance on ambient/global registries and determinism
|
||||
|
||||
- Mark Seemann (ploeh, 2019): after discussion with co-author van Deursen, "Ambient Context really is an anti-pattern." Reasons given: he has never used it and superior alternatives exist; he says the claim is unproven. https://blog.ploeh.dk/2019/01/21/some-thoughts-on-anti-patterns/ (WebFetch). Examples he cites as found in the BCL (search summary, not verified on page): `Thread.CurrentPrincipal`, `CultureInfo.CurrentCulture`, `HttpContext.Current`. DI book 2nd ed. chapter 5 lists it among DI anti-patterns: https://www.oreilly.com/library/view/dependency-injection-principles/9781617294730/OEBPS/Text/c05.xhtml (not opened).
|
||||
- MessagePack README (above) is itself a library-author statement on global mutable defaults. MongoDB CSHARP-4424 (above) states the shared-library init-order problem. EF Core docs (above) state assembly-scan order is undefined.
|
||||
- No .NET guidance specifically about AppDomain-wide/assembly-scan registries and write decisions found.
|
||||
|
||||
## E. Not verified / gaps
|
||||
- protobuf-net lazy registration, freezing and bug history.
|
||||
- Why System.Text.Json made Default immutable; no maintainer statement found.
|
||||
- Json.NET DefaultSettings guidance.
|
||||
- RavenDB exact error text and HTTP status; whether the stored document is unchanged after refusal.
|
||||
- Marten same-id-across-subclasses behaviour.
|
||||
- MongoDB driver behaviour on replacing a document whose `_t` differs.
|
||||
- Several items above rest on WebSearch summaries I did not open (marked "summary").
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
# Prior art: structural versus name-inclusive schema ids
|
||||
|
||||
Serves the store-identity question raised 2026-10-27 in campaign `variants` (operator: member and declared names vary with runtime naming conventions). Eyes pass, session `self-2026-10-27b-eureka`. Facts only; items marked "summary" rest on search summaries not opened.
|
||||
|
||||
# Prior art: structural vs name-inclusive schema identity (campaign variants)
|
||||
|
||||
Eyes pass, 2026-10-27. Facts and evidence pointers only. "(fetched)" = page opened via WebFetch (the answer is a small-model extraction of the page, not raw text). "(summary)" = WebSearch result summary, page not opened.
|
||||
|
||||
## 0. Local facts (read-only)
|
||||
|
||||
- `F:\Projects\CultLib\src\GameCult.Caching\CultCache.cs:717-718` schemaId = Sha256(BuildSemanticFingerprint(...)). `:735-761` fingerprint = `schemaName|schemaVersion` then per member ordered by Slot: `slot:MemberName:TypeName:ref|value:TargetSchemaName:one|many`. `contentHash` (`:716`) is a separate Sha256 of canonical JSON (`:763+`).
|
||||
- `TypeName` = `CultSchemaTypeNames.FromType`: CLR `Type.FullName` (namespace-qualified), generics as `Name<args>`, arrays `T[]` (`src\GameCult.Caching\CultSchemaTypeNames.cs:9-31`). So the fingerprint contains .NET type names as well as member names.
|
||||
- Contract `Contracts\cultcache-persistence-format.md:257-284` lists what canonicalization must preserve: slots, types, required/default, nullability, collection semantics, enum constraints, encoding shape. Member names and declared schema name are not in that list. `:171-179` says the id is not derived from C#/Rust/TS type names and the same shape should give the same id "regardless of origin language". `:180-190` defines schemaId = "same semantic persistence shape?" vs contentHash = "exact same canonical schema document?". Example catalog entry (`:140-170`) carries optional `supersedesSchemaIds`, `migrationFamily`; its example canonicalSchema fields do carry `"name": "Name"` (PascalCase). Soft-migration reader flow `:286-345` (exact_match / soft_migrate / reject).
|
||||
- Runtimes do not derive ids; they emulate C# names:
|
||||
- Python: `packages\cultcache-py\src\cultcache_py\documents.py:326` `member_name=_pascal_case(field.name)`; `:516-517` splits on `_` and capitalises. `schema_id` defaults to `self.type` (`:218`); `compatible_schema_ids` field (`:188,242`).
|
||||
- TypeScript: `packages\cultcache-ts\src\codec.ts:226-232` `pascalCase` (splits on non-alphanumerics), `:267` `memberName: pascalCase(field.name)`; throws if a codec has no C# type name (`:~262`).
|
||||
- Rust: `packages\cultcache-rs\src\lib.rs:295,308` `serde(rename_all = "camelCase")` on envelope structs; `schema_id: Some(T::TYPE.to_string())` at `:2856`. I did not trace how Rust document types obtain a sha256 id.
|
||||
- Python test pins a literal id from shared vectors: `packages\cultcache-py\tests\test_cultcache.py:808` (`tests\vectors\document-variants-c0`).
|
||||
- C# also has `CompatibleSchemaIds` (`CultCache.cs:28,313,570`): a record whose persisted id is in the list is accepted (an alias-list mechanism; I saw only the grep lines, not the logic).
|
||||
- Ruling `variants:ruling:wire-id-is-version` (eureka-state, InForce, ruled 2026-09-30, operator quote "Agreed on schema identity"): wire schema id = declared version string `<name>.vN`; every runtime stamps/resolves it exactly; a wire binding without `.v<digits>` is refused; "Store ids are unchanged." Cited source: document-variants-cut.md:552-555 (not opened).
|
||||
|
||||
## 1. How established formats define identity/compatibility, and whether names participate
|
||||
|
||||
Protocol Buffers (fetched https://protobuf.dev/programming-guides/proto3/ and /json/)
|
||||
- Field number identifies the field in the binary wire; changing it = delete + add. Field names appear only in TextProto/JSON encodings; reserve deleted numbers and, for JSON/text, names. The proto3 page has no explicit safe/unsafe entry for renaming; names matter only for JSON/text.
|
||||
- JSON mapping: names become lowerCamelCase unless `json_name` is set; "Parsers accept both the lowerCamelCase name" and the original proto name (/programming-guides/json/).
|
||||
- Message type identity in `Any` is by name: default type URL `type.googleapis.com/<package>.<message>`, "a globally unique identifier" (proto3 page, #any). Type identity is nominal; compatibility is by field numbers.
|
||||
|
||||
Apache Avro (fetched https://avro.apache.org/docs/1.11.1/specification/)
|
||||
- Parsing Canonical Form (PCF): STRIP keeps only attributes relevant to parsing (type, name, fields, symbols, items, values, size); doc and aliases dropped; FULLNAMES replaces short names with fullnames and drops namespace attributes. Textually equal PCF means "the same" for any reader. Names ARE in the canonical form (record and field names), as is field declaration order. Defaults are absent from the kept list (extractor's inference; spec text not confirmed).
|
||||
- Fingerprints: CRC-64-AVRO in single-object encoding (marker C3 01 + 8-byte LE fingerprint); spec recommends at least a 64-bit Rabin, with SHA-256 / MD5 options for larger sets.
|
||||
- Name rules: `[A-Za-z_][A-Za-z0-9_]*`, dot-separated namespaces, equality on case-sensitive fullname.
|
||||
- Resolution: records match on same unqualified name; "Named types and fields may have aliases"; implementations "may optionally" use aliases to map writer to reader. So in Avro names participate in identity (fingerprint) and resolution; aliases are the rename mechanism and are excluded from the fingerprint.
|
||||
|
||||
Cap'n Proto (fetched https://capnproto.org/language.html)
|
||||
- Files and types carry 64-bit IDs; `capnp id` generates them randomly. Default ID for a declaration without one = first 8 bytes of MD5 of (parent scope ID, declaration name). "You cannot change the name of a type that doesn't have an explicit ID"; symbolic names can change if ID/ordinals stay. Stated rationale for IDs: short, unambiguous, full names waste wire space. The page does not say why structural hashes were rejected; I found no Varda statement (search pointed at a mailing-list thread "Schema Refactoring and Unique IDs", Aug 2023, unopened).
|
||||
- Encoding page (https://capnproto.org/encoding.html, fetched): adding fields is backward compatible because field positions depend only on lower-numbered fields; nothing on type identity.
|
||||
|
||||
FlatBuffers (fetched https://flatbuffers.dev/evolution/ and /schema/)
|
||||
- Field positions by declaration order or explicit `id: n` (contiguous from 0, on all fields if used). Renaming "generally OK" because names are not in the binary. `file_identifier` is exactly 4 chars at bytes 4-7; docs frame it as a file-level check and recommend unions for distinguishing messages.
|
||||
|
||||
Thrift (fetched https://raw.githubusercontent.com/apache/thrift/master/doc/specs/thrift-binary-protocol.md)
|
||||
- Binary protocol: field header = type + field id; "the field name is not encoded". (IDL page gave nothing on omitted ids.)
|
||||
|
||||
MessagePack-CSharp (fetched https://github.com/MessagePack-CSharp/MessagePack-CSharp, first 100k of 105k chars)
|
||||
- `[Key(int)]` on all members => array encoding; string keys => map. Keys should be sequential from 0 (gaps cost nulls); do not reuse indexes of removed members; keep obsolete members until clients update. Union keys are ints, unique, serialized as [key, object]. No type identity in the payload.
|
||||
|
||||
Microsoft Bond (fetched https://microsoft.github.io/bond/manual/bond_cpp.html)
|
||||
- Changing field ordinals breaks wire compat; no explicit versioning; Simple JSON matches by field name so a rename breaks it. No schema hash/GUID documented there. Bebop: wiki page empty (points to docs.bebop.sh, not opened).
|
||||
|
||||
Unison (fetched https://www.unison-lang.org/docs/the-big-idea/ and /docs/language-reference/unique-types; hash size from search summary)
|
||||
- Definitions identified by hash of syntax tree; names are "separately stored metadata"; hash excludes names (512-bit SHA3 per search summary, unopened).
|
||||
- Structural types: "Unison does not uniquely identify a type by its name"; same-shape types are the same type (Suit/Direction, four nullary constructors, interchangeable). `unique` types: compiler generates a UUID that feeds the hash, so identity becomes nominal. Which is the default keyword was not confirmed.
|
||||
|
||||
IPLD (fetched https://ipld.io/docs/schemas/)
|
||||
- Matching is structural: "the names of types aren't expected to be found in the serialized data"; match is by structural outline. The page does not say schemas are themselves content-addressed. Versioning is left to developers; "Migrations" subpage not opened.
|
||||
|
||||
## 2. Failure modes of purely structural identity
|
||||
|
||||
- Unison documents it directly: structurally identical enums are one type unless declared `unique` (unison-lang.org/docs/language-reference/unique-types, fetched). Its mitigation is a per-declaration UUID mixed into the hash (identity re-nominalised).
|
||||
- Language literature (summary, pages not opened): structural typing is prone to accidental conformance/type equivalence; nominal typing prevents it at the cost of rigidity (Wikipedia "Nominal type system"; arXiv 1007.2123 lecture notes; Erlang nominals doc https://www.erlang.org/doc/system/nominals.html says nominal types exist to prevent misuse of same-structure types). TypeScript "brand" = phantom discriminator field. No serialization-specific source found.
|
||||
- Formats that avoid the collision by carrying a name or tag outside the shape: protobuf Any type URL (full name), Avro fullname in PCF, Cap'n Proto explicit IDs, Unison `unique`, MessagePack-CSharp union key ints, FlatBuffers file_identifier/unions.
|
||||
- Not found: a documented incident of two same-shape serialized types colliding under a structural id (one search, nothing).
|
||||
|
||||
## 3. Failure modes of name-inclusive identity across languages
|
||||
|
||||
- Protobuf: default camelCase conversion is "always valid but not necessarily unique"; proto3 compiler rejects JSON-name conflicts; serialized duplicate keys parse differently by runtime (Edition Zero design doc, summary: https://flutter.googlesource.com/third_party/protobuf/+/HEAD/docs/design/editions/edition-zero-json-handling.md). Snake_case vs camelCase JSON mismatches in practice: https://github.com/fullstorydev/grpcurl/issues/105 (summary); https://github.com/open-telemetry/opentelemetry-proto/issues/476 (summary: spec contradicted itself on camelCase vs original names). Protobuf's mitigation: parsers accept both forms.
|
||||
- Serde (fetched https://serde.rs/container-attrs.html): `rename_all` accepts lowercase, UPPERCASE, PascalCase, camelCase, snake_case; per-field `rename`.
|
||||
- Avro restricts names to `[A-Za-z_][A-Za-z0-9_]*` with case-sensitive fullname equality, so snake_case and PascalCase spellings are distinct names; renames go through aliases, which PCF drops.
|
||||
- Cap'n Proto: auto-derived IDs depend on the declaration name, so a rename without an explicit ID changes the ID.
|
||||
- Bond Simple JSON: rename breaks compatibility (name matching).
|
||||
- Local: Python/TS already convert to PascalCase to reproduce C#'s fingerprint (section 0); the fingerprint's TypeName is the CLR full name (`CultSchemaTypeNames.cs:30`) and TS throws when a codec lacks a C# type name.
|
||||
- Registry normalisation (summary): Confluent Schema Registry `normalize.schemas=true` maps semantically-equal schemas to one id (https://www.confluent.io/blog/best-practices-for-confluent-schema-registry/); sources disagree on whether JSON Schema property order is normalised.
|
||||
|
||||
## 4. Migration precedents for changing identity derivation
|
||||
|
||||
- Git SHA-1 -> SHA-256 (fetched https://git-scm.com/docs/hash-function-transition): per-repo format marker (`extensions.objectFormat`, `compatObjectFormat`, `repositoryFormatVersion=1` makes old git refuse); bidirectional SHA-256<->SHA-1 mapping (pack idx v3, `loose-object-idx`); four transition modes (dark launch, early, late, post); conversion at the network boundary. It is a design doc, not a statement of what has shipped.
|
||||
- IPFS CIDv0 -> CIDv1 (fetched https://docs.ipfs.tech/concepts/content-addressing/): v0 (46 chars, "Qm") and v1 (self-describing version/codec/multihash) coexist; `ipfs cid format -v 1` converts; v1->v0 only for dag-pb.
|
||||
- Docker manifest schema 1 -> 2 (summary, mirror pages unopened): digests differ per manifest format; pull-by-digest gets no translation, pull-by-tag may be translated; repush converts. https://pulp.plan.io/issues/2099.
|
||||
- Warpcast API hash v1 -> v2 (summary, https://warpcast.notion.site/Warpcast-API-Hash-Migration-Public-ccdd6de8ac604783b9596cfd623ddbac): two-week dual-hash period, then only v2.
|
||||
- Cap'n Proto explicit-ID escape hatch for renamed/moved declarations; Avro aliases as the rename bridge in resolution; MessagePack-CSharp keep-obsolete-members (all fetched, above).
|
||||
- Local: contract `supersedesSchemaIds` / `migrationFamily` (`cultcache-persistence-format.md:~140-170`) and C#/Py `CompatibleSchemaIds` exist as alias lists.
|
||||
- Not found: Unison changing its hash derivation (one search, no docs); any serialization store that switched from name-inclusive to structural ids.
|
||||
|
||||
## Could not verify / not done
|
||||
- Cap'n Proto designer's rationale against structural hashing (not in docs; mailing list unopened).
|
||||
- Avro: whether PCF drops defaults (spec text) and any JIRA on it; spec page was summarised by the fetch model.
|
||||
- Bebop, Thrift IDL id-omission rule, Unison default struct/unique keyword, IPLD Migrations page, Edition Zero doc (summary only), Confluent blog (summary only), nominal/structural papers (titles only).
|
||||
- Rust runtime's sha256 derivation path; `document-variants-cut.md:552-555` not opened; `CompatibleSchemaIds` logic not read.
|
||||
- eureka-state worked (whoami ok); only the one ruling was queried.
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
# Prior art: presence provider as a library, launcher as a consumer
|
||||
|
||||
Served: `cultlib-gaps:cut_spec:cut-cultnet-rs-presence-provider-a.r1` and the
|
||||
Defaulted rulings `presence-provider-network-free-core`,
|
||||
`presence-credentials-both-idunn-drivers` and `presence-provider-consumers-adopt`,
|
||||
applying the operator's `gamecult-chat:ruling:presence-one-owner-library-launcher-consumer`.
|
||||
Imagination pass, 2026-10-29, from recall of the cited manuals (not re-fetched).
|
||||
|
||||
- libsystemd `sd_notify(3)`: a service links a small library and reports its
|
||||
own state (READY, STATUS, WATCHDOG); programs that cannot link it use
|
||||
`systemd-notify(1)` or a wrapper. Same split as cultnet-rs provider plus
|
||||
idunn-launch as the sidecar consumer.
|
||||
- `sd_listen_fds_with_names(3)`: checks LISTEN_PID against the process, sets
|
||||
close-on-exec on passed descriptors, finds them by name, and can unset the
|
||||
LISTEN_* environment. This set is the descriptor rule the provider adopts.
|
||||
- systemd watchdog (`WATCHDOG=1` within `WatchdogSec`): the model for the
|
||||
warming heartbeat.
|
||||
- Consul: TTL checks updated by the service through the client API, versus
|
||||
script/HTTP checks the agent runs for services that cannot. In-process
|
||||
versus sidecar again.
|
||||
- Kubernetes: kubelet-run probes observe from outside; readiness gates are
|
||||
reported by the app. Idunn's route challenge is the outside observer, the
|
||||
signed presence the app's own report.
|
||||
|
||||
Existing copies compared: CodexConnector `src/idunn_health.rs` (two
|
||||
descriptors, strict PID check), Idunn launcher (two descriptors, no PID check),
|
||||
cultnet-ts `idunn-runtime-authority.ts` (descriptors by name, systemd private
|
||||
PID case). The provider takes the strictest union.
|
||||
|
|
@ -1,286 +0,0 @@
|
|||
# Analytic noise integration along warped rays: feasibility probe
|
||||
|
||||
Campaign `cultmath-tapes`, Imagination probe, 2026-10-03. This is a measurement,
|
||||
not a spec. The tape target stays parked under ruling
|
||||
`cultmath-tapes:ruling:tape-target-unparks-when-asura-stable`.
|
||||
|
||||
**Verdict.** Exact integration of the noise does not beat sampling for optical
|
||||
depth on either shipped field.
|
||||
- The exact line integral of CultMath `snoise` is cheap and correct. Affine warp
|
||||
spans are long, at 100 to 300 world units.
|
||||
- The exact noise integral is not the optical depth, because both densities are
|
||||
nonlinear in the noise.
|
||||
- The void's wall profile is `pow(·, 0.40)` with two clamps.
|
||||
- Aetheria's fog is clamped at zero and goes opaque within a few world units.
|
||||
- Given the *exact* span mean of the noise for free, the resulting transmittance
|
||||
is still 3.6x worse than the shipped march on the void and 22x worse on the
|
||||
wells.
|
||||
- Exact integration wins only on the linear functional `∫ n dt` of octaves the
|
||||
march undersamples. That gain does not survive the density's nonlinearity.
|
||||
- Aetheria's triangle noise is integrable exactly as piecewise linear, but costs
|
||||
3x to 22x the march, and its shipped form does not meet the preconditions.
|
||||
|
||||
## Scenarios and method
|
||||
|
||||
- **Void:** the shipped void (`NoiseBoundTests.VoidField.Shipped()` at
|
||||
`hands/cultmath-interval-ops` 2c8585bd).
|
||||
- Rh 198, S 50, Rc 248, K 1/30, A 20, F0 0.01 (cells of 100 and 25 world), warp
|
||||
D 10.
|
||||
- The shipped orbiting camera, N = 8 tiles at f = 935, one jittered pixel ray per
|
||||
tile.
|
||||
- **Wells:** the test's Aetheria wells at warp D = 60 (`FogField.Wells(60)`).
|
||||
- F0 1/414.2167, A 20, B 10, extinction 0.5, flow frequency 1/512, period 8.
|
||||
- Like the test, this uses the snoise field shape (r1's), not Aetheria's own
|
||||
triangle noise. Triangle noise is measured separately below, on the same rays.
|
||||
- **Rays:** 200 per scenario, and 120 for the triangle basis.
|
||||
- The wells give 147 rays with an active interval; the rest look up and miss
|
||||
the fog.
|
||||
- The active interval is where the envelope leaves the density to the noise.
|
||||
It is cut where the true optical depth reaches 6.
|
||||
- Active length per ray: void median 140 world (p10 114, p90 193); wells median
|
||||
268 (p10 137, p90 623).
|
||||
- **March steps:** the void's march step (LodStep, 12.5 in z) is a median 14.9
|
||||
world along the ray. The wells' quadratic grid gives a median of 9.1 in the
|
||||
active region.
|
||||
- **Warp:** `w(p) = flow(p) · shift`, so the affine error scales exactly with
|
||||
`|shift|`.
|
||||
- Spans are grown at the worst phase shift, Period / 2. A span is therefore
|
||||
valid at every frame's phase, for any camera that crosses the same world
|
||||
segment.
|
||||
- Each span is anchored at p0 with J·d from a central difference. It grows in
|
||||
1-unit (void) or 2-unit (wells) steps until `|w − (w0 + J·d·Δ)| > τ`.
|
||||
- **Exact simplex integral:** CultMath `snoise(float3)` has kernel radius² 0.5,
|
||||
so its support never leaves the incident simplices.
|
||||
- Along a line it is therefore a sum over the lattice points within √0.5 of the
|
||||
line. Each point contributes `∫ (0.5 − |x|²)⁴ (g·x) dt` on its chord, a
|
||||
closed-form degree-9 polynomial.
|
||||
- Validation:
|
||||
- The lattice sum matches `snoise` within 2e-4 (float32 rounding at
|
||||
coordinates near 300).
|
||||
- The segment integral matches a 5e-4-step quadrature within 2.5e-5 per
|
||||
unit length.
|
||||
- **Run:** probe code was in a scratch clone (probe-clone@91c8cbf1, never merged)
|
||||
and ran on Yggdrasil through the verify stopgap, in a dotnet 10 container with
|
||||
4 CPUs. Timings are single-threaded C# on that host and are relative.
|
||||
|
||||
## 1. Affine-valid span length (ray length, world units)
|
||||
|
||||
| τ (× coverage cell) | Void p10 / median / p90 | Wells p10 / median / p90 |
|
||||
|---|---|---|
|
||||
| 0.05 | 94 / 129 / 211 | 71 / 103 / 169 |
|
||||
| 0.10 | 141 / 197 / 324 | 108 / 151 / 253 |
|
||||
| 0.25 | 263 / 420 / 1244 | 184 / 275 / 715 |
|
||||
| 0.05 × detail cell | 44 / 63 / 97 | 34 / 47 / 75 |
|
||||
|
||||
- One affine span at τ = 0.1 covers about 13 void march steps and about 16 wells
|
||||
march steps.
|
||||
- The void shell is shorter than one span. The tiled spans are clipped by the
|
||||
active interval, at a median of 27 world and 2 spans per ray.
|
||||
- On the wells, the tiled spans run a median of 128 world, with 2 per ray (p90 4).
|
||||
- The tangent anchor is the worst affine choice. A secant or Chebyshev fit over
|
||||
the span cuts the error bound by 2x to 8x, so the spans could be about 1.4x to
|
||||
2.8x longer at the same τ. That was not measured.
|
||||
|
||||
## 2. Simplex cells per span, per octave
|
||||
|
||||
Octave `xm` is the coverage octave's frequency times m, warped like the coverage
|
||||
octave. The shipped detail octave is x4 and is *unwarped* in both fields, so it
|
||||
needs no affine span at all.
|
||||
|
||||
Counts are for τ = 0.1 tiled spans with both warp phases, given as the median and
|
||||
then the p90. The rows are simplex cells crossed, then lattice points touched,
|
||||
which is the exact work.
|
||||
|
||||
| Octave | Void cells | Void lattice points | Wells cells | Wells lattice points |
|
||||
|---|---|---|---|---|
|
||||
| x1 | 6 / 16 | 10 / 16 | 4 / 8 | 8 / 10 |
|
||||
| x2 | 8 / 27 | 10 / 26 | 8 / 12 | 10 / 14 |
|
||||
| x4 | 13 / 52 | 14 / 44 | 12 / 20 | 14 / 20 |
|
||||
| x8 | 20 / 100 | 20 / 80 | 22 / 38 | 22 / 34 |
|
||||
| x16 | 40 / 201 | 35 / 154 | 44 / 72 | 38 / 60 |
|
||||
| x32 | 75 / 396 | 62 / 302 | 86 / 144 | 70 / 112 |
|
||||
| x64 | 151 / 793 | 118 / 592 | 172 / 287 | 132 / 218 |
|
||||
|
||||
- On the same spans, the march takes a median of 2 samples per span on the void
|
||||
(p90 10) and 12 on the wells (p90 25).
|
||||
- A straight line touches 3.44 lattice points per noise unit.
|
||||
|
||||
## 3. Cost: exact against the march
|
||||
|
||||
- **Measured cost.**
|
||||
- One snoise is 530 to 610 ns in C# on this host.
|
||||
- One lattice-point integral (hash, gradient, closed-form polynomial) is
|
||||
85 ns, or 0.16 snoise.
|
||||
- With this probe's naive box-scan enumeration it is 1.24 snoise. That is an
|
||||
upper bound; a simplex walk visits only crossed cells.
|
||||
- **How the table counts.** Exact is lattice points × 0.16 + 1 snoise per span
|
||||
for the Jacobian. March is 1 snoise per sample. Both sides carry the two warp
|
||||
phases.
|
||||
- **Tolerance.** τ is 0.1 × *that octave's own* cell, so the affine error is
|
||||
comparable across octaves.
|
||||
- **Columns.** The two error columns are RMS errors of the active-length mean
|
||||
of one warped octave. The last error column is what replacing the octave by its
|
||||
expectation of 0 costs.
|
||||
|
||||
| Octave | Void exact/march cost | Void err exact / march / expectation | Wells exact/march cost | Wells err exact / march / expectation |
|
||||
|---|---|---|---|---|
|
||||
| x1 | 0.36 | 1.1e-2 / 1.6e-3 / 0.21 | 0.11 | 1.9e-2 / 9e-5 / 0.29 |
|
||||
| x2 | 0.46 | 1.0e-2 / 4.7e-3 / 0.13 | 0.15 | 1.5e-2 / 2e-4 / 0.21 |
|
||||
| x4 | 0.68 | 9.0e-3 / 4.0e-2 / 0.10 | 0.23 | 1.1e-2 / 5e-4 / 0.18 |
|
||||
| x8 | **1.03** | 5.8e-3 / 8.0e-2 / 0.067 | 0.36 | 7.2e-3 / 1.1e-3 / 0.10 |
|
||||
| x16 | 1.64 | 4.0e-3 / 9.0e-2 / 0.050 | 0.56 | 6.4e-3 / 9.4e-3 / 0.088 |
|
||||
| x32 | 2.89 | 2.8e-3 / 0.105 / 0.036 | 0.87 | 4.7e-3 / 3.3e-2 / 0.057 |
|
||||
| x64 | 5.26 | 2.0e-3 / 0.095 / 0.025 | **1.44** | 3.2e-3 / 5.3e-2 / 0.040 |
|
||||
|
||||
- **Where exact stops paying on cost:**
|
||||
- At the measured kernel cost: x8 on the void and between x32 and x64 on the
|
||||
wells.
|
||||
- At the brief's model of one snoise per cell crossed: about x1 to x2 on the
|
||||
void and x8 on the wells, from the per-span table in the probe log.
|
||||
- **Exact against the march on accuracy:**
|
||||
- At the shipped step the march is *more* accurate for octaves it oversamples:
|
||||
void x1 to x2, wells x1 to x8. The exact path's error floor is the affine warp
|
||||
error, about 1e-2 at τ = 0.1 per cell, and it scales with τ.
|
||||
- Exact is more accurate at about 0.7x the cost where the march undersamples:
|
||||
void x4 to x8, wells x16 to x32.
|
||||
- Above about x16 on the void, the undersampled march is worse than simply
|
||||
using the expectation (0.095 against 0.025 at x64). That is a band-limiting
|
||||
result, which the footprint octave weights already exploit. It is not an
|
||||
exact-integration result.
|
||||
|
||||
## 4. High-octave variance over a span
|
||||
|
||||
- **Span-mean statistics.** Over straight lines of noise length ℓ, the std of
|
||||
the snoise span mean, against a point std of 0.358, is:
|
||||
- 0.234 at ℓ = 1;
|
||||
- 0.170 at ℓ = 2;
|
||||
- 0.121 at ℓ = 4;
|
||||
- 0.087 at ℓ = 8;
|
||||
- 0.061 at ℓ = 16;
|
||||
- 0.031 at ℓ = 64.
|
||||
|
||||
This is about ℓ^-0.5: the high octaves average out like a random field, not
|
||||
like a periodic one.
|
||||
- **Detail octave replaced by its expectation of 0,** on τ = 0.1 spans, measured
|
||||
as |ΔOD| per span:
|
||||
- Void: median 0.000 and p90 0.091. Relative to the span's OD (OD > 0.01) the
|
||||
median is 2.5% and the p90 is 7.6%. The mean detail displacement is a median
|
||||
1.0 world (p90 3.6) against the ramp width S = 50.
|
||||
- Wells: median 0.11 and p90 5.7 optical depths. The relative median is 72%.
|
||||
- **Why the wells are so bad.** Normalised to the clamp, the density moves
|
||||
A·fade/B = 2 per unit of noise, and with extinction 0.5 that is 1 optical depth
|
||||
per world unit per unit of noise. The mean detail displacement of 1.5 world
|
||||
(p90 3.8) is 15% to 38% of the floor blend B = 10. The wells' optical depth is
|
||||
set by where the clamp boundary lies, and that is exactly what the expectation
|
||||
erases.
|
||||
- So the expectation is adequate for the void's detail octave and is not
|
||||
adequate for the wells.
|
||||
|
||||
## 5. The clamp and other nonlinearity
|
||||
|
||||
- **Void.** 50.0% of τ = 0.1 spans cross a regime boundary: 0 (d' ≤ Rh), the
|
||||
wall ramp, or K (d' ≥ Rc). 45.6% of the active length lies in the ramp, where
|
||||
the density is `K max(0, 1 − 1.5 (1 − (d'/Rc)²)^0.40)`. That is not a
|
||||
polynomial in n, so exact ∫n does not give ∫ρ there. Outside the ramp the
|
||||
density is constant and needs no noise.
|
||||
- **Wells.** 42.3% of spans cross the clamp. Only 9.8% of the active length is
|
||||
unclamped, so most of the length where the noise "matters" is length where the
|
||||
noise only decides whether the density is zero.
|
||||
- **The decisive check: transmittance error.** This is the sup over the ray of
|
||||
|T_est − T_true|, with the truth taken on a 0.5- or 1-unit grid.
|
||||
|
||||
| Estimator | Void median / p90 | Wells median / p90 |
|
||||
|---|---|---|
|
||||
| Shipped march | 0.020 / 0.038 | 0.033 / 0.155 |
|
||||
| March at 4x density | 0.006 / 0.009 | 0.030 / 0.049 |
|
||||
| Density of the *exact* span-mean n (τ = 0.1 spans) | 0.072 / 0.125 | 0.719 / 0.997 |
|
||||
|
||||
The last row is an upper bound on what any exact-mean method can deliver
|
||||
without also modelling the distribution inside the span. It is the
|
||||
Heitz-style correction the survey describes. It loses to the march on both
|
||||
fields.
|
||||
|
||||
## Triangle basis (operator's addition)
|
||||
|
||||
The triangle noise is `triNoise3d` in Aetheria `Assets/Shaders/Volumetric.cginc`
|
||||
(origin/master f1dee184).
|
||||
- `tri(x) = |frac(x) − 0.5|`.
|
||||
- It runs two Nimitz-style iterations. Each iterates `p += tri3(bp·2)`, scales
|
||||
`bp` by 1.8 and `p` by 1.2, and adds `tri(p.z + tri(p.x + tri(p.y)))/z`.
|
||||
- It makes 18 tri calls per evaluation.
|
||||
|
||||
As shipped, `density()` uses it as follows:
|
||||
- `pow(triNoise3d(·), exponent)`, with Noise.Exponent −0.25 multiplied by a
|
||||
slope `flatness` read from a texture (NOISE_SLOPE is on).
|
||||
- Two phases, warped by `globalFlow`, which is
|
||||
`Tri3D = cross(normalize(tri3(P)), normalize(tri3(1.618P))) × 15`.
|
||||
- The fine octave at ×8 is evaluated at a `pos` whose y the coarse octave has
|
||||
already displaced.
|
||||
- Amplitude −36.17.
|
||||
|
||||
Self's reading, tested:
|
||||
|
||||
- **"The warped ray is an exact polyline."** This holds for the warp *inside*
|
||||
`triNoise3d`. It does not hold for Aetheria's flow: `normalize` and `cross`
|
||||
make the flow piecewise rational. With the normalize dropped, the cross product
|
||||
of two piecewise-linear vectors is piecewise quadratic. tri composed with a
|
||||
piecewise polynomial stays piecewise polynomial of the same degree, with
|
||||
breakpoints at quadratic roots, so that variant is still closed-form.
|
||||
Counted along the real flow at the worst shift, the kinks run 15% to 30% above
|
||||
the straight-ray counts, but the pieces between them are curved.
|
||||
- **"The clamp roots are linear."** This holds only if the exponent is 1. With
|
||||
−0.25·flatness, each piece is `(a + bt)^e`. One such piece has a closed-form
|
||||
integral, but the clamp's root on a sum of two powered phases plus the
|
||||
smoothstep fade has none.
|
||||
- **"Whole periods give the exact mean."** This holds for one triangle wave at
|
||||
whole periods, where the mean is exactly 0.25. It fails for the composed form,
|
||||
which has no period along a general line; the scales 2, 1.8 and 1.2 and the
|
||||
nested warp are incommensurate.
|
||||
- Measured on lines, the span-mean std of `triNoise3d` is 0.029 at ℓ = 1,
|
||||
0.010 at ℓ = 8 and 0.0042 at ℓ = 64, against a point std of 0.083. That is
|
||||
about ℓ^-0.47, the same random-field decay as simplex.
|
||||
- Its bias against the global mean of 0.1995 is within ±0.0015, so the
|
||||
expectation is unbiased. It is not exact.
|
||||
- **The cost decides it.** The exact piecewise-linear walk is verified, matching
|
||||
a 1e-4 quadrature within 3.4e-8. But the nest has 77.7 linear pieces per noise
|
||||
unit of straight line, against 3.44 lattice points for simplex.
|
||||
- One piece costs 1.38 point evaluations with a derivative carried.
|
||||
- At Aetheria's scale, on the wells rays, the pieces per world unit and the
|
||||
exact cost relative to the march are:
|
||||
|
||||
| Octave | Pieces per world unit | Exact cost / march |
|
||||
|---|---|---|
|
||||
| x1 | 0.20 | 2.8x |
|
||||
| x2 | 0.38 | 5.5x |
|
||||
| x4 | 0.76 | 11x |
|
||||
| x8 (shipped fine) | 1.5 | 22x |
|
||||
| x64 | 12 | 176x |
|
||||
|
||||
- On the void rays it is 4.1x at x1 and 29x at x8.
|
||||
- At the same time the march's RMS error on the span mean is already small:
|
||||
2.4e-3 at x1 and 1.7e-2 at x8.
|
||||
|
||||
## Per-basis verdict
|
||||
|
||||
| Basis | Exact line integral | Under the shipped warp | Optical depth | Verdict |
|
||||
|---|---|---|---|---|
|
||||
| Simplex (CultMath `snoise`) | Closed form, 0.16 snoise per lattice point, 3.44 points per noise unit; validated | Affine spans of 100 to 300 world at τ = 0.1 cell; error floor about 1e-2 | Not delivered: the void's pow profile and the wells' clamp make ρ nonlinear in n; the exact-mean transmittance loses 3.6x and 22x | **Viable kernel, wrong target.** Pays only if the density were affine in n over the span, or as a control variate for a march (survey §4, Kettunen 2021) |
|
||||
| Triangle (`triNoise3d`) | Closed form per linear piece; validated | Not piecewise linear under Aetheria's normalized-cross flow or the −0.25 power; made linear by exponent 1 and a non-normalized flow | Same nonlinearity, plus 78 pieces per noise unit | **Loses.** 3x to 22x the march even where its preconditions hold |
|
||||
| Gabor kernels (survey §1.2, not probed) | Closed form for any affine-transformed kernel (complex erf, at most 16 terms) | The affine warp spans above transfer directly, with each span a whitening transform | Same density-nonlinearity limit | **Candidate smooth basis for large structure**, to be measured only if the density is first made linear in the noise over a span |
|
||||
|
||||
## What would change the verdict
|
||||
|
||||
- **A density that is affine in the noise inside a span.** For example, the
|
||||
void's ramp replaced by a linear ramp in d', with clamp crossings bracketed by
|
||||
the interval bound. Exact simplex integration would then deliver optical depth
|
||||
directly in the 54% of void length that is outside the ramp's clamps. As
|
||||
measured, that 54% is constant density and needs no noise at all.
|
||||
- **Exact integration as a control variate,** not a replacement. The exact mean
|
||||
is cheap and unbiased for ∫n, and the march corrects the nonlinear residual.
|
||||
That is the Kettunen construction. It was not measured here.
|
||||
- **Wider skipping.** The operator's first idea, skipping space outside the warp
|
||||
range, is already what the interval envelope does, and the probe's active
|
||||
intervals are its output.
|
||||
- On the void the noise matters over only about 140 world per ray.
|
||||
- On the wells, opacity ends the ray within a few world units of entering the
|
||||
unclamped region.
|
||||
|
|
@ -1,425 +0,0 @@
|
|||
# Analytic Noise Integration Along Warped Rays: Prior Art Survey
|
||||
|
||||
This is the facts file for an idea the operator raised on 2026-10-03:
|
||||
|
||||
> "I would be happy if we could just skip the space we know has no meaningful structure
|
||||
> because it's outside of the noise warp range. But what you said about affine arithmetic
|
||||
> makes me wonder if we can somehow, even within the distorted domain, figure out a series
|
||||
> of optimal (in terms of extracting structure over multiple frames) spans for which we can
|
||||
> analytically integrate the noise gradients over those ray segments."
|
||||
|
||||
Self's sketch, as given to this pass:
|
||||
|
||||
- Simplex noise along a straight ray is piecewise polynomial in t, so its line integral
|
||||
is closed-form per crossed cell.
|
||||
- A domain warp p + w(p) is nearly affine over a short span, with an affine-arithmetic
|
||||
error bound, so within that span the warped ray is a straight line in noise space.
|
||||
- Low octaves are integrated exactly. High octaves are replaced by their expectation,
|
||||
with a variance bound.
|
||||
- Clamped density max(0, n - c) is integrated by root-finding per piece.
|
||||
- Spans are cached and refined across frames.
|
||||
|
||||
Eyes pass, 2026-10-03. This file gives facts and evidence pointers only. It makes no
|
||||
recommendation.
|
||||
|
||||
Verification marks:
|
||||
|
||||
- **[fetched]** The claim was read in the cited source's full text during this pass.
|
||||
PDFs were converted with `pdftotext -layout`.
|
||||
- **[search]** The claim came from a search-result summary or abstract, not a full read.
|
||||
- **[derivation]** The claim is elementary mathematics worked out in this pass from a
|
||||
fetched definition. It is not a claim made by any cited source.
|
||||
- **[not found]** The pass searched for this kind of work and found nothing.
|
||||
|
||||
## 0. Premises of the sketch, checked against definitions
|
||||
|
||||
- **[fetched] + [derivation]** Simplex noise kernel degree. Gustavson, *Simplex noise
|
||||
demystified* (2005), gives the 3D reference code. Each of the four simplex corners
|
||||
contributes `t^4 * dot(g, d)` with `t = 0.6 - |d|^2`, and the contribution is zero
|
||||
when `t < 0`. On a straight ray `d` is affine in the ray parameter, so `|d|^2` is
|
||||
quadratic and each corner term is a polynomial of degree 8 + 1 = 9. The pieces have
|
||||
two kinds of breakpoint: the simplex faces the ray crosses (where the corner set
|
||||
changes), and the entry and exit of each corner's support sphere (`|d|^2 = 0.6`).
|
||||
Both kinds are roots of polynomials of degree 2 or lower in the ray parameter, so the
|
||||
breakpoints are closed-form. With the 0.6 radius, the support extends past the
|
||||
simplex, and the reference code truncates it at the simplex boundary. The resulting
|
||||
small discontinuity across faces is a known property of that constant. It does not
|
||||
change the piecewise-polynomial structure.
|
||||
- **[derivation]** Classic Perlin noise (Perlin 2002 quintic fade `6t^5 - 15t^4 + 10t^3`,
|
||||
read in Gustavson's text **[fetched]**) is, within one lattice cube along a straight
|
||||
ray, a polynomial of degree 3 x 5 + 1 = 16. Its breakpoints are the cube faces.
|
||||
- **[fetched]** Piece count grows with frequency. Balint (CESCG 2019, section 2)
|
||||
states that many noise functions are piecewise integrable, but the number of pieces
|
||||
is linear in the noise frequency. Their cost is therefore similar to numerical
|
||||
integration. Balint designed cosine noise to escape this limit (section 1.1 below).
|
||||
- **[derivation]** For the clamp `max(0, n - c)` on a degree-9 or degree-16 piece,
|
||||
the roots of `n - c` are needed. Polynomials of degree 5 or more have no general
|
||||
closed-form roots (Abel-Ruffini), so the clamp needs numerical root isolation per
|
||||
piece, for example Sturm sequences, Descartes' rule or Bernstein-basis subdivision.
|
||||
The integral between the isolated roots is closed-form.
|
||||
|
||||
## 1. Closed-form line integrals of noise, and analytic optical depth
|
||||
|
||||
### 1.1 Cosine noise: Balint, *Closed Form Transmittance in Heterogeneous Media Using Cosine Noise*, CESCG 2019 (non-peer-reviewed)
|
||||
|
||||
- **[fetched]** It computes noise defined as an offset plus a sum of cosines of
|
||||
`x . S_i`. The paper uses 24 quasirandom vectors S_i, with Sobol directions on the
|
||||
sphere and lengths in [0.8, 1.2], to look roughly like Perlin noise. Fractal octaves
|
||||
are further cosine sets. Its line integral is a sum of sines, so the optical depth
|
||||
over any segment is exact. The paper says this takes two noise samples, one at each
|
||||
segment end. It also gives closed-form distance sampling, by inverting the integral
|
||||
with binary search.
|
||||
- **[fetched]** Cost: two noise-sized evaluations per segment, independent of octave
|
||||
count. Table 1 reports transmittance evaluation at 179 ms closed-form against
|
||||
1250 ms for delta tracking, about 7x faster. A real-time demo with 16 samples ran
|
||||
at 80 fps at 675 x 1200 (RTX 2080 Ti).
|
||||
- **[fetched]** Error: exact, with no error term, for the bare sum. The limits are
|
||||
stated in section 6. Multiplying by a shape function, or composing with a shaping
|
||||
function, generally destroys the elementary antiderivative. The workarounds
|
||||
recover exactness only piecewise: constant multipliers between bounding isosurfaces,
|
||||
or a cubic polynomial interpolant of the multiplier along the ray, since the product
|
||||
of a polynomial and cosines has an elementary antiderivative. Negative densities from
|
||||
a low offset are handled by clamping samples and integrals, which is not an exact
|
||||
`max(0, .)` integral. Domain warp is not discussed.
|
||||
|
||||
### 1.2 Gabor kernels: Condor, Hermann, Yurtsever, Didyk, *Gabor Fields: Orientation-Selective Level-of-Detail for Volume Rendering*, ACM TOG 45(4) art. 62, 2026 (arXiv 2602.05081)
|
||||
|
||||
- **[fetched]** It computes closed-form definite line integrals of anisotropic 3D Gabor
|
||||
kernels along an arbitrary ray segment (eq. 9). Each kernel is whitened into its
|
||||
canonical space with a ZCA matrix, so any covariance, that is any affine-transformed
|
||||
kernel, uses the same formula. The segment integral is the real part of a
|
||||
complex-erf (Faddeeva) difference.
|
||||
- **[fetched]** Cost: the complex erf is evaluated by a truncated Taylor series with
|
||||
early exit, capped at 16 terms. Segments shorter than 1e-4 in whitened units fall back
|
||||
to a single midpoint evaluation. Kernels are spatially clamped with an adaptive extent
|
||||
that shrinks with frequency (eq. 15). The paper also renders procedural clouds as
|
||||
Gaussians modulated by Gabor noise.
|
||||
- **[fetched]** Error: each segment integral is exact apart from the erf truncation.
|
||||
Orientation-selective masking of kernels whose line integral is near zero trades a
|
||||
small error, or variance under stochastic selection, for speed. The paper reports
|
||||
about 1.78x to 1.96x speedups. Per segment the density-integral estimator is
|
||||
unbiased, but transmittance is biased by Jensen's inequality. Eq. 19 gives the
|
||||
second-order bias as `0.5 * Var(tau) * exp(-E[tau])`.
|
||||
|
||||
### 1.3 Analytically integrable media, older
|
||||
|
||||
- **[search]** Iser, *Real-time Light Transport in Analytically Integrable
|
||||
Quasi-heterogeneous Media*, CESCG 2018. Balint cites it **[fetched]** as using only
|
||||
exponential and spherical density functions.
|
||||
- **[fetched via Gabor Fields related work]** The closed-form Gaussian-primitive
|
||||
transmittance line (Knoll et al. 2021; Condor et al. 2025, *Don't Splat your
|
||||
Gaussians*) is the base that Gabor Fields extends.
|
||||
|
||||
### 1.4 Piecewise-polynomial segment integration in scientific volume rendering
|
||||
|
||||
- **[fetched]** Engel, Kraus, Ertl, *High-Quality Pre-Integrated Volume Rendering Using
|
||||
Hardware-Accelerated Pixel Shading*, Graphics Hardware 2001. The scalar field along
|
||||
the ray is assumed piecewise linear between samples. The volume rendering integral of
|
||||
each linear segment, through an arbitrary nonlinear transfer function, is then one
|
||||
lookup in a table indexed by front scalar, back scalar and segment length. Cost: one
|
||||
texture lookup per segment, plus recomputing the table when the transfer function
|
||||
changes. Error: exact for the piecewise-linear model. The model error is the
|
||||
scalar field's deviation from linear between samples.
|
||||
- **[search]** Williams, Max, Stein, *A High Accuracy Volume Renderer for Unstructured
|
||||
Data*, IEEE TVCG 4(1), 1998. Exact integration through tetrahedra with linearly
|
||||
varying scalar and piecewise-linear transfer functions.
|
||||
|
||||
### 1.5 Polynomial noise along a ray
|
||||
|
||||
- **[not found]** No published closed-form segment integral of Perlin or simplex noise
|
||||
was found, as a method or a code library. Balint's remark in section 0 is the only
|
||||
published statement found on the piecewise approach, and it dismisses it on cost
|
||||
grounds.
|
||||
|
||||
## 2. Noise designed for analytic filtering or integration
|
||||
|
||||
- **[fetched]** Lagae, Lefebvre, Drettakis, Dutré, *Procedural Noise using Sparse Gabor
|
||||
Convolution*, ACM TOG 28(3), 2009. It computes a sparse-convolution noise of Poisson
|
||||
impulses with Gabor kernels. Each kernel is a Gaussian in frequency, so filtering with
|
||||
a Gaussian pixel filter multiplies Gaussians, which gives an analytically filtered
|
||||
kernel. Cost: the paper typically uses 100 impulses per kernel, or 25 to 50 at lower
|
||||
densities. Table 2 (GTX 280) reports, at 20 impulses per cell, 290 fps for 2D noise,
|
||||
111 fps for unfiltered surface noise and 50 fps for filtered surface noise. Error:
|
||||
the filtering is exact for a Gaussian filter. The noise statistics converge with
|
||||
impulse density.
|
||||
- **[fetched]** Lagae, Drettakis, *Filtering Solid Gabor Noise*, ACM TOG 30(4), 2011. It
|
||||
slices solid Gabor noise, filters each slice of each Gabor kernel, and gets a
|
||||
generalisation of the projection-slice theorem. It also states that wavelet noise
|
||||
integrates solid noise perpendicular to the surface. That is a line integral of noise
|
||||
along the normal, done in the basis.
|
||||
- **[search]** Cook, DeRose, *Wavelet Noise*, ACM TOG 24(3), 2005. A quadratic B-spline
|
||||
noise band, which is almost band-limited, built from a precomputed coefficient tile.
|
||||
Its 3D-to-surface projection integrates the noise along the normal. The original PDF
|
||||
was not retrievable in this pass. Pixar and ACM both returned HTML.
|
||||
- **[fetched, survey]** Lagae et al., *A Survey of Procedural Noise Functions*, CGF 2010.
|
||||
- Lewis's sparse convolution noise has a power spectrum equal to the kernel's,
|
||||
scaled.
|
||||
- Frequency clamping (Norton, Rockwood, Skolmoski 1982) cancels bands above the filter
|
||||
rate. It works only for narrowly band-pass noise, and Perlin noise is only weakly
|
||||
band-pass.
|
||||
- Nonlinear functions of noise, such as thresholds and colour maps, are not filtered
|
||||
by filtering the noise. That is unsolved in general.
|
||||
- Heidrich, Slusallek, Seidel 1998 (below) computes an area average of a procedural
|
||||
shader with an error bound, using affine arithmetic.
|
||||
- Perlin noise's amplitude distribution is visibly non-Gaussian, because it is zero at
|
||||
every lattice point and has few gradients. The other noises surveyed are
|
||||
approximately Gaussian.
|
||||
- **[fetched, via the survey above]** "Filtering procedural textures" lineage:
|
||||
- Rhoades et al. 1992 filters the colour table over `[N - e, N + e]`.
|
||||
- Hart et al. 1999 sets `e` from the local noise gradient.
|
||||
- Lagae 2009 sets the range from the analytically known loss of variance.
|
||||
|
||||
## 3. Affine arithmetic, reduced AA, Taylor models, and bounds on warps
|
||||
|
||||
### 3.1 Gamito and Maddock
|
||||
|
||||
- **[fetched]** Gamito, Maddock, *Ray Casting Implicit Fractal Surfaces with Reduced
|
||||
Affine Arithmetic*, The Visual Computer 23:155-165, 2007. The full method is read in
|
||||
Gamito's PhD thesis (Sheffield), sections 5.3 to 5.4.
|
||||
- **Computes:** a root of `g(t) = 0` along a ray, by subdividing intervals of t. Each
|
||||
interval is tested with an inclusion `G(T)` computed in reduced AA (Messine's AF1).
|
||||
The form is `t0 + t1 e1 + t2 e2`: e1 is the shared ray-parameter symbol, and e2
|
||||
absorbs every new nonlinearity by condensation after each operation.
|
||||
- **Noise in RAA:** gradient-noise kernels lose no correlation under RAA, because the
|
||||
three multiplications are local to one kernel and the cubic Hermite fade is done by
|
||||
a single Chebyshev affine approximation. The same holds for sparse convolution
|
||||
noise. Cellular noise loses accuracy, because `min` needs cancellation between
|
||||
condensed terms, and the F2 and F3 kernels were not implementable.
|
||||
- **Interval optimisation:** the parallelogram `g1/t1` slope plus or minus `g2`
|
||||
shrinks `[ta, tb]` before subdividing (eq. 5.27).
|
||||
- **Cost:** Table 5.2, gradient-noise hypertexture, average evaluations per ray and
|
||||
time:
|
||||
|
||||
| Method | Evaluations per ray | Time |
|
||||
|---|---|---|
|
||||
| IA | 116.57 | 7m35s |
|
||||
| Standard AA | 61.69 | 40m41s |
|
||||
| RAA | 61.69 | 6m43s |
|
||||
| RAA + interval optimisation | 46.39 | 5m04s |
|
||||
|
||||
RAA costs three numbers per quantity against two for IA. Standard AA is tight
|
||||
but slow because its error-symbol lists grow.
|
||||
- **Error bound:** conservative inclusion, as proven for AA by Comba and Stolfi 1993.
|
||||
Rounding error is ignored.
|
||||
- **[fetched]** Gamito thesis, chapter 9, *Surface Deformation with Flow Mapping*.
|
||||
- Domain deformation `f(d(x))` with `d` defined by streamlines, that is by an ODE.
|
||||
This is the case closest to a domain warp in this survey.
|
||||
- Gamito states that a robust and tight AA estimate of the deformed ray `s(t)` was not
|
||||
obtainable through the numerical ODE solution, so robustness was given up.
|
||||
- The replacement test uses Lipschitz unbounding spheres at `s(ta)` and `s(tb)`. A
|
||||
span is skipped if `|s(tb) - s(ta)| < min(ra, rb)` (eq. 9.23). A sharp turn of the
|
||||
deformed ray between the endpoints can make this test wrong.
|
||||
- He names truncated Taylor-series range estimation (Nedialkov et al. 2004) as the
|
||||
possible fix, at higher cost and complexity.
|
||||
|
||||
### 3.2 Other affine and interval arithmetic sources
|
||||
|
||||
- **[fetched, bibliographic]** Comba, Stolfi, *Affine Arithmetic and its Applications to
|
||||
Computer Graphics*, SIBGRAPI 1993. de Figueiredo, Stolfi, *Affine Arithmetic: Concepts
|
||||
and Applications*, Numerical Algorithms 37, 2004. Gamito cites both. AA keeps
|
||||
first-order correlations through shared noise symbols. Each nonaffine operation adds
|
||||
one symbol, whose coefficient bounds the linearisation error. Chebyshev or min-range
|
||||
affine approximations give that coefficient for unary functions.
|
||||
- **[search]** Knoll, Hijazi, Kensler, Schott, Hansen, Hagen, *Fast Ray Tracing of
|
||||
Arbitrary Implicit Surfaces with Interval and Affine Arithmetic*, CGF 28(1), 2009.
|
||||
Inclusion-preserving reduced AA, SIMD CPU at interactive rates, and a stackless GPU
|
||||
version.
|
||||
- **[fetched, abstract]** Fryazinov, Pasko, Comninos, *Fast Reliable Ray-tracing of
|
||||
Procedurally Defined Implicit Surfaces Using Revised Affine Arithmetic*, Bournemouth
|
||||
TR-NCCA-2009-04. Revised AA, which also keeps one accumulated error symbol, is
|
||||
reported fastest among the IA and AA variants compared, including on pseudo-random
|
||||
procedural objects.
|
||||
- **[fetched]** Sharp, Jacobson, *Spelunking the Deep: Guaranteed Queries on General
|
||||
Neural Implicit Surfaces via Range Analysis*, ACM TOG 41(4), 2022. This is AA through
|
||||
long compositions of nonlinear layers, which is structurally like noise composed with
|
||||
a warp. Table 1 compares the variants. Time is relative to one scalar evaluation,
|
||||
length is the 1D region that can be certified, and raycast time is relative to the
|
||||
best method:
|
||||
|
||||
| Variant | Time | Length | Raycast time |
|
||||
|---|---|---|---|
|
||||
| Interval | 2.4x | 0.011 | 34.3x |
|
||||
| Affine-full | 95.3x | 0.821 | 8.4x |
|
||||
| Affine-fixed (no new symbols kept, extra error condensed into one) | 4.7x | 0.306 | 1.0x |
|
||||
|
||||
The paper uses affine-fixed for ray casting and affine-full for 3D queries.
|
||||
- **[fetched, survey quote of abstract]** Heidrich, Slusallek, Seidel, *Sampling of
|
||||
Procedural Shaders using Affine Arithmetic*, ACM TOG 17(3):158-176, 1998. It computes
|
||||
the average of a procedural shader over a finite area, with an AA error bound.
|
||||
- **[fetched, project page]** Keeter, *Massively Parallel Rendering of Complex
|
||||
Closed-Form Implicit Surfaces*, ACM TOG 39(4), 2020. Interval arithmetic over a
|
||||
shallow, high-branching tile hierarchy does two things: it skips empty tiles, and it
|
||||
prunes the expression tape per tile. One benchmark drops the expression size by about
|
||||
two orders of magnitude. It requires only C0 continuity, so warps and blends that break
|
||||
Lipschitz continuity are allowed.
|
||||
|
||||
### 3.3 Lipschitz bounds through warps
|
||||
|
||||
- **[fetched]** Galin, Guérin, Paris, Peytavie, *Segment Tracing Using Local Lipschitz
|
||||
Bounds*, CGF 39(2), 2020. It computes a local Lipschitz bound over a ray segment
|
||||
instead of a global one. Section 4.4 covers warps `f o w^-1`, and bounds the gradient
|
||||
by the product of the field gradient bound over the transformed segment and the
|
||||
Jacobian norm bound over the segment. Cost: fewer field queries than sphere tracing,
|
||||
with no acceleration structure. Error: conservative, if the local bounds are
|
||||
conservative.
|
||||
- **[fetched]** Seyb, Jacobson, Nowrouzezahrai, Jarosz, *Non-linear Sphere Tracing for
|
||||
Rendering Deformed Signed Distance Fields*, ACM TOG 38(6), 2019.
|
||||
- **Computes:** the ray in deformed space as a curve in undeformed space, by
|
||||
integrating an ODE whose derivative is the inverse-deformation Jacobian times the ray
|
||||
direction. An initial value comes from rasterising a deformed hull mesh.
|
||||
- **Error control:** step requirements for the curve are separated from those for the
|
||||
SDF. Adaptive Runge-Kutta (RKF45 and DP54 evaluated) is used far from the surface,
|
||||
and forward Euler near it, against an error tolerance.
|
||||
- **Scope:** surfaces only. There is no density integration.
|
||||
- **[fetched]** Moinet, Neyret, *Fast Sphere Tracing of Procedural Volumetric Noise for
|
||||
Very Large and Detailed Scenes*, CGF 2025 (Eurographics), HAL hal-05046040.
|
||||
- **Computes:** sphere tracing of FBM density fields, using first- and second-order
|
||||
(2-Lipschitz) forward inclusion functions. The FBM partial sums act as a nested
|
||||
bounding volume hierarchy: the remaining octaves are bounded by their amplitude sum
|
||||
`s(i+1, n)`, so evaluation stops early once a point is provably outside.
|
||||
- **Cost:** up to 12x faster than classical sphere tracing on opaque or constant-density
|
||||
scenes. Only up to 1.8x on non-constant density, and that gain comes from empty-space
|
||||
skipping alone. One example frame: 16 ms against 110 ms naive at 1024 x 1024 on a
|
||||
4080 Ti.
|
||||
- **Stated limits:** FBM used as a domain warp is not supported, nor are nonlinear
|
||||
transforms of FBM. The paper names analytic estimation of the contribution of
|
||||
sub-pixel detail as an open problem.
|
||||
|
||||
### 3.4 Taylor models
|
||||
|
||||
- **[search]** Berz, Makino, *Verified Integration of ODEs and Flows Using Differential
|
||||
Algebraic Methods on High-Order Taylor Models*, Reliable Computing 4, 1998. Makino,
|
||||
Berz, *Taylor Models and Other Validated Functional Inclusion Methods*, IJPAM 2003.
|
||||
- **Computes:** a Taylor model is a polynomial of order n plus an interval remainder
|
||||
bound, which encloses any function given as code over a box. The sharpness of the
|
||||
remainder scales with the box width to the power n + 1. The method also gives
|
||||
verified ODE flows.
|
||||
- **Verified integration:** Berz and Makino also published *New Methods for
|
||||
High-Dimensional Verified Quadrature* (Reliable Computing 1999). There the integral
|
||||
of the polynomial part is exact, and the remainder bound times the domain measure
|
||||
bounds the error.
|
||||
- First-order AA is the n = 1 case in spirit: an affine part plus a bounded remainder.
|
||||
|
||||
### 3.5 Affine bound on a warp over a span
|
||||
|
||||
- **[derivation]** Over a segment of half-length r, the best affine approximation
|
||||
(Chebyshev) of a C2 coordinate function has sup error at most `r^2 / 4` times the
|
||||
maximum of its second derivative along the segment. Linear interpolation of the
|
||||
endpoints has `r^2 / 2` times that maximum. The noise error caused by a displacement
|
||||
error of size `delta` is at most `L_noise * delta`. This is the same chain-rule
|
||||
structure Galin uses in section 4.4.
|
||||
|
||||
## 4. Statistical or expected-value treatment of high-frequency detail
|
||||
|
||||
- **[fetched]** Heitz, Nowrouzezahrai, Poulin, Neyret, *Filtering Color Mapped Textures
|
||||
and Surfaces*, I3D 2013. It computes the filtered value of a colour map applied to a
|
||||
procedural function as `<C, N(mu, sigma^2)>`. The noise's footprint distribution is
|
||||
replaced by a Gaussian with the footprint's mean and variance. This equals the colour
|
||||
map convolved with `N(0, sigma^2)` and evaluated at the mean, which is precomputed into
|
||||
a 2D table indexed by (mu, sigma). Cost: one lookup after mu and sigma are computed.
|
||||
Error: exact when the driving function's footprint distribution is Gaussian. For a
|
||||
specific noise instance it converges as the footprint grows, and the paper states this
|
||||
limit.
|
||||
- **[derivation]** For `X ~ N(mu, sigma^2)`, `E[max(0, X - c)] = (mu - c) Phi(z) +
|
||||
sigma phi(z)` with `z = (mu - c) / sigma`. This is the rectified-Gaussian mean, and the
|
||||
scalar instance of the Heitz 2013 construction for the map `max(0, . - c)`. The
|
||||
survey's fetched finding that Perlin noise is non-Gaussian bears on how accurate it is.
|
||||
- **[search]** Olano, Baker, *LEAN Mapping*, I3D 2010. Dupuy et al., *LEADR Mapping*, ACM
|
||||
TOG 32(6), 2013. They store the first and second moments of slopes in mipmaps. Linear
|
||||
filtering of the moments gives a Gaussian or Beckmann slope distribution per
|
||||
footprint. Cost: two extra texture channels and linear filtering. Error: the Gaussian
|
||||
assumption.
|
||||
- **[fetched]** Gabor Fields (1.2): band and orientation masking of high-frequency
|
||||
kernels, with the Jensen bias of transmittance `~ 0.5 Var(tau) exp(-E[tau])`.
|
||||
- **[search]** Vicini, Jakob, Kaplanyan, *A Non-exponential Transmittance Model for
|
||||
Volumetric Scene Representations*, ACM TOG 40(4), 2021. Bitterli et al., *A Radiative
|
||||
Transfer Framework for Non-exponential Media*, ACM TOG 37(6), 2018. Jarabo, Aliaga,
|
||||
Gutierrez 2018, arXiv 1805.02651. Unresolved, correlated sub-voxel density makes
|
||||
transmittance non-exponential. Positive correlation gives slower decay than exponential.
|
||||
- **[search]** Kettunen, d'Eon, Pantaleoni, Novák, *An Unbiased Ray-Marching
|
||||
Transmittance Estimator*, ACM TOG 40(4), 2021. A power-series estimator built around a
|
||||
control variate. The control variate is a ray-marched mean-density estimate, and rare
|
||||
higher-order terms remove the bias. It is relevant as the known way to make a
|
||||
deterministic or expected-value optical depth unbiased.
|
||||
- **[fetched]** Schneider, *The Real-time Volumetric Cloudscapes of Horizon: Zero Dawn*,
|
||||
SIGGRAPH 2015 Advances course. The high-detail noise is applied only as erosion where
|
||||
the low-detail sample is non-zero. Rays take cheap large steps until the low-detail
|
||||
isosurface is hit, step back once, and then take full samples. After several zero
|
||||
samples they return to cheap stepping. This is an octave-level skip heuristic with no
|
||||
bound. Moinet (3.3) is the bounded version for FBM.
|
||||
|
||||
## 5. Temporal reuse of per-pixel or per-tile raymarch structure
|
||||
|
||||
- **[fetched]** Schneider 2015 (above). The full raymarch cost about 20 ms. Each frame
|
||||
renders 1 of 16 pixels per 4 x 4 block into a quarter-resolution buffer, and
|
||||
reprojects the previous frame. Where reprojection fails, for example at screen edges,
|
||||
a low-resolution buffer is substituted. The paper reports 10x or more faster, with a
|
||||
target of about 2 ms on PS4.
|
||||
- **[search]** Klein, Strengert, Stegmaier, Ertl, *Exploiting Frame-to-Frame Coherence
|
||||
for Accelerating High-Quality Volume Raycasting on Graphics Hardware*, IEEE
|
||||
Visualization 2005. First-hit depths of the previous frame are reprojected by point
|
||||
splatting to estimate per-pixel ray start points. About 2x on typical datasets.
|
||||
- **[search]** Yagel, Shi, *Accelerating Volume Animation by Space-Leaping*, IEEE
|
||||
Visualization 1993. A C-buffer of first non-transparent voxel coordinates, updated
|
||||
across frames. Gudmundsson, Randén 1990 introduced inter-frame empty-space skipping.
|
||||
- **[search]** Scherzer et al., *Temporal Coherence Methods in Real-Time Rendering*,
|
||||
CGF 2012 STAR (Eurographics 2011). Reprojection caches, with depth-tolerance validity
|
||||
tests for disocclusion.
|
||||
- **[search]** Hillaire, *Physically Based Sky, Atmosphere and Cloud Rendering in
|
||||
Frostbite*, SIGGRAPH 2016 course. Temporal reprojection for volumetric clouds. Details
|
||||
were not read in this pass.
|
||||
- **[fetched]** Gamito thesis, chapter 7: progressive refinement rendering of
|
||||
Lipschitz-bounded and AA-bounded surfaces. It refines across passes within one image,
|
||||
not across animated frames.
|
||||
- **[not found]** No source was found that caches per-ray span partitions, affine
|
||||
forms or certified intervals across frames and refines them.
|
||||
|
||||
## 6. Warp linearisation combined with exact segment integration
|
||||
|
||||
- **[not found]** No source was found that combines piecewise affine linearisation of a
|
||||
domain warp, with an error bound, and exact closed-form integration of noise over the
|
||||
resulting segments.
|
||||
- Nearest pieces, each covering part of the combination:
|
||||
|
||||
| Source | What it covers | What it leaves out |
|
||||
|---|---|---|
|
||||
| Gamito ch. 9 | Bounding a deformed ray | No integral. AA gave up on the ODE warp |
|
||||
| Seyb 2019 | ODE curved ray with adaptive error control | Surfaces only |
|
||||
| Galin 2020 | Jacobian-norm local bounds through warps | Surfaces, not integration |
|
||||
| Keeter 2020 | Interval pruning that tolerates warps | Surfaces |
|
||||
| Gabor Fields 2026 | Closed form for any affine-transformed Gabor kernel along a segment | No warp |
|
||||
| Balint 2019 | Closed form for cosine noise | Composition and warps break it |
|
||||
| Pre-integration (Engel 2001) | Exact per-segment integral | Under a piecewise-linear model of the scalar |
|
||||
| Moinet 2025 | Bounded FBM octave skipping | Excludes FBM used as a domain warp |
|
||||
|
||||
- **[search]** Rezk-Salama et al., *Fast Volumetric Deformation on General Purpose
|
||||
Hardware*, Graphics Hardware 2001. Fetched text: piecewise-trilinear deformation
|
||||
patches with adaptive subdivision, rendered by object-aligned slicing. The
|
||||
approximate inverse is `x + O(delta^2)`. A search summary describes the deformed rays
|
||||
as polylines, but the fetched text uses slicing, not per-ray exact integration.
|
||||
|
||||
## Sources
|
||||
|
||||
- Balint 2019: https://www.cl.cam.ac.uk/~rkm38/pdfs/balint2019cosine_noise.pdf
|
||||
- Condor et al. 2026, Gabor Fields: https://arxiv.org/pdf/2602.05081
|
||||
- Gamito PhD thesis: https://staffwww.dcs.shef.ac.uk/people/S.Maddock/phd_theses/gamito/thesis_small.pdf
|
||||
- Gamito, Maddock 2007: https://link.springer.com/article/10.1007/s00371-006-0090-7
|
||||
- Lagae et al. 2009, Gabor noise: https://www-sop.inria.fr/reves/Basilic/2009/LLDD09/LLDD09PNSGC_paper.pdf
|
||||
- Lagae, Drettakis 2011: https://inria.hal.science/inria-00606907/file/paper_0049_preprint.pdf
|
||||
- Lagae et al. 2010 survey: https://www-sop.inria.fr/reves/Basilic/2010/LLCDDELPZ10/LLCDDELPZ10STARPNF.pdf
|
||||
- Heitz et al. 2013: https://inria.hal.science/hal-00765799
|
||||
- Sharp, Jacobson 2022: https://arxiv.org/pdf/2202.02444
|
||||
- Seyb et al. 2019: https://cs.dartmouth.edu/~wjarosz/publications/seyb19nonlinear.html
|
||||
- Galin et al. 2020: https://hal.science/hal-02507361
|
||||
- Moinet, Neyret 2025: https://inria.hal.science/hal-05046040
|
||||
- Keeter 2020: https://www.mattkeeter.com/research/mpr/
|
||||
- Fryazinov et al. 2009: https://eprints.bournemouth.ac.uk/11708/1/TR-NCCA-2009-04.pdf
|
||||
- Knoll et al. 2009: https://onlinelibrary.wiley.com/doi/10.1111/j.1467-8659.2008.01189.x
|
||||
- Berz, Makino 1998: https://link.springer.com/article/10.1023/A:1024467732637
|
||||
- Engel et al. 2001: https://www3.cs.stonybrook.edu/~mueller/teaching/cse616/engel.pdf
|
||||
- Gustavson 2005: https://cgvr.cs.uni-bremen.de/teaching/cg_literatur/simplexnoise.pdf
|
||||
- Schneider 2015: https://advances.realtimerendering.com/s2015/
|
||||
- Kettunen et al. 2021: https://jannovak.info/publications/URM/URM.pdf
|
||||
- Vicini et al. 2021: https://dvicini.github.io/non-exponential-representation/
|
||||
- Rezk-Salama et al. 2001: https://www3.cs.stonybrook.edu/~mueller/teaching/cse616/p17-rezk-salama.pdf
|
||||
- Klein et al. 2005: https://www.researchgate.net/publication/4188150
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
# Cross-process file locking and atomic replace: prior art
|
||||
|
||||
Facts only. Gathered 2026-10-02 for fork `variants:question:ts-python-write-lock`. No conclusions, no recommendation.
|
||||
|
||||
Provenance legend: **[src]** read from CultLib source this pass; **[primary]** vendor/spec/upstream doc or source fetched this pass (the fetch tool summarises pages through a small model, so quoted phrasing is paraphrase-grade unless shown in quotes); **[secondary]** search-result snippet or third-party issue, weak; **[UNVERIFIED]** not confirmed by any source read, or derived by inference only. Nothing here was tested by running code on Windows or Linux.
|
||||
|
||||
Dates caveat: the doc-site fetches reported release dates for `fs2 0.4.3` ("September 9, 2026") and `fs4 1.1.0` ("September 23, 2026"). The fs2 date is implausible against my memory of that crate (long stable, last release years earlier); treat crate dates as **[UNVERIFIED]**.
|
||||
|
||||
---
|
||||
|
||||
## 0. What CultLib does today (source, this checkout)
|
||||
|
||||
| Runtime | Lock | Temp name | Replace | Pointer |
|
||||
|---|---|---|---|---|
|
||||
| C# | Sidecar `<file>.lock`, `new FileStream(lockPath, OpenOrCreate, ReadWrite, FileShare.None, bufferSize 1)`. On `IOException`: if `wait` is false return null, else sleep 10 ms and retry for up to 30 s, after which the exception propagates. | `.{name}.{Guid:N}.tmp`, `FileMode.CreateNew`, `FileShare.None`, `WriteThrough`, `Flush(true)` | `File.Replace(temp, path, null)` if target exists, else `File.Move` | `F:\Projects\CultLib\src\GameCult.Caching\CultCache.cs` lines ~3475-3530 |
|
||||
| Rust | Sidecar `<file>.lock` (`.lock` appended to full file name), opened `create/read/write/truncate(false)` (std default share mode), then `fs2::FileExt::lock_exclusive` / `lock_shared` / `try_lock_exclusive` (WouldBlock => `None`). Unlock explicit, then drop. | `{name}.{uuid v4}.tmp`; staged file `sync_all` then closed | Unix: `fs::rename`. Windows: `MoveFileExW(MOVEFILE_REPLACE_EXISTING \| MOVEFILE_WRITE_THROUGH)` retried 25 times, 20 ms apart, on ERROR_ACCESS_DENIED(5)/SHARING_VIOLATION(32)/LOCK_VIOLATION(33). Comment cites a Muninn crash on 2026-09-11. | `F:\Projects\CultLib\packages\cultcache-rs\src\lib.rs` ~925-945, 1040-1084, 2870-3030; `Cargo.toml`: `fs2 = "0.4"`, edition 2024 |
|
||||
| TypeScript | None. In-process promise queue only (`next`/`result` chain). | `${file}.tmp-${pid}-${Date.now()}-${random}` (unique per writer) | `fs/promises.rename`, retried 10 times with 10 ms doubling on EPERM/EBUSY/EACCES; temp removed on error. No fsync of temp. | `F:\Projects\CultLib\packages\cultcache-ts\src\single-file-messagepack-backing-store.ts` ~143-205 |
|
||||
| Python | None. `threading.RLock` per store object (in-process only). | Fixed `path.with_suffix(suffix + ".tmp")`, i.e. `store.cc.tmp`, shared by all writers | `Path.replace` (`os.replace`); no fsync | `F:\Projects\CultLib\packages\cultcache-py\src\cultcache_py\stores.py` lines 70-82, 121-126 |
|
||||
|
||||
Both C# and Rust name the lock `<store path>.lock`. The same-name sidecar is the only thing the two currently share. Both also hold the lock around read-modify-write of the snapshot **[src]**. A lock file is never deleted by either (no stale-file problem; the lock is the kernel object, not the file's existence) **[src, inferred from absence of delete]**.
|
||||
|
||||
Note re the fork text: C# does use a lock (stated in the C# code comment: "excludes other handles in this process and in others alike").
|
||||
|
||||
---
|
||||
|
||||
## 1. Language and OS primitives
|
||||
|
||||
### 1.1 .NET `FileStream` / `FileShare`
|
||||
|
||||
- **Windows**: `FileShare` maps to the `dwShareMode` of `CreateFile`. `FileShare.None` = share mode 0: "Any request to open the file (by this process or another process) will fail until the file is closed." **[primary]** https://learn.microsoft.com/en-us/dotnet/api/system.io.fileshare . Win32 side: dwShareMode 0 "cannot be opened again, either by the application that opened it or by another application, until its handle has been closed"; failure is ERROR_SHARING_VIOLATION. **[primary]** https://learn.microsoft.com/en-us/windows/win32/fileio/creating-and-opening-files . Share modes are checked at **open** time and are not byte-range locks; they do not call `LockFileEx` **[inferred]**.
|
||||
- **Unix**: .NET emulates `FileShare` with advisory `flock(2)`. Source (`SafeFileHandle.Unix.cs`, runtime main): `lockOperation = (share == FileShare.None) ? LOCK_EX : LOCK_SH`, called as `FLock(handle, lockOperation | LOCK_NB)`; on `EWOULDBLOCK` an `IOException` is thrown; handle close issues `LOCK_UN`. **[primary]** https://raw.githubusercontent.com/dotnet/runtime/main/src/libraries/System.Private.CoreLib/src/Microsoft/Win32/SafeHandles/SafeFileHandle.Unix.cs
|
||||
- So non-`None` opens take a **shared** flock; `None` takes an **exclusive** flock; non-blocking in all cases. A plain read with `FileShare.ReadWrite | Delete` still takes `LOCK_SH`, which would conflict with a writer holding `LOCK_EX` on the same file (only relevant if the same file is opened both ways). **[inferred from source above]**
|
||||
- Disable switch: env `DOTNET_SYSTEM_IO_DISABLEFILELOCKING`; locking is off by default on Browser/WASI and iOS/tvOS (not Mac Catalyst). **[primary]**
|
||||
- Because it is advisory flock, it excludes only other openers that also take flock (other .NET processes, or any program calling flock on that file). It does not stop a process that merely `open()`s and writes. **[inferred]**
|
||||
- Since which .NET version: **[UNVERIFIED]**. I believe the flock-based FileShare emulation has existed since .NET Core 1.x/2.x, and .NET 6 rewrote FileStream into strategies while keeping it, but no source read this pass dates it.
|
||||
- Windows `FileShare.Delete` is meaningful; the Unix code does not differentiate it. **[primary]**
|
||||
- `File.Replace(src, dst, backup)` -> Win32 `ReplaceFile`; throws if volumes differ. **[primary]** https://learn.microsoft.com/en-us/dotnet/api/system.io.file.replace . Unix mapping to `rename` **[UNVERIFIED]**.
|
||||
|
||||
### 1.2 Rust
|
||||
|
||||
- `std::fs::File::lock`, `lock_shared`, `try_lock`, `try_lock_shared`, `unlock`: stable since **Rust 1.89.0**. **[primary]** https://doc.rust-lang.org/std/fs/struct.File.html
|
||||
- Unix/WASI: `flock` (`LOCK_EX`, `LOCK_SH`, `+LOCK_NB`, `LOCK_UN`). Windows: `LockFileEx` (flags `LOCKFILE_EXCLUSIVE_LOCK`, `LOCKFILE_FAIL_IMMEDIATELY`) and `UnlockFile`.
|
||||
- Docs: lock "may be advisory or mandatory", platform-dependent; on Windows the file must be opened for write (or read+append) for an exclusive lock; re-locking the same handle or a clone is unspecified, possibly deadlock; locks released when the file and all duplicated handles close; "this may change in the future".
|
||||
- `fs2` 0.4.x (what CultCache Rust uses): Unix `flock(2)`; Windows `LockFileEx` (source `src/windows.rs`: `lock_shared` passes 0, `lock_exclusive` passes `LOCKFILE_EXCLUSIVE_LOCK`, try variants add `LOCKFILE_FAIL_IMMEDIATELY`; `unlock` uses `UnlockFile`). Docs: "advisory"; do not lock one `File` concurrently; duplicated files need great care. **[primary]** https://docs.rs/fs2/latest/fs2/trait.FileExt.html , https://raw.githubusercontent.com/danburkert/fs2-rs/master/src/windows.rs . An earlier summary of the fs2 docs page said "LockFile" for Windows; the source file says `LockFileEx`. Maintenance status of fs2: **[UNVERIFIED]**.
|
||||
- `fs4`: fork of fs2 adding async, rustix instead of libc; docs say `flock`/`fcntl` on Unix, `LockFileEx` on Windows. Whether std's `File::lock` is mentioned or deprecates it: not stated on the page read. **[primary]** https://docs.rs/fs4/latest/fs4/ . Which Unix call it picks by default (flock vs fcntl): **[UNVERIFIED]**.
|
||||
- Windows default share mode of Rust `OpenOptions`: `FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE`; `share_mode(0)` makes exclusive. **[primary]** https://doc.rust-lang.org/std/os/windows/fs/trait.OpenOptionsExt.html
|
||||
- `std::fs::rename`: Unix `rename(2)`; Windows 10 1607+ `MoveFileExW(MOVEFILE_REPLACE_EXISTING)` or `SetFileInformationByHandle(FileRenameInfoEx)` (POSIX semantics); earlier, MoveFileExW only. "may change in future". **[primary]** https://doc.rust-lang.org/std/fs/fn.rename.html . (CultCache Rust bypasses std rename on Windows and calls MoveFileExW itself, per 0.)
|
||||
|
||||
### 1.3 Node
|
||||
|
||||
- No built-in advisory file lock in `node:fs`; **[UNVERIFIED]** by a direct docs read this pass, but nothing in the packages read contradicts it. Options seen:
|
||||
- `proper-lockfile`: "mkdir strategy" - creates `<target>.lock` as a **directory**; works across local and network FS; stale detection by lock dir **mtime** (default `stale` 10 s, min 5 s; mtime refreshed every `update` = stale/2, min 1 s); `onCompromised` callback when refresh fails or is late; avoids `O_EXCL` because "O_EXCL is broken on NFS" (their README); contrasts with `lockfile` package which uses ctime for staleness, unsuitable for long-running holders. **[primary]** https://raw.githubusercontent.com/moxystudio/node-proper-lockfile/master/README.md
|
||||
- `lockfile` (npm), `fs-ext` (native `flock`/`fcntl` bindings): not fetched. **[UNVERIFIED]**
|
||||
- Libuv rename on Windows uses `MoveFileExW(MOVEFILE_REPLACE_EXISTING)`; reports say it fails with EPERM while any handle holds the destination open, and refuses a READONLY destination; some runtimes (Bun PR) fall back to POSIX-semantic replace. **[secondary]** search snippets: https://github.com/oven-sh/bun/pull/43997 , https://github.com/joyent/libuv/issues/283 . Not read from libuv source: **[UNVERIFIED]**.
|
||||
- `npm/write-file-atomic`: temp name = `filename + "." + murmurhex(__filename, process.pid, ++invocations)` (plus worker thread id); same-process writes to one file queued and serialised; fsync on by default; no cross-process lock mentioned. **[primary]** https://raw.githubusercontent.com/npm/write-file-atomic/main/README.md
|
||||
|
||||
### 1.4 Python
|
||||
|
||||
- `fcntl.flock()` wraps `flock(2)`; docs: "On some systems, this function is emulated using fcntl()". `fcntl.lockf()` is "essentially a wrapper around the fcntl() locking calls". Unix only. "On at least some systems, LOCK_EX can only be used if the file descriptor refers to a file opened for writing." The page does not state whether flock and lockf interact. **[primary]** https://docs.python.org/3/library/fcntl.html
|
||||
- `msvcrt.locking(fd, mode, nbytes)`: locks `nbytes` from the current file position (may extend past EOF); `LK_LOCK`/`LK_RLCK` retry once per second, 10 attempts, then `OSError`; `LK_NBLCK` fails immediately; regions cannot overlap; adjacent regions not merged. Underlying Win32 call not stated in docs. **[primary]** https://docs.python.org/3/library/msvcrt.html . (The CRT `_locking` is documented elsewhere as `LockFile`-based; **[UNVERIFIED]** here.)
|
||||
- `filelock` (tox-dev): `FileLock` alias = `UnixFileLock` (`fcntl.flock`, described as kernel-enforced and released on crash) on Unix; `WindowsFileLock` described as `LockFileEx` byte-range locking on Windows (older versions used `msvcrt.locking`; version not checked, **[UNVERIFIED]**); `SoftFileLock` is a "cooperative file-existence marker", with same-host PID inspection and reclaim from dead processes; docs recommend verifying shared-filesystem semantics before deploying soft locks; no statement on cross-type exclusion. **[primary]** https://py-filelock.readthedocs.io/en/latest/index.html
|
||||
- `portalocker`: README fetch 404; its primitives are **[UNVERIFIED]** (not read).
|
||||
- `os.replace` on Windows semantics (MoveFileEx REPLACE_EXISTING): **[UNVERIFIED]**, not fetched.
|
||||
|
||||
### 1.5 Interoperability matrix (flock / fcntl / LockFileEx / share modes)
|
||||
|
||||
| Question | Finding | Source |
|
||||
|---|---|---|
|
||||
| Linux, local FS: does `flock` exclude `fcntl` (POSIX byte-range) locks? | No. man flock(2): on Linux "there is no interaction between the types of lock placed by flock() and fcntl(2)" (local filesystems). | **[primary]** https://man7.org/linux/man-pages/man2/flock.2.html |
|
||||
| Linux over NFS (since 2.6.12) | flock is emulated as whole-file fcntl byte-range locks, so flock and fcntl **do** interact there. | same |
|
||||
| Linux over CIFS (since 5.5) | flock emulated with SMB byte-range locks; flock and fcntl interact; and the lock becomes **mandatory**: I/O from other descriptors on a locked file fails with EACCES. | same |
|
||||
| Duplicate fds / fork | A lock belongs to the open file description; shared by dup/fork; persists until `LOCK_UN` or **all** duplicates close. Independent `open()`s in the same process conflict with each other under flock. | same |
|
||||
| Does a Rust `flock` (std or fs2) exclude a .NET-on-Unix `FileShare.None` open? | Both are `flock(LOCK_EX)` on the same inode; by the sources above they should conflict (.NET's non-blocking attempt gets EWOULDBLOCK -> IOException -> CultCache C# retries). **Not tested.** | **[inferred]**; **[UNVERIFIED]** empirically |
|
||||
| Same, but .NET `FileShare.ReadWrite` opener (shared flock) vs Rust `lock_exclusive` | Conflicts (LOCK_SH vs LOCK_EX), so a reader opening the lock file with any FileShare would block/fail against a Rust holder. CultCache does not open the lock file for reading. | **[inferred]** |
|
||||
| Windows: does a share-mode-0 open (.NET `FileShare.None`) exclude a Rust `LockFileEx` locker? | Order A: .NET holds the file with share mode 0. Rust's `OpenOptions::open` (share mode default RWD) then fails at `CreateFile` with ERROR_SHARING_VIOLATION, before it can lock. In `open_lock_file` that is a `with_context` error, **not a wait/retry** (looked at the source). Order B: Rust holds a handle (RWD) and `LockFileEx`; .NET `CreateFile` with share mode 0 conflicts with the existing open handle's access -> ERROR_SHARING_VIOLATION -> `IOException` -> C# retries up to 30 s. So mutual exclusion holds in both orders, but in order A Rust errors instead of waiting. | **[inferred]** from MS CreateFile share-mode table + Rust OpenOptionsExt default; **[UNVERIFIED]** empirically |
|
||||
| Windows: is `LockFileEx` mandatory? | Exclusive region lock "denies all other processes both read and write access to the specified region"; shared lock denies writes; locks are not honoured through mapped views. If a process dies or closes the handle the OS releases the locks, but "the time it takes ... depends upon available system resources" (MS recommends explicit unlock). Handle needs GENERIC_READ or GENERIC_WRITE. | **[primary]** https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-lockfileex |
|
||||
| Windows: second handle in same process | "If the locking process opens the file a second time, it cannot access the specified region through this second handle until it unlocks the region." | same |
|
||||
| Rust-on-Windows `LockFileEx` vs Python `msvcrt.locking` (same region?) | `LockFileEx` regions conflict if they overlap; fs2 locks which byte range (offset 0, len u32::MAX..?) : **[UNVERIFIED]**; msvcrt.locking locks `nbytes` from the current position (so depends on `lseek`); whether CRT `_locking` uses LockFile/LockFileEx and which range would need to match: **[UNVERIFIED]**. |
|
||||
| Python Unix `flock` vs Rust/.NET flock | Same kernel primitive on Linux/macOS (Python's flock "may be emulated using fcntl on some systems", so platform-dependent). Python `lockf` is fcntl: independent from flock on local Linux FS. | **[primary]** python docs + man page |
|
||||
| macOS flock vs fcntl | **[UNVERIFIED]** (not fetched). |
|
||||
| POSIX fcntl lock semantics | Per-process: `close()` of **any** fd for the file by the process drops all of that process's fcntl locks (SQLite doc 2.2 below). flock locks are per open-file-description and do not have that hazard. | **[primary]** SQLite howtocorrupt; flock(2) |
|
||||
| Python `fcntl.flock` on Windows | Module is Unix only (docs). | **[primary]** |
|
||||
|
||||
---
|
||||
|
||||
## 2. Lockfile protocols in established software
|
||||
|
||||
### git
|
||||
- Lockfile API: lock for `foo` is `foo.lock`, created with `O_CREAT|O_EXCL`; writer writes new content into it; `commit_lock_file()` renames it over `foo`; `rollback_lock_file()` removes it; writers exclude each other; readers never block and see either old or new content (relies on atomic rename); cleanup of outstanding lockfiles is registered with `atexit` and signal handlers. **[primary]** https://git-scm.com/docs/api-lockfile
|
||||
- `lockfile.c`: retry with quadratic backoff for at least `timeout_ms`; PID file registered as a tempfile for signal/atexit cleanup; failure message says another git process may be running "or the lock file may be stale" and tells the user to remove it manually. **[primary]** https://raw.githubusercontent.com/git/git/master/lockfile.c
|
||||
- Consequences recorded in the docs: a hard kill (SIGKILL, power loss) leaves a `.lock` file and later writers fail until it is removed; git does not time-based-reclaim index.lock **[inferred from message text, not stated; UNVERIFIED]**. Note the git design makes the lock file **the temp file** (lock name = staging name); CultCache's current design has a separate sidecar lock plus uniquely named temp.
|
||||
|
||||
### SQLite (see also section 4)
|
||||
- Unix default VFS: POSIX advisory locks via `fcntl()`; Windows VFS: `LockFile`/`LockFileEx`/`UnlockFile`. Five states UNLOCKED/SHARED/RESERVED/PENDING/EXCLUSIVE. **[primary]** https://www.sqlite.org/lockingv3.html (byte-range layout not stated on that page).
|
||||
- Built-in unix VFS variants: `unix` (POSIX advisory), `unix-dotfile` (dot-file locking), `unix-excl` (holds exclusive lock, WAL index in heap), `unix-none` (no locking), `unix-namedsem` (VxWorks). "Except for 'unix' and 'unix-excl', the various Unix VFSes all use incompatible locking implementations"; processes using different VFSes on one DB "may not see each others locks ... resulting in database corruption". **[primary]** https://www.sqlite.org/vfs.html . (`unix-flock` / `unix-afp` / `unix-nfs` appear in SQLite's source as separate io-method variants; not listed on that page: **[UNVERIFIED]**.)
|
||||
|
||||
### LMDB
|
||||
- Uses a separate `lock.mdb` file for the reader table and writer mutex. Header warnings: stale reader slots from aborted programs make the DB grow quickly (`mdb_reader_check`); stale writer lock clears automatically on Windows, BSD with SysV semaphores, and Linux with robust POSIX mutexes, otherwise all DB users must be closed; "Do not have open an LMDB database twice in the same process at the same time. Not even from a plain open() call - close()ing it breaks flock() advisory locking."; "Do not use LMDB databases on remote filesystems, even between processes on the same host. This breaks flock() on some OSes"; `MDB_NOLOCK` disables locking entirely. **[primary]** https://raw.githubusercontent.com/LMDB/lmdb/mdb.master/libraries/liblmdb/lmdb.h
|
||||
- Note: that warning is about `flock` and `close()`, which contradicts the usual statement that only fcntl locks have the close() hazard; the source of the discrepancy (older Linux/BSD semantics, or the emulated flock on some OS) is **[UNVERIFIED]**.
|
||||
|
||||
### Cargo / npm / pip / Gradle
|
||||
- Cargo: package-cache and build-dir lock using an OS file lock, prints "Blocking waiting for file lock"; source fetch (`src/cargo/util/flock.rs`) 404; details (flock/fs4/std, handling of filesystems without locking) **[UNVERIFIED]**.
|
||||
- Gradle: `DefaultFileLockManager` holds cross-process locks on cache/daemon-registry files; reports of builds hanging on locks left by crashed or killed daemons. **[secondary]** web search snippets only (https://discuss.gradle.org/t/gradle-daemon-keeps-locking-intermediate-files-preventing-builds/38004 , https://www.javathinking.com/blog/gradle-build-is-hanging-without-failure-defaultfilelockmanager-acquiring-and-releasing-lock-on-daemon-addresses-registry/ ); Gradle's own docs on lock protocol not read. **[UNVERIFIED]**
|
||||
- npm: `write-file-atomic` has no cross-process lock (above); npm's cache (cacache) locking: **[UNVERIFIED]**, not fetched.
|
||||
- pip: **[UNVERIFIED]**, not fetched.
|
||||
|
||||
### Windows rename retry (Postgres)
|
||||
- `pgrename()` on Windows calls `MoveFileEx(from, to, MOVEFILE_REPLACE_EXISTING)` and retries on ERROR_ACCESS_DENIED, ERROR_SHARING_VIOLATION, ERROR_LOCK_VIOLATION: up to 100 retries, 0.1 s apart (~10 s). Comment: other applications may have the file open without sharing flags; LOCK_VIOLATION seen from some anti-virus software; bounded because the caller may hold locks affecting other backends. **[primary]** https://raw.githubusercontent.com/postgres/postgres/master/src/port/dirmod.c . CultCache Rust uses the same error set with 25 x 20 ms (0.5 s); TS retries EPERM/EBUSY/EACCES 10 times with doubling from 10 ms (about 10 s worst case) **[src]**.
|
||||
|
||||
---
|
||||
|
||||
## 3. Temp-file naming and rename atomicity
|
||||
|
||||
- Unique temp names per writer: C# GUID, Rust uuid v4, TS pid+time+random **[src]**; npm write-file-atomic pid + counter hash **[primary]**; Python CultCache fixed `.tmp` **[src]**. Rust additionally has a startup cleanup that deletes only temps matching `<store>.<uuid>.tmp` (tests at `lib.rs` ~3420, 4667-4689 distinguish `not-a-uuid.tmp`, bare `.tmp`, neighbour stores) **[src]**. Note the naming differs between runtimes: C# `.{name}.{guid:N}.tmp` (leading dot, no dashes) vs Rust `{name}.{uuid-with-dashes}.tmp` vs TS `{name}.tmp-...`; whether the Rust stale-temp sweeper recognises C#/TS temps: it matches the Rust format only (per the test names) **[src, partial reading]**.
|
||||
- Fixed temp name consequence (inferred, not tested): two processes writing `store.cc.tmp` concurrently share one file: interleaved `write_bytes` (truncate-and-write) can yield a mixed or truncated file that one process then renames into place, and the other process's `replace` can fail with source-not-found. **[inferred]**
|
||||
- POSIX `rename()`: if `new` exists it "shall be removed and old renamed to new"; the rationale requires the action to be atomic; a directory entry named `new` stays visible to other threads throughout and refers to either old or new file; on failure other than I/O errors `new` is unaffected; spec does not define crash durability. **[primary]** https://pubs.opengroup.org/onlinepubs/9799919799/functions/rename.html
|
||||
- Directory fsync: the usual pattern is write temp, fsync temp, rename, then fsync the parent directory so the rename survives power loss; many projects have filed bugs for omitting the directory fsync. **[secondary]** search snippets (e.g. https://github.com/npm/write-file-atomic/issues/64 titled "Rename atomicity is not enough"; https://0xkiire.com/crash-consistency-fsync-rename/). Linux ext4 `auto_da_alloc` replace-via-rename heuristic: **[UNVERIFIED]**, not fetched. Whether CultCache Rust/C# fsync the parent directory on Unix: **[UNVERIFIED]** (not seen in the excerpts read; Rust does `sync_all` on the temp, C# `Flush(true)` + `WriteThrough`; TS and Python do neither).
|
||||
- Windows `MoveFileExW`:
|
||||
- `MOVEFILE_REPLACE_EXISTING` replaces the destination if ACLs allow; errors if destination is an existing directory; `MOVEFILE_WRITE_THROUGH` waits for the move to be flushed (documented as guaranteeing flush of a copy+delete move). Same-volume rename is a metadata operation. The doc page does not use the word "atomic" and does not describe behavior when the destination is open by another handle. **[primary]** https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-movefileexw
|
||||
- Open-handle failure: reported from libuv/Bun and Postgres as ERROR_ACCESS_DENIED / SHARING_VIOLATION / LOCK_VIOLATION when another process has the destination open without FILE_SHARE_DELETE, or an anti-virus scan holds it. **[primary]** Postgres comment; **[secondary]** libuv/Bun.
|
||||
- Rust's std rename (1.0+ Windows 10 1607) may use `FileRenameInfoEx` with POSIX semantics, which can replace an open destination; CultCache Rust calls `MoveFileExW` directly instead **[src; primary for std]**.
|
||||
- Windows `ReplaceFileW` (used by .NET `File.Replace`): replaced file opened with GENERIC_READ|DELETE|SYNCHRONIZE and share mode `READ|WRITE|DELETE`; replacement file opened with **no sharing mode specified**; preserves creation time, short name, object id, DACLs, encryption, compression, named streams; resulting file has the replacement's file ID; all files must be on the same volume; documented failure states leave the system in partially-moved states: ERROR_UNABLE_TO_REMOVE_REPLACED (1175; names unchanged), ERROR_UNABLE_TO_MOVE_REPLACEMENT (1176; with no backup, the replaced file "no longer exists" and the replacement exists under its original name), ERROR_UNABLE_TO_MOVE_REPLACEMENT_2 (1177). `REPLACEFILE_WRITE_THROUGH` "not supported". The page makes no statement that ReplaceFile is atomic; it describes it as combining steps. **[primary]** https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-replacefilew
|
||||
- So on Windows, C# (`File.Replace`) and Rust/TS/Python (MoveFileEx or equivalent) use different primitives with different failure windows. **[inferred from the two pages]**
|
||||
- .NET `File.Move` with overwrite on Windows/Unix: **[UNVERIFIED]** (CultCache C# uses `File.Move(temp, path)` only when the target does not exist; there is a window in which another writer creates the target between the `File.Exists` check and the move; the lock covers it when taken **[src, inferred]**).
|
||||
|
||||
---
|
||||
|
||||
## 4. Known failures and warnings
|
||||
|
||||
SQLite "How To Corrupt An SQLite Database File" (https://www.sqlite.org/howtocorrupt.html) **[primary]**:
|
||||
- 2.1 Filesystems with broken or missing lock implementations (network filesystems, NFS) can corrupt when multiple processes access.
|
||||
- 2.2 POSIX advisory locks are cancelled by `close()`: `close()` on any descriptor for the file by any thread in the process releases the process's locks on that file; example: thread B opens, reads and closes the DB file for a backup and thread A's locks vanish silently. SQLite works around it with a mutex-protected global list of open files, which fails if two copies of SQLite are linked into one app (2.3; a commercial product shipped this bug with infrequent corruption on Linux/Mac). WAL multi-process defenses added in 3.51.0 (2025-11-04).
|
||||
- 2.4 Two processes using different locking protocols (e.g. POSIX advisory vs dot-file) cannot see each other's locks; all connections must use the same protocol.
|
||||
- 2.5 Unlinking or renaming the DB file while in use: processes end up with different physical files sharing one journal; SQLite warns (since 3.7.17) when the file is unlinked while open.
|
||||
- 2.6 Multiple links (hard/symbolic links): same hazard; resolved by canonicalising symlinks since 3.10.0.
|
||||
- 2.7 Carrying a connection across `fork()`: all locking mechanisms fail with unpredictable results.
|
||||
- 1.3/1.4 Hot journal deleted, moved or mispaired with database.
|
||||
- The SQLite VFS page: incompatible unix VFS locking => corruption (section 2 above).
|
||||
|
||||
Other recorded failure modes:
|
||||
- Windows `LockFileEx` locks are released by the OS on process death but with a lag (MS doc). **[primary]**
|
||||
- CIFS flock becomes mandatory (man flock(2)). **[primary]**
|
||||
- Lock file left behind: git (cleanup via atexit/signal; a hard kill leaves `.lock`, message tells operator to remove) **[primary]**; Gradle hangs after killed daemons **[secondary]**; `proper-lockfile` and `filelock.SoftFileLock` provide mtime / PID-based stale reclaim as a mitigation **[primary]**.
|
||||
- Lock protocols that are compatible only when everyone uses the same one: SQLite (above); `filelock` docs do not state cross-type exclusion between `UnixFileLock`/`SoftFileLock`. **[primary]**
|
||||
- Lost-update by missing cross-process lock around read-modify-write: `write-file-atomic` documents only same-process serialisation; no source read this pass reports a concrete lost-update bug in it. A search-based report of corrupted/missing "settings.json" on Windows from a failed atomic write exists as an unvetted issue (https://github.com/BarganConstantin/ccdeck/issues/772, **[secondary]**, read-only destination). Reports specific to cross-language writers of one file, other than SQLite's different-VFS warning: not found this pass. **[UNVERIFIED]**
|
||||
- Lock file deleted by one participant while others hold or wait on it (different inode afterward): general hazard of delete-on-unlock lock files, not sourced this pass beyond SQLite 2.5 (unlink of a live file); **[UNVERIFIED]** as a named issue for flock sidecars.
|
||||
|
||||
---
|
||||
|
||||
## 5. Open items not resolved by reading (candidates for an empirical check; nothing done)
|
||||
|
||||
1. Do the C# `FileShare.None` lock and Rust `fs2::lock_exclusive` actually exclude each other on Windows (both open orders) and on Linux? Only inferred above.
|
||||
2. Does fs2 on Windows lock the same byte range as std 1.89 `File::lock` and as Python `msvcrt.locking`/`filelock`? Unread.
|
||||
3. macOS behavior of flock vs fcntl and of .NET FileShare there. Unread.
|
||||
4. .NET release that introduced Unix FileShare-via-flock. Unread.
|
||||
5. Whether any runtime fsyncs the parent directory after rename on Unix. Unread.
|
||||
6. Cargo, npm, pip, Gradle lock protocol primary sources. Unread.
|
||||
7. `portalocker`, `fs-ext`, `lockfile`, `proper-lockfile` behavior under Windows share modes. Unread.
|
||||
|
|
@ -1,305 +0,0 @@
|
|||
# Culling Efficiency Against an Oracle: Prior Art Survey
|
||||
|
||||
This is the facts file for Eureka question `cultmath-tapes:question:wells-ceiling-oracle`,
|
||||
which interprets ruling `cultmath-tapes:ruling:operator-wells-contract-vs-ceiling`. That
|
||||
ruling holds scenario (c) to "at least 90% of the probe-free ceiling (the cost of the
|
||||
dense cells alone)". The question asks what "ceiling" means:
|
||||
|
||||
- (a) the pre-pass's own mask, under which a pre-pass that skips nothing scores 0.98;
|
||||
- (b) an oracle mask, the cells where at least one ray samples nonzero density.
|
||||
|
||||
Under (b), Imagination measured 0.38 at warp 0 and 0.19 at warp D.
|
||||
|
||||
This survey covers how published work measures culling or skipping efficiency against
|
||||
ground truth, and what efficiency figures conservative bounds reach. Eyes pass,
|
||||
2026-10-03. It gives facts and evidence pointers only, with no recommendation.
|
||||
|
||||
Verification marks:
|
||||
|
||||
- **[fetched]** The claim was read in the cited source's full text during this pass.
|
||||
PDFs were converted with `pdftotext -layout`.
|
||||
- **[search]** The claim came from a search-result summary, not a full read.
|
||||
- **[not found]** The source was read, and the named kind of measurement is absent from it.
|
||||
|
||||
Reference kinds used below:
|
||||
|
||||
- **oracle**: an exact or ground-truth answer computed independently of the method,
|
||||
such as the exact visible set or the set of truly non-empty cells.
|
||||
- **self**: the method's own result, or a baseline that does no culling.
|
||||
- **peer**: another approximate method.
|
||||
|
||||
## 1. Visibility culling: conservative versus exact visible set
|
||||
|
||||
### 1.1 Survey vocabulary
|
||||
|
||||
- **[fetched]** Cohen-Or, Chrysanthou, Silva, Durand, *A Survey of Visibility for
|
||||
Walkthrough Applications* (IEEE TVCG 2003). Section 2 lists "Tightness of
|
||||
approximation" as a review criterion. It notes that few papers report the ratio
|
||||
between the size of the potentially visible set and the size of the visible set,
|
||||
and asks later work to report it. Most surveyed techniques are conservative, which
|
||||
means they overestimate the visible set.
|
||||
https://www.cs.tau.ac.il/~dcor/online_papers/papers/visibility-survey-ieee.pdf
|
||||
- **[fetched]** Bittner, *Hierarchical Techniques for Visibility Computations* (PhD
|
||||
thesis, CTU Prague, 2002), chapter 2, equations 2.1 to 2.4. It defines a quality
|
||||
measure against the exact PVS `S_E`:
|
||||
- relative overestimation `Q_o = |S_A \ S_E| / |S_E|`;
|
||||
- relative underestimation `Q_u = |S_E \ S_A| / |S_E|`;
|
||||
- the expected quality, which is the expectation of these over inputs.
|
||||
|
||||
The same text appears in Bittner and Wonka, *Visibility in Computer Graphics*
|
||||
(section 4.3).
|
||||
https://dcgi.fel.cvut.cz/home/bittner/publications/diss_noimages.pdf
|
||||
http://www.casa.ucl.ac.uk/mike-michigan-april1/mike's%20stuff/attach/bittner_wonka.pdf
|
||||
- **[fetched]** Nirenstein, Blake, Gain, *Exact From-Region Visibility Culling* (EGWR
|
||||
2002), introduction and Table 1. The paper classifies algorithms as exact,
|
||||
conservative, aggressive or approximate. Conservative methods "incur a false
|
||||
visibility error". The paper states:
|
||||
- exact visibility "provides a benchmark for comparing other approaches";
|
||||
- the percentage of polygons culled is "a very crude and imperfect metric", because
|
||||
it ignores the type, distribution and magnitude of visibility error.
|
||||
|
||||
Reported results (Table 2) are culling fractions of the whole scene: 99.45% for the
|
||||
town and 99.12% for the forest. No conservative method is measured against the
|
||||
exact set in this paper **[not found]**. A search summary attributed a 95.5% figure
|
||||
for extended projection to this paper, but that figure is not in the full text.
|
||||
https://people.cs.uct.ac.za/~jgain/wp-content/papercite-data/pdf/nirenstein2002.pdf
|
||||
|
||||
### 1.2 Measured conservative-versus-exact ratios
|
||||
|
||||
- **[fetched]** Bittner, Wonka, Wimmer, *Fast Exact From-Region Visibility in Urban
|
||||
Scenes* (EGSR 2005), section 6. The exact 2.5D algorithm is used as the oracle, and
|
||||
the paper simulates conservative methods against it:
|
||||
- **Section 6.2, one penumbra wedge per occluder.** The conservative PVS was more
|
||||
than 47% larger than the exact PVS for 2 m view cells and more than 76% larger
|
||||
for 4 m view cells. As exact/conservative ratios these are ≤ 0.68 and ≤ 0.57.
|
||||
- **Section 6.3, object-space discretization** (Wonka et al.'s method, a 1400×1400
|
||||
grid, Vienna). The PVS overestimate averaged 17%, a ratio of about 0.85.
|
||||
- **Section 6.3, Table 4, line-space discretization** (Leyvand et al.). PVS sizes by
|
||||
grid resolution, with the exact PVS last:
|
||||
|
||||
| Scene | 512 | 1024 | 2048 | 16384 | exact |
|
||||
|---------|--------|--------|--------|-------|-------|
|
||||
| Vienna | 633 | 535 | 489 | 455 | 235 |
|
||||
| Atlanta | 50,744 | 25,751 | 10,082 | 2,656 | 1,152 |
|
||||
|
||||
The ratios exact/conservative below are computed from that table, not quoted from
|
||||
the paper:
|
||||
- Vienna: 0.37, 0.44, 0.48, 0.52;
|
||||
- Atlanta: 0.023, 0.045, 0.11, 0.43.
|
||||
|
||||
The paper says the overestimation comes from conservative shrinking and from gaps
|
||||
"between formerly connected occluders".
|
||||
|
||||
https://peterwonka.net/Publications/pdfs/2005.EGSR.Bittner.FastExactFromRegionVisibility.pdf
|
||||
|
||||
### 1.3 Run-time occlusion culling against an ideal renderer
|
||||
|
||||
- **[fetched]** Mattausch, Bittner, Wimmer, *CHC++: Coherent Hierarchical Culling
|
||||
Revisited* (Eurographics 2008), section 7 (Figure 7) and the conclusion. The
|
||||
reference algorithm "OPT" renders only the visible nodes of the hierarchy and
|
||||
issues no queries. It was implemented by recording visibility with an exact
|
||||
stop-and-wait pass, so it is an oracle at node granularity that is independent of
|
||||
the method under test. CHC++ is reported to be "for most cases within a few percent
|
||||
of the 'ideal' method".
|
||||
|
||||
Caveat: occlusion queries are exact per-node tests, not conservative range bounds.
|
||||
The gap to OPT measures query overhead and latency, not bound looseness.
|
||||
|
||||
The paper also separates out the "optimal" algorithm defined by Guthe et al.
|
||||
(NOHC-OPT), and shows CHC++ "clearly below" that optimum. That earlier optimum was a
|
||||
cost-model optimum, not an oracle.
|
||||
https://dcgi.fel.cvut.cz/home/bittner/publications/chc++.pdf
|
||||
- **[fetched]** Zhang et al., *Visibility Culling using Hierarchical Occlusion Maps*
|
||||
(SIGGRAPH 1997). It reports "% culling" of the total model, and frame time
|
||||
normalized to rendering without HOM. Both are **self** references. No comparison
|
||||
with the exact visible set **[not found]**.
|
||||
https://www.cs.princeton.edu/courses/archive/spr01/cs598b/papers/zhang97.pdf
|
||||
- **[search]** Wonka et al., *Guided Visibility Sampling* (SIGGRAPH 2006). This is
|
||||
an aggressive, sampling-based PVS method. Its Table 2 reports error as false pixels
|
||||
on a 1000×1000 screen, together with PVS size. Section 4.5 compares it to Bittner's
|
||||
exact algorithm on one scene, because exact solvers do not run on the larger test
|
||||
scenes. The text was extracted in this pass, but not read in full.
|
||||
https://peterwonka.net/Publications/pdfs/2006.SG.Wonka.GuidedVisibilitySampling.final.pdf
|
||||
|
||||
## 2. Empty-space skipping in volume rendering
|
||||
|
||||
- **[fetched]** Li, Mueller, Kaufman, *Empty Space Skipping and Occlusion Clipping for
|
||||
Texture-based Volume Rendering* (IEEE Vis 2003):
|
||||
- **Section 5, Table 1.** Empty-space skipping is measured as a frame-rate speedup
|
||||
over no skipping, 2.8× on average. This is a **self** reference.
|
||||
- **Section 5, Table 2.** It reports the percentage of non-empty voxels skipped
|
||||
through occlusion, 48% to 64% with a 56.2% average. The denominator is all
|
||||
non-empty voxels, not the truly occluded voxels. The authors write that "there
|
||||
are still many occluded voxels that have not been skipped" and call the number
|
||||
"smaller than we have expected". The truly occluded set is not computed
|
||||
**[not found]**.
|
||||
- **Section 5.** Overhead is reported separately. The opacity map and clipping cost
|
||||
about 10% of the empty-space-skipping render time, measured with a mode that pays
|
||||
the overhead but skips nothing ("ESS+M").
|
||||
|
||||
https://www3.cs.stonybrook.edu/~mueller/papers/OcclusionClipping.pdf
|
||||
- **[fetched]** Zellmann, *Comparing Hierarchical Data Structures for Sparse Volume
|
||||
Rendering with Empty Space Skipping* (arXiv 1912.09596, 2019). It reports frame
|
||||
rates and build times, and compares only against naive ray marching and simple
|
||||
macrocell grids, as the paper itself states. No step count or sample count against
|
||||
an ideal traversal **[not found]**.
|
||||
https://arxiv.org/abs/1912.09596
|
||||
- **[search]** Hadwiger et al., *SparseLeap* (IEEE TVCG 24(1), 2018). It builds
|
||||
per-pixel lists of ray segments by rasterizing occupancy geometry. The full text
|
||||
was not retrieved (the repository returned HTML), so it is unknown whether it
|
||||
reports skip efficiency against the true empty space.
|
||||
https://vccvisualization.org/research/sparseleap/
|
||||
- **[search]** Deakin and Knackstedt, *Efficient ray casting of volumetric images using
|
||||
distance maps for empty space skipping* (Computational Visual Media 6(1), 2020). The
|
||||
full text was not retrieved.
|
||||
|
||||
## 3. Interval and affine arithmetic for implicits, noise and SDF marching
|
||||
|
||||
- **[fetched]** Keeter, *Massively Parallel Rendering of Complex Closed-Form Implicit
|
||||
Surfaces* (SIGGRAPH 2020):
|
||||
- **Section 3, Interval arithmetic.** Ambiguous regions "either contain the shape's
|
||||
boundary or are a false positive (due to interval arithmetic's conservative
|
||||
behavior)". The false-positive share is not measured **[not found]**.
|
||||
- **Section 2.1, Figure 5.** Reported efficiency is against **self** and peer
|
||||
baselines: a brute-force per-pixel interpreter (about 58 ms/MP) and a compiled
|
||||
kernel (about 3 ms/MP).
|
||||
- **Section 3.2, Figure 6.** Tape length is reduced 17× in 64² tiles and 216× in 8²
|
||||
subtiles, from 6056 clauses to means of 356 and 28.
|
||||
- **Section 5, Figures 9 and 10.** Heatmaps of work per pixel show extra work at
|
||||
model edges, where interval evaluation "can't skip entire regions". The text says
|
||||
many smooth blends lead "to less useful interval evaluation results".
|
||||
- **Section 6.** It names reduced affine arithmetic as a way "to more tightly bound
|
||||
intervals".
|
||||
|
||||
https://www.mattkeeter.com/research/mpr/keeter_mpr20.pdf
|
||||
- **[fetched]** Keeter, *Gradients are the new intervals* (blog, 2025-05-14). It
|
||||
compares interval evaluation with gradient-based pseudo-intervals by tape length
|
||||
(120.2 against 121.5 clauses) and by render time. These are peer references. There
|
||||
are no tile-classification counts and no ground truth **[not found]**.
|
||||
https://www.mattkeeter.com/blog/2025-05-14-gradients/
|
||||
- **[fetched]** Heidrich, Slusallek, Seidel, *Sampling Procedural Shaders Using Affine
|
||||
Arithmetic* (ACM TOG 17(3), 1998):
|
||||
- **What "error" measures.** Error is the width of the computed range per pixel. It
|
||||
is not compared with the true range from dense sampling **[not found]**. The
|
||||
tightness against the true variation is argued qualitatively, from shader cross
|
||||
sections.
|
||||
- **Section 3.2.** Noise over an affine form that spans several lattice cells falls
|
||||
back to interval arithmetic, and a span of more than two cells returns the full
|
||||
noise range [0, 1]. For turbulence (blue marble), 81% of pixels lie within 5%
|
||||
error and 59% within 1%. The eroded shader, with high-frequency noise, gets 77%
|
||||
and 53%.
|
||||
- **Section 4.** Against AA, interval arithmetic gave errors "up to 50 percent where
|
||||
affine arithmetic produces errors below 1/256" (wood shader, Figures 6 and 7).
|
||||
|
||||
https://vccimaging.org/Publications/Heidrich1998SPS/Heidrich1998SPS.pdf
|
||||
- **[fetched]** Gamito and Maddock, *Ray casting implicit fractal surfaces with reduced
|
||||
affine arithmetic* (The Visual Computer 23(3), 2007). Section 5, Tables 1 to 3, use
|
||||
a sphere hypertextured with three octaves of noise. The accuracy proxy is the
|
||||
average number of interval evaluations per ray, a **peer** comparison with no oracle
|
||||
count. The rows below are reconstructed from a mangled text layout, and the
|
||||
reconstruction agrees with the paper's prose.
|
||||
|
||||
| Noise | IA evals/ray | IA time | Standard AA evals/ray | Reduced AA evals/ray | Reduced AA + interval optimisation, evals/ray | Its time |
|
||||
|------------------------|--------------|-----------|-----------------------|----------------------|-----------------------------------------------|-----------|
|
||||
| Perlin gradient | 78.40 | 8m27.5s | 34.60 | 34.60 | 20.81 | 3m43.2s |
|
||||
| Lewis sparse convolution | 48.44 | 26m31.0s | 23.25 | 23.25 | 13.46 | 6m29.3s |
|
||||
| Worley cellular | 45.70 | 25m54.7s | 32.56 | 43.78 | 21.29 | 14m25.9s |
|
||||
|
||||
The text cites "the excessive conservativeness of IA estimates", and says IA
|
||||
compensates by being fast.
|
||||
https://eprints.whiterose.ac.uk/id/eprint/3762/1/gamito_newraycastvisual.pdf
|
||||
- **[fetched]** Knoll, Hijazi, Kensler, Schott, Hansen, Hagen, *Fast Ray Tracing of
|
||||
Arbitrary Implicit Surfaces with Interval and Affine Arithmetic* (CGF 28(1), 2009).
|
||||
Section 6.1.1 and Tables 1 and 2 report frames per second for IA against reduced
|
||||
AA:
|
||||
- RAA is "generally 1.5 to 2× faster", and "3 to 4 times faster" for functions
|
||||
"with high bound overestimation";
|
||||
- IA is faster for superquadrics.
|
||||
|
||||
These are **peer** references, with no oracle count of wasted subdivisions
|
||||
**[not found]**.
|
||||
https://sci.utah.edu/~knolla/cgrtia.pdf
|
||||
- **[fetched]** Sharp and Jacobson, *Spelunking the Deep: Guaranteed Queries on General
|
||||
Neural Implicit Surfaces via Range Analysis* (SIGGRAPH 2022), section 3.4 and Table 1.
|
||||
Tightness is measured as "the typical size of a region which can be bounded away
|
||||
from the zero":
|
||||
|
||||
| Method | 1D length | 3D volume | Ray-cast time (relative to the fastest) |
|
||||
|---------------|-----------|-----------------|-----------------------------------------|
|
||||
| interval | 0.011 | < 0.001 × 10⁻³ | 34.3× |
|
||||
| affine (full) | 0.821 | 3.499 × 10⁻³ | 8.4× |
|
||||
| affine (fixed)| 0.306 | 0.267 × 10⁻³ | 1.0× |
|
||||
|
||||
The prose says interval arithmetic "yields extremely pessimistic bounds". These are
|
||||
**peer** references, with no oracle region size **[not found]**.
|
||||
https://arxiv.org/abs/2202.02444
|
||||
- **[fetched]** Mitchell, *Robust Ray Intersection with Interval Arithmetic* (Graphics
|
||||
Interface 1990). It reports root-finding timings for its method and for peer
|
||||
root finders. No pruning-against-oracle measure **[not found]**.
|
||||
http://graphicsinterface.org/wp-content/uploads/gi1990-8.pdf
|
||||
- Duff, *Interval Arithmetic and Recursive Subdivision for Implicit Functions and
|
||||
Constructive Solid Geometry* (SIGGRAPH 1992), and Snyder, *Interval Analysis for
|
||||
Computer Graphics* (SIGGRAPH 1992), were not retrieved in this pass.
|
||||
https://doi.org/10.1145/142920.134027
|
||||
|
||||
## 4. Conservative rasterization and tile classification
|
||||
|
||||
- **[fetched]** Hasselgren, Akenine-Möller, Ohlsson, *Conservative Rasterization*
|
||||
(GPU Gems 2, chapter 42):
|
||||
- **Section 42.1.** It defines overestimated rasterization as every pixel whose cell
|
||||
intersects the polygon, and underestimated rasterization as every pixel whose
|
||||
cell lies wholly inside it.
|
||||
- **Section 42.5.** Overdraw is discussed qualitatively: it depends on triangle
|
||||
acuity and resolution. Figure 42-7 compares the two proposed algorithms only. No
|
||||
count of excess fragments against the exact overlap set **[not found]**.
|
||||
|
||||
https://developer.nvidia.com/gpugems/gpugems2/part-v-image-oriented-computing/chapter-42-conservative-rasterization
|
||||
- **[search]** Antochi, Juurlink, Vassiliadis, Liuha, *Scene management models and
|
||||
overlap tests for tile-based rendering* (DSD 2004), and the companion *Efficient
|
||||
tile-aware bounding-box overlap test*. In a search summary, the bounding-box
|
||||
triangle-to-tile test "can generate up to 30% false positives" on common workloads,
|
||||
measured against exact overlap. The full text could not be retrieved (the TU Delft
|
||||
host refused the connection, and CiteSeerX returned HTML).
|
||||
https://citeseerx.ist.psu.edu/document?doi=5c78f05f185b9513bf930588dd2a4ae334090620
|
||||
|
||||
## 5. Summary of reference kinds and reported figures
|
||||
|
||||
| Source | Reference | Figure reported |
|
||||
|--------|-----------|-----------------|
|
||||
| Bittner et al. 2005, §6.2 | oracle (exact PVS) | conservative PVS +47% / +76% (ratio ≤ 0.68 / ≤ 0.57) |
|
||||
| Bittner et al. 2005, §6.3 | oracle | object discretization +17% (≈ 0.85); line-space 0.023–0.52 by scene and resolution |
|
||||
| Bittner thesis, ch. 2 | oracle | defines `Q_o`, `Q_u` relative to `|S_E|` |
|
||||
| Cohen-Or et al. 2003 | — | notes the PVS/visible-set ratio is rarely reported |
|
||||
| Nirenstein et al. 2002 | oracle (its own exact) | percent culled; calls that metric crude |
|
||||
| CHC++ 2008 | oracle (OPT, node level) | within a few percent of OPT (exact queries, not bounds) |
|
||||
| HOM 1997 | self | % of model culled, normalized frame time |
|
||||
| Li, Mueller, Kaufman 2003 | self / all non-empty | 2.8× speedup; 56% of non-empty voxels skipped; overhead ≈ 10% reported separately |
|
||||
| Zellmann 2019 | self | fps against no skipping |
|
||||
| Keeter 2020 | self (brute force, compiled) | 17× / 216× tape reduction; false-positive share not measured |
|
||||
| Heidrich et al. 1998 | none (range width) | IA error up to 50% where AA < 1/256 |
|
||||
| Gamito, Maddock 2007 | peer | IA 2.1–3.8× more evaluations per ray than reduced AA + optimisation on noise |
|
||||
| Knoll et al. 2009 | peer | RAA 1.5–4× faster than IA |
|
||||
| Sharp, Jacobson 2022 | peer | IA provable region about 75× smaller in 1D than full AA |
|
||||
| GPU Gems 2, ch. 42 | peer | qualitative overdraw |
|
||||
| Antochi et al. 2004 [search] | oracle (exact overlap) | bounding box up to 30% false positives |
|
||||
|
||||
On the interval side (section 3), none of the sources read in this pass reports a
|
||||
pruning or culling fraction against an oracle mask, such as the truly empty cells or
|
||||
the truly non-ambiguous tiles. They report only against self or peer baselines. The
|
||||
oracle-referenced ratios found are all on the visibility side (sections 1.2 and 1.3),
|
||||
and the one from rasterization (section 4) is search-only. No published figure was
|
||||
found for interval-arithmetic culling of noise or warped-noise fields as a fraction of
|
||||
an oracle.
|
||||
|
||||
## 6. Local context (from the eureka mind, not re-measured)
|
||||
|
||||
- **Question** `cultmath-tapes:question:wells-ceiling-oracle` (admitted 2026-10-03,
|
||||
Imagination). At 3ac7250a it gives:
|
||||
- warp 0: oracle efficiency 0.38, mask ceiling 0.96, cull fraction 0.95;
|
||||
- warp D = 60: oracle efficiency 0.19, mask ceiling 0.99, cull fraction 0.84;
|
||||
- a pre-pass that skips nothing: 0.14 against the oracle.
|
||||
- **Finding** `cultmath-tapes:finding:cut-interval-ops.s2.wells-ceiling-contract-vacuous`
|
||||
(Soul, confirmed by mutation). The mask-referenced (c) check reports 0.98 with the
|
||||
wells skip disabled. Locations are in
|
||||
`packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs`: lines 1257–1260, 1437
|
||||
and 1461.
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
# Union and record decode tolerance: prior art and CultCache's stance
|
||||
|
||||
Fork: thing2:question:contract-decode-tolerance. Follow-up: thing2:follow_up:cultcache-union-parity.
|
||||
Eyes pass, 2026-10-10, session self-2026-10-10c-eureka. Facts only; no recommendation.
|
||||
Tags: [fetched] read this session; [local] read in the repo this session; [background] prior knowledge, not refetched; [not found] the fetched page did not say.
|
||||
|
||||
## 1. Established systems
|
||||
|
||||
### Protocol Buffers
|
||||
- Unknown fields: proto3 "preserve[s] unknown fields and include[s] them during parsing and in the serialized output", matching proto2. Lost on JSON serialization and on field-by-field copy into a new message; CopyFrom/MergeFrom avoid the loss. (protobuf.dev/programming-guides/proto3/, "Unknown Fields") [fetched]. The version proto3 started preserving (3.5) [background].
|
||||
- Unknown oneof case: no distinct state. The page warns a NOT_SET result is ambiguous: the oneof may be unset, or set to a field from a newer schema [fetched]. A oneof member the reader lacks is an ordinary unknown field, so it sits in the unknown-field set and is re-emitted [background].
|
||||
- Unknown enum values: "unrecognized enum values will be preserved in the message" (raw int in open-enum languages, accessor in Java) and re-serialized [fetched].
|
||||
|
||||
### Cap'n Proto
|
||||
- Union: a separate tag says which member is set. Adding a member to a union is safe because "existing programs should already know to check the union's tag"; they "may or may not behave reasonably when the tag has a value they don't recognize". Moving an existing field into a new union can show an old reader "a garbage value or throw an exception" (capnproto.org/language.html, "Unions", "Evolving Your Protocol") [fetched].
|
||||
- which() on an unknown discriminant: [not found] in the fetched language and encoding pages. C++ generated which() returns the tag as an enum and the reader is expected to switch with a default [background].
|
||||
- Preservation: [not found] on the fetched pages; structs "can be traversed (e.g., copied) without knowing their type" (encoding.html, Structs) [fetched]. Copy preservation of unknown data is [background].
|
||||
|
||||
### Avro (schema resolution; reader schema and writer schema both required)
|
||||
- Both unions: reader takes its first branch matching the writer's selected branch; "if none match, an error is signalled". Writer union, reader non-union: the selected writer branch must match, else error (Avro spec, Schema Resolution) [fetched; also quoted from the 1.11.1 spec in docs/research/read-only-record-signal-prior-art.md:176-178].
|
||||
- Record, writer has an extra field: "the writer's value for that field is ignored". Reader field missing from the writer: reader's default if it has one; no default is an error [fetched].
|
||||
- Enum, writer symbol absent in reader: reader's declared default if any, else error [fetched].
|
||||
- Preservation: none. Extra fields are dropped by resolution [fetched].
|
||||
|
||||
### FlatBuffers
|
||||
- Union evolution: appending a member is safe ("CodeV1 will not recognize the another_a"); inserting in the middle without an explicit discriminant misreads members; inserting with an explicit discriminant is safe (flatbuffers.dev/evolution/) [fetched].
|
||||
- What an old reader sees for an unknown union type, and re-serialization preservation: [not found] on that page. Generated readers expose a type byte plus a union table pointer; an unknown type value falls outside the generated enum [background].
|
||||
|
||||
### Thrift
|
||||
- Unknown fields and unknown union field ids: [not found] in thrift-rpc.md. Protocol readers skip unrecognized field ids and drop the data; an unknown union id leaves the union empty, and a union with no field set can fail validation [background].
|
||||
|
||||
### MessagePack-CSharp [Union]
|
||||
- README Union section says only "Unique union keys are required"; it states no behaviour for an unknown key and no option for it [fetched, first 100k chars of the README].
|
||||
- Missing keys: members initialize to default (README, Object Serialization) [fetched]. Extra array elements for [Key(int)] and unknown string keys: README silent; it shows a manual formatter that loops and skips extras [fetched].
|
||||
- Generated and dynamic union formatters skip the payload and yield null when the key is absent from the union's key map [background]. The estate's own measurement agrees: unknown arm key decodes to null, no error (question document, "Measured in the C# reference... MessagePack-CSharp 3.1.7") [local, via eureka-state view]. The vendored copy at F:\Projects\CultPong\Assets\Scripts\MessagePack\Resolvers\DynamicUnionResolver.cs does a key-map TryGetValue at :347 (type-map TryGetValue at :206); the miss branch was not read.
|
||||
- No configurable unknown-union behaviour found in the README; whether a newer version adds one [not found].
|
||||
|
||||
### serde
|
||||
- enum-representations page says nothing on unknown variant names, #[serde(other)] or deny_unknown_fields [fetched]. serde_derive de.rs was fetched but the identifier/enum_ modules holding unknown_variant were not in the excerpt [not found].
|
||||
- [background] default: an unknown variant is an error ("unknown variant `X`, expected one of ..."); #[serde(other)] on a unit variant makes the identifier decoder return it for any unknown name (units only, payload discarded); unknown struct fields are ignored unless #[serde(deny_unknown_fields)]; a missing field is an error unless #[serde(default)] or Option.
|
||||
|
||||
### Bincode / postcard
|
||||
- [background] Both are non-self-describing: an enum is a variant index (bincode u32, postcard varint) followed by fields in order; an out-of-range index is a decode error; there are no field tags, so an extra or missing slot is detectable only by running out of or leaving bytes. Evolution is by schema versioning outside the format. Not fetched.
|
||||
|
||||
## 2. Preservation across an older reader, and the failure each answers
|
||||
| System | Unknown data survives re-encode? | Failure answered |
|
||||
|---|---|---|
|
||||
| Protobuf | yes: unknown fields (hence unknown oneof cases) and unknown enum ints, in binary APIs only [fetched] | old intermediary re-serializing a newer message (purpose: background) |
|
||||
| Cap'n Proto | page silent; reader sees a tag it may not know and must handle it [fetched] | in-place access; append-only evolution |
|
||||
| Avro | no: extra field dropped, or error on no branch / no default [fetched] | writer schema travels with data, so resolution is exact and loud |
|
||||
| FlatBuffers | page silent [fetched] | append-only evolution, explicit discriminants |
|
||||
| Thrift | no (skipped) [background] | forward tolerance for old readers |
|
||||
| MessagePack-CSharp | no: arm becomes null (measured), extra slot skipped | not documented |
|
||||
| serde | no; error by default [background] | type safety; leniency is opt-in |
|
||||
| Bincode/postcard | n/a [background] | compactness; no evolution in the format |
|
||||
| Unity SerializeReference, DataContract ExtensionData | yes: bytes retained and recoverable (docs/research/read-only-record-signal-prior-art.md:182-205) [local] | data loss from missing types |
|
||||
|
||||
## 3. CultCache's own stance (F:\Projects\CultLib)
|
||||
- docs/persisted-record-encoding.md:1-19: persisted records are verified by re-encoding what was decoded and comparing bytes; a record that reads but re-encodes differently is refused, on live data at startup or next read. Three Idunn guards cited at :10-14. [local] A tolerated extra trailing slot or a null-collapsed arm would not re-encode to the stored bytes; whether those guards would run on such records was not tested.
|
||||
- No evolution/compatibility doc: `ls docs | grep persist|evol|compat|schema|version` returns only persisted-record-encoding.md. docs/document-variants-cut.md:327-333 covers union-slot reuse at registration ("late union-slot reuse passes registration"; "interface unions are never minted") [local, grep lines only].
|
||||
- Reference union handling: src/GameCult.Caching/CultInspectorModel.cs:768-774 classifies an abstract/interface type by its [UnionAttribute]s ordered by key and refuses a type with none ("declares no [Union] subtypes", :772); also :120. This is the inspector's shape classifier; decode is MessagePack-CSharp's. No other use of UnionAttribute in src/GameCult.Caching. [local]
|
||||
- Trailing-slot tolerance: packages/cultcache-ts/test/cult-cache.test.ts:1176-1195, "CultCache interop reader accepts missing compatible trailing slots and rejects mismatched slots": a legacy-payload store with fewer slots reads with `tags: []`; a mismatched-type payload rejects with /Expected string/u. Missing trailing slots are tested; extra trailing slots are not. [local]
|
||||
- TS decode: packages/cultcache-ts/src/cult-cache.ts:679-692 wraps a formatter as `schema.parse(decode(payload))`, so TS strictness is whatever the zod schema says; no TS union codec (follow-up text). [local]
|
||||
- Rust: packages/cultcache-rs/src/*.rs has no unknown-arm or extra-slot handling found by grep; "unknown" hits are store-header tests (:4889-4890 refuses an unknown header by name; :5009-5019 unknown-header.msgpack, variant-v2.msgpack). Rust enum arms persist as serde external tags (follow-up text). [local]
|
||||
- Consumers persisting unions: Aetheria (F:\Projects\Aetheria) has `[Union]` abstract classes in Assets/Scripts/ServerShared: Provenance.cs:96-98 (keys 0-2), ItemInstance.cs:18-21 (keys 0-3) and :29-31, Environment.cs:73. The assembly registers CultCache documents (AssemblyInfo.cs:3-4; Galaxy.cs, ItemData.cs, AetheriaStores.cs reference CultCache); ItemInstance is held in entity item lists (Entity.cs:2003). An older build reading a store with a newer arm key would take the C# null-arm path, if these types sit inside persisted CultCache documents; that persistence path was not traced. Aetheria-legacy has more unions (Behaviors.cs:29-38, Item.cs:43-63, Contract.cs:10-11); commented-out keys 8, 9 at Behaviors.cs:37-38 show arms retired in place. [local]
|
||||
- Ghostlight and Huginn: not searched for [Union] (the grep matched only Aetheria). Follow-up text: "No live record crosses runtimes as a union today" [local, view]. Same-runtime older-build-reads-newer-store: unverified.
|
||||
- The question document records the C# measurement: extra trailing slot skipped and equal; missing trailing slot leaves member default; unknown arm null, for a Node in a list and for an Outcome; Rust and TS union decoders with explicit keys refuse by construction; Rust extra-slot behaviour unmeasured [local, view].
|
||||
|
||||
## 4. Operator's stated value, and how each option relates (inferred)
|
||||
Verbatim, operator-rapport.md:50: "nobody should have to carry bytes they don't understand ... CultCache's whole promise is that a cache's contents are self-identifying via the upfront catalog." Context: pushing back on storing retired slots as metadata (:49). [local]
|
||||
- Preserve-unknown (protobuf, Unity): the older reader carries bytes the catalog does not describe. Opposite to the stated value.
|
||||
- Drop silently (Avro extra fields, MessagePack slot skipping, Thrift skip): nobody carries the bytes, but the loss is silent and a re-encode no longer equals the stored bytes (persisted-record-encoding.md:4-6).
|
||||
- Null the arm (current C# union behaviour): the unknown arm becomes a value the catalog does not describe either; the record decodes as if valid.
|
||||
- Refuse (Avro no-branch, serde default, Rust/TS explicit-key decoders, store-header refusal): the reader declares it cannot understand the bytes; consistent with self-identifying content because undescribed content is not accepted.
|
||||
- Tolerate trailing slots but refuse unknown arms (fork option 1): the catalog-described slot list governs required members; slots beyond it are skipped, which is the drop-silently pattern for slots and the refuse pattern for arms.
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
# Cycles CPU FMA contraction: facts for the kernel-pin fork
|
||||
|
||||
Serves finding `cultmath-tapes:finding:cut-cycles-converters.s1.kernel-pin-unraised-fork` (kind Finding, InForce, Medium, admitted 2026-10-13, soul-cycles-converters-s1).
|
||||
Gathered by eyes-cycles-fma, session self-2026-10-14-eureka, 2026-10-14. Facts only; no recommendation.
|
||||
|
||||
Strength marks: [src] read from source text; [doc] vendor or standards documentation; [web] issue, forum or search-result summary; [abs] absence of evidence (searched, not found).
|
||||
"Raw" Blender paths below are fetched as `https://projects.blender.org/api/v1/repos/blender/blender/raw/<path>?ref=<tag or branch>`. Local copies of the main-branch files are in `...\scratchpad\eyes-cycles-fma\dl\`.
|
||||
|
||||
## 0. The finding's own record (what is being tested)
|
||||
|
||||
- [src] The finding quotes the oracle probes: with the AVX2 kernel, 17 function rows differ from committed fixtures (floored_modulo 151/1024 cases ... color 55/1024), worst `mix_overlay` 493 ulp, `cycles_floored_modulo` 958152704 ulp (sign flip). Yggdrasil's /proc/cpuinfo shows avx2 and fma.
|
||||
- [src] Oracle code: `kernel_check()` in `tools/cycles-oracle/oracle.py` bakes FLOORED_MODULO of 10000 by 0.1 and compares to numpy unfused `a - floor(a/b)*b`; exits 4 if the kernel fuses. Read with `git show e48f9488:packages/cultmath/tools/cycles-oracle/oracle.py` (lines ~528-551), README lines 20-30, `CyclesOracleTests.cs` lines 5-18 (those paths are not in the working tree at main; they exist at commit e48f9488). The oracle Blender is 5.2.2.
|
||||
|
||||
## 1. Cycles CPU kernels and flags
|
||||
|
||||
1. [src] The CMake file defines two x86 CPU kernel variants only: an SSE4.2 baseline and an AVX2 kernel (`WITH_KERNEL_AVX2`). No AVX-512 flag or kernel appears; `kernel.cpp` lists `cpu` and `cpu_avx2`. `intern/cycles/CMakeLists.txt` lines 76-130, 137-143 (main and v5.2.2); `intern/cycles/device/cpu/kernel.cpp` line 11 (v5.2.2): `KERNEL_NAME_EVAL(cpu, name), KERNEL_NAME_EVAL(cpu_avx2, name)`.
|
||||
2. [src] GCC/Clang flags (CMakeLists.txt lines 97-100): SSE4.2 = `-msse -msse2 -msse3 -mssse3 -msse4.1 -msse4.2`; AVX2 = SSE4.2 flags + `-mavx -mavx2 -mfma -mlzcnt -mbmi -mbmi2 -mf16c`. The `-mfma` flag is what makes FMA instructions available only in the AVX2 kernel. MSVC: AVX2 kernel `/arch:AVX2`, SSE4.2 kernel empty on x64 (lines 82-89). Intel compilers use `/QxCORE-AVX2` or `-xcore-avx2`.
|
||||
3. [src] Contraction flags, GCC/Clang block (CMakeLists.txt lines 160-180, main = v5.2.2): `-fno-trapping-math`, `-fno-math-errno`, `-fno-signed-zeros`, `-ffp-contract=on`, `-freciprocal-math`; `-fassociative-math` is commented out ("problems with bsdf_D for GGX on Linux (#130389)"); `-ffinite-math-only` deliberately not set. These are appended to the global `CMAKE_CXX_FLAGS`, so they apply to both kernels; only `-mfma` differs. GCC additionally gets `-fno-signaling-nans`, `-fno-rounding-math`, `-mfpmath=sse`.
|
||||
4. [src] MSVC (non-clang) block (line 149): `/fp:fast`, with the comment "Unlike GCC/clang we still use fast math, because there is no fine grained control and the speedup we get here is too big to ignore." For clang-cl the GCC/Clang flag list is passed with a `/clang:` prefix (line 198).
|
||||
5. [src] History of the contraction flag (Forgejo API commit messages):
|
||||
- `f96f3c33af`, 2024-09-30, "Cycles: Add some more math optimizations": added `-ffp-contract=fast`, `-freciprocal-math`, `-fassociative-math`. Message: "These are used on Windows and HIP anyways, so our code can't expect exact IEEE semantics in any case." Measured "~1.5% speedup in my very quick test".
|
||||
- `0fc58f0aba`, 2024-11-25, "Disable -fassociative-math on Linux to avoid GGX issues".
|
||||
- `7b19e74cad`, 2025-12-14 (PR 150318): changed `fast` to `on`: "Disable FMA auto-vectorization across different statements in Cycles, because GCC 15.2.1 manages to ruin the viewport's scaling and offset" (GCC bug 106902). It also removed a `remove_cc_flag("-ffp-contract=off")` block ("Not needed to make cycles automated tests pass with -march=native. However Blender itself needs this flag").
|
||||
- Before 2024-09-30 the CMake file carried no Cycles-specific contraction flag; the global Blender flag was `-ffp-contract=off` (stripped for Cycles by that `remove_cc_flag` block). [src, inferred from the removed lines; the earlier state was not read at an older tag.]
|
||||
6. [doc] GCC definition: `-ffp-contract=on` "enables contraction within one expression, but not across different statements"; `fast` forms FMA "if the target has native support"; default `off` for ISO C, `fast` otherwise. Source: https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html. (Which GCC release implemented `on` for C++ was not checked.)
|
||||
7. [src] Cycles' floored modulo is one C++ expression: `return (b != 0.0f) ? a - floorf(a / b) * b : 0.0f;` in `intern/cycles/util/math_base.h` (v5.2.2 line 613-616; main line 646-649). `NODE_MATH_FLOORED_MODULO` calls it from `kernel/svm/math_util.h` line 164. `safe_modulo` (truncated) uses `fmodf`, not an expression. `-freciprocal-math` also permits `a/b` to become `a * (1/b)`.
|
||||
8. [src] Env switch: `intern/cycles/util/debug.cpp` (v5.2.2) line 29 `CHECK_CPU_FLAGS(avx2, "CYCLES_CPU_NO_AVX2")`, line 23 `flag = (getenv(env) == nullptr)`; presence of the variable disables the AVX2 kernel. [web] added 2015-02 by Sergey Sharybin, commit 4b04a4e, described as developer-only: https://lists.blender.org/pipermail/bf-blender-cvs/2015-February/073396.html (via search summary).
|
||||
9. [src] The AVX2 kernel is selected at runtime by CPU capability: `system.cpp` `system_cpu_support_avx2()` requires avx2 and f16c ("F16C is considered part of AVX2 for our purpose"), so a default Blender on any AVX2 CPU uses it.
|
||||
10. [src] Cycles' own render tests are image tests with a default `fail_threshold = 0.016`, `fail_percent = 1` (`tests/python/modules/render_report.py` lines 287-288), overridden per test (e.g. 0.032, 0.06, 0.048; `tests/python/cycles_render_tests.py` ~339-357). They compare images, not per-node bit patterns. A grep for AVX/NO_AVX in these two files found nothing, so the tests do not appear to pin a kernel. [src for what was found; absence is [abs]]
|
||||
|
||||
## 1b. GPU backends
|
||||
|
||||
11. [src] CUDA: `intern/cycles/device/cuda/device_impl.cpp` line ~225 passes `--use_fast_math` to nvcc (nvcc fuses FMA by default; `--fmad` default is documented NVIDIA behaviour, not fetched here). `kernel/device/cuda/compat.h` line 89 has the comment "Use fast math functions".
|
||||
12. [src] HIP: `kernel/device/hip/compat.h` line 91 "Use fast math functions" (the HIP compile flags were not located; the commit message in item 5 states HIP already runs without exact IEEE semantics).
|
||||
13. [src] Metal: `device/metal/device_impl.mm` line 570 `options.fastMathEnabled = YES;`.
|
||||
14. [src] oneAPI: `kernel/device/oneapi/CMakeLists.txt` line 71 `-ffast-math`, line 83 `-cl-fast-relaxed-math` for spir64.
|
||||
15. [src] OptiX: not separately examined (OptiX kernels are CUDA-compiled).
|
||||
16. [src] The fp-contract/fast-math choices are thus explicit in all of: CPU (`on`, both kernels), MSVC CPU (`/fp:fast`), CUDA, HIP, Metal, oneAPI.
|
||||
|
||||
## 1c. Do Blender developers treat CPU/GPU numeric differences as bugs
|
||||
|
||||
17. [web] Issue #101114 (Power math node precision): a developer said they do not handle such precision issues as bugs since implementation is by the GPU driver and manufacturer. https://developer.blender.org/T101114 (search-result summary; page not read).
|
||||
18. [web] Issue #68687 (Eevee modulo inconsistent): a developer said the imprecision "is likely to remain as it is an implementation behavior we cannot ensure"; reporters noted Cycles gave the expected result. https://developer.blender.org/T68687 (summary).
|
||||
19. [web] Issue #53348 (Cycles CPU vs GPU in World nodes): a developer attributed the difference to "--fast-math we use for CUDA" and noted disabling it costs about 15% speed. https://projects.blender.org/blender/blender/issues/53348 (summary; page returned 403 to WebFetch).
|
||||
20. [web] Issue #67448 "Shader node float precision issue cause different results on CPU and GPU" exists (archived); its content was not read (redirect not followed). https://developer.blender.org/T67448
|
||||
21. [abs] No Blender issue specific to Floored Modulo and FMA/AVX2 was found; no Blender document stating a CPU/GPU numeric parity guarantee was found.
|
||||
|
||||
## 2. Shader languages
|
||||
|
||||
22. [doc] HLSL `precise`: separate operations stay separate ("where a mul and add operation might have been fused into a mad operation, precise forces the operations to remain separate"; fusion then only via the explicit `mad` intrinsic); order of operations preserved; IEEE NaN/INF honoured. It propagates only to operations that contribute to the `precise`-qualified variable; marking shader outputs is recommended. https://learn.microsoft.com/en-us/windows/win32/direct3dhlsl/dx-graphics-hlsl-variable-syntax
|
||||
23. [doc] HLSL `mad`: may be fused or mul+add in hardware; for `precise` values the hardware must be consistent across all uses of that mad on that hardware. https://learn.microsoft.com/en-us/windows/win32/direct3dhlsl/mad (search summary). [web] The HLSL team blog says any operation may be refactored unless `precise`, and `/Gis` makes all computations precise: https://learn.microsoft.com/ko-kr/archive/blogs/marcelolr/precise-and-ieee-strictness-in-hlsl (summary).
|
||||
24. [abs] DXC's default contraction of plain `a*b+c` was not confirmed from DXC docs or source; clang HLSL PR #119820 "Make fast math the default for HLSL" exists (https://lists.llvm.org/pipermail/cfe-commits/Week-of-Mon-20241209/654101.html), content not read.
|
||||
25. [src] Vulkan SPIR-V environment (https://registry.khronos.org/vulkan/specs/1.3/html/chap37.html, fetched and grepped): "Implementations may rearrange floating-point operations using any of the mathematical properties governing the expressions in precise arithmetic ... may involve a different number of rounding steps"; "This rearrangement can be prevented for particular operations by using the NoContraction decoration." Without NoContraction the flags AllowContract/AllowReassoc/AllowRecip/AllowTransform are assumed. Same page: OpFMod "Inherited from x - y * floor(x/y)"; OpFRem inherited from `x - y * trunc(x/y)`; OpFDiv 2.5 ULP for |y| in [2^-126, 2^126]; and the page lists `OpFmaKHR`/`shaderFmaFloat32` (a core-spec FMA capability) as new.
|
||||
26. [doc] GLSL: `precise` qualifier ("the qualifier precise will ensure that operations contributing to a variable's value are performed in the order and with the precision specified") "effectively prohibits the arbitrary use of fused multiply-add"; under `precise`, `fma()` is one operation and `a*b+c` is two. Sources: GL_ARB_gpu_shader5 / OES_gpu_shader5 text and https://registry.khronos.org/OpenGL-Refpages/gl4/html/fma.xhtml (search summary). `invariant` was not looked up.
|
||||
27. [doc] SPIR-V `NoContraction` goes on the intermediate-result instructions, not the variable; GLSL `precise` lowers to it in glslang/ANGLE. [web] LLVM-based backends have contracted NoContraction multiplies into fma before (bug report in search results, wording in item summary only).
|
||||
28. [web] Metal: `MTLCompileOptions.fastMathEnabled`/`mathMode` (fast vs safe); in safe mode MSL requires correctly rounded FP32 add, sub, mul, div, fma; `metal::precise` namespace gives precise library functions; explicit `fma()` is the advice for controlling fusion. https://developer.apple.com/documentation/metal/mtlcompileoptions/mathmode.md and https://developer.apple.com/forums/thread/90959 (summaries). Current Xcode default for fast math was not confirmed ([web] sources conflict). No Metal `fp_contract` pragma found [abs].
|
||||
29. [abs] Unity's shader compiler (FXC/DXC via HLSLcc/SPIRV-Cross) contraction behaviour: no documentation found.
|
||||
|
||||
## 3. C# / .NET
|
||||
|
||||
30. [doc] `Math.FusedMultiplyAdd` (and `MathF`): "Returns (x * y) + z, rounded as one ternary operation"; infinite-precision multiply and add then one rounding. Available .NET Core 3.0+ and .NET 5-11. https://learn.microsoft.com/en-us/dotnet/api/system.math.fusedmultiplyadd
|
||||
31. [web] dotnet/runtime issue #10137 "[Jit] Recognize FMA pattern and Emit Fused Multiply Add" closed 2024-02-05 as COMPLETED with the maintainer comment: "Closing this. We have explicit `FusedMultiplyAdd` APIs and will likely cover any future 'pick the better of MultiplyAdd or FusedMultiplyAdd' via an explicit `MultiplyAddEstimate` API." (read with `gh issue view 10137 -R dotnet/runtime`). Earlier comments there state the intent that `a*b+c` stay two IEEE roundings. PR #61037 adds an arm64 MADD fold for integers only (summary).
|
||||
32. [web] Issue #98704 "Math.FusedMultiplyAdd returns wrong results on systems without FMA3 support", closed COMPLETED 2025-05-06: the software fallback had been an estimate (suspected ucrtbase `fma`), so correct rounding was guaranteed only with hardware FMA3; `COMPlus_EnableFMA=0` did not reproduce it on FMA3 hardware. Fix details were not read. Also [web] TA-Lib issue #340 reports the same misrounding concern.
|
||||
33. [abs] A statement in .NET docs that RyuJIT never contracts floating-point `a*b+c` was not found; #10137's closure is the nearest evidence. The CultMath repo's own use of FusedMultiplyAdd/MathF.FusedMultiplyAdd was not searched.
|
||||
|
||||
## 4. Other reference implementations and tolerance policies
|
||||
|
||||
34. [src] OpenShadingLanguage testsuite (`testsuite/runtest.py` lines 82-87): image diff thresholds `failthresh = 0.004`, `hardfail = 0.01`, `failpercent = 0.02`, warn at 2x failthresh; "A test fails if more than failpercent of pixel values differ by more than failthresh ... or if even one pixel differs by more than hardfail." Raw URL: https://raw.githubusercontent.com/AcademySoftwareFoundation/OpenShadingLanguage/main/testsuite/runtest.py
|
||||
35. [src] Blender's Cycles render tests: item 10 (image thresholds, default 0.016/1%).
|
||||
36. [doc] Vulkan precision table lists per-operation ULP classes (item 25); functions defined as expressions inherit the budgets of their constituents (GLES3 CTS spec, https://fuchsia.googlesource.com/third_party/vulkan-cts/+/30e25b62d60b36b923d19a0c0759ef6cb7201f9e/doc/testspecs/GLES3/functional.shaders.builtin_functions.precision.txt, summary). Historical draft GL_ARB_shader_precision: `a*b+c` "correctly rounded single operation or sequence of two correctly rounded operations", fma same as a*b+c (https://developer.download.nvidia.com/opengl/specs/GL_ARB_shader_precision.txt, summary).
|
||||
37. [abs] Not found/not examined: MaterialX reference-vs-generated shader tolerances (only structure of MaterialXTest located); glTF sample renderer conformance; any published "bit-exact vs ulp class" policy statement from Khronos, MaterialX, OSL or Blender; the Vulkan CTS source ULP constants for mix/smoothstep/fma.
|
||||
|
||||
## 5. Floored modulo specifics
|
||||
|
||||
38. [src] Definition in Cycles: `a - floorf(a / b) * b` (item 7). For a=10000, b=0.1f the quotient a/b rounds to 100000 (float) and floorf gives 100000, so the unfused product 100000*b rounds to 10000 and the result is 0, as the oracle records; the fused form computes the residual of the exact product (b is not exactly 0.1), yielding -1.49e-4 as the finding records. [abs] This arithmetic was not re-run here; it restates the finding's `kernel_check` docstring (oracle.py).
|
||||
39. [abs] No Blender issue or commit mentioning Floored Modulo together with FMA, contraction or AVX2 was found. [web] Related reports concern modulo/wrap disagreements between Cycles and Eevee/GPU drivers (#101562, #68687, #88151).
|
||||
40. [doc] Vulkan states OpFMod is "inherited from x - y * floor(x/y)", i.e. the same formula, so a GPU implementation of it follows its own contraction/precision rules (item 25).
|
||||
|
||||
## Coverage and gaps
|
||||
|
||||
- Strong [src]: Cycles flag set per ISA and compiler, the history of `-ffp-contract`, `safe_floored_modulo`, `CYCLES_CPU_NO_AVX2` mechanism, GPU backends' fast-math flags, Cycles/OSL image-test thresholds, Vulkan NoContraction and OpFMod text, HLSL `precise`.
|
||||
- Gaps: which compiler/version builds official Blender 5.2.2 release binaries (matters for how `-ffp-contract=on` is honoured); the 4.x flags before/after 2024-09-30 at release tags; DXC/Unity default contraction; Metal contraction pragma; MaterialX, glTF and Vulkan CTS numeric tolerances; a direct statement that RyuJIT never contracts; Blender developer position on CPU/GPU parity beyond forum/issue summaries (several pages 403 to WebFetch).
|
||||
- Files of record: `C:\Users\Meta\AppData\Local\Temp\claude\F--Projects-CultLib\0d5eef6e-e366-457f-bbc6-0151080ae1a8\scratchpad\eyes-cycles-fma\dl\` (CMakeLists.txt, math_base.h, math_util.h, vk.html).
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
# Decode stack safety: prior art (facts only)
|
||||
|
||||
Serves follow-up `cultlib-gaps:follow_up:decode-stack-safety` (campaign cultlib-gaps), source finding `thing2:finding:cut-contract-rs.s1.decode-stack-overflow-within-bound`.
|
||||
Gathered 2026-10-12 by eyes-decode-stack at CultLib `67c79d5c`. No recommendation. Web facts come from WebFetch/WebSearch summaries of the pages named; items marked UNVERIFIED were not confirmed against primary source.
|
||||
|
||||
## A. The local door (what exists today)
|
||||
|
||||
- `packages/cultcache-rs/src/lib.rs:503` `MAX_PAYLOAD_DEPTH = 256`; `check_payload_depth` (~526-540) is an iterative walk with a `[usize; 256]` count stack, scalars skipped by length.
|
||||
- `lib.rs:598-604` `decode_payload`: `check_payload_depth(payload)?; rmp_serde::from_slice(payload)`. Its doc comment calls rmp-serde's own counter (1024) "only a backstop".
|
||||
- Derive: `packages/cultcache-rs/cultcache-rs-derive/src/lib.rs:294-330` generates `impl Deserialize` with a `Visitor::visit_seq` over slot-ordered arrays (`deserialize_tuple`); unknown trailing elements are consumed via `IgnoredAny`.
|
||||
- rmp-serde resolved version: 1.3.1 (rmp 0.8.15; `packages/cultcache-rs/Cargo.lock`). Its depth limit is a `u16` `depth: 1024`, decremented per array/map/ext (`~/.cargo/registry/src/*/rmp-serde-1.3.1/src/decode.rs` lines 181, 213, 294, 566, 583, 616), error `DepthLimitExceeded`, tunable with `Deserializer::set_max_depth` (clamped to u16::MAX, lines 307-310). CHANGELOG line 95: "Default stack protector depth is now 1024 instead of 1000."
|
||||
- Upstream master rmp-serde (fetched summary, not checked line by line against 1.3.1): the `deserialize_newtype_struct` MSGPACK_EXT_STRUCT_NAME branch builds `ExtDeserializer` without `depth_count!`; the `any_inner` ext path does use it.
|
||||
- Existing local tests that already use sized threads: `packages/cultcache-rs/tests/union_parity.rs` ~786-830: `stack_budget_debug_2mib` (comment: "A debug build on a 2 MiB thread carries 405 containers of this document; the bound keeps every accepted payload below that") on a slim fixture (`wrap_chain(126, ...)`), and `the_walk_does_not_recurse` (100000 nested arrays refused on a 256 KiB thread). No stacker, `stack_size`, or thread spawn in `src/` or `Cargo.toml`.
|
||||
- The rmp-serde 1024 counter and the CultLib 256 bound both count containers; per-level stack cost depends on the target type (follow-up text: 15-19 KiB release, 87-95 KiB debug per level for contract-sized types).
|
||||
|
||||
## B. Rust serde ecosystem
|
||||
|
||||
### serde_json
|
||||
- Default limit 128: `Deserializer::new` sets `remaining_depth: 128` (field `u8`); `check_recursion!` returns `ErrorCode::RecursionLimitExceeded` at 0; invoked in `deserialize_any/seq/map/struct/enum`. https://github.com/serde-rs/json/blob/master/src/de.rs
|
||||
- `disable_recursion_limit()` exists only under Cargo feature `unbounded_depth`. Docs: "Parse arbitrarily deep JSON structures without any consideration for overflowing the stack"; tells the caller to "provide some other way to protect against stack overflows" and names `serde_stacker`. Docs also warn that Display, Debug and Drop on the result can overflow. https://docs.rs/serde_json/latest/serde_json/struct.Deserializer.html
|
||||
- The limit is a nesting-level count; no per-type frame accounting (code read: counter decrement only). The serde_json README mentions no recursion limit.
|
||||
|
||||
### serde_stacker (dtolnay)
|
||||
- Adapter wrapping a serde (de)serializer that grows the stack dynamically; README examples use `serde_json::Deserializer`, call `disable_recursion_limit()`, wrap with `serde_stacker::Deserializer::new`, nest 10,000 levels. README names Display/Debug/Drop as other overflow points and shows a manual iterative drop. README does not say it depends on `stacker` (UNVERIFIED). https://github.com/dtolnay/serde-stacker
|
||||
- It is a serde-level `Deserializer` wrapper, so it hooks calls routed through the wrapped deserializer.
|
||||
|
||||
### rmp-serde / rmpv
|
||||
- rmp-serde: depth counter 1024 default (section A). Search found no RUSTSEC advisory or issue for rmp / rmp-serde stack overflow (negative search, not proof). https://github.com/3Hren/msgpack-rust (rmp-serde/src/decode.rs).
|
||||
- rmpv not inspected.
|
||||
|
||||
### Other Rust deserializers
|
||||
- ciborium: `recurse: usize` default 256 in `from_reader`; `from_reader_with_recursion_limit`; doc: "Set a high recursion limit at your own risk (of stack exhaustion)!" https://github.com/enarx/ciborium (ciborium/src/de/mod.rs)
|
||||
- serde_cbor: RUSTSEC-2019-0025, tags nested without limit, <1 kB input overflows the stack, fixed in 0.10.2 by limiting nested tags. https://rustsec.org/advisories/RUSTSEC-2019-0025
|
||||
- rustc-serialize: RUSTSEC-2022-0004, JSON parser with no depth limit, "stack overflow, which aborts the process", unpatched, advises serde. https://rustsec.org/advisories/RUSTSEC-2022-0004
|
||||
- yaml-rust: RUSTSEC-2018-0006, deep nesting aborts, fixed by depth check. https://rustsec.org/advisories/RUSTSEC-2018-0006
|
||||
- serde-json-wasm: GHSA-j87p-gjr6-m4pv, overflow on deep untrusted JSON, fixed 1.0.1 by recursion depth check. https://advisories.gitlab.com/pkg/cargo/serde-json-wasm/GHSA-j87p-gjr6-m4pv/
|
||||
- quick-xml PR 982: adds a depth cap to its serde deserializer, default 128 "matching serde_json". https://github.com/tafia/quick-xml/pull/982
|
||||
- prost: `const RECURSION_LIMIT: u32 = 100`, comment "100 is the default recursion limit in the C++ implementation"; disabled by feature `no-recursion-limit`. https://github.com/tokio-rs/prost/blob/master/prost/src/lib.rs
|
||||
- bincode, postcard, simd-json: searches returned nothing on depth handling; not established either way.
|
||||
- Rust forum user report: serde overflowing the default stack beyond roughly 100 levels in some types, worked around with a 32 MiB thread. https://users.rust-lang.org/t/deserialize-large-nested-struct-without-stack-overflow/136335
|
||||
|
||||
## C. Stack-growth mechanism: stacker / psm / stacksafe
|
||||
|
||||
- `stacker::maybe_grow(red_zone, stack_size, f)`: checks whether the current stack is within `red_zone` bytes of its end; if so allocates a new stack of "at least `stack_size` bytes" and runs `f` there; else runs on the current stack. The closure runs on a stack with at least `red_zone` bytes available. https://docs.rs/stacker/latest/stacker/fn.maybe_grow.html
|
||||
- Source (rust-lang/stacker `src/lib.rs`): remaining stack = stack pointer minus thread-local `STACK_LIMIT` (lazily from `guess_os_stack_limit`); if `remaining_stack()` is `None`, `maybe_grow` "immediately grow[s]"; panics are caught on the new stack and re-raised after the segment is freed (`catch_unwind` / `resume_unwind`); on unsupported platforms `_grow` is a no-op that just calls the callback.
|
||||
- Platform: README says psm on most platforms, a Fibers-based implementation on Windows; "On all unsupported platforms this library is a noop... code will continue to hit the guard pages". https://github.com/rust-lang/stacker
|
||||
- psm README table (rust-lang/stacker/psm/README.mkd): Windows x86_64 Available Yes / Tested Yes / Callstack "Yes, but disabled"; Windows i686, aarch64, arm: No/No/No; Linux x86_64, i686, aarch64, arm: Yes/Yes/Yes; wasm: No/No/No ("callstack is not even in an address space"). Text: stacks allocated the usual way are not valid on Windows; Fibers offered as an "unnecessarily slow and inflexible" alternative.
|
||||
- CONFLICT, not reconciled: stacker's code selects a Windows backend (`backends::windows::_grow`, `cfg(all(windows, not(miri)))`) while the psm table marks Windows callstack "disabled"; stacker has a wasm32 branch (alloc-based restore guard) while psm lists wasm as No. An older docs.rs stacker 0.1.0 page lists a different verified-platform list.
|
||||
- Not found in sources read: per-call cost numbers; Windows aarch64 behavior of stacker beyond the psm table.
|
||||
- rustc usage (ensure_sufficient_stack, red zone / segment constants): two fetches returned 404; not recorded.
|
||||
- `stacksafe` (fast/stacksafe): `#[stacksafe]` attribute checks remaining stack at function entry and allocates a new segment below a threshold; defaults 128 KiB threshold, 2 MiB segment; optional `serde` feature gives stack-safe (de)serialization for the `StackSafe<T>` wrapper; MSRV 1.88.0; credits Orson Peters' `recursive` crate; says it supports platforms stacker supports (dependency on stacker not confirmed). https://github.com/fast/stacksafe
|
||||
|
||||
## D. Dedicated thread with sized stack
|
||||
|
||||
- `std::thread`: default spawned-thread stack "currently 2 MiB on all Tier-1 platforms"; `Builder::stack_size`; `RUST_MIN_STACK` (overridden by `stack_size`, "may be ignored after program start"); "the stack size of the main thread is not determined by Rust". https://doc.rust-lang.org/std/thread/index.html
|
||||
- Local use is in tests only (`union_parity.rs`, section A). No library was found in this pass that spawns its own decode thread for untrusted input; the Rust forum report (section B) and the protobuf.js advisory describe it as a caller-side or process-level workaround.
|
||||
- protobuf.js CVE-2026-44289 workarounds: reject excessively nested messages at an outer protocol boundary, or isolate decoding "in a process that can be safely restarted". https://corgea.com/advisories/vulnerabilities/CVE-2026-44289
|
||||
|
||||
## E. Non-recursive decoders
|
||||
|
||||
- `deser` crate (mitsuhiko, experimental): "does not recurse on the call stack, so deep nesting needs no recursion limit"; nested sinks/emitters kept in an arena; not serde-compatible; calls itself "an experiment". https://github.com/mitsuhiko/deser/wiki
|
||||
- `core-json`: no-std JSON parser with a fixed-size or caller-supplied state stack; typed layer is a separate crate, `core-json-traits`. https://docs.rs/core-json
|
||||
- No serde-compatible explicit-stack derive deserializer found (search negative).
|
||||
- msgpack-javascript: decoder iterative, `StackPool` array of states; DecoderOptions has no max depth, only length caps (`maxStrLength`, `maxBinLength`, `maxArrayLength`, `maxMapLength`, `maxExtLength`); pool memory grows with deepest nesting; array states allocate `new Array(size)` up front. https://github.com/msgpack/msgpack-javascript/blob/main/src/Decoder.ts
|
||||
- msgpack-python C unpacker (`unpack_template.h`): explicit `unpack_stack stack[MSGPACK_EMBED_STACK_SIZE]`, `start_container` returns -3 at `top >= SIZE`. `unpack.h` defines the size as 1024 (`unpack_define.h` default is 32). Local probe (msgpack 1.2.2, CPython 3.13.15, `python -I`): 100, 256, 1000 nested arrays decode ok; 100000 raises `StackError`.
|
||||
- CultLib's own `check_payload_depth` walkers are iterative (Rust lib.rs ~526; TS `packages/cultcache-ts/src/payload-depth.ts`; C# `CultPayloadDepth.cs`).
|
||||
|
||||
## F. Advisories and other-language limits
|
||||
|
||||
- MessagePack-CSharp CVE-2026-48506 / GHSA-vh6j-jc39-fggf (published 2026-06-25): `MessagePackReader.TrySkip()` recursed without counting depth, bypassing `MaximumObjectGraphDepth`; reachable through generated formatters' `reader.Skip()` on unknown keys; uncatchable StackOverflowException; affected <2.5.301 and 3.0.0 to <3.1.7; fix: iterative loop with a heap pending-count. https://securelayer7.net/lab/cve-2026-48506-messagepack-csharp-tryskip-uncontrolled-recursion-dos . Related: CVE-2026-48512 (`ConvertFromJson` recursion), CVE-2026-48513 (Union deserialization not consistently under depth enforcement).
|
||||
- Default `MaximumObjectGraphDepth`: the sources read do not state it (a figure of 500 was not confirmed).
|
||||
- protobuf: documented unmarshal depth limits Java 100, C++ 100, Go 10000 (plan to lower to 100); the page gives no rationale. https://protobuf.dev/programming-guides/proto-limits/ . CVE-2024-7254: protobuf-java nested SGROUP unknown fields/maps unbounded recursion, StackOverflow, CVSS 4.0 8.7, fixed 3.25.5 / 4.27.5 / 4.28.2. https://cveawg.mitre.org/api/cve/CVE-2024-7254 (CONFLICT: RHEL reports not affected; an OpenCVE page says no fix, against the CVE record's fixed versions).
|
||||
- Go encoding/gob CVE-2022-30635: uncontrolled recursion on ignored fields; fix adds a depth limit of 10,000; fixed in 1.17.12 / 1.18.4. https://go.googlesource.com/go/+/6fa37e98ea4382bf881428ee0c150ce591500eb7 . Go encoding/json nesting constant: not confirmed.
|
||||
- .NET System.Text.Json: `JsonSerializerOptions.MaxDepth` default 0 meaning 64; exceeding throws `JsonException`. https://learn.microsoft.com/en-us/dotnet/api/system.text.json.jsonserializeroptions.maxdepth
|
||||
- Python `json`: docs say the module "does not impose any such limits beyond those of the relevant Python datatypes themselves or the Python interpreter itself"; RecursionError for deep input not mentioned. https://docs.python.org/3/library/json.html . The pure-Python msgpack fallback Unpacker recurses (`# TODO should we eliminate the recursion?`), has no depth option, converts `RecursionError` to `StackError`. https://github.com/msgpack/msgpack-python/blob/main/msgpack/fallback.py
|
||||
- msgpack5 (JS) CVE-2026-107298: no nesting limit, deep input exhausts the decoder stack (title seen only). https://vulert.com/vuln-db/CVE-2026-107298
|
||||
|
||||
## G. CultLib's other runtimes
|
||||
|
||||
- C# reference: `src/GameCult.Caching.MessagePack/CultMessagePackSecurity.cs`: `UntrustedData.WithMaximumObjectGraphDepth(256)` as backstop; the rule is `CultPayloadDepth.Check`. MessagePack package pinned 3.1.7 (`GameCult.Caching.MessagePack.csproj`), the TrySkip fix version. Other C# surfaces use bare `MessagePackSecurity.UntrustedData` (`src/GameCult.Mesh/CultMeshProviderSessionWire.cs:231`, `src/GameCult.Networking/CultNetSchemaMessageSerialization.cs:15`, `src/GameCult.Networking/MessageSerialization.cs:8`).
|
||||
- TypeScript: `@msgpack/msgpack ^3.1.3` (`packages/cultcache-ts/package.json`); decoder iterative with no depth option (section E); encode passes `maxDepth: MAX_PAYLOAD_DEPTH * 2` (`src/document-formatter.ts:21`); `checkPayloadDepth` iterative. node_modules not present, so the installed version was not read.
|
||||
- Python: `msgpack>=1.0` (`packages/cultcache-py/pyproject.toml`); `documents.py:135` and `stores.py:97,384` call `msgpack.unpackb(..., raw=False)`; the C extension has an explicit 1024 stack, so 256-bounded payloads fit; the pure-Python fallback is recursive.
|
||||
- Whether a typed (class-mapped) TS/Py/C# decode layer above these raw decoders recurses per type was not inspected in this pass.
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
# Device capture runners: how established projects capture real-GPU results, and what a browser driver can silently get wrong
|
||||
|
||||
For `cultmath-tapes:question:device-run-driver` (campaign `cultmath-tapes`) and follow-up `cultmath-tapes:follow_up:glsl-browser-parity-run`.
|
||||
Gathered by eyes-ag3-device-driver, session self-2026-10-15-ag3, 2026-10-16. Facts only; no recommendation.
|
||||
|
||||
Strength marks: [src] read from a primary source text this pass; [doc] vendor or standards documentation read this pass; [web] search-result or fetch-summary of a page (a small model summarised it; check before quoting); [abs] searched, not found.
|
||||
Chromium doc pages were read at `main` or at an old pinned revision as noted; Chromium GPU bot details drift, so the dates matter.
|
||||
|
||||
## 0. What the question asks (the record)
|
||||
|
||||
- [src] Question `device-run-driver` (Imagination, 2026-10-16): a static page (`packages/cultmath/tools/device-runner`) compiles each family's call against `CultMath.glsl`, reads back through an RGBA32UI target, emits raw bits; C# judges. Options: `committed-captures` (page + stdlib Python server, any WebGL2 browser is a driver, captures committed with renderer, revision, shader sha, args hash, result bits, judged by `DeviceCaptureTests` on Linux), `playwright-live` (Microsoft.Playwright test dependency, Windows-only, ANGLE D3D11, must refuse SwiftShader), `both`.
|
||||
- [src] Prior precedent named in the question: three Soul/Hands passes through the desktop app's built-in browser on Starfire, renderer `ANGLE (NVIDIA GeForce GTX 1070 Direct3D11)`.
|
||||
- [src] Follow-up `glsl-browser-parity-run` records both GLSL files probed on a GTX 1070 (phacelle within 3.1e-6 of max(|v|,1), frustum check 0/256).
|
||||
|
||||
## 1. How established projects capture real-GPU results for CI
|
||||
|
||||
| Project | What runs where | What is stored, and where | What identifies the device | Source |
|
||||
|---|---|---|---|---|
|
||||
| Khronos WebGL conformance suite | Static HTML pages. Open `webgl-conformance-tests.html` in the browser under test (`?run=1` auto-starts), then "display text summary". Run by hand in any browser. | Formal results go as a pull request to the `KhronosGroup/WebGLConformanceSubmissions` repo. Older README copies said email `webgl_conformance_submissions@khronos.org`. Only WebGL implementors submit. | README instruction: "Verify that the User Agent and WebGL renderer strings identify your browser and target correctly." The summary text carries them. | [src] raw.githubusercontent.com/KhronosGroup/WebGL/main/sdk/tests/README.md (read 2026-10-16); [web] search summary of CONFORMANCE_RULES.txt |
|
||||
| Khronos WebGL conformance rules | Results carry forward: "results submitted for an earlier version of the browser carry forward to later versions ... that do not cause any previously passing test to fail"; for OS versions likewise (a new OS version needs a new submission). | The carry-forward rule is the only staleness rule found. | | [web] search summary of CONFORMANCE_RULES.txt, not read in full |
|
||||
| Vulkan CTS conformance submission | Run on the vendor's driver builds, every fraction. | Submission package must contain full `TestResults.qpa` logs per driver build and fraction, `git status` and `git log --first-parent <release tag>^..HEAD` output of a clean CTS checkout, `git format-patch` output of any CTS changes over the release tag, and a statement file with a `CONFORM_VERSION` line carrying the CTS git tag, plus PRODUCT, CPU, OS. `verify_submission.py` checks the package against the current mustpass list. | PRODUCT/CPU/OS lines in the statement. | [web] search summaries of `external/vulkancts/README.md` in Fuchsia/Android/Chromium mirrors (mirror copies differ by version) |
|
||||
| Chromium GPU bots | Physical machines in Swarming; most tests via Telemetry launching the full browser; `webgl_conformance_tests` and pixel tests. `chromium.gpu.fyi` tests top-of-tree ANGLE. | Expected outcomes in per-test expectation files (`pixel_expectations.txt` under `content/test/gpu/gpu_tests/test_expectations/`, `Failure` entries). Pixel images are not in the repo (Skia Gold, below). Some pixel tests check only a few pixels with handwritten code so one check serves all vendors. | Swarming task dimensions: `gpu` = PCI vendor:device-driver version (example `10de:2184-440.100`), plus `os`, `cpu`, `pool`. The page does not describe a runtime renderer-string check. | [src] chromium.googlesource.com/chromium/src/+/main/docs/gpu/gpu_testing.md (read 2026-10-16) |
|
||||
| Skia Gold (Chromium pixel tests) | goldctl client sends the produced image, the test name and hardware/software config metadata to a service. | Approved images live in a service (storage bucket + triage UI), not git. A test passes if the hash of the produced image is in that test's approved-hash list; default exact matching, multiple baselines per test. An unknown hash fails until a human triages it. Doc says local runs "differ slightly from bot images" and uploads need allow-listing; `--local-pixel-tests` disables upload. | Config metadata uploaded with each image. | [web] search summary of `docs/gpu/gpu_pixel_testing_with_gold.md` (pinned old revisions); [src] gpu_testing.md for the local-run flags |
|
||||
| WebGPU CTS in Chromium | gpuweb/cts compiled to JS, run through WPT and Telemetry; Telemetry covers non-virtual devices with real GPUs. A pinned revision is auto-rolled. | Expectations per platform: Fail/Skip may be any CTS query; Slow/Crash/Timeout must list parameters in loader order. | | [web] search summaries of Chromium `web_tests/webgpu/README.md` and a 2022 W3C public-gpu post by Kai Ninomiya (not read in full) |
|
||||
| Dawn node runner | `dawn.node` binding runs the CTS without a browser; `run-cts` can instead drive a Chrome instance. | `--output <path>` writes a results file, `--expect <path>` diffs a later run against it. Dawn keeps `webgpu-cts/expectations.txt`, pruned when tests start passing, with wide `Failure` wildcards for flaky ones. | | [web] search summaries of Dawn `src/dawn/node/README.md` and commit `f78edf56` ("Expand CTS expectations for Win11 QC CI bot") |
|
||||
| three.js | `test/e2e/puppeteer.js` screenshots almost all examples and compares to stored references; helpers `deterministic-injection.js`, `clean-page.js`, `check-coverage.js` exist in an older release's `test/e2e`. | Reference screenshots in the repo. | A 2020 issue: screenshots were black on macOS with the GPU-path flags; a maintainer floated using SwiftShader everywhere "to avoid GPU-specific bugs". Current flags and CI setup not confirmed. | [web] search summaries of discourse.threejs.org and a lightrun mirror of the GitHub issue; [abs] current `puppeteer.js` flags not read |
|
||||
| Babylon.js / Babylon Native | Visualization tests list scenes in `tests/test/visualization/config.json` (Babylon Native: `Apps\ValidationTests\Scripts\config.json`); each entry names a reference image, optional per-scene pixel-difference threshold. | Reference images in `ReferenceImages/` in the repo; `-u` regenerates. Babylon Native CI fails on too-different images and stores rendered/error images as an artifact. | | [web] search summaries of forum.babylonjs.com and a Babylon Native `ContinuousIntegration.md` fork; [abs] an `errorRatio` key was not found |
|
||||
| glslang / SPIRV-Cross | No GPU. SPIRV-Cross round-trips `shaders/` through glslangValidator/spirv-as and SPIRV-Cross, diffs output text against committed `reference/` files; `test_shaders.py` exits non-zero on difference, `--update` regenerates references that then ride in the PR; says to use the right glslang and SPIRV-Tools versions. Only hardware option: `--malisc` static cycle counts. | Reference text in the repo. | | [src] raw.githubusercontent.com/KhronosGroup/SPIRV-Cross/main/README.md (read 2026-10-16); [abs] glslang's own test practice not read |
|
||||
| dEQP GLES3 builtin precision tests | Random inputs through built-ins in a shader; outputs via transform feedback or framebuffer pixel; compared to a reference interval, not a single value. | Spec text of tolerances (section 4). | | [src] doc/testspecs/GLES3/functional.shaders.builtin_functions.precision.txt (Fuchsia mirror, read 2026-10-16) |
|
||||
|
||||
Observations across the rows (no ranking):
|
||||
- [src/web] Where real-GPU outputs are exact or large, the approved results are stored outside a source repo (Gold, Swarming expectations) or as submission packages (WebGL, Vulkan CTS). Where outputs are text from a deterministic tool (SPIRV-Cross) the reference is committed beside the source and `--update` regenerates it.
|
||||
- [web] Only the Vulkan CTS submission package was found to record the exact source revision (tag plus `git log`) inside the result bundle; WebGL's rule is the carry-forward rule above. No project was found that refuses a committed capture automatically because a shader source hash changed. [abs]
|
||||
- [src] Chromium's GPU doc keys a bot's hardware by PCI id and driver version rather than by a renderer string read inside the page.
|
||||
|
||||
## 2. Headless browser drivers on real GPUs
|
||||
|
||||
### 2.1 Fallback to a software renderer
|
||||
|
||||
- [src] Chromium SwiftShader doc (read at `refs/heads/main`): flags `--use-gl=angle --use-angle=swiftshader` (SwANGLE as the ES driver); `--use-gl=angle --use-angle=swiftshader-webgl --enable-unsafe-swiftshader` (WebGL fallback). "Automatic fallback to SwiftShader-backed WebGL is deprecated ... WebGL context creation will soon fail instead of falling back." `--enable-unsafe-swiftshader` opts in and suppresses the console warning. The page does not describe how to verify the renderer.
|
||||
- [web] Blink-dev "Intent to Remove: SwiftShader Fallback": deprecation period began Chrome 130; estimated desktop ship Chrome 137; a later reply from Rick Byers says API owners consider it high-risk and may phase it, so the milestone was unconfirmed in the thread; Finch feature `AllowSwiftShaderFallback`; headless SwiftShader use "will still be supported by opting in"; desktop only. Sources disagree on the shipped milestone (a Winaero Chrome 137 write-up says WARP replaces it on Windows, which the blink-dev page does not mention; Samsung's browser policy page says M139). Not resolved. [web]
|
||||
- [src] Chrome developer blog "Supercharge Web AI model testing": "By default, Headless Chrome disables GPU." `chrome://gpu` showed "Software only, hardware acceleration unavailable" until drivers were fixed; "Some combinations of flags allow detection of the GPU, but don't allow you to actually use the GPU." Failing renderer string: `ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver-5.0.0)`; working: `ANGLE (NVIDIA Corporation, Tesla T4/PCIe/SSE2, OpenGL ES 3.2 NVIDIA 525.105.17)`. Linux flags there: `--headless=new --use-angle=vulkan --enable-features=Vulkan --disable-vulkan-surface --enable-unsafe-webgpu`. Setup was Colab, not CI.
|
||||
- [web] A Playwright-on-GPU-runner write-up (createit.com) reported renderer ANGLE-on-Vulkan with SwiftShader; headed Chromium under a virtual display used the GPU; a Vulkan flag set later gave `llvmpipe`, still software.
|
||||
- [src] Chromium GPU testing doc: example bot flags `--use-gl=angle --use-angle=gl --use-cmd-decoder=passthrough --force_high_performance_gpu`, passed via `--extra-browser-args`.
|
||||
- [src] Playwright `chromiumSwitches.ts` at `main` (read 2026-10-16) contains no GPU, SwiftShader, ANGLE or headless switch. [abs] What the downloaded headless shell binary itself defaults to was not found.
|
||||
- [abs] No source was found on `--use-angle=d3d11` combined with headless Playwright on Windows, and none on the renderer string a Windows GPU reports under headless.
|
||||
- [web] `--disable-gpu` is reported by one source to force software rendering.
|
||||
|
||||
### 2.2 Detecting a fallback from inside the page
|
||||
|
||||
- [doc] `WEBGL_debug_renderer_info` exposes `UNMASKED_RENDERER_WEBGL` through `gl.getExtension(...)` then `gl.getParameter(...)`; MDN says it may be limited to privileged contexts depending on privacy settings, and Firefox disables it when `privacy.resistFingerprinting` is true, so `getExtension` can return null (MDN, via search result).
|
||||
- [doc] `failIfMajorPerformanceCaveat: true` makes `getContext` return null if the browser would use a dramatically slow implementation (MDN, via search result). [web, unconfirmed here] whether Chrome treats SwiftShader as such a caveat was not found in a source this pass.
|
||||
- [src] Strings observed in sources: SwiftShader `ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) ...))` and `Google SwiftShader`; hardware `ANGLE (NVIDIA ..., Direct3D11)` form as in the question's precedent.
|
||||
- [src] Chromium's own bots do not read a string; they identify hardware by Swarming dimensions (section 1).
|
||||
- [web] `WebGLShaderPrecisionFormat` from `getShaderPrecisionFormat(FRAGMENT_SHADER, HIGH_FLOAT)`: `precision` 23 means real highp float; it reports mantissa bits, nothing about subnormal handling; Safari mis-reported it as of April 2020 (WebKit bug via webglfundamentals).
|
||||
- [web] A sample WebGL report line: `ANGLE (Intel(R) HD Graphics 4600 Direct3D11 vs_5_0 ps_5_0)`, highp/highp, precision 23.
|
||||
|
||||
### 2.3 Windows service and session 0
|
||||
|
||||
- [web] Microsoft XPDM-vs-WDDM table: on WDDM, Session 0 isolation means a Direct3D 9 HAL device is never available from a service (NULLREF, HAL, REF "No"; WARP10 "Yes").
|
||||
- [web] Microsoft support article 978635: starting with Windows 8.1 / Server 2012 R2, Direct3D 11 supports use from a Windows service. WARP is recommended by Microsoft for service/server environments.
|
||||
- [web] VTK issue 18657 (GitLab): swap-chain creation failed on Windows CI runners because the process ran in Session 0, which has no graphics hardware access; runners were moved from system services to scheduled tasks.
|
||||
- [abs] No source found on headless Chrome or ANGLE behaviour specifically inside a service. The pattern the sources support is an interactive user session or a scheduled task that runs only when the user is logged on.
|
||||
|
||||
## 3. Committed-capture designs: versioning against source hashes and refusing stale captures
|
||||
|
||||
- [src] SPIRV-Cross: references are text regenerated by the tool; staleness is detected by re-running the tool and diffing, not by a stored hash. The tool versions (glslang, SPIRV-Tools) are named in the README as the ones to use.
|
||||
- [web] Skia Gold: the approved set is keyed by test name plus image hash; an unknown hash fails until triaged. Config metadata is uploaded with the image. No source-revision binding was found.
|
||||
- [web] Vulkan CTS: the result package carries the CTS tag, `git log` and patches; a checker script validates the package against the mustpass list for the version.
|
||||
- [web] WebGL: carry-forward rule (earlier passing results hold for later browser versions that fail no previously passing test).
|
||||
- [web] Babylon / three.js: reference image names live in a config list; regeneration is a flag (`-u`); a missing reference is an error (a 404 in a Karma run, Babylon forum).
|
||||
- [src] Chromium expectation files: per-test `Failure` entries are removed once a test passes (gpu_testing.md); Dawn prunes `expectations.txt` the same way.
|
||||
- [abs] No project was found whose committed device capture embeds a hash of the shader source it ran and is rejected by CI when that hash no longer matches the checked-in source. (Search was for the pattern, not an exhaustive survey of repositories.)
|
||||
|
||||
## 4. Float behaviour that bears on judging captures across ANGLE/D3D11, native GL and mobile GPUs
|
||||
|
||||
### 4.1 What the standards guarantee
|
||||
|
||||
- [doc] Qualifiers are minimums. highp float "is at least a 32 bit value", range about +-2^62 in the WebGL2Fundamentals table (exponents stripped in extraction, inferred); mediump at least 16 bit, range +-2^14, relative precision 2^-10; lowp at least 9 bit, range +-2 (webgl2fundamentals precision lesson; asawicki.info April 2015 for the 2^-10 and 2^-8 figures). On a desktop GPU mediump "will still most likely use 32bits internally"; the lesson's author's Pixel 2 XL used 16 bit for mediump and lowp.
|
||||
- [doc] Highp in fragment shaders is optional in OpenGL ES 2.0 (a shader declaring it fails to compile where unsupported). [web] GL_ARB_shader_precision text: internal processing need not match IEEE 754; rounding mode cannot be set and is undefined; "any denormalized value input into a shader or potentially generated by an operation in a shader can be flushed to 0."
|
||||
- [src] dEQP GLES3 precision spec tolerances (ULP unless noted): `+ - *` under 1 ULP; `/` 2.5 ULP for 2^-126 <= |y| <= 2^127-1; sin/cos 2^-11 abs on [-pi, pi]; asin/acos 4; atan2 6; atan 5; exp 3+2|x| highp; inversesqrt 2; log 3 ULP highp outside the central range; exact (0 ULP) abs, sign, floor, trunc, roundEven, ceil, min, max, clamp, step; derived functions (tan, pow, sqrt, mix, smoothstep, fract, mod ...) judged by their expansions. The page does not mention flush to zero. Lowp and mediump results on overflow "must produce an infinity or the max/min value".
|
||||
- [web] Olli Etuaho on the WebGL list, 14 Nov 2014: proposes testing only at the spec minimums, with subnormals flushed to zero, since code that works there will likely work with more bits; concedes not simulating built-in internals is a gap.
|
||||
|
||||
### 4.2 Direct3D 11 (the ANGLE/D3D11 path)
|
||||
|
||||
- [doc] Microsoft Learn "Floating-point rules (Direct3D 11)" (updated 2025-04-15), 32-bit rules: "Denorms are flushed to sign-preserved zero on input and output of any floating-point mathematical operation" (exceptions: I/O or data movement that does not manipulate the data). Add/sub/mul within 0.5 ULP, and the page says hardware may truncate; sqrt and rcp 1 ULP; rcp and rsq have a separate relaxed requirement; "Fused operations (such as mad, dp3) produce results that are no less accurate than the worst possible serial ordering of evaluation of the unfused expansion", each unfused step allowed 1 ULP (D3D10 page: 1 ULP for mul/div). NaN bit patterns are not preserved. min/max flush denorms for comparison but the result "may or may not be denorm flushed". Doubles preserve denorms; 16-bit floats preserve denorms.
|
||||
- [abs] No source found that states how ANGLE's D3D11 translation, fxc/dxc flags, or NVIDIA's D3D11 driver treat denormals or contraction beyond the D3D rules above.
|
||||
- [web] ANGLE's `FeaturesD3D.h` workaround list contains HLSL-compiler-specific rewrites (for example expanding `pow` into multiplies on an affected compiler, per search summary); a Wine D3D11 trig test failed on one NVIDIA configuration with roughly 0.1% relative error, not on AMD (Wine bug thread, cause not identified).
|
||||
- [web] ANGLE D3D11 vs D3D9 rendering differences are reported in Mozilla bug 1122912 (unresolved in the excerpt).
|
||||
|
||||
### 4.3 Mobile and other native GPUs
|
||||
|
||||
- [web] ARM blog "Benchmarking floating point precision in mobile GPUs, Part III": Vivante GC4000 and Imagination SGX 554 "do not support subnormal values" (below 2^-126 treated as zero); Mali-T604 supports subnormals, and the authors say Mali Midgard is the only mobile family they know to; the measuring shader divides by 2 per exponent step and multiplies back, drawing underflow pixels red. No ULP measurements in that part; Part II (not read) is said to find GPUs taking "the easy (round-to-zero) way out".
|
||||
- [web] asawicki.info: `length`, `normalize`, `distance` and dot products in a `mediump` shader can overflow at (74,74,74) because 3*74^2 = 16428 exceeds 2^14; a GPU may pass because it uses more precision than required.
|
||||
- [abs] No source read this pass on Adreno, Apple, current Mali or PowerVR denormal and contraction behaviour; the ARM post covers 2013-era parts.
|
||||
- [abs] FMA contraction in GLSL: no WebGL or GLSL ES source was read here on whether `a*b+c` may be fused (see `docs/research/cycles-fma-contraction.md` for the Cycles CPU case and the D3D "mad" fused-op tolerance above).
|
||||
|
||||
## 5. Gaps left by this pass
|
||||
|
||||
- Current three.js `test/e2e/puppeteer.js` flags and Babylon `config.json` tolerance keys were not read from the repositories (search summaries only).
|
||||
- Gold, WebGPU CTS and Dawn node pages are search summaries, not full reads.
|
||||
- Whether Chrome's shipped (not 130-era) behaviour fails WebGL context creation when no GPU is usable, versus using WARP on Windows, is unresolved between the sources in section 2.1.
|
||||
- Nothing here was measured on Starfire; no run of any driver was made.
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
# Prior art: a header flag one writer sets and content another writer cannot see
|
||||
|
||||
Serves: `variants:question:non-csharp-writers-mark-ids` (raised in
|
||||
`variants:follow_up:foreign-ids-under-v1-header`, campaign `variants`). Gathered by
|
||||
Imagination, session self-2026-10-14b-eureka, 2026-10-14. Facts with pointers; the
|
||||
recommendation lives in the question, not here.
|
||||
|
||||
The CultLib case: the store header (`cultcache.store.v1` / `v3`) says whether any record
|
||||
holds an element id, so a reader older than ids refuses the store instead of rewriting
|
||||
elements without them. An element id is an ordinary keyed string in the payload, so a
|
||||
runtime that does not know which member is the id (cultcache-py, -ts, -rs today) cannot
|
||||
tell from content whether to mark. The persistence contract
|
||||
(`src/GameCult.Caching/Contracts/cultcache-persistence-format.md:424-427` at 6afa8ed9)
|
||||
says those runtimes keep a `v3` header they found and do not decide.
|
||||
|
||||
## How established formats split the job
|
||||
|
||||
| System | Who sets the flag | What an implementation that does not understand the feature does | Pointer |
|
||||
|---|---|---|---|
|
||||
| ext4 superblock | The code that first uses a feature sets its bit in `s_feature_compat`, `_ro_compat` or `_incompat`. | Unknown `incompat` bit: refuse to mount (fsck stops too). Unknown `ro_compat` bit: mount read-only, never write. Unknown `compat` bit: read and write freely; fsck should not modify. | https://www.kernel.org/doc/html/latest/filesystems/ext4/super.html (feature fields) |
|
||||
| SQLite file header | The writer's journal mode sets the write/read version bytes (offsets 18, 19); the schema format number (offset 44) is set by the writer of the newer schema features. | Read version 1-2 with write version > 2: open read-only. Read version > 2: neither read nor write. | https://www.sqlite.org/fileformat.html section 1.3.3, 1.3.10 |
|
||||
| git repository format | A repository declares `core.repositoryformatversion = 1` and the `extensions.*` it uses; the tool enabling a feature writes its extension key. | Any unimplemented `extensions.*` key in a v1 repo: the operation must not proceed (read or write). | https://git-scm.com/docs/repository-version |
|
||||
| Protocol Buffers | No flag. New fields are self-delimited by tag. | Parsers keep unknown fields and write them back (proto2 always; proto3 again since 3.5), so an old reader does not drop a newer writer's data on rewrite. | https://protobuf.dev/programming-guides/proto3/#unknowns |
|
||||
| Avro container files | The writer embeds its full schema in the file header. | Reader resolves writer schema against its own; a writer field the reader lacks is ignored, so it is lost if the reader rewrites. | https://avro.apache.org/docs/current/specification/ ("Schema Resolution") |
|
||||
| Parquet | Writer stamps `created_by` (application and version) in FileMetaData. | Readers keep a list of known-bad writer versions and distrust their metadata (parquet-mr ignores min/max statistics from writers before 1.8.0, PARQUET-251). | https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift (`created_by`); https://issues.apache.org/jira/browse/PARQUET-251 |
|
||||
|
||||
## Patterns, as facts
|
||||
|
||||
- Flag-based formats (ext4, SQLite, git) put the duty to set the flag on the writer that
|
||||
introduces the content. None of them has a writer that carries feature content while
|
||||
not knowing it is that feature: an implementation that does not understand a feature
|
||||
either does not write (ro_compat, SQLite read-only) or does not touch the store (git).
|
||||
- Formats without a flag (protobuf, Avro) handle the older reader by preservation
|
||||
(protobuf keeps unknown fields) or accept loss (Avro drops unknown writer fields on
|
||||
rewrite). CultCache's C# 1.x readers skip unknown keyed slots and do not preserve
|
||||
them, which is why the `v3` header exists.
|
||||
- Parquet's `created_by` is a third option: readers judge content by who wrote it and
|
||||
compensate for writers known to get a flag or statistic wrong.
|
||||
- No system surveyed asks a reader to infer a feature flag from content it cannot
|
||||
recognise; where content cannot be recognised, the burden is on writers.
|
||||
|
||||
## Mechanism facts from this pass (probes at 6afa8ed9 on Yggdrasil)
|
||||
|
||||
- A C# cache that pulls the Python-written `py-store/leaf-record.cc` (v1, element ids
|
||||
`000000000021/22`) holds that record with `HoldsIds = true`, `IdsInMemoryOnly = false`;
|
||||
a C# write that adds an unrelated record copies it forward and leaves the header `v1`.
|
||||
The ids survive in C#; a pre-id reader with a type lacking the id slot would drop them.
|
||||
- cultcache-py, -ts and -rs read `v3` and keep it on rewrite; none has a declaration of
|
||||
which member is an element id.
|
||||
|
|
@ -1,462 +0,0 @@
|
|||
# Foreign Records: Prior Art Survey
|
||||
|
||||
Facts file for the single-file "foreign records" fork (lay-back versus refusing
|
||||
whole-view writes while holding records the writer does not own). Eyes pass,
|
||||
2026-10-02. Facts and evidence pointers only; no recommendation.
|
||||
|
||||
Verification marks:
|
||||
|
||||
- **[fetched]** the claim was read on the cited page during this pass.
|
||||
- **[search]** the claim came from a search-result summary of the cited page,
|
||||
not a full read.
|
||||
- **[memory]** recalled from the cited source, not re-read in this pass.
|
||||
Treat it as unverified.
|
||||
|
||||
## 0. Our design, for comparison
|
||||
|
||||
Source: `src/GameCult.Caching/Contracts/cultcache-persistence-format.md` (main, 614fd445).
|
||||
|
||||
- Store is `[header, catalog, records]`; the catalog "must embed a catalog entry
|
||||
for every schema referenced by the records it contains" (Embedded Schema Catalog).
|
||||
- The stated purpose of the catalog is to let another implementation "decide
|
||||
whether it can: read directly, soft-migrate with warnings, or refuse the data
|
||||
honestly".
|
||||
- Soft migration lists "persisted schema has extra slots the local reader can
|
||||
safely ignore or preserve" and the warning "field preserved but not surfaced
|
||||
natively". That is slot-level preservation inside a record the reader owns. The
|
||||
contract does not mention records under schema ids the reader does not own;
|
||||
the lay-back ruling lives elsewhere (grep of `src/**/*.md` for "foreign" or
|
||||
"lay-back" found nothing).
|
||||
- Concurrency: "A plain flush and an unconditional commit are last-writer-wins
|
||||
... a single file is replaced by the writer's whole view." "Conditional commit
|
||||
is the only safe multi-process write." C# and Rust implement conditional
|
||||
commit; TypeScript and Python do not yet (Transaction Visibility).
|
||||
|
||||
Granularity note used throughout: most systems below handle unknown *fields
|
||||
inside a record whose type the writer knows*. Our fork is about unknown *whole
|
||||
records* (schema ids) inside a container the writer rewrites. Each entry says
|
||||
which granularity it covers.
|
||||
|
||||
## 1. Serialization formats
|
||||
|
||||
### 1.1 Protocol Buffers: unknown-field preservation
|
||||
|
||||
- **What it does.** Unknown fields are "well-formed protocol buffer serialized
|
||||
data representing fields that the parser does not recognize". Proto3 messages
|
||||
"preserve unknown fields and include them during parsing and in the serialized
|
||||
output, which matches proto2 behavior." [fetched] https://protobuf.dev/programming-guides/proto3/ (Unknown Fields)
|
||||
- **Where preservation fails, per the docs.** Serializing to JSON; iterating
|
||||
fields to populate a new message. Mitigation given: use binary, use
|
||||
`CopyFrom()`/`MergeFrom()`. TextFormat prints unknown fields by number but
|
||||
cannot parse them back. [fetched] same page.
|
||||
- **History.** Proto3 shipped dropping unknown fields. v3.5.0 release notes:
|
||||
"Unknown fields are now preserved in proto3 for most of the language
|
||||
implementations for proto3 by default." C++ and Python offer
|
||||
`DiscardUnknownFields()`, Java a `DiscardUnknownFieldsParser`, for callers who
|
||||
relied on dropping. The release notes give no rationale. [fetched]
|
||||
https://github.com/protocolbuffers/protobuf/releases/tag/v3.5.0
|
||||
- **Why it was restored.** No official rationale document was found. The
|
||||
user-facing request is issue #272 (2015-04-07): "I know that unknown fields
|
||||
have been removed from proto3, but I am trying to get an explanation about why
|
||||
this change was made". The thread body was not readable in this pass. [fetched,
|
||||
partial] https://github.com/protocolbuffers/protobuf/issues/272. Secondary
|
||||
accounts attribute the reversal to pass-through and read-modify-write
|
||||
intermediaries in uncoordinated upgrades. [search]
|
||||
https://github.com/scalapb/ScalaPB/issues/406, https://kmcd.dev/posts/protobuf-unknown-fields/.
|
||||
**The primary reasoning is unverified.**
|
||||
- **Cost and regret.** Go: preservation through `XXX_unrecognized []byte`
|
||||
broke users who compared proto3 messages with `==` or used them as map keys.
|
||||
[search] https://groups.google.com/d/msg/golang-nuts/F5xFHTfwRnY/sPv5nTVXBQAJ.
|
||||
JSON round trips still drop unknown fields; there is an open request to keep
|
||||
them. [search] https://github.com/golang/protobuf/issues/1390. A secondary
|
||||
source flags preserved unknown fields as a way to smuggle unvalidated payloads
|
||||
past validation at trust boundaries. [fetched] https://kmcd.dev/posts/protobuf-unknown-fields/
|
||||
- **Granularity.** Field level, inside a message the reader's code knows.
|
||||
Protobuf does not preserve unknown *message types*; the closest is
|
||||
`google.protobuf.Any` (type URL plus bytes), which carries an opaque typed
|
||||
payload as a declared field. [memory]
|
||||
- **Differs from us.** There is no in-band schema. Unknown bytes are
|
||||
uninterpretable without the newer `.proto`, and only tag and wire type are
|
||||
self-describing. There is no container rewrite or multi-process store
|
||||
concern; protobuf is a message codec.
|
||||
|
||||
### 1.2 Protobuf field-number reuse
|
||||
|
||||
- **Rule.** "Field numbers should never be reused. Never take a field number out
|
||||
of the reserved list for reuse with a new field definition." Listed
|
||||
consequences: "Developer time lost to debugging, A parse/merge error (best case
|
||||
scenario), Leaked PII/SPII, Data corruption." Syntax: `reserved 2, 15, 9 to 11;`
|
||||
and `reserved "foo", "bar";`. [fetched] https://protobuf.dev/programming-guides/proto3/
|
||||
- Best practices: "Never re-use a tag number. It messes up deserialization."
|
||||
Reserve deleted numbers. "Almost never change the type of a field." Renaming
|
||||
breaks JSON and text format because they key on names. [fetched]
|
||||
https://protobuf.dev/best-practices/dos-donts/
|
||||
- Kleppmann (2012): "you must never reuse the tag number for another field in
|
||||
future, because you may still have data stored that uses that tag for the field
|
||||
you deleted". [fetched] https://martin.kleppmann.com/2012/12/05/schema-evolution-in-avro-protocol-buffers-thrift.html
|
||||
- **Named public incidents:** none found in this pass. The docs' consequence
|
||||
list is the strongest primary evidence; blog posts repeat it. **Unverified.**
|
||||
- **Differs from us.** Our `schemaId` is a content hash of the canonical slot
|
||||
schema, so changing slot meaning changes the id. The reuse hazard moves from
|
||||
"number collides" to "a record under an old id is carried, or reinterpreted,
|
||||
after an undeclared rename".
|
||||
|
||||
### 1.3 Avro and schema registries
|
||||
|
||||
- **Resolution.** The reader needs the writer's schema. Fields in the writer
|
||||
schema but not the reader's are ignored. Reader fields missing from the writer
|
||||
take the reader default, or raise an error when there is no default. Aliases
|
||||
map renames. [fetched] https://avro.apache.org/docs/1.11.1/specification/ (Schema Resolution)
|
||||
- **Object Container Files** carry the schema once in the header (`avro.schema`,
|
||||
required). One file has one writer schema. [fetched] same page.
|
||||
- **Kleppmann (2012):** Avro needs "the exact same version of the schema as the
|
||||
writer of the data", so you either "annotate each record with its schema
|
||||
version" or use "a schema registry". [fetched] blog above.
|
||||
- **Confluent wire format:** byte 0 is the magic byte (0), bytes 1 to 4 are the
|
||||
big-endian schema id; the deserializer fetches the writer schema by id.
|
||||
[fetched] https://docs.confluent.io/platform/current/schema-registry/fundamentals/serdes-develop/index.html
|
||||
- **Compatibility modes:** BACKWARD (the default), FORWARD, FULL, their
|
||||
`_TRANSITIVE` forms, and NONE. BACKWARD is the default "so that you can rewind
|
||||
consumers to the beginning of the topic". Upgrade order follows the mode:
|
||||
consumers first for BACKWARD, producers first for FORWARD. [fetched]
|
||||
https://docs.confluent.io/platform/current/schema-registry/fundamentals/schema-evolution.html
|
||||
- **Failure mode.** Resolution projects writer data onto the reader schema, so
|
||||
writer-only fields are gone from the decoded object. A read-modify-write by an
|
||||
older reader loses them unless the application keeps the writer record.
|
||||
[inference from the spec; no Avro doc states it as a warning]
|
||||
- **Differs from us.** Kafka records are immutable log entries, so consumers
|
||||
never rewrite the producer's records. A container file is written once by one
|
||||
writer. Neither case has whole-file rewrite by several processes.
|
||||
|
||||
### 1.4 Thrift
|
||||
|
||||
- Generated readers switch on field id and call
|
||||
`TProtocolUtil.skip(iprot, schemeField.type)` in the default case: unknown
|
||||
fields are skipped. [search; generated-code pattern] Kleppmann (2012) says the
|
||||
same of protobuf and Thrift: the parser "can figure out how many bytes it needs
|
||||
to skip". [fetched]
|
||||
- **Conflict.** "Thrift: The Missing Guide" says unknown fields "are not
|
||||
discarded, and if the message is later serialized, the unknown fields are
|
||||
serialized along with it". That text reads as copied from protobuf docs.
|
||||
[fetched] https://diwakergupta.github.io/thrift-missing-guide/. Apache Thrift's
|
||||
generated Java skips without retaining. **Retention is unverified and likely
|
||||
false for stock Apache Thrift.**
|
||||
- Rules: never change numeric tags; new fields optional; prefix removed fields
|
||||
`OBSOLETE_`. [fetched] Missing Guide.
|
||||
|
||||
### 1.5 Cap'n Proto
|
||||
|
||||
- Field ordinals are append-only: "as long as each new member's number is larger
|
||||
than all previous members"; "You cannot change a field, method, or enumerant's
|
||||
number." [fetched] https://capnproto.org/language.html
|
||||
- Unknown fields survive a copy: "If you copy the struct from one message to
|
||||
another (e.g. by calling a set() method on a parent object), the extra fields
|
||||
will be preserved." Caveat: "copying a struct value from another message into
|
||||
an element of a list will truncate the value". [fetched] https://capnproto.org/cxx.html
|
||||
- The wire format carries just enough structure (struct or list, sizes) to copy
|
||||
an object recursively without its schema. [search]
|
||||
https://capnproto.org/encoding.html. Preservation is a property of the encoding,
|
||||
not of a per-type rule.
|
||||
- **Differs from us.** The copy is opaque; nothing is reinterpreted. There is no
|
||||
catalog and no store.
|
||||
|
||||
### 1.6 FlatBuffers
|
||||
|
||||
- Add new fields at the end; never remove one, mark it `deprecated` instead (no
|
||||
accessors are generated); explicit `id` attributes relax ordering. Old readers
|
||||
ignore unknown fields. [search] https://flatbuffers.dev/evolution/
|
||||
- The object API (unpack, mutate, pack) builds a fresh buffer from native
|
||||
fields. Whether unknown fields survive that round trip: **not verified in
|
||||
this pass** [memory: they do not].
|
||||
- Known defect: C++ object API with deprecated fields generated non-compiling
|
||||
code. [search] https://github.com/google/flatbuffers/issues/5186
|
||||
|
||||
### 1.7 Microsoft Bond
|
||||
|
||||
- `bonded<T>` defers deserialization and holds a reference to the serialized
|
||||
form. "Using bonded<Response> allows the intermediary to aggregate responses,
|
||||
preserving their full content, even though schema of Response is not known
|
||||
when the aggregator is built." Transcoding through a bonded value preserves
|
||||
fields not in `T`. [fetched] https://microsoft.github.io/bond/manual/bond_cpp.html
|
||||
- Untagged protocols (Simple Binary) "serialize only data and thus require that
|
||||
consumers know the payload schema via some out-of-band mechanism"; Bond also
|
||||
has runtime schemas (`SchemaDef`) for that. [fetched; SchemaDef from memory]
|
||||
- Changing ordinals is a breaking change. [fetched]
|
||||
- **Note.** Bond's preservation is an explicit opt-in wrapper type at the
|
||||
declared pass-through point, not default retention of everything.
|
||||
|
||||
### 1.8 MessagePack-based schema systems
|
||||
|
||||
- MessagePack itself has no schema. MessagePack-CSharp (`[Key(n)]` int keys, the
|
||||
basis of our slot layout) has no documented unknown-key or extension-data
|
||||
retention for typed objects. A search found no such feature and no issue
|
||||
requesting it. **Unverified absence.**
|
||||
https://github.com/MessagePack-CSharp/MessagePack-CSharp
|
||||
- Comparable opt-in field-level retention in other ecosystems:
|
||||
Newtonsoft.Json and System.Text.Json `[JsonExtensionData]` capture unmapped
|
||||
properties into a dictionary and write them back; serde `#[serde(flatten)]` into
|
||||
a map does the same, and is incompatible with `deny_unknown_fields`. [search]
|
||||
https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/migrate-from-newtonsoft,
|
||||
https://serde.rs/attr-flatten.html. All are field level and opt in per type.
|
||||
- **FIDL (Fuchsia), RFC-0033:** messages with unknown fields "MUST be validated
|
||||
and parsed successfully"; the default is `flexible` (unknown values allowed and
|
||||
exposed), and the `strict` keyword makes unknown fields invalid. Unknown
|
||||
handles must be closed by default. The RFC frames strictness as a security tool
|
||||
for protocols where misinterpreting new data is dangerous. [fetched]
|
||||
https://fuchsia.dev/fuchsia-src/contribute/governance/rfcs/0033_handling_unknown_fields_strictness
|
||||
|
||||
## 2. Embedded stores and document databases
|
||||
|
||||
### 2.1 SQLite
|
||||
|
||||
- **Unknown tables.** The writer modifies pages through the B-tree and rollback
|
||||
or WAL journal. Tables a program never touches are never rewritten, so there is
|
||||
no rule about them. [memory; consistent with the file format doc]
|
||||
- **Unknown schema syntax.** A library older than 3.31.0 opening a schema with
|
||||
generated columns "will perceive the generated column syntax as an error and
|
||||
will report that the database schema is corrupt". Older versions can read and
|
||||
write newer files "as long as the database schema does not contain features
|
||||
... not understood by the earlier version". [search] https://sqlite.org/gencol.html.
|
||||
The whole database is refused, not just the table.
|
||||
- **File format versions.** Write version > 2: "the database file must be treated
|
||||
as read-only". Read version > 2: "that database cannot be read or written".
|
||||
[fetched] https://www.sqlite.org/fileformat2.html (offsets 18 and 19). This is
|
||||
a format-level read-only fallback for content the library cannot safely write.
|
||||
- **Schema change across connections.** The schema cookie (offset 40) is bumped
|
||||
on every schema change. A prepared statement checks it and reprepares or fails
|
||||
with `SQLITE_SCHEMA`. [fetched] same page.
|
||||
- `user_version` (offset 60) and `application_id` (offset 68) are application
|
||||
owned; SQLite does not use them. [fetched]
|
||||
- **Whole-file rewrite.** SQLite's own comparison: custom and wrapped
|
||||
pile-of-files formats "usually require a rewrite of the entire document in
|
||||
order to change a single byte", and application concurrency logic for them is
|
||||
"a notorious bug-magnet". [fetched] https://www.sqlite.org/appfileformat.html
|
||||
- **Differs from us.** The schema is SQL text in `sqlite_schema`, not a typed
|
||||
slot catalog. Writes are partial and in place, and there is one implementation
|
||||
rather than five.
|
||||
|
||||
### 2.2 LMDB
|
||||
|
||||
- Opaque byte keys and values, no schema; named sub-databases. Copy-on-write
|
||||
B+tree with MVCC. One writer at a time, serialized by a mutex in the lock file;
|
||||
readers do not block the writer and the writer does not block readers;
|
||||
several processes may open one environment. [search; lmdb.tech/doc was not
|
||||
reachable over TLS in this pass] https://en.wikipedia.org/wiki/Lightning_Memory-Mapped_Database,
|
||||
https://lmdb.readthedocs.io/
|
||||
- `MDB_INCOMPATIBLE`: an operation does not match a database's flags (for
|
||||
example dupsort), or "the database type changed". [search]
|
||||
- **Differs from us.** There are no types, so "foreign" is not a concept;
|
||||
untouched keys are never rewritten.
|
||||
|
||||
### 2.3 Core Data (Apple)
|
||||
|
||||
- **Model mismatch is refused.** "The model used to open the store is
|
||||
incompatible with the one used to create the store" (Cocoa error 134100)
|
||||
unless a migration runs: lightweight (inferred mapping, using
|
||||
`NSMigratePersistentStoresAutomaticallyOption` and
|
||||
`NSInferMappingModelAutomaticallyOption`) or custom/staged. [search]
|
||||
https://developer.apple.com/forums/thread/100639
|
||||
- Store metadata holds per-entity version hashes (`NSStoreModelVersionHashes`).
|
||||
`isConfiguration(withName:compatibleWithStoreMetadata:)` compares them with
|
||||
the model. [search]
|
||||
https://developer.apple.com/documentation/coredata/nsmanagedobjectmodel/isconfiguration(withname:compatiblewithstoremetadata:)
|
||||
- **Atomic stores (XML, binary) are the closest analogue to our single file.**
|
||||
They keep the whole object graph in memory and save the whole file: "the old
|
||||
file is not deleted until the new one has been successfully written."
|
||||
[fetched] https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/CoreData/PersistentStoreFeatures.html.
|
||||
Combined with the refusal above, an atomic store is never rewritten by a model
|
||||
that does not cover every entity in it. The page says nothing on multi-process
|
||||
access to atomic stores. [fetched: absence]
|
||||
- **Differs from us.** Single runtime, entity-hash catalog in metadata, a
|
||||
migration-or-refuse gate at open. No foreign entity is ever carried.
|
||||
|
||||
### 2.4 Realm
|
||||
|
||||
- Opening with a changed schema requires a higher `schemaVersion`, plus a
|
||||
`migrationBlock` where needed; otherwise "Migration is required". Added and
|
||||
removed properties are applied to the on-disk schema automatically on
|
||||
migration. [search] https://github.com/realm/realm-js/issues/4752,
|
||||
https://realm.netlify.app/docs/swift/latest/
|
||||
- A changelog entry allows opening old frozen versions "with a schema that
|
||||
contains additional classes, but not additional properties". [search]
|
||||
https://github.com/realm/realm-core/releases?page=5
|
||||
- Whether a Realm opened with a class *subset* keeps the other tables untouched:
|
||||
**not verified** [memory: the tables are kept, since Realm writes in place by
|
||||
table and does not rewrite the whole file].
|
||||
- Realm file-format upgrades are one-way. [memory]
|
||||
|
||||
### 2.5 CouchDB and PouchDB
|
||||
|
||||
- `PUT /{db}/{docid}` writes a new revision of the whole document; there is no
|
||||
field merge, and omitted fields are gone in the new revision. A missing or
|
||||
stale `_rev` returns `409 Conflict`: "specified revision is not latest for
|
||||
target document". The revision may be given in the body, as `rev`, or in
|
||||
`If-Match`. [fetched] https://docs.couchdb.org/en/stable/api/document/common.html
|
||||
- Under replication, conflicting revisions are all kept; one deterministic
|
||||
winner is chosen and the others are marked `_conflicts`; CouchDB does not merge.
|
||||
[search] https://docs.couchdb.org/en/stable/replication/conflicts.html
|
||||
- There is no schema; unknown fields survive only if the client writes them
|
||||
back. Document-mapper layers drop them: Mongoose's `strict` option "ensures
|
||||
that values passed to our model constructor that were not specified in our
|
||||
schema do not get saved to the db", and is "enabled by default". [fetched]
|
||||
https://mongoosejs.com/docs/guide.html (MongoDB, cited as the same pattern).
|
||||
- **RxDB** (on PouchDB or other storage): any schema `version` > 0 requires
|
||||
`migrationStrategies` for each step; a strategy returning `null` deletes the
|
||||
document; writes during a running migration fail with `COL25`; in multi-tab
|
||||
setups "exactly one tab is running a migration of a collection". [fetched]
|
||||
https://rxdb.info/migration-schema.html. Opening storage written by a newer
|
||||
RxDB major is refused ("Cannot open database state with newer RxDB version").
|
||||
[search] https://github.com/pubkey/rxdb/issues/5836
|
||||
- **Differs from us.** The per-document `_rev` gives optimistic concurrency at
|
||||
record granularity, and documents are never rewritten as a side effect of
|
||||
writing another.
|
||||
|
||||
### 2.6 IndexedDB
|
||||
|
||||
- No field schema: values are structured clones, stored by value. Schema is
|
||||
object stores and indexes, versioned by an integer. [fetched] https://www.w3.org/TR/IndexedDB/
|
||||
- Opening with a higher version fires `upgradeneeded` inside a versionchange
|
||||
transaction. Opening with a *lower* version than the stored one fails with
|
||||
`VersionError`: "An attempt was made to open a database using a lower version
|
||||
than the existing version." Older code is refused outright. [fetched] same spec.
|
||||
- Other open connections receive `versionchange`; if they stay open the upgrader
|
||||
gets `blocked`. [fetched] same spec and
|
||||
https://developer.mozilla.org/en-US/docs/Web/API/IDBOpenDBRequest/blocked_event
|
||||
- **Differs from us.** The browser engine is the single writer implementation
|
||||
and the schema has one integer owner per database.
|
||||
|
||||
### 2.7 Whole-file rewrite while preserving unknown content
|
||||
|
||||
Systems found that rewrite a container while keeping parts they do not
|
||||
understand:
|
||||
|
||||
- **Automerge binary format.** Programs that edit documents must "maintain all
|
||||
columns, even those that they don't understand the meaning of" and write null
|
||||
into unknown columns for new rows. Implementations "must preserve operations
|
||||
containing actions they don't support". Unknown value type codes: "MUST read and
|
||||
store the type code and `length` bytes". Changes are SHA-256 hashed over their
|
||||
binary form, so byte preservation is required for hash integrity. [fetched]
|
||||
https://automerge.org/automerge-binary-format-spec/. The unit carried is an
|
||||
immutable, content-addressed change; nothing preserved is ever re-decided by
|
||||
the carrier.
|
||||
- **Core Data atomic stores** rewrite the whole file but refuse to open under an
|
||||
incompatible model (2.3), so they never carry unknown entities.
|
||||
- No system was found that rewrites a whole mutable file while carrying
|
||||
mutable records of unknown types that other writers may change concurrently.
|
||||
**Absence of evidence, not a verified negative.**
|
||||
|
||||
## 3. Literature
|
||||
|
||||
- **Kleppmann, *Designing Data-Intensive Applications* (1st ed., O'Reilly 2017),
|
||||
ch. 4 "Encoding and Evolution", section "Dataflow Through Databases".** [memory,
|
||||
not re-read] The section says an older version of code may read, update and
|
||||
write back a record written by newer code, and newer fields can be lost unless
|
||||
the old code keeps unknown fields. Figure 4-7 shows this. The section contains
|
||||
the line "data outlives code" [search; confirmed in secondary notes
|
||||
https://gist.github.com/bcherny/b870a60d1650973df7e400c8603ac76d]. It also says
|
||||
rewriting data into a new schema is possible but expensive, so most databases
|
||||
avoid it. **The exact wording is unverified.**
|
||||
- **Scherzinger, Klettke, Störl, "Managing Schema Evolution in NoSQL Data
|
||||
Stores", DBPL 2013.** Contrasts eager migration with *lazy* migration (migrate
|
||||
on read or touch) for schema-less stores whose data grows heterogeneous.
|
||||
[search] https://arxiv.org/abs/1308.0514
|
||||
- **Ringlstetter, Scherzinger, Bissyandé, "Data model evolution using
|
||||
object-NoSQL mappers: folklore or state-of-the-art?", BIGDSE@ICSE 2016,
|
||||
pp. 33-36.** Mapper evolution annotations (rename, remove, transform on load)
|
||||
are used on GitHub, but mostly for tasks other than evolving the data model.
|
||||
[search] https://dblp.org/rec/conf/icse/RinglstetterSB16.html
|
||||
- **Scherzinger and Sidortschuck, "An Empirical Study on the Design and Evolution
|
||||
of NoSQL Database Schemas", ER 2020.** [search, not read]
|
||||
https://arxiv.org/pdf/2003.00054
|
||||
- **Bancilhon and Spyratos, "Update semantics of relational views", ACM TODS
|
||||
6(4), 1981, pp. 557-575.** A view update is translated by holding a chosen
|
||||
*complement* constant; a complete set of view updates has a translator iff it
|
||||
is translatable under constant complement. [search]
|
||||
https://dl.acm.org/doi/10.1145/319628.319634. Mapping (this survey's framing,
|
||||
not the paper's): a writer's owned records are a view of the store, the foreign
|
||||
records are the complement, and lay-back is constant-complement translation.
|
||||
- **Foster, Greenwald, Moore, Pierce, Schmitt, "Combinators for bidirectional tree
|
||||
transformations", POPL 2005 / TOPLAS 2007.** A lens is `get: S -> V` and
|
||||
`put: V x S -> S`. Laws: GetPut, `put(get(s), s) = s`; PutGet,
|
||||
`get(put(v, s)) = v`; PutPut is the stronger optional law. `put` takes the
|
||||
source `s` as an argument, so the complement is restored from whatever `s` is
|
||||
supplied. [search for the paper; the law statements are from memory, and one
|
||||
search summary had GetPut and PutGet swapped]
|
||||
https://www.cis.upenn.edu/~bcpierce/papers/lenses-toplas-final.pdf
|
||||
- **Litt, van Hardenberg, Henry, "Project Cambria" (Ink & Switch, Oct 2020).**
|
||||
Bidirectional lenses between schema versions, arranged as a graph. In
|
||||
Cambria-Automerge every write is tagged with its writer's schema and translated
|
||||
at read time, so "old changes can be evolved using lenses that didn't even
|
||||
exist at the time of the original change". Lenses travel inside the document.
|
||||
Stated limits: lossy conversions (scalar to array), one-way changes
|
||||
(split/merge), no external lookups, untested performance. "There are limits to
|
||||
interoperability." [fetched] https://www.inkandswitch.com/cambria/
|
||||
- **Local-first and CRDT, Automerge:** see 2.7. Unknown ops, columns and value
|
||||
types from newer peers are preserved byte for byte and are immutable once
|
||||
written. [fetched]
|
||||
|
||||
## 4. Concurrency facts for whole-file rewrite
|
||||
|
||||
- **Last-writer-wins on whole replace** loses a concurrent writer's changes
|
||||
unless the write is conditional. Our contract already says this (section 0).
|
||||
- **HTTP optimistic concurrency.** RFC 9110 §13.1.1 If-Match exists to prevent
|
||||
the "lost update" problem; a failed precondition returns 412 Precondition
|
||||
Failed (§15.5.13). [memory; the page fetch truncated before those sections]
|
||||
https://www.rfc-editor.org/rfc/rfc9110.html#section-13.1.1
|
||||
- **Per-record CAS:** CouchDB `_rev`, 409 (2.5).
|
||||
- **Git ref update:** `git update-ref <ref> <new> <old>` stores the new value
|
||||
"after verifying that the current value of the <ref> matches <old-oid>".
|
||||
Transactions lock every ref (`.lock` files) at `prepare`; "If all <ref>s can be
|
||||
locked with matching <old-oid>s simultaneously, all modifications are
|
||||
performed. Otherwise, no modifications are performed." [fetched]
|
||||
https://git-scm.com/docs/git-update-ref. The pattern: take an exclusive lock
|
||||
file, compare the old value under the lock, write, rename into place.
|
||||
- **SQLite** serializes writers by file lock (rollback) or WAL write lock;
|
||||
schema-cookie checks catch schema drift between prepare and run (2.1).
|
||||
- **LMDB:** single writer mutex across processes, MVCC readers (2.2).
|
||||
- **IndexedDB:** schema change is an exclusive versionchange transaction that
|
||||
waits on other connections (2.6).
|
||||
- **RxDB:** one migrating tab, writes refused during migration (2.5).
|
||||
- **Crash consistency of temp-plus-rename.** Pillai et al., "All File Systems Are
|
||||
Not Created Equal: On the Complexity of Crafting Crash-Consistent
|
||||
Applications", OSDI 2014. The ALICE tool found 60 crash vulnerabilities across
|
||||
11 widely used applications; persistence properties, including how rename
|
||||
orders against data writes, vary across six Linux file systems. [search]
|
||||
https://research.cs.wisc.edu/adsl/Publications/alice-osdi14.pdf. Whether
|
||||
`fsync` of the temp file and its directory is needed for durability of the
|
||||
rename: [memory: yes on Linux ext4 and others]. Windows `MoveFileEx` with
|
||||
`MOVEFILE_REPLACE_EXISTING` and `ReplaceFile` atomicity guarantees: **not
|
||||
verified in this pass.**
|
||||
|
||||
## 5. Cross-cutting facts, as observed (not conclusions)
|
||||
|
||||
- Every system found that preserves unknown data does so at *field* level
|
||||
inside a record whose type the code owns (protobuf, Cap'n Proto, Bond opt-in,
|
||||
JSON extension data, serde flatten), or as *immutable content-addressed*
|
||||
units (Automerge changes).
|
||||
- Systems that hold several record types in one store either rewrite only what
|
||||
they touch (SQLite, LMDB, Realm [unverified], CouchDB, IndexedDB), or rewrite
|
||||
the whole file and refuse a model that does not cover the file (Core Data
|
||||
atomic stores).
|
||||
- Systems where older code meets a newer schema at open time do one of:
|
||||
refuse to open (IndexedDB `VersionError`, Core Data 134100, RxDB newer-major,
|
||||
SQLite unknown schema syntax), go read-only (SQLite write version > 2), or
|
||||
require a migration before any write (Realm, RxDB, Core Data).
|
||||
- Protobuf moved from dropping to preserving (3.5). Its documented residual
|
||||
losses are in conversion paths (JSON, field-by-field copy), and Go's
|
||||
preservation broke message comparability.
|
||||
- FIDL made preserve-and-expose the default and added `strict` as an opt-in
|
||||
refusal, justified on security grounds.
|
||||
|
||||
## Not verified in this pass
|
||||
|
||||
- Primary rationale for restoring proto3 unknown fields in 3.5.
|
||||
- Any named incident caused by protobuf field-number reuse.
|
||||
- Stock Apache Thrift retention of unknown fields (the Missing Guide says yes,
|
||||
the generated-code pattern says skip).
|
||||
- FlatBuffers object-API round trip of unknown fields.
|
||||
- MessagePack-CSharp unknown-key retention (absence).
|
||||
- Realm behaviour opening with a class subset.
|
||||
- Exact DDIA wording and figure number.
|
||||
- LMDB primary docs (TLS failure); RFC 9110 section text.
|
||||
- Windows rename and replace atomicity semantics.
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
# Ground motion law: prior art (facts only)
|
||||
|
||||
Serves fork: cultmath-tapes, site-ground motion law (invariant `one-motion-owner`, target `cultmath-tapes:target:r2`), blocks `cut-site-ground`.
|
||||
Gathered by Eyes, session self-2026-10-14b-eureka, 2026-10-14. No recommendation. Committed by Self before the fork was put.
|
||||
|
||||
Method: primary PDFs were downloaded to the session scratchpad and read with pdftotext ("read"). "[secondary]" marks facts taken from a web-search summary, forum or blog, not read at the primary source. "[not verified]" marks things looked for and not reached.
|
||||
|
||||
## 1. How shipped volumetric clouds move, and what breaks
|
||||
|
||||
### 1.1 Horizon Zero Dawn, Schneider and Vos, SIGGRAPH 2015
|
||||
Source: https://www.advances.realtimerendering.com/s2015/The%20Real-time%20Volumetric%20Cloudscapes%20of%20Horizon%20-%20Zero%20Dawn%20-%20ARTR.pdf (99 slides, read)
|
||||
- Density = low-frequency 3D Perlin-Worley base shape times height gradient and coverage, eroded by a high-frequency 3D Worley texture (slides ~34-37).
|
||||
- A 128^2, 3-channel 2D curl noise texture "is non divergent and is used to fake fluid motion"; it distorts the cloud shapes and distorts the sample position of the second (detail) 3D noise "to fake the swirly distortions from atmospheric turbulence" (slides 33, 37). The curl texture is a static distortion of the detail lookup, not a time-integrated flow.
|
||||
- Summary slide (48): noises are custom, "made from Perlin, Worley and Curl noise", and "all animated in a given wind direction". Wind direction varies over altitude (slide 21). High cirrus/alto layers are 2D textures "tiling and scrolling at different speeds and directions" (slide 87).
|
||||
- Large-scale evolution is not noise motion: coverage, precipitation and cloud type come from a 2D weather map driven by a simulation, with a wind-speed control (slides 39-44).
|
||||
- Reprojection (slides 92-93): quarter-res buffer updates 1 of 16 pixels per 4x4 block each frame; "We reproject the previous frame to ensure we have something persistent"; where reprojection fails (screen edges) the low-res result is substituted. Stated as making the shader "10x faster or more"; raw cost ~20 ms, target ~2 ms.
|
||||
|
||||
### 1.2 Nubis, Evolved, Schneider, SIGGRAPH 2022
|
||||
Source: https://advances.realtimerendering.com/s2022/SIGGRAPH2022-Advances-NubisEvolved-NoVideos.pdf (read)
|
||||
- Slide "Skies / Vertical Profile Model / Animating Density": the motion law for sky density is a sample-position offset, `noise_sample_position = sample_position - wind_direction * scroll_offset` (comment "Deform sample coordinates").
|
||||
- Slide family "VFX / Animating Superstorm Density / The Mesocyclone": each ring samples noise at `GetRotatedPosition(sample_position, superstorm_center, time_offset * RingRotationSpeed[n])`, later with a per-ring `ring_skew[n]`: different rotation rates per layer about one center, no flow field.
|
||||
- Slide "VFX / Animating Cloud Density For Skies" (code listing): the reprojection pass reads a stored per-pixel `cloud_distance` from a "CloudAttrWorkingBuffer", reconstructs `cloud_world_space = invView * (eyeRay * cloud_distance)`, applies the SAME motion law to that world position (rotate about the storm center blended by `superstorm_mask`; scroll `cloud_world_space + scroll_direction_2D * deltaTime`), then projects it with `inReprojectionMatrix` to get the previous-frame UV. The history lookup is displaced by the density's own motion, using one depth per pixel written by the march.
|
||||
- The 2022 and 2023 decks show an "Aero Cloud Solver" frame (Apply Buoyant Force, Advect Density, Advect Velocity, Non-Divergence, Sourcing) among the history slides, as the offline VFX approach.
|
||||
|
||||
### 1.3 Nubis3 (Nubis Cubed), Schneider, SIGGRAPH 2023
|
||||
Source: https://advances.realtimerendering.com/s2023/Nubis%20Cubed%20(Advances%202023).pdf (read)
|
||||
- Voxel clouds, slide "Sampling Density": `inSamplePosition -= float3(cCloudWindOffset.x, cCloudWindOffset.y, 0.0) * voxel_cloud_animation_speed`. Wind offset only; no curl, no flow field.
|
||||
- A comparison table lists "Evolution: Yes" for the Vertical Profile method and "Pseudomotion Only" for the Envelope method, and "Pseudomotion Only" in later tables for the voxel methods.
|
||||
- March is SDF-accelerated: "Hybrid SDF, Adaptive and Jittered samples"; jitter is `animated_hash` within 250 m of camera, `static_hash` beyond.
|
||||
- No temporal reprojection slide found in the extracted text.
|
||||
|
||||
### 1.4 Frostbite, Hillaire, SIGGRAPH 2016
|
||||
Source: https://media.contentapi.ea.com/content/dam/eacom/frostbite/files/s2016_pbs_frostbite_sky_clouds.pdf (read; section 5.5.3 and the cloud intro)
|
||||
- 5.5.3: clouds rendered in a single pass; each frame samples are randomly offset within their step range; current frame blended with previous "according to a constant blend factor" (EMA); "For the final frame to not look blurry when the camera is moving/rotating very fast, we re-project the previous result according to previous and current camera properties". Camera-only reprojection; no density-motion term stated.
|
||||
- Text cites Guerette's visual-flow technique to give a panoramic cloud texture the illusion of motion along the wind direction, noting it has no variation of cloud shape, weather or lighting.
|
||||
- Frostbite volumetric fog temporal blend, as reproduced by Flax Engine: Halton jitter, 7% current / rest history (https://flaxengine.com/?p=941) [secondary]. Godot docs: higher blend "makes ghosting much worse", moving fog volumes and lights leave a trail (https://docs.godotengine.org/en/stable/tutorials/3d/volumetric_fog.html) [secondary].
|
||||
- Hillaire 2015 (Frostbite unified volumetrics) temporal section: [not verified] (158 MB PPTX; SlideShare copy cuts off before it).
|
||||
|
||||
### 1.5 Toft, Bowles, Zimmermann (Studio Gobo), "Optimisations for Real-Time Volumetric Cloudscapes", arXiv 2016
|
||||
Source: https://arxiv.org/abs/1609.05344 (PDF read)
|
||||
- Calls the Horizon 1/16-pixel scheme "a fairly complex technique which may introduce visual artifacts as a result of the 4x4 pattern by which pixels are updated".
|
||||
- Alternative: far fewer march steps + random per-pixel start offset + analytic integration per step + Playdead-style TAA (Pedersen, INSIDE, 2016) on a low-res buffer. Timings, 1080p GTX 1080 (Fig. 3): half res 128 steps 128.0 ms; half res 8 steps jitter+TAA 7.5 ms; quarter res 8 steps jitter+TAA 2.4 ms.
|
||||
|
||||
### 1.6 UE Volumetric Clouds
|
||||
- Official component docs list View/Reflection/Shadow sample-count scales and a transmittance stop threshold; nothing on temporal behavior: https://dev.epicgames.com/documentation/unreal-engine/volumetric-cloud-component-properties-in-unreal-engine [secondary].
|
||||
- Forum: Mode 2 traces 1 pixel per 4x4 block and reprojects 15, "lots of latency", cloud "would leak after reprojection over opaque meshes": https://forums.unrealengine.com/t/volumetriccloud/688516 [secondary].
|
||||
- Forum hypothesis (unconfirmed): a VolumetricCloud writes neither depth nor velocity, so TSR has nothing to reject history on, causing trailing and banding inside a cloud: https://forums.unrealengine.com/t/tsr-ghosting-against-a-volumetriccloud-the-player-stands-inside-every-rejection-knob-measured-none-help/2837437 [secondary].
|
||||
- Bug UE-198490 (fixed 5.4): horizontal flicker lines from the temporal component; workaround `r.VolumetricRenderTarget.Mode=1`: https://issues.unrealengine.com/issue/UE-198490 [secondary].
|
||||
|
||||
### 1.7 Red Dead Redemption 2 clouds (Bauer, SIGGRAPH 2019)
|
||||
Talk exists ("Creating the Atmospheric World of Red Dead Redemption 2: A Complete and Integrated Solution", Advances in Real-Time Rendering in Games 2019). Slides [not verified]: not among the PDF/PPTX links on https://www.advances.realtimerendering.com/s2019/index.htm; a guessed URL returned 404. Nothing about its motion or reprojection was read.
|
||||
|
||||
### 1.8 Shadertoy-scale nebulae
|
||||
[not verified]. No Shadertoy source was read in this pass.
|
||||
|
||||
### 1.9 What the sources say breaks
|
||||
- Pulsing and repetition: Vlachos names these as the two problems of two-phase flow-map animation (3.1).
|
||||
- Stretching: a texture advected by a non-uniform flow stretches without bound unless regenerated (Neyret, 3.3).
|
||||
- 4x4 update-pattern artifacts (Toft et al., 1.5); latency and leaking over opaque geometry in 1/16 modes (UE forum, 1.6).
|
||||
- Camera-only reprojection fails when content moves: https://www.wayline.io/blog/altos-1-1-better-temporal-reprojection-for-volumetric-clouds-in-unity ("When the player moves their camera, the history becomes useless") [secondary]. A hobby engine skips a velocity buffer "since the clouds move slowly", uses bilinear history sampling and EMA: https://handmade.network/p/75/monter/blog/p/7201-engine_optimization__fast_high-quality_cloudscape_through_noise_and_reprojection [secondary].
|
||||
|
||||
## 2. Temporal reprojection of raymarched volumes whose density moves
|
||||
|
||||
- A volume has no single surface depth. Nubis 2022 writes a per-pixel `cloud_distance` during the march, rebuilds a world point from it, applies the density motion law to that point, then projects with the previous view-projection (1.2). This is the only shipped density-motion-aware reprojection found at a primary source.
|
||||
- Horizon 2015 and Frostbite 2016 reproject with camera properties only (1.1, 1.4).
|
||||
- AtmoPlugin (hobby project, GitHub PR #15): raymarch writes a "contribution-weighted" reprojection depth into alpha, taken where opacity exceeds 5%, with fallbacks surface depth, scene depth, mid-atmosphere, infinity; temporal pass reprojects the pixel center with previous camera and planet transforms: https://github.com/Upa2sdo2l/AtmoPlugin/pull/15 [secondary].
|
||||
- Two hybrid depth-of-field papers reproject semi-transparent regions by average world position of far-field hits, with a 3x3 neighborhood fallback when there is no hit, and lerp by latest hit ratio during motion to reduce ghosting: https://arxiv.org/pdf/2210.04981 and https://arxiv.org/pdf/2210.06158 [secondary].
|
||||
- A search for a published "transmittance-weighted depth" reprojection for clouds returned nothing; it is not an established term in anything reached.
|
||||
- Reported costs and artefacts of history: higher history weight = more ghosting for moving volumes (Godot, 1.4); EMA "stable in static scenes but performs poorly under dynamic volumetric lighting", and variance clipping plus low-discrepancy jitter tested on froxel data, implemented in Frostbite (a thesis at https://gupea.ub.gu.se/items/e05613c2-e1d4-48b0-b41e-c32f4b0ecbae, blocked by bot protection; only a search summary was seen) [secondary]; UE TSR trailing when no depth/velocity is written (1.6).
|
||||
- Playdead's TAA (Pedersen, GDC 2016) is the history-reprojection reference cited by both Frostbite and Toft et al.
|
||||
|
||||
## 3. Flow maps, curl noise, advected textures
|
||||
|
||||
### 3.1 Flow maps, Vlachos, Valve, SIGGRAPH 2010 "Water Flow in Portal 2"
|
||||
Source: https://www.advances.realtimerendering.com/s2010/Vlachos-Waterflow(SIGGRAPH%202010%20Advanced%20RealTime%20Rendering%20Course).pdf (read)
|
||||
- Builds on Max and Becker 1995 "Flow visualization using moving textures": offset the UV by the 2D flow vector scaled by time; "distortion looks reasonable for the first 1/3 of uv space".
|
||||
- "Blend the short animated segment in two layers. Each layer is offset half a phase so we can hide the restart for each layer."
|
||||
- Two named problems: repetition ("the same normals will flow through the same point") and pulsing ("the surface appears to pulse in a repeating pattern"). Repetition solved by offsetting the second layer's sample; pulsing solved by a noise texture that offsets phase per pixel. Cost vs two scrolling normal maps: +2 texture fetches, +21 ALU instructions.
|
||||
- Colors (Portal 2): distortion interval offset to -fraction..+fraction so the peak is at rest, favouring less distortion than for normals. Listed future work: dynamic per-frame flow vectors.
|
||||
- Catlike Coding (https://catlikecoding.com/unity/tutorials/flow/texture-distortion/) [secondary]: p=frac(time), weight `1 - |1 - 2p|`, second pattern shifted half a period so weights sum to 1; Perlin noise in the flow map's A channel added to time staggers the pulse as a wave; per-phase "jump" vector times floor(time) shifts UVs only while weight is zero, limited to about +/-0.25, loop length is LCM of jump denominators; strong deformation makes compressed flow maps blocky; large-area flow maps have low effective resolution.
|
||||
- Golus flow-map notes: [not verified].
|
||||
|
||||
### 3.2 Curl noise, Bridson, Hourihan, Nordenstam, TOG 26(3) 2007
|
||||
Source: https://www.cs.ubc.ca/~rbridson/docs/bridson-siggraph2007-curlnoise.pdf (read, 3 pages)
|
||||
- Velocity = curl of a noise-valued potential (2D scalar potential rotated 90 degrees; 3D three noise functions, "the same noise function evaluated at large offsets"). Divergence-free by construction: "No sources or sinks ('gutters')"; contrasted with plain noise as velocity, which "will generally contain many sinks" where particles accumulate.
|
||||
- Derivatives by finite difference (displacement 10^-4 of the domain "works fine in single precision"). With noise at length scale L: vortices of diameter about L, speeds O(1/L). Several octaves with power-law amplitude falloff resemble Kolmogorov turbulence.
|
||||
- Time variation: "achieved very simply by using time-varying noise"; pseudo-advection of Flow Noise mentioned as untried.
|
||||
- Boundaries: potential multiplied by smooth ramp of distance to solids, `ramp(r) = 15/8 r - 10/8 r^3 + 3/8 r^5`.
|
||||
- The paper names particle advection as a primary use. It says Perlin and Neyret's Flow Noise textures "cannot be used for moving particle systems".
|
||||
- Perlin and Neyret, "Flow Noise" (SIGGRAPH 2001 sketch): rotating gradients over time plus pseudo-advection of small octaves by large ones; needs noise with analytic derivatives. https://evasion.inrialpes.fr/~Fabrice.Neyret/flownoise/index.gb.html ; Gustavson's simplex noise code exposes the derivative and a rotation parameter `t` for it (https://www.itn.liu.se/~stegu76/simplexnoise/DSOnoises.html) [secondary].
|
||||
|
||||
### 3.3 Advected textures, Neyret, SCA 2003
|
||||
Source: https://evasion.inrialpes.fr/Publications/2003/Ney03/neyret161.pdf (read)
|
||||
- A noise texture advected by a flow stretches; periodic regeneration prevents stretch from growing on steady flows, but the regeneration rate (latency) is hard to tune and blending regenerated layers causes ghosting and contrast loss.
|
||||
- Method: accumulate local deformation (time integral of the strain-tensor norm) per layer since its last regeneration; blend layers of different latency by a criterion on that accumulated deformation against a user target (analogous to MIP-mapping); each layer blends three phase-shifted regenerated textures; proposes a procedural-texture blending scheme that avoids contrast loss.
|
||||
- Needs a velocity field to compute deformation; the paper's flow came from a low-resolution fluid simulation.
|
||||
|
||||
### 3.4 Does a span / Lipschitz bound survive advection or a time-dependent warp
|
||||
No source found states it. What was read:
|
||||
- Seyb, Jacobson, Nowrouzezahrai, Jarosz, "Non-linear sphere tracing for rendering deformed signed distance fields", TOG 38(6) 2019 (PDF read): stepping in deformed space needs the Lipschitz constant of S o D^-1; "for affine transformations this is trivial [Stander and Hart 1994], but for more complicated deformations Lipschitz constants can be tedious or even impossible to compute analytically"; a global constant degrades step size where it is large; local constants "require a non-negligible computation whenever the deformation changes, i.e., every frame". Their remedy: trace in undeformed space as an ODE, avoiding the inverse deformation. https://par.nsf.gov/servlets/purl/10172295
|
||||
- Galin et al., "Segment Tracing Using Local Lipschitz Bounds", CGF 39(2) 2020: local Lipschitz bounds per ray segment, bounds derived per operator and primitive (abstract/search summary only) https://diglib.eg.org/handle/10.1111/cgf13951 [secondary].
|
||||
- Bridson 2007: speed of a noise-derived velocity is O(1/L) per octave (informal amplitude bound).
|
||||
- Neyret 2003: the quantity that grows under advection is the time-integrated strain-tensor norm.
|
||||
- A bound on the time derivative of a warped field, or a space-time Lipschitz constant: [not found].
|
||||
|
||||
## 4. Particle layers coherent with a volumetric field
|
||||
- No shipped system found that drives volumetric density and a particle layer from one stated shared motion law.
|
||||
- Niagara Curl Noise Force: reads a pre-baked, medium-resolution tiling curl-noise field by default, or computes a Perlin-derived curl at higher cost; docs do not state the divergence-free property: https://dev.epicgames.com/documentation/unreal-engine/particle-spawn-group-reference-for-niagara-effects-in-unreal-engine [secondary].
|
||||
- Frostbite 2015 couples particles to fog by voxelizing particle extinction into the same froxel volume, so they are lit together (shared volume, not shared velocity): https://www.ea.com/news/physically-based-unified-volumetric-rendering-in-frostbite [secondary].
|
||||
- Flax: volume particles write into the fog's 3D textures: https://docs.flaxengine.com/manual/graphics/fog-effects/volumetric-fog.html [secondary]. Godot demo of particle-faked fog animated with wind/attractors; "billboards make the fog appear to rotate with the camera": https://github.com/Bauxitedev/volumetric-fog-demo [secondary].
|
||||
- Bridson's curl noise is built for particle advection and the divergence-free property is its stated reason (3.2).
|
||||
- Nubis 2022 / Nubis3 slides read carry no particle layer sharing the density motion law.
|
||||
|
||||
## 5. Not reached
|
||||
RDR2 (Bauer 2019) slides; Hillaire 2015 temporal section; Golus flow-map notes; any Shadertoy nebula source; UE internals beyond forum reports; any source bounding the time derivative of a warped noise field.
|
||||
|
|
@ -1,291 +0,0 @@
|
|||
# An integration-native volumetric noise domain: probe
|
||||
|
||||
Campaign `cultmath-tapes`, Imagination probe, 2026-10-03, under ruling
|
||||
`cultmath-tapes:ruling:integration-native-noise-domain`. This is research under the
|
||||
parked tape target. It has no spec, no shipped-field change and no committed code.
|
||||
|
||||
It follows `analytic-integration-feasibility.md`, which found that exact noise
|
||||
integrals are cheap but that the shipped densities are nonlinear in the noise. The
|
||||
question here is the reverse one. Suppose a domain is designed so that span
|
||||
integration holds by construction. Does it buy more visual detail per unit cost
|
||||
than the shipped fields?
|
||||
|
||||
On the operator's instruction (relayed by Self), the reference is physical, not
|
||||
Aetheria's tuned compositing:
|
||||
- transmittance is Beer–Lambert, `T = exp(-τ)`;
|
||||
- the images use single scattering with Henyey–Greenstein `g = 0.6` and albedo 1;
|
||||
- both fields are integrated the same way.
|
||||
|
||||
The shipped look is a comparison, not a target.
|
||||
|
||||
## Verdict
|
||||
|
||||
**On the void, yes, by a wide margin. On the wells, modestly.** At or below the
|
||||
shipped march's cost, the integration-native field carries much more resolvable
|
||||
detail, and it is exact. The catches:
|
||||
- The look changes character. The default cascade reads as fibres or brush strokes,
|
||||
not as soft cloud.
|
||||
- The cost is traversal, not integration.
|
||||
- The field cannot be ray-marched at all. Every consumer, shadow rays included,
|
||||
must use the exact path.
|
||||
|
||||
Headline numbers, 512 x 256, single-threaded C# on Yggdrasil:
|
||||
|
||||
| Void, transmittance image | Cost per pixel | vs shipped march | msf (grad²/var) | Power above 1/8 cycle/px | Pixels with \|∇T\| > 0.05 |
|
||||
|---|---|---|---|---|---|
|
||||
| Shipped field, converged reference (595 samples) | n/a | n/a | 0.0111 | 0.20% | 0.00% |
|
||||
| Shipped march (11.4 samples) | 28.9 µs (44 snoise3) | 1.00x | 0.0178 | 0.71% | 0.00% |
|
||||
| Integration-native, 2 levels, exact | 13.9 µs | **0.48x** | **0.0755** | **2.95%** | 41.9% |
|
||||
| Integration-native, 3 levels, exact | 35.7 µs | 1.24x | 0.125 | 9.15% | 54.7% |
|
||||
| Integration-native, 6 levels (LOD stops at 4 to 5), exact | 74.2 µs | 2.57x | 0.159 | 12.5% | 60.9% |
|
||||
|
||||
- **Mean density is matched.**
|
||||
- The integration-native field places mass from the shipped field's noise-averaged
|
||||
density.
|
||||
- Mean window optical depth is 1.62 to 1.74 against the shipped 1.81.
|
||||
- Mean T is 0.25 to 0.40 against 0.17. The difference comes from intermittency:
|
||||
the image std is 0.29 against 0.05, at a near-equal mean.
|
||||
- **msf is normalised by variance.** It measures spatial frequency, not contrast.
|
||||
- **Single-scattering images, void.** The images are 256 x 128 with a point light at
|
||||
the sun, and both are tonemapped with the same exposure. The shipped image's msf
|
||||
is 0.0023, with 0.22% of power above 1/8. The integration-native image's msf is
|
||||
0.101, with 16.9% of power above 1/8.
|
||||
- **Wells.**
|
||||
- Transmittance is a near-opaque silhouette for both fields: equal msf (0.011),
|
||||
so it carries no signal.
|
||||
- Single scattering at 6 levels (1.20x the march cost):
|
||||
- msf is 0.073 against 0.069;
|
||||
- power above 1/8 is 10.1% against 7.0%;
|
||||
- the edge fraction is 16.7% against 9.5%.
|
||||
- At 4 levels the cost is 0.77x the march.
|
||||
|
||||
**Exactness.** Against a converged march of the same field at 0.02 world:
|
||||
- The relative τ error is a median 3e-5 and a maximum of 1.0e-4 on the void, and a
|
||||
median 1e-5 and a maximum of 1.0e-4 on the wells.
|
||||
- The binned gather against brute force over all roots differs by at most 1.3e-15.
|
||||
- The shipped march against its own converged reference: |ΔT| is a median 0.0055
|
||||
(p90 0.013) on the void and a median 0.0006 (max 0.83 on grazing rays) on the
|
||||
wells.
|
||||
|
||||
**The detail is only reachable by integration.**
|
||||
- Marching the same integration-native field at the shipped step gives a void |ΔT|
|
||||
median of 0.14, a p90 of 0.48 and a maximum of 0.83.
|
||||
- A field this detailed is beyond the march at its step. That is the operator's
|
||||
premise, measured: the shipped fields' softness fits the regime that renders them.
|
||||
|
||||
## The primitive set
|
||||
|
||||
The primitive set has one design rule: nonlinearity lives in kernel placement, and
|
||||
linearity lives in the integration. The density is a sum of nonnegative,
|
||||
compact-support kernels. Everything nonlinear is evaluated once per kernel, at its
|
||||
centre, and never per sample. That covers envelopes, clamps, `pow` profiles, the
|
||||
flow warp and placement.
|
||||
|
||||
### Bases (closed-form line integral over a segment)
|
||||
|
||||
| Primitive | Why it integrates | Cost per span (measured unless marked) |
|
||||
|---|---|---|
|
||||
| Compact polynomial ellipsoid kernel, `(1 − u)^k` with `u = \|A⁻¹(x − μ)\|²` | On a line, u is quadratic in t, so the kernel is a polynomial of degree 2k between the two roots of `u = 1`, which are closed form. Same algebra as the simplex lattice integral in the first probe | k = 0: 5.5 to 7.4 ns; k = 1: 8 to 9.5 ns; k = 2: 11.7 ns. About 0.01 to 0.02 snoise3 (snoise3 is 610 to 650 ns in C#) |
|
||||
| Truncated Gaussian ellipsoid, `exp(−κu)` on `u ≤ 1` | `exp` of a quadratic is a Gaussian in t, giving an erf difference. Truncation only clips the limits | 32 to 36 ns with A&S 7.1.26 erf (error ≤ 1.5e-7), about 0.05 snoise3 |
|
||||
| k = 0 ellipsoid (constant density) | Chord length; this is EVER's primitive (Mai et al. 2024 [memory]) | 5.5 ns |
|
||||
| Gabor kernel (Gaussian × cosine) | Complex erf, any affine transform (Gabor Fields 2026, eq. 9 [fetched in the prior-art file]) | Not probed. Up to 16 Taylor terms [paper] |
|
||||
| Cosine sum (Balint 2019) | Integral is a sum of sines | Not probed. Two noise-sized evaluations per segment [paper]. Signed, so it needs an offset; low contrast |
|
||||
| Simplex lattice (CultMath `snoise`) | First probe: degree 9 per lattice point | 0.16 snoise3 per point, 3.44 points per noise unit. Signed: only usable where density is affine in it |
|
||||
|
||||
### Warps
|
||||
|
||||
| Warp | Why it stays exact | Cost |
|
||||
|---|---|---|
|
||||
| Per-kernel affine warp: centre moved by `f(μ)·s`, shape by `(I + J·s)` | An affine image of an ellipsoid is an ellipsoid, so the ray stays straight and the kernel stays closed form. The warp *defines* the field, so there is no approximation error. It is evaluated once per root per frame (7 flow evaluations with the Jacobian) | Frame build 39 ms (void, 2540 roots) and 57 ms (wells, 16947 roots), or 19 and 27 ns per 1080p pixel amortised |
|
||||
| Children inherit the parent's affine frame | The cascade has descendants that are exact affine images, so the warp is evaluated only at the roots | One 3x3 product per child, from a table |
|
||||
| Not allowed | A per-point warp `x + w(x)` inside a span: the warped ray curves, which was the first probe's problem | n/a |
|
||||
|
||||
### Transfer and placement
|
||||
|
||||
| Rule | Status |
|
||||
|---|---|
|
||||
| Any function of the kernel centre: the shipped clamp, `pow`, the smoothstep fade, the noise-averaged density `E_n[ρ(x, n)]` from 16 empirical noise quantiles, through a 1D LUT over d (void) or s (wells) | Exact by construction, at one lookup per kernel |
|
||||
| Affine transfer of the sum (`a + b Σ K_i`) | Exact |
|
||||
| Any 1D function of an *affine* coordinate, `∫ f(a + bt) dt = ΔF / b` (pre-integration, Engel 2001) | Exact [derivation, not probed]: height fog `exp(−y/h)` and ramps in y |
|
||||
| Polynomial or exp of a *quadric* coordinate; an indicator or clamp of a quadric (chord through roots) | Exact [derivation, not probed]: spherical shells and paraboloid bowls as envelopes |
|
||||
| Product of the sum with a polynomial envelope in t | Exact, polynomial times polynomial [derivation, not probed] |
|
||||
| Forbidden inside a span: `f(Σ K_i)` for nonlinear f, `max`/`min` of fields, a clamp of a signed sum, or noise displacing an envelope's argument | These are exactly what broke the first probe |
|
||||
|
||||
### Composition: the mass-conserving multiplicative cascade
|
||||
|
||||
- **Roots** sit on a jittered grid: spacing H0 = 30 world on the void and 40 on the
|
||||
wells, one root per cell.
|
||||
- Mass is `P(μ)·H0³`, so the mean density equals the shipped noise-averaged
|
||||
density.
|
||||
- The ellipsoid is elongated along the flow direction projected on the shell or
|
||||
fog layer (aspect 1.3 to 2.4), with ±0.6 rad of jitter, and flattened along the
|
||||
normal (×0.5 void, ×0.4 wells).
|
||||
- There are two copies per root, at the shipped two-phase shifts with weights w0
|
||||
and 1 − w0. Time animation is therefore the shipped crossfade, exact under
|
||||
summation.
|
||||
- **Children.** Each node keeps 25% of its mass and gives 75% to Nc = 5 children.
|
||||
- Child weights are `u²·P(child)/P(parent)`, normalised. The `u²` makes the cascade
|
||||
intermittent; the P ratio sharpens it toward the envelope.
|
||||
- A child is scale 0.4, with aspect up to 1.6 and ±0.6 rad of rotation.
|
||||
- Children sit along the parent's major axis: offset 0.6 along it, 0.21 across.
|
||||
- All of this comes from a 256-entry table indexed by hash. A descendant bound of
|
||||
1.77 times the ellipsoid holds the subtree, so the parent is a BVH node.
|
||||
- **LOD.** A node is a leaf when its children would be smaller than 0.5 × the pixel
|
||||
footprint at that depth. It then keeps its full mass. Because the cascade conserves
|
||||
mass, truncation is a mean-preserving prefilter: the expectation step of the first
|
||||
probe, exact here instead of approximate.
|
||||
- **Gather.**
|
||||
- Roots are binned by their subtree AABB into a hash grid of H0 cells.
|
||||
- The ray walks the grid with Amanatides–Woo DDA and mailboxing, clipped to the
|
||||
shipped active windows, so both fields integrate the same span.
|
||||
- It exits early once Στ ≥ 7, which is valid because every term is nonnegative.
|
||||
|
||||
## "Juice" under these constraints
|
||||
|
||||
| Effect | Mechanism here | Measured or prior art |
|
||||
|---|---|---|
|
||||
| Filaments, wisps | Anisotropic children chained along the parent's major axis, which follows the flow | Visible in `void-T-IN-L6.png` as streaks along the flow. Gabor Fields uses anisotropic Gabor kernels for orientation-selective detail [fetched] |
|
||||
| Sharp edges | Low-order kernels: k = 1 is the default, k = 0 gives hard ellipsoids | k = 0 (`L6-hard`) changes msf by −9% on the void. Edges come from kernel boundaries, not clamps |
|
||||
| Voids | Regions with no kernel mass are exactly zero and are skipped by the gather at no cost | Structural |
|
||||
| Turbulent intermittency | Random multiplicative cascade, the p-model of turbulent dissipation (Meneveau & Sreenivasan 1987 [memory]); clustered kernels, as in a Neyman–Scott process [memory] | Image std 0.29 against 0.05 at near-equal mean OD |
|
||||
| Flow and animation | Per-kernel warp at the shipped flow and phases. Alternatives are Lagrangian advection of kernel centres with lifetimes (advected textures, Neyret 2003 [memory]) and Gabor phase for oscillatory detail (flow noise, Perlin & Neyret 2001 [memory]) | Only the two-phase crossfade was measured |
|
||||
| Fine detail riding on structure | Children exist only inside parents, so detail appears only where matter is. This is Schneider's erosion idea (2015 [fetched]) without the per-sample clamp | Structural |
|
||||
|
||||
Wavelet turbulence (Kim et al. 2008 [memory]) and curl noise (Bridson 2007 [memory])
|
||||
were not used. A per-point warp is ruled out above. Curl noise could orient kernels,
|
||||
evaluated per kernel, at no cost to exactness.
|
||||
|
||||
## Measurements
|
||||
|
||||
**Setup.**
|
||||
- Probe source: scratch clone `probe-clone@5ec9f529`,
|
||||
`packages/cultmath/probe/DomainProbe/Program.cs`, never merged.
|
||||
- Run on Yggdrasil through the verify stopgap: dotnet 10 container, 4 CPUs, tiered
|
||||
JIT off.
|
||||
- Full log: Self's scratchpad, `dom-full1.log`.
|
||||
- The scenes are those of the first probe, with fixed cameras:
|
||||
- Void: the shipped orbiting camera at time 20, at the 1920-wide f = 935 field of
|
||||
view, rendered at 512 x 256.
|
||||
- Wells: warp D = 60, camera at xz (1200, 0) looking at the central well, slopes
|
||||
x ±1 and y from −0.85 to 0.15.
|
||||
- The window is the shipped field's active intervals. Every method integrates the
|
||||
same window.
|
||||
|
||||
**Cost.**
|
||||
- Single-threaded C#, best of three passes over the same 2048 random pixels.
|
||||
- The host is shared, and absolute timings moved by up to 2x between the smoke run
|
||||
and the full run; Flow went from 125 to 660 ns. Ratios within one run are the
|
||||
reliable numbers.
|
||||
- Flow is 47% of a shipped sample in this run, so a flow-cached march would cost
|
||||
about 15 µs on the void and 31 µs on the wells.
|
||||
|
||||
| Config (void) | Cost vs march | Roots visited / nodes tested / kernels integrated per pixel | Kernels per level |
|
||||
|---|---|---|---|
|
||||
| L1 | 0.12x | 43 / 8 / 8 | 8 |
|
||||
| L2 | 0.48x | 59 / 25 / 20 | 8, 12 |
|
||||
| L3 | 1.24x | 74 / 42 / 28 | 8, 12, 8 |
|
||||
| L4 | 1.81x | 82 / 60 / 35 | 8, 12, 8, 7 |
|
||||
| L6 (default) | 2.57x | 93 / 86 / 36 | 8, 12, 8, 7, 1, 0 |
|
||||
| L6, no child placement | 1.79x | 93 / 91 / 39 | same metrics as L6 |
|
||||
| L6, no LOD | 2.95x | 93 / 103 / 49 | 8, 12, 8, 7, 7, 7 |
|
||||
|
||||
| Config (wells) | Cost vs march | Kernels integrated per pixel |
|
||||
|---|---|---|
|
||||
| L1 | 0.04x | 5 |
|
||||
| L2 | 0.23x | 11 |
|
||||
| L3 | 0.49x | 16 |
|
||||
| L4 | 0.77x | 20 |
|
||||
| L6 | 1.20x | 25 |
|
||||
| L6, no child placement | 0.62x | 27 |
|
||||
|
||||
**Where the time goes.**
|
||||
- The integrals are about 36 kernels × 10 ns, or 0.4 µs of a 74 µs pixel.
|
||||
- The rest is traversal:
|
||||
- roots visited but missed: 93 visited for 8 hits, because AABBs of elongated
|
||||
rotated ellipsoids are loose;
|
||||
- child generation;
|
||||
- placement lookups. The wells' placement costs a 5-bowl `Sdf`, and dropping
|
||||
child placement halves the wells cost with identical metrics.
|
||||
|
||||
**Aliasing.** One sample per pixel against four on the default void field: |ΔT| has
|
||||
an RMS of 0.015 (p90 0.022). At 4 spp the msf is 0.146 against 0.159. So about 92%
|
||||
of the measured detail is resolved structure, not sparkle. Exact along the ray is
|
||||
not the same thing as antialiased across the pixel; the LOD rule is the prefilter.
|
||||
|
||||
**Variants, void L6 (msf / power above 1/8).**
|
||||
- k = 1 default: 0.159 / 12.5%.
|
||||
- Hard k = 0: 0.145 / 11.0%.
|
||||
- Gaussian (erf): 0.174 / 14.2%, at 0.89x the polynomial cost here. The erf kernel is
|
||||
3x the polynomial kernel in isolation, but the kernel is not where the time goes.
|
||||
|
||||
## Images
|
||||
|
||||
All images are in Self's session scratchpad,
|
||||
`C:\Users\Meta\AppData\Local\Temp\claude\F--Projects-CultLib\b7e842a1-2045-474e-a51b-240d832b770b\scratchpad\integration-domain-images\`:
|
||||
|
||||
- **Transmittance, 512 x 256.** Shown as `sqrt(1 − T)`, white for opaque.
|
||||
- `void-T-shipped-ref.png`, `void-T-shipped-march.png`.
|
||||
- `void-T-IN-L1.png`, `void-T-IN-L3.png`, `void-T-IN-L6.png`,
|
||||
`void-T-IN-L6-hard.png`, `void-T-IN-L6-gauss.png`.
|
||||
- The wells equivalents, which are silhouettes and uninformative.
|
||||
- **Single scattering, 256 x 128.**
|
||||
- Method: HG g = 0.6 and albedo 1. The point light is at the void's sun with 1/r²
|
||||
falloff; the wells use a directional sun with a sky background.
|
||||
- Each view sample takes a shadow ray: a 2-world march for the shipped field, an
|
||||
exact traversal for the integration-native field.
|
||||
- Tonemap: `1 − exp(−1.5 L / p99(shipped))`, then gamma 2.2, the same exposure for
|
||||
both fields.
|
||||
- `void-SS-shipped.png`, `void-SS-IN-L6.png`, `wells-SS-shipped.png`,
|
||||
`wells-SS-IN-L6.png`.
|
||||
|
||||
What they show:
|
||||
- The shipped void is a soft, low-contrast haze.
|
||||
- The integration-native void is dense flow-aligned streaks with deep gaps, like
|
||||
hair or brush strokes.
|
||||
- The shipped wells are smooth rolling dunes. The integration-native wells are
|
||||
layered pancakes with streaked detail.
|
||||
|
||||
## The catches
|
||||
|
||||
1. **The character is a design parameter, and this probe picked one.**
|
||||
- High anisotropy, axis-chained children and an intermittent `u²` split give
|
||||
fibres.
|
||||
- A cloudier look would need rounder children, wider offsets and less
|
||||
intermittency. That trades away some measured detail and was not swept.
|
||||
- The detail metrics measure spatial frequency, not taste. The operator has to
|
||||
look at the images.
|
||||
2. **The cost is a BVH, not arithmetic.**
|
||||
- Integration is nearly free. Traversal, child generation and placement dominate.
|
||||
- Tighter root bounds (OBB tests before AABB bins), cheaper placement and fewer
|
||||
children are obvious levers, but none was measured.
|
||||
- On a GPU, variable-length kernel lists per ray diverge. 3DGRT (Moënne-Loccoz et
|
||||
al. 2024 [memory]) puts the same problem on hardware ray tracing.
|
||||
- CPU C# ratios do not transfer to the GPU, where snoise is relatively cheaper.
|
||||
3. **It is all or nothing.**
|
||||
- This field cannot be marched. At the shipped step, |ΔT| is a median 0.14.
|
||||
- Shadow rays, light probes and any sampler consumer need the exact traversal too.
|
||||
The single-scattering pass here cost an exact shadow traversal per view sample:
|
||||
257 s for both void SS images together, against 54 s for the whole 512 x 256
|
||||
shipped transmittance set.
|
||||
- Lighting has no closed form. The in-scatter integral is still quadrature. What
|
||||
the domain makes exact is every optical depth inside it. A per-kernel lighting
|
||||
cache would amortise shadows, but that is a different shading model (splat-like),
|
||||
and so a product fork.
|
||||
4. **Exact along the ray, not across the pixel.** The LOD truncation is the
|
||||
prefilter. 8% of the measured detail at 1 spp is aliasing.
|
||||
5. **The wells gain little** because the shipped wells fog is near-opaque. Its look
|
||||
is the surface silhouette, and that is set at root scale. The domain pays most
|
||||
where the medium is semi-transparent, as in the void.
|
||||
6. **Timing noise.** Absolute costs vary up to 2x between runs on the shared host.
|
||||
The cost ratios above are within one run.
|
||||
|
||||
## What would change the verdict
|
||||
|
||||
- A sweep toward a cloud-like character: round children, Nc = 8, wider offsets,
|
||||
Gaussian kernels. It should be judged on the single-scattering images against the
|
||||
same cost.
|
||||
- Tight root culling: an exact ellipsoid-versus-bin test, or roots stored by
|
||||
oriented bound. If roots visited fall from 93 to near the 8 hits, the void L3 cost
|
||||
would fall well under the march.
|
||||
- A GPU port of one level-limited configuration, to replace the C# ratios with
|
||||
shader timings.
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
# Inverse procedural modelling: describing messy meshes as programs
|
||||
|
||||
Eyes pass, 2026-10-10. Operator: "What if the model had some trash geo and its goal is to describe it parametrically?" Context: Tripo hard-surface hulls; CultMath intervals and affine forms on main, tape/brush grammar parked (docs/cultmath-tape-target.md); the earlier GameCult/VibeGeometry attempt (2026-05). Facts only; tags mark FETCHED, SEARCH (search summary only) and KNOWLEDGE (unverified).
|
||||
|
||||
# Eyes facts: inverse procedural modelling (mesh -> parametric program)
|
||||
Date 2026-10-10. Tags: FETCHED (page/file read this session), SEARCH (search-result digest only), KNOWLEDGE (unverified recall).
|
||||
Search digests come from a small model; SEARCH numbers are not paper-verified.
|
||||
|
||||
## 0. What exists in GameCult
|
||||
- FETCHED F:\Projects\CultLib\docs\cultmath-tape-target.md: steps 2-5 (tape, pruning, HLSL interpreter, meshing) PARKED (2026-10-02,
|
||||
ruling tape-target-unparks-when-asura-stable). Step 1 intervals + affine forms are on main (docs/cultmath-interval-ground-cut.md).
|
||||
The "grammar of nested brushes" is operator intent only (quote 2026-10-02: space station from "a grammar of nested brushes");
|
||||
doc says the station project is "a separate project with its own target". Chosen shape (c): port Fidget ideas into CultLib (Fidget =
|
||||
MPL-2.0 Rust, v0.5.1). Meshing plan: surface nets + Asura tile pass (Newton refinement onto f=0, crease snapping from field gradients);
|
||||
"Thin authored bevels can be shaded by their analytic normals". Tape gradients "reuse CultMath's analytic-gradient primitives".
|
||||
- FETCHED grep CultLib\docs for brush/tape: "brush" hits in cultmath-interval-ground-cut.md are Aetheria noise/gravity-well field brushes
|
||||
(PowerBrush (1-x^2)^16 etc.), not CSG brushes. No CSG brush grammar or tape code exists in CultLib; only the parked doc.
|
||||
- FETCHED (voidbot) separate repo GameCult/GameCult.Geometry.Csg ("CultGeometry CSG", Rust; local F:\Projects\GameCult.Geometry.Csg).
|
||||
Mesh-based convex plane-splitting CSG (not SDF), RealtimeCSG-shaped API: ordered Additive/Subtractive/Intersecting brush streams, boxes,
|
||||
oriented boxes, CylinderZ/DomeCapZ/FloretArm, dirty-frontier, OBJ output, plus domain-tree/selected-cut LOD schemas (src/cult_geometry).
|
||||
Doc text: "agents should revise intent by moving, adding, or renaming brushes, not by poking triangles"; "brush list is the durable
|
||||
design surface. The mesh is a cache."
|
||||
|
||||
## 0b. The earlier "vibe geometry" attempt
|
||||
- FETCHED voidbot gamecult-site GameCult/Projects/VibeGeometry.md: repo GameCult/VibeGeometry, started 2026-05-04, last public push
|
||||
2026-05-07; "agent-authored Blender scene generation: geometry nodes, Python builders, semantic graph contracts, render evidence, and
|
||||
the ongoing war against decorative node spaghetti". Org ledger: "Started as architecture and persistence scaffolding, then dived almost
|
||||
immediately into CSG grammar, parity fixtures, native bridge probing, mesh generation, and performance work."
|
||||
- FETCHED local git F:\Projects\VibeGeometry-cultgeometry-rename (93 commits). Commit 09bff8c (2026-05-29, "Focus VibeGeometry on vg-csg")
|
||||
deleted the scene-generation tree; recoverable at 09bff8c^: docs/research/procedural-doctrine.md (~1000-line "spatial reasoning
|
||||
playbook"), dream-machine-grammar.md, geonodes-translation-corpus.md, ircss-french-houses-* (translations of Blender node graphs into
|
||||
Geometry Script Python), builds/{aetheria-bloom-habitat, lucent-tether-habitat, dream-machine-level, industrial-maintenance-loop}.md,
|
||||
notes/architecture-rationale.md, a "reusable procedural helper library" (aa4d49d). The DSL was Blender Python via Geometry Script
|
||||
(fork F:\Projects\geometry-script; upstream PR carson-katri/geometry-script#69 linked by Metacrat 2026-05-04) emitting geometry-node
|
||||
graphs, plus the Rust brush DSL (LevelDsl: solid_box, cut_box, solid_oriented_box, dome_cap_z, floret_arm).
|
||||
- FETCHED notes/architecture-rationale.md @09bff8c^, section "Core Failure": "The central failure mode is local plausibility without global
|
||||
coherence. An agent can write plausible Blender Python, create a large node graph, and still lose the visual machine ... the script
|
||||
runs, the graph exists, the render has objects in it, and somehow the target has been replaced by decorative procedural fog".
|
||||
"Seductive proxies": valid Python, non-empty renders, high node counts, pretty materials, parameter sliders.
|
||||
procedural-doctrine.md: "Ornament in the wrong frame is not detail. It is a confident lie". Commit titles record frame errors:
|
||||
"Correct Bloom terrace slum orientation", "Correct Lucent dome spin-gravity frame", "Move Lucent ray florets outside dome".
|
||||
- FETCHED VibeGeometry README: "failed experiments are useful without being allowed to hold a steering wheel"; old mission "deleted
|
||||
from the live tree"; live mission narrowed to runtime CSG (domain tree -> LOD CSG -> selected cut -> triangles).
|
||||
- NOT FOUND: no indexed operator or agent statement that models "weren't good enough at spatial reasoning to use the DSL". voidbot
|
||||
search_history (Discord) returned only unrelated hits. The repo docs above are the only failure statements in project words
|
||||
(coherence and frame errors; the phrase "spatial reasoning" appears only in the playbook title and its body).
|
||||
|
||||
## 1. Inverse CSG / shape-program synthesis
|
||||
Most train or test on DeepCAD / ShapeNet / ABC / Infinigen; none retrieved was evaluated on noisy AI-generated hard-surface meshes.
|
||||
- InverseCSG (Du et al., SIGGRAPH Asia 2018, MIT). SEARCH: mesh/point samples -> CSG program; geometric processing (RANSAC-style primitive
|
||||
detection) turns the mixed discrete/continuous problem into a purely discrete one; careful point samples guide a discrete search;
|
||||
divide-and-conquer segmentation; program synthesis (solver) per segment. Claims >100 primitive parts, compact programs, beats prior
|
||||
methods on compactness/runtime. D2CSG authors (SEARCH) criticise it: RANSAC needs tuning, noisy initial primitives, long search.
|
||||
Project page inversecsg.csail.mit.edu lists code; licence not checked.
|
||||
- CSGNet (Sharma 2018): KNOWLEDGE only; supervised, voxel/2D -> CSG sequence.
|
||||
- UCSG-Net (Kania, NeurIPS 2020). SEARCH: unsupervised; predicts box/sphere params; differentiable indicator on SDF; several CSG layers with
|
||||
soft operator choice; structure and params learned jointly by gradient descent; autoencoding scope (2D/3D), no mesh-to-program at test
|
||||
for unseen exotic shapes beyond the trained net.
|
||||
- CAPRI-Net (CVPR 2022). SEARCH: axis-aligned quadric primitives, fixed 3-layer assembly; ABC point-cloud Chamfer 0.09 vs UCSG 1.43; ~5.3 vs
|
||||
12.92 convex shapes. D2CSG (2023), CSG-Stump (2021): SEARCH, unsupervised compact trees. SECAD-Net (CVPR 2023): SEARCH, sketch-extrude,
|
||||
editable; CD 2.94/4.20 (ABC/Fusion) vs UCSG 3.14/4.45.
|
||||
- Point2CAD (Liu et al.): SEARCH only that a repo exists (jofcodeusal/point2cad mirror, Docker toshas/point2cad); method not retrieved.
|
||||
Point2Cyl (CVPR 2022): SEARCH, supervised net to extrusion cylinders. DeepCAD (Wu 2021): KNOWLEDGE, ~178k sketch-extrude sequences.
|
||||
- ShapeAssembly (Jones 2020, TOG): SEARCH; programs of cuboid proxies attached to each other with free parameters; extraction from PartNet.
|
||||
ShapeMOD (2021): SEARCH; discovers macros across a corpus minimising calls and free parameters. Repos/licences not found by search
|
||||
(KNOWLEDGE: github rkjones4/ShapeAssembly exists; unverified).
|
||||
- CAD-Recode (Rukhovich et al., ICCV 2025, arXiv 2412.14042). FETCHED abstract: point cloud -> CadQuery Python (sketch-extrude); point-cloud
|
||||
projector + small pretrained LLM decoder; trained on 1M procedurally generated CAD sequences; eval DeepCAD, Fusion360, CC3D (real scans);
|
||||
output usable by off-the-shelf LLMs for editing and QA. SEARCH (unverified): Qwen 0.5B/1.5B decoders; DeepCAD mean CD 3.43 -> 0.30, IoU
|
||||
77.6 -> 92.0 vs CAD-SIGNet; test time: 10 sampled candidates executed, min-Chamfer kept. Code github.com/filaPro/cad-recode (SEARCH);
|
||||
HF dataset filapro/cad-recode is CC-BY-NC-4.0 (SEARCH); repo licence unchecked. Structure by autoregressive decoding, params as literal
|
||||
tokens, re-ranking by Chamfer. Abstract says nothing on fillets/chamfers.
|
||||
- MeshCoder (Dai et al., NeurIPS 2025, arXiv 2508.14879). FETCHED: 16,384-point cloud -> triplane tokenizer -> Llama-3.2-1B (LoRA) ->
|
||||
Blender Python. API: cube/cylinder/UV-sphere/cone/torus, Translation (sweep 2D section along 3D trajectory), Bridge Loop, Boolean
|
||||
(union/intersect/diff), Array, Fill Grid, Spoon, Fork. Data: ~10M part-code pairs, ~1M object-code pairs, 41 categories (Infinigen
|
||||
Indoor). Table 2 averages CD x1e-2 / IoU%: Shape2Prog 6.00/45.0, PLAD 1.87/67.6, MeshCoder 0.06/86.75. Worst: Fork 58.9 IoU, CeilingLight
|
||||
65.8, Array parts 78.9. Limits: human-made objects only; geometry only; training objects kept only if every part CD < 5e-3. LLM does not
|
||||
edit; edits are manual param changes; GPT-4o used only for QA on the code. Output is part-segmented. Licence not stated in paper.
|
||||
- CADCodeVerify (Alrashedy, ICLR 2025, arXiv 2410.05340). SEARCH: text prompt -> CadQuery (GPT-4); VLM writes/answers validation questions
|
||||
about renders and refines; CADPrompt benchmark (200 objects); GPT-4 point-cloud distance -7.30%, compile rate +5.5% (v2; v1 said 5.0%
|
||||
success). Target is a prompt, not a mesh.
|
||||
- Newer render-loop agents (SEARCH): SEIG "Thinking in Blender" (arXiv 2606.02580): single reference image -> staged Blender code (geometry,
|
||||
materials, composition, lighting), VLM generator + verifier per stage; FETCHED abstract gives no numbers, no code link. LL3M (2508.08228):
|
||||
text -> Blender with critic reading renders. ShapeCraft (2510.17603): renders bbox/part/global views as feedback. SceneCraft (2403.01248):
|
||||
render-critique-revise. Not found by search: "3D-Premise", "LLMto3D", Img2CAD/Text2CAD specifics.
|
||||
|
||||
## 2. Classical primitive fitting as a front end
|
||||
- Efficient RANSAC (Schnabel/Wahl/Klein, CGF 2007): SEARCH/FETCHED page text: planes, spheres, cylinders, cones, tori in unorganised clouds;
|
||||
multi-million-point sets "within less than a minute". CGAL Shape Detection implements it plus Region Growing (Lafarge&Mallet). CGAL is
|
||||
dual GPL/LGPL + commercial; the Shape Detection package's own licence NOT confirmed (digest guessed GPL).
|
||||
- GlobFit (Li/Wu/Chrysanthou/Sharf/Cohen-Or/Mitra, SIGGRAPH 2011): SEARCH; starts from RANSAC primitives, discovers global relations
|
||||
(orientation, placement, equality; symmetry) and enforces them by constrained optimisation; assumes man-made, repeated, globally aligned
|
||||
parts. Code: archived code.google.com/archive/p/globfit, "research use only" wording.
|
||||
- Mitra 2006 symmetry detection, SPFN (2019): KNOWLEDGE/SEARCH only. ParSeNet (2020): SEARCH; B-spline patch decomposition, beats RANSAC
|
||||
and SPFN baselines. No repos/licences retrieved.
|
||||
- What fits supply as data: exact primitive parameters with inlier sets and residuals, segment labels, relation graph (GlobFit).
|
||||
InverseCSG uses the primitives to make the problem discrete. Recorded weakness: noisy primitives, parameter tuning (SEARCH).
|
||||
|
||||
## 3. Differentiable fitting of SDF/CSG programs
|
||||
- UCSG-Net (above): soft operator selection; structure learned jointly.
|
||||
- Fuzzy Boolean differentiable CSG (dgp.toronto.edu/~hsuehtil/pdf/fuzzyBoolean.pdf): SEARCH; gradient descent over boolean and primitive
|
||||
parameters; notes prior work pre-fixes tree structure and operators and optimises only primitive params; replaces min/max with a unified
|
||||
fuzzy operator so the operation type is optimised. Costs listed: analytic SDF per primitive type, per-iteration SDF evaluation, image
|
||||
losses need differentiable sphere tracing.
|
||||
- DiffCSG (arXiv 2409.01421): SEARCH; differentiable rasterisation of mesh CSG, image-space gradients to primitive params; image-guided
|
||||
editing, not mesh fitting.
|
||||
- ResFit, Residual Primitive Fitting with SuperFrusta (arXiv 2512.09201): SEARCH; one analytic SDF primitive with 8 params (dilation, taper,
|
||||
bulge, onion hollowing, profile roundness, axial scaling); alternates global shape analysis and local primitive optimisation; code "to be
|
||||
open-sourced upon acceptance".
|
||||
- Evolutionary mesh -> CSG abstraction (SEARCH; authors not retrieved): fast, coarse. Sigmoid-over-SDF soft occupancy with alternating
|
||||
add-primitive / joint-refine loop (SEARCH, primitive-scene paper).
|
||||
- Not found: a published gradient fit of an SDF program with bevel/round parameters to a messy hard-surface mesh.
|
||||
- Handling of discrete structure across sources: soft selection (UCSG, CSG-Stump, fuzzy Boolean); pre-fixed structure (InverseCSG after
|
||||
RANSAC; CAPRI-Net fixed layers); greedy add/prune (ResFit); neural token decoding (CAD-Recode, MeshCoder).
|
||||
|
||||
## 4. LLM spatial reasoning in 3D code generation
|
||||
- BlenderGym (Gu et al., CVPR 2025, arXiv 2504.01786). FETCHED: 245 start-goal Blender scene pairs, five tasks (placement, lighting,
|
||||
procedural material, blend shape, procedural geometry); the VLM sees start and goal and edits Blender code (target-matching setting).
|
||||
Table 1 best-VLM vs human: geometry Chamfer 1.120 (GPT-4-Turbo) vs 0.334; geometry photometric loss 6.747 vs 1.269; placement CD 8.324 vs
|
||||
1.532; blend shape CD 0.904 vs 0.399. Paper: VLMs "particularly poorly in procedural material and geometry editing"; all open-source
|
||||
models except MiniCPM failed to produce executable code on >75% of geometry instances. Verifier agreement with humans: human 0.79,
|
||||
Claude 3.5 Sonnet 0.66, random 0.5. Scaling a small verifier (InternVL2-8B, k 1 -> 64 over 32 candidates) beat unscaled GPT-4o and
|
||||
Claude 3.5 on blend shape. Low compute: less verification share (0.33); high compute: more (0.62-0.73). Models are 2024-25 era. Code
|
||||
licence not in retrieved text.
|
||||
- BenchCAD (arXiv 2605.10865): SEARCH; 17,900 CadQuery programs, 106 part families; frontier models recover coarse outer geometry but fail
|
||||
faithful parametric programs; sweeps/lofts/twist-extrudes replaced by sketch-extrude; single-literal errors (5.6 vs 5.85) at IoU >= 0.9.
|
||||
BenchCAD-Edit: text instruction sets the upper bound; the image is "at best a clarifier and at worst a distractor" for thinking models.
|
||||
- Text2CAD-Bench (2605.18430): SEARCH; execution failures even at easy level; sweep/loft/shell widely fail; executing != correct.
|
||||
- Text-to-B-Rep paper (2603.11831): SEARCH; spatial misalignment of primitives, omitted fillets, geometry contradicting described layout;
|
||||
authors blame weak precise 3D spatial reasoning.
|
||||
- "Foundation Models for Automatic CAD Generation" (2607.05573): SEARCH, single source; numeric analytic feedback peaked at round 1; VLM
|
||||
critique of renders kept improving in rounds 2-3 for DeepSeek/Qwen; VLM critics struggle on rotationally symmetric geometry.
|
||||
- Multi-view conditioning beat single-view and sketch on Chamfer/F-score in a B-Rep paper (SEARCH, not CadQuery).
|
||||
- Target vs description: BlenderGym gives a target and still shows a large human gap; CAD-Recode and MeshCoder avoid prompting by training
|
||||
point-cloud encoders; no controlled prompted-LLM study of target-vs-description was found.
|
||||
- MindCube spatial study (SEARCH): errors 20% final reasoning step, 13% 3D reconstruction, 6% generated programs.
|
||||
|
||||
## 5. Hard-surface specifics
|
||||
- SDF rounding (Quilez distfunctions, SEARCH): sdRoundBox = box SDF minus r, an iso-offset so the shape grows by r. Polynomial smin with k
|
||||
as blend band; Quilez marks smin as "bound" not exact. Chamfer/fillet operators not retrieved; KNOWLEDGE: hg_sdf (Mercury) has
|
||||
fOpUnionChamfer / fOpUnionRound / fOpUnionStairs (unverified here).
|
||||
- MeshCoder (FETCHED): no fillet/chamfer primitive; booleans, sweeps, bridge-loop, arrays only.
|
||||
- BenchCAD / text-to-B-Rep (SEARCH): omitted fillets/refinement features named as failure; sweeps/lofts degrade to extrudes.
|
||||
- CAD-Recode/DeepCAD data are sketch-extrude (SEARCH/KNOWLEDGE); fillet/chamfer not covered in what was retrieved.
|
||||
- InverseCSG output: boolean of primitives, no blends (KNOWLEDGE). CAPRI-Net quadrics: smooth surface in one primitive (SEARCH). ResFit
|
||||
primitive has a profile-roundness parameter (SEARCH).
|
||||
|
||||
## Gaps
|
||||
- Operator/agent words on why VibeGeometry failed: not indexed (see 0b).
|
||||
- Not read at body level: CAD-Recode fillet handling, InverseCSG experiments, GlobFit relation list, SEIG numbers, ResFit results.
|
||||
- Licences unverified: InverseCSG, ShapeAssembly, CAD-Recode repo, MeshCoder, GlobFit, CGAL Shape Detection, Point2CAD.
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
# Facts for `cultmath-tapes:question:lattice-site-weight-source`
|
||||
|
||||
Agent eyes-lattice-site-weight, session self-2026-10-14-eureka, 2026-10-14. Facts only, no recommendation.
|
||||
Marks: [src] source code, [doc] repo doc, [web] web page, [abs] absence found, [probe] measured by the scripts beside this file.
|
||||
The eureka-state view of the question id failed (`view` wants a PipelineRef, not the id string); the question text was taken from the brief.
|
||||
|
||||
## 1. What the fauna probe did (Aetheria 4354ef2c, read via `git show`)
|
||||
|
||||
- [src] `docs/research/2026-10-14-trophic-dag-probe/trophic_probe.py` `lattice_web` (lines 95-119): per lattice cell `(cx,cz)` in a (2*WIN+1)^2 window (WIN=2): jittered site `(c+.5)*LAT` plus a drifting offset; weight `wt = (hash*.5+.5) * (1 + BOOST*prox)`, `prox = max over wells of exp(-(|site - well| / (2.2*.103*r))^2)`, BOOST 1.6; `d = hypot(...) * wt`; F1, F2 and a polynomial smooth-min `m` accumulated in loop order; output `max(soft^2, exp(-((f2-f1)/.10)^2))`. Wells orbit with zone time, so the site weight is a function of time.
|
||||
- [src] The weight is read at the site's lattice position `(cx+.5)*LAT`, not at the query point and not at the drifted site.
|
||||
- [doc] `docs/aetheria-fauna-map.md` B34: largest presence step for `lattice` .124 (8 steps over .1); "not discontinuities: a traced member's web value rises .03 per step for 5 steps, a thin weighted border sweeping a fixed candidate"; window 7x7 changes nothing; boost .6 removes the steps (.047); reading the lattice drift at the member's cycle start removes them at full boost (.011, `cyc.log`).
|
||||
- [doc] Map r4 gap (1): "a 2D weighted lattice cellular with the smooth-min blend (her node) on `pcg3d` cells and zone time"; "The search window follows the weight range ... The CultMath node states the weight range it supports" (map lines 438, 444-446).
|
||||
- [doc] Prior art doc `docs/research/2026-10-14-trophic-dag-filaments-prior-art.md` 1.1.7 [M]: power diagram evaluated pointwise by the same neighbour search "only if the search window is widened with the weight range"; marked "Neither derivation checked against a source".
|
||||
|
||||
## 2. Literature and implementations
|
||||
|
||||
- [web] Wikipedia, Weighted Voronoi diagram (https://en.wikipedia.org/wiki/Weighted_Voronoi_diagram): multiplicative = distance divided by positive site weight (circular Dirichlet tessellation, edges circular arcs and line segments); additive = weight subtracted from distance (hyperbolic Dirichlet tessellation, hyperbola arcs and segments); power diagram = weight subtracted from squared distance. A multiplicatively weighted cell may be non-convex, disconnected or hold holes. The page says nothing on continuity.
|
||||
- [web] Sangil Lee, "Understanding the Variations of Cellular Noise" (https://sangillee.com/_posts/2025-04-18-Cellular-noises/): weighted Voronoi as effective distance `(1/w)*|x - seed|` with `w = 0.2 + 0.8 * random3(cell).z`, i.e. the per-site weight is a component of the hash of the lattice cell id, computed inside the noise function; the weighted example loops 5x5 instead of 3x3 (no reason given).
|
||||
- [web] Inigo Quilez, Voronoise (https://iquilezles.org/articles/voronoise/): `hash3(cell id)` yields jitter in `.xy` and a per-cell value in `.z`; 5x5 window; notes `min()` is discontinuous and the smooth blend fixes that. No weighted variant.
|
||||
- [web] Worley noise, Wikipedia and The Book of Shaders (https://thebookofshaders.com/12/): the standard is current cell plus neighbours (3x3), with a wider pass for exact borders; Worley's original used a variable number of points per cell. Nothing found on per-point weight attributes beyond the Lee article; the continuity guidance in the search results was general-knowledge reasoning, not sourced.
|
||||
- [abs] No source found stating a continuity theorem for weighted Worley; no source found for a node that takes its per-site weight from an external field function. The only per-site weight mechanism found in shader writing is the hash of the cell id.
|
||||
- [probe] Continuity as observed (section 4): F1 stays continuous as long as the window contains the true winner; failure occurs when it does not.
|
||||
|
||||
## 3. CultMath today
|
||||
|
||||
- [src] `F:\Projects\CultLib\packages\cultmath\src\CultMath\math.cs:909` `public static CultCellular cellular(float3 p)`: one argument, 3D, unweighted; sites `neighbor + jitter` with jitter from `pcg3d(int3(neighbor))` through `cellular_unit`; fixed 5x5x5 window with a per-offset lower-bound prune (`max(0,|d|-1)`), whose exactness proof (comment above the function) relies on the jitter being in [0,1) and on equal weights; returns `CultCellular { nearest = (grad F1, F1), edge = (grad(F2-F1), F2-F1), id = pcg4d of the winning cell mapped to [0,1) }` (`CultCellular.cs`). Hash is of the integer cell, never the float bits.
|
||||
- [src] `math.cs:831-847` `smin_grad(float4 a, float4 b, float k)`: quadratic smooth-min with analytic gradient (value-and-gradient form).
|
||||
- [src] `Voronoi.cs` (`Voronoi.SampleTones`, native `cultmath_apollonian_voronoi_tones`) is a UI-tone colour sampler over a hash-jittered field with a hard-coded 5x5 loop; takes x, y, tone, span; not a weighted-cell function.
|
||||
- [abs] No function in CultMath takes a weight, a callback, or a site span; no weighted cellular, no per-site hash output exposed besides `id`. (Map B28 states the same.) HLSL twin at `shaders/CultMath.hlsl:440-503`.
|
||||
- [doc] `packages/cultmath/docs/design.md` ~265-340: `cellular` section, search-radius proof and pruned/unpruned test pin.
|
||||
- Fact for composition: `CultCellular.id` is already a per-cell hash scalar available to the caller after the call, but only for the winning cell, after F1/F2 are decided.
|
||||
|
||||
## 4. Probe: where does the weight come from, 2D, 4 orbiting wells
|
||||
|
||||
Scripts: `siteweight_probe.py`, `siteweight_probe2.py`; raw numbers `probe_results.json`, `probe_results2.json`. numpy, scratch; hash is a lowbias32-style integer hash (stand-in for pcg3d). Units: lattice cell = 1; domain [1,7]^2, 500x500 grid (600 for shape rows); wells with radii .6-1.2 cells orbiting a centre; weight `wt = base(.5..1) * (1 + B*prox)`, B = 1.6, WIN = 2, the fauna probe's smooth-min and border output.
|
||||
|
||||
Correction (2026-10-14, from the Aetheria Self's body-Voronoi probe, Aetheria 05a44541): the fauna probe's `trophic_probe.py` weights at the cell centre `(cx+.5)*LAT` (section 1 above), which is the `cellc` variant below, not `site`. An earlier label here called `site` the fauna probe's method.
|
||||
|
||||
Variants: `base` (no field); `site` (field at the jittered site position); `qpt` (field at the query point, same factor on every candidate); `cellc` (node calls the field at the integer cell centre, `cell + .5`, no jitter); `site_t0` (as `site` but field read at t=0 while wells have moved); `warp` (extra: query scaled `q*(1+B*prox(q))`).
|
||||
|
||||
### 4a. Winner identity, fraction of grid pixels whose winning cell differs (t=0 / t=8)
|
||||
|
||||
| variant | vs `site` | vs `base` |
|
||||
|---|---|---|
|
||||
| base | .147 / .156 | 0 / 0 |
|
||||
| qpt | .147 / .156 | 0 / 0 (identical argmin to base) |
|
||||
| cellc | .127 / .114 | .185 / .147 |
|
||||
| site_t0 | 0 / .226 | .147 / .147 |
|
||||
| warp | .675 / .646 | .668 / .639 |
|
||||
| site | 0 / 0 | .147 / .156 |
|
||||
|
||||
Mean absolute difference of the border output vs `site`: base .169/.186, qpt .168/.180, cellc .164/.156, site_t0 0/.234, warp .310/.280. Number of distinct cells on the grid: 53 for base, site and qpt; 54 cellc; 143/128 warp.
|
||||
|
||||
[probe] `qpt` leaves every winner unchanged because the factor is common to all candidates; it changes F1, F2 and the smooth-min scale, which moves the border line (border pixel fraction near wells .164 vs .300 for base, far .250 vs .281).
|
||||
|
||||
### 4b. Cell size and border near wells (pixels per cell, mean; "near" = within 1 well radius), t=0
|
||||
|
||||
| | near | far | border fraction near / far |
|
||||
|---|---|---|---|
|
||||
| base | 10951 | 10515 | .300 / .281 |
|
||||
| site | 9672 | 11439 | .270 / .253 |
|
||||
| qpt | 10951 | 10515 | .164 / .250 |
|
||||
| cellc | 11562 | 12274 | .274 / .262 |
|
||||
| warp | 3447 | 8566 | .291 / .302 |
|
||||
|
||||
### 4c. Pop measure, dense spatial line (6001 points over 6 cells, step 1e-3 cell), max |step| of F1 and of output, window sweep vs window 6 reference
|
||||
|
||||
- With the true winner inside the window, every variant's max F1 step is small (site .0010-.0033, qpt .0019-.0079, cellc .0009-.0041, warp .0054-.022, base .0007-.0013 per 1e-3).
|
||||
- WIN=1 (3x3): `site` F1 error vs window 6 up to .174 (diagonal line, B=1.6; max output step .95); `cellc` .155-.194; `base` .0065; `qpt` .0092-.0113; `warp` none seen. At WIN=2 and B=1.6 all variants' F1 error is 0.
|
||||
- Weight range: `site` at B=6 (weight ratio about 12:1), t=8, horizontal line: WIN=2 error .0169 (output step .371); WIN=3 error 0.
|
||||
- Note: even `base` (weights .5-1, ratio 2) shows F1 error .0065 at WIN=1.
|
||||
|
||||
### 4d. Pop measure, time at fixed query points (wells move), step refinement (worst point from a dt=.01 scan, refined)
|
||||
|
||||
| variant | dt .01 max output step | dt 1e-3 | dt 1e-4 | dt 1e-5 | max F1 step at dt 1e-5 |
|
||||
|---|---|---|---|---|---|
|
||||
| site | .1083 | .01125 | .00113 | .000113 | 2.9e-6 |
|
||||
| qpt | .00461 | .000461 | 4.6e-5 | 4.6e-6 | 4.1e-6 |
|
||||
| cellc | .1060 | .0204 | .00206 | .000207 | 5.2e-6 |
|
||||
|
||||
[probe] All three steps scale linearly with dt: no discontinuity in time for any variant when the window holds the winner. Winner-id changes over t in [0,20] at 4 query points (dt .01): base 0, qpt 0, site 17, cellc 15, site_t0 0.
|
||||
|
||||
### 4e. Caveats of the probe
|
||||
Static smooth proximity field, 4 wells, 2D, one seed of the hash; 3 boost values in the pop table only B=1.6 and 6.0; no gradient-bearing outputs measured; `cellc` field is sampled at the cell centre, so it differs from `site` by the jitter distance; no visual images rendered, only the numeric measures above. The `warp` variant is an addition outside the three asked and is reported for completeness.
|
||||
|
|
@ -1,559 +0,0 @@
|
|||
{
|
||||
"grid": {
|
||||
"t=0.0": {
|
||||
"base": {
|
||||
"winner_differs_from_site": 0.14684,
|
||||
"winner_differs_from_base": 0.0,
|
||||
"out_absdiff_vs_site_mean": 0.1692938271424024,
|
||||
"out_absdiff_vs_site_max": 0.9999999999999667,
|
||||
"n_cells": 53
|
||||
},
|
||||
"site": {
|
||||
"winner_differs_from_site": 0.0,
|
||||
"winner_differs_from_base": 0.14684,
|
||||
"out_absdiff_vs_site_mean": 0.0,
|
||||
"out_absdiff_vs_site_max": 0.0,
|
||||
"n_cells": 53
|
||||
},
|
||||
"qpt": {
|
||||
"winner_differs_from_site": 0.14684,
|
||||
"winner_differs_from_base": 0.0,
|
||||
"out_absdiff_vs_site_mean": 0.1679024745847925,
|
||||
"out_absdiff_vs_site_max": 1.0,
|
||||
"n_cells": 53
|
||||
},
|
||||
"cellc": {
|
||||
"winner_differs_from_site": 0.126984,
|
||||
"winner_differs_from_base": 0.184648,
|
||||
"out_absdiff_vs_site_mean": 0.16449204766510442,
|
||||
"out_absdiff_vs_site_max": 0.99999757371696,
|
||||
"n_cells": 54
|
||||
},
|
||||
"warp": {
|
||||
"winner_differs_from_site": 0.6748,
|
||||
"winner_differs_from_base": 0.667952,
|
||||
"out_absdiff_vs_site_mean": 0.31037303493350155,
|
||||
"out_absdiff_vs_site_max": 1.0,
|
||||
"n_cells": 143
|
||||
},
|
||||
"site_t0": {
|
||||
"winner_differs_from_site": 0.0,
|
||||
"winner_differs_from_base": 0.14684,
|
||||
"out_absdiff_vs_site_mean": 0.0,
|
||||
"out_absdiff_vs_site_max": 0.0,
|
||||
"n_cells": 53
|
||||
},
|
||||
"site_window2_vs_window5_winner_differs": 0.0
|
||||
},
|
||||
"t=8.0": {
|
||||
"base": {
|
||||
"winner_differs_from_site": 0.155996,
|
||||
"winner_differs_from_base": 0.0,
|
||||
"out_absdiff_vs_site_mean": 0.18560227222422834,
|
||||
"out_absdiff_vs_site_max": 0.9999999025356109,
|
||||
"n_cells": 53
|
||||
},
|
||||
"site": {
|
||||
"winner_differs_from_site": 0.0,
|
||||
"winner_differs_from_base": 0.155996,
|
||||
"out_absdiff_vs_site_mean": 0.0,
|
||||
"out_absdiff_vs_site_max": 0.0,
|
||||
"n_cells": 53
|
||||
},
|
||||
"qpt": {
|
||||
"winner_differs_from_site": 0.155996,
|
||||
"winner_differs_from_base": 0.0,
|
||||
"out_absdiff_vs_site_mean": 0.17977625463968147,
|
||||
"out_absdiff_vs_site_max": 0.999999999999996,
|
||||
"n_cells": 53
|
||||
},
|
||||
"cellc": {
|
||||
"winner_differs_from_site": 0.114268,
|
||||
"winner_differs_from_base": 0.146552,
|
||||
"out_absdiff_vs_site_mean": 0.1556440909167879,
|
||||
"out_absdiff_vs_site_max": 0.9999919751523085,
|
||||
"n_cells": 54
|
||||
},
|
||||
"warp": {
|
||||
"winner_differs_from_site": 0.646396,
|
||||
"winner_differs_from_base": 0.639384,
|
||||
"out_absdiff_vs_site_mean": 0.2802002446118991,
|
||||
"out_absdiff_vs_site_max": 1.0,
|
||||
"n_cells": 128
|
||||
},
|
||||
"site_t0": {
|
||||
"winner_differs_from_site": 0.226044,
|
||||
"winner_differs_from_base": 0.14684,
|
||||
"out_absdiff_vs_site_mean": 0.23360220832359216,
|
||||
"out_absdiff_vs_site_max": 1.0,
|
||||
"n_cells": 53
|
||||
},
|
||||
"site_window2_vs_window5_winner_differs": 0.0
|
||||
}
|
||||
},
|
||||
"line_pop": {
|
||||
"horizontal z=2.6 (near well 0)": {
|
||||
"base t=0.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.000679179751586656,
|
||||
"maxjump_out_per_step": 0.011527150729375713,
|
||||
"maxjump_f1_with_window6": 0.000679179751586656,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"base t=0.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.000679179751586656,
|
||||
"maxjump_out_per_step": 0.011527150729375713,
|
||||
"maxjump_f1_with_window6": 0.000679179751586656,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"site t=0.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.0010050426622043585,
|
||||
"maxjump_out_per_step": 0.016002826669016756,
|
||||
"maxjump_f1_with_window6": 0.0010050426622043585,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"site t=0.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.0010050426622043585,
|
||||
"maxjump_out_per_step": 0.016002826669016756,
|
||||
"maxjump_f1_with_window6": 0.0010050426622043585,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"site t=0.0 B=6.0 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.001918579519211816,
|
||||
"maxjump_out_per_step": 0.03595224397909913,
|
||||
"maxjump_f1_with_window6": 0.001918579519211816,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"site t=0.0 B=6.0 WIN=3": {
|
||||
"maxjump_f1_per_step": 0.001918579519211816,
|
||||
"maxjump_out_per_step": 0.03595224397909913,
|
||||
"maxjump_f1_with_window6": 0.001918579519211816,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"qpt t=0.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.0019043178713147313,
|
||||
"maxjump_out_per_step": 0.016796471326461004,
|
||||
"maxjump_f1_with_window6": 0.0019043178713147313,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"qpt t=0.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.0019043178713147313,
|
||||
"maxjump_out_per_step": 0.016796471326461004,
|
||||
"maxjump_f1_with_window6": 0.0019043178713147313,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"qpt t=0.0 B=6.0 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.0056772408954191,
|
||||
"maxjump_out_per_step": 0.04338667429132481,
|
||||
"maxjump_f1_with_window6": 0.0056772408954191,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"qpt t=0.0 B=6.0 WIN=3": {
|
||||
"maxjump_f1_per_step": 0.0056772408954191,
|
||||
"maxjump_out_per_step": 0.04338667429132481,
|
||||
"maxjump_f1_with_window6": 0.0056772408954191,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"cellc t=0.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.000899555983544964,
|
||||
"maxjump_out_per_step": 0.028931107704223913,
|
||||
"maxjump_f1_with_window6": 0.000899555983544964,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"cellc t=0.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.000899555983544964,
|
||||
"maxjump_out_per_step": 0.028998803253634242,
|
||||
"maxjump_f1_with_window6": 0.000899555983544964,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"cellc t=0.0 B=6.0 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.001589550701590614,
|
||||
"maxjump_out_per_step": 0.02212547196092063,
|
||||
"maxjump_f1_with_window6": 0.001589550701590614,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"cellc t=0.0 B=6.0 WIN=3": {
|
||||
"maxjump_f1_per_step": 0.001589550701590614,
|
||||
"maxjump_out_per_step": 0.02212547196092063,
|
||||
"maxjump_f1_with_window6": 0.001589550701590614,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"warp t=0.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.005413713480415305,
|
||||
"maxjump_out_per_step": 0.11861272322439464,
|
||||
"maxjump_f1_with_window6": 0.005413713480415305,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"warp t=0.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.005413713480415305,
|
||||
"maxjump_out_per_step": 0.6979479036971218,
|
||||
"maxjump_f1_with_window6": 0.005413713480415305,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"warp t=0.0 B=6.0 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.019199908363967788,
|
||||
"maxjump_out_per_step": 0.28876383821796175,
|
||||
"maxjump_f1_with_window6": 0.019199908363967788,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"warp t=0.0 B=6.0 WIN=3": {
|
||||
"maxjump_f1_per_step": 0.019199908363967788,
|
||||
"maxjump_out_per_step": 0.28876383821796175,
|
||||
"maxjump_f1_with_window6": 0.019199908363967788,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"base t=8.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.000679179751586656,
|
||||
"maxjump_out_per_step": 0.011527150729375713,
|
||||
"maxjump_f1_with_window6": 0.000679179751586656,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"base t=8.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.000679179751586656,
|
||||
"maxjump_out_per_step": 0.011527150729375713,
|
||||
"maxjump_f1_with_window6": 0.000679179751586656,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"site t=8.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.0011605444949153165,
|
||||
"maxjump_out_per_step": 0.03721603977227228,
|
||||
"maxjump_f1_with_window6": 0.0011605444949153165,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"site t=8.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.0011605444949153165,
|
||||
"maxjump_out_per_step": 0.037218919278060314,
|
||||
"maxjump_f1_with_window6": 0.0011605444949153165,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"site t=8.0 B=6.0 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.01726416929635244,
|
||||
"maxjump_out_per_step": 0.37104796823449204,
|
||||
"maxjump_f1_with_window6": 0.0024839789447830274,
|
||||
"f1_err_vs_win6": 0.016881040623005106
|
||||
},
|
||||
"site t=8.0 B=6.0 WIN=3": {
|
||||
"maxjump_f1_per_step": 0.0024839789447830274,
|
||||
"maxjump_out_per_step": 0.045046050707719165,
|
||||
"maxjump_f1_with_window6": 0.0024839789447830274,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"qpt t=8.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.0019988841823728176,
|
||||
"maxjump_out_per_step": 0.01873902874992317,
|
||||
"maxjump_f1_with_window6": 0.0019988841823728176,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"qpt t=8.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.0019988841823728176,
|
||||
"maxjump_out_per_step": 0.01873902874992317,
|
||||
"maxjump_f1_with_window6": 0.0019988841823728176,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"qpt t=8.0 B=6.0 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.005628071367035137,
|
||||
"maxjump_out_per_step": 0.04897917252830741,
|
||||
"maxjump_f1_with_window6": 0.005628071367035137,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"qpt t=8.0 B=6.0 WIN=3": {
|
||||
"maxjump_f1_per_step": 0.005628071367035137,
|
||||
"maxjump_out_per_step": 0.04897917252830741,
|
||||
"maxjump_f1_with_window6": 0.005628071367035137,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"cellc t=8.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.0013893624582810293,
|
||||
"maxjump_out_per_step": 0.0186819346008138,
|
||||
"maxjump_f1_with_window6": 0.0013893624582810293,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"cellc t=8.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.0013893624582810293,
|
||||
"maxjump_out_per_step": 0.02079886198517822,
|
||||
"maxjump_f1_with_window6": 0.0013893624582810293,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"cellc t=8.0 B=6.0 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.0033405760083177194,
|
||||
"maxjump_out_per_step": 0.07493559630083158,
|
||||
"maxjump_f1_with_window6": 0.0033405760083177194,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"cellc t=8.0 B=6.0 WIN=3": {
|
||||
"maxjump_f1_per_step": 0.0033405760083177194,
|
||||
"maxjump_out_per_step": 0.07493559630083158,
|
||||
"maxjump_f1_with_window6": 0.0033405760083177194,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"warp t=8.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.005131686955684001,
|
||||
"maxjump_out_per_step": 0.10245451309831222,
|
||||
"maxjump_f1_with_window6": 0.005131686955684001,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"warp t=8.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.005131686955684001,
|
||||
"maxjump_out_per_step": 0.5048292489051344,
|
||||
"maxjump_f1_with_window6": 0.005131686955684001,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"warp t=8.0 B=6.0 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.01955931573768127,
|
||||
"maxjump_out_per_step": 0.2975581851502386,
|
||||
"maxjump_f1_with_window6": 0.01955931573768127,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"warp t=8.0 B=6.0 WIN=3": {
|
||||
"maxjump_f1_per_step": 0.01955931573768127,
|
||||
"maxjump_out_per_step": 0.2975581851502386,
|
||||
"maxjump_f1_with_window6": 0.01955931573768127,
|
||||
"f1_err_vs_win6": 0.0
|
||||
}
|
||||
},
|
||||
"diagonal": {
|
||||
"base t=0.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.001275910800458191,
|
||||
"maxjump_out_per_step": 0.03528728288006466,
|
||||
"maxjump_f1_with_window6": 0.001275910800458191,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"base t=0.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.006834249882349042,
|
||||
"maxjump_out_per_step": 0.45027124784270633,
|
||||
"maxjump_f1_with_window6": 0.001275910800458191,
|
||||
"f1_err_vs_win6": 0.006469535309243879
|
||||
},
|
||||
"site t=0.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.0018160559768694018,
|
||||
"maxjump_out_per_step": 0.036994428279204405,
|
||||
"maxjump_f1_with_window6": 0.0018160559768694018,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"site t=0.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.17389687729083692,
|
||||
"maxjump_out_per_step": 0.9512571305077048,
|
||||
"maxjump_f1_with_window6": 0.0018160559768694018,
|
||||
"f1_err_vs_win6": 0.17352236500589702
|
||||
},
|
||||
"site t=0.0 B=6.0 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.0032951968389483,
|
||||
"maxjump_out_per_step": 0.08101849084444412,
|
||||
"maxjump_f1_with_window6": 0.0032951968389483,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"site t=0.0 B=6.0 WIN=3": {
|
||||
"maxjump_f1_per_step": 0.0032951968389483,
|
||||
"maxjump_out_per_step": 0.08049836455119364,
|
||||
"maxjump_f1_with_window6": 0.0032951968389483,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"qpt t=0.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.0026472302788482782,
|
||||
"maxjump_out_per_step": 0.03868824720262809,
|
||||
"maxjump_f1_with_window6": 0.0026472302788482782,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"qpt t=0.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.010707146916472454,
|
||||
"maxjump_out_per_step": 0.21289370234612265,
|
||||
"maxjump_f1_with_window6": 0.0026472302788482782,
|
||||
"f1_err_vs_win6": 0.009199392603544854
|
||||
},
|
||||
"qpt t=0.0 B=6.0 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.007883634786070015,
|
||||
"maxjump_out_per_step": 0.07213687421025916,
|
||||
"maxjump_f1_with_window6": 0.007883634786070015,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"qpt t=0.0 B=6.0 WIN=3": {
|
||||
"maxjump_f1_per_step": 0.007883634786070015,
|
||||
"maxjump_out_per_step": 0.07213687421025916,
|
||||
"maxjump_f1_with_window6": 0.007883634786070015,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"cellc t=0.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.0021042254469000876,
|
||||
"maxjump_out_per_step": 0.03605934023336077,
|
||||
"maxjump_f1_with_window6": 0.0021042254469000876,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"cellc t=0.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.15454958335751312,
|
||||
"maxjump_out_per_step": 0.47361732317771077,
|
||||
"maxjump_f1_with_window6": 0.0021042254469000876,
|
||||
"f1_err_vs_win6": 0.15418392181872642
|
||||
},
|
||||
"cellc t=0.0 B=6.0 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.004089971897996092,
|
||||
"maxjump_out_per_step": 0.04544931913493744,
|
||||
"maxjump_f1_with_window6": 0.004089971897996092,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"cellc t=0.0 B=6.0 WIN=3": {
|
||||
"maxjump_f1_per_step": 0.004089971897996092,
|
||||
"maxjump_out_per_step": 0.04544931913493744,
|
||||
"maxjump_f1_with_window6": 0.004089971897996092,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"warp t=0.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.007786414559647126,
|
||||
"maxjump_out_per_step": 0.10615772398185219,
|
||||
"maxjump_f1_with_window6": 0.007786414559647126,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"warp t=0.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.007786414559647126,
|
||||
"maxjump_out_per_step": 0.10615772398185219,
|
||||
"maxjump_f1_with_window6": 0.007786414559647126,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"warp t=0.0 B=6.0 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.0220788526153487,
|
||||
"maxjump_out_per_step": 0.2726388272791984,
|
||||
"maxjump_f1_with_window6": 0.0220788526153487,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"warp t=0.0 B=6.0 WIN=3": {
|
||||
"maxjump_f1_per_step": 0.0220788526153487,
|
||||
"maxjump_out_per_step": 0.2726388272791984,
|
||||
"maxjump_f1_with_window6": 0.0220788526153487,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"base t=8.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.001275910800458191,
|
||||
"maxjump_out_per_step": 0.03528728288006466,
|
||||
"maxjump_f1_with_window6": 0.001275910800458191,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"base t=8.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.006834249882349042,
|
||||
"maxjump_out_per_step": 0.45027124784270633,
|
||||
"maxjump_f1_with_window6": 0.001275910800458191,
|
||||
"f1_err_vs_win6": 0.006469535309243879
|
||||
},
|
||||
"site t=8.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.00155291340314212,
|
||||
"maxjump_out_per_step": 0.04226739191047357,
|
||||
"maxjump_f1_with_window6": 0.00155291340314212,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"site t=8.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.11344119297302535,
|
||||
"maxjump_out_per_step": 0.3917028634066005,
|
||||
"maxjump_f1_with_window6": 0.00155291340314212,
|
||||
"f1_err_vs_win6": 0.11285505036112697
|
||||
},
|
||||
"site t=8.0 B=6.0 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.002453426564246408,
|
||||
"maxjump_out_per_step": 0.03688685308461992,
|
||||
"maxjump_f1_with_window6": 0.002453426564246408,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"site t=8.0 B=6.0 WIN=3": {
|
||||
"maxjump_f1_per_step": 0.002453426564246408,
|
||||
"maxjump_out_per_step": 0.03688685308461992,
|
||||
"maxjump_f1_with_window6": 0.002453426564246408,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"qpt t=8.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.0018171966866530331,
|
||||
"maxjump_out_per_step": 0.03560130205583023,
|
||||
"maxjump_f1_with_window6": 0.0018171966866530331,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"qpt t=8.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.011573594040749535,
|
||||
"maxjump_out_per_step": 0.44945714067888365,
|
||||
"maxjump_f1_with_window6": 0.0018171966866530331,
|
||||
"f1_err_vs_win6": 0.011263134181283219
|
||||
},
|
||||
"qpt t=8.0 B=6.0 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.004178672365624969,
|
||||
"maxjump_out_per_step": 0.06700822884555246,
|
||||
"maxjump_f1_with_window6": 0.004178672365624969,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"qpt t=8.0 B=6.0 WIN=3": {
|
||||
"maxjump_f1_per_step": 0.004178672365624969,
|
||||
"maxjump_out_per_step": 0.06700822884555246,
|
||||
"maxjump_f1_with_window6": 0.004178672365624969,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"cellc t=8.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.0013657606892926166,
|
||||
"maxjump_out_per_step": 0.03848639655536479,
|
||||
"maxjump_f1_with_window6": 0.0013657606892926166,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"cellc t=8.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.19448286979973972,
|
||||
"maxjump_out_per_step": 0.9778972151563999,
|
||||
"maxjump_f1_with_window6": 0.0013657606892926166,
|
||||
"f1_err_vs_win6": 0.1940193666886757
|
||||
},
|
||||
"cellc t=8.0 B=6.0 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.0016119831862634326,
|
||||
"maxjump_out_per_step": 0.05348368104496215,
|
||||
"maxjump_f1_with_window6": 0.0016119831862634326,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"cellc t=8.0 B=6.0 WIN=3": {
|
||||
"maxjump_f1_per_step": 0.0016119831862634326,
|
||||
"maxjump_out_per_step": 0.05348368104496215,
|
||||
"maxjump_f1_with_window6": 0.0016119831862634326,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"warp t=8.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.00389655575000758,
|
||||
"maxjump_out_per_step": 0.06278112824237458,
|
||||
"maxjump_f1_with_window6": 0.00389655575000758,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"warp t=8.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.00389655575000758,
|
||||
"maxjump_out_per_step": 0.4429137542853424,
|
||||
"maxjump_f1_with_window6": 0.00389655575000758,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"warp t=8.0 B=6.0 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.012386023943556368,
|
||||
"maxjump_out_per_step": 0.1972931166165931,
|
||||
"maxjump_f1_with_window6": 0.012386023943556368,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"warp t=8.0 B=6.0 WIN=3": {
|
||||
"maxjump_f1_per_step": 0.012386023943556368,
|
||||
"maxjump_out_per_step": 0.1972931166165931,
|
||||
"maxjump_f1_with_window6": 0.012386023943556368,
|
||||
"f1_err_vs_win6": 0.0
|
||||
}
|
||||
}
|
||||
},
|
||||
"time_pop": {
|
||||
"base": {
|
||||
"maxjump_f1_per_dt": 0.0,
|
||||
"maxjump_out_per_dt": 0.0,
|
||||
"winner_changes_total": 0
|
||||
},
|
||||
"site": {
|
||||
"maxjump_f1_per_dt": 0.006243969970252006,
|
||||
"maxjump_out_per_dt": 0.10827554277384255,
|
||||
"winner_changes_total": 17
|
||||
},
|
||||
"qpt": {
|
||||
"maxjump_f1_per_dt": 0.004880867091880958,
|
||||
"maxjump_out_per_dt": 0.004611044852681684,
|
||||
"winner_changes_total": 0
|
||||
},
|
||||
"cellc": {
|
||||
"maxjump_f1_per_dt": 0.006179970448791816,
|
||||
"maxjump_out_per_dt": 0.10604425529368433,
|
||||
"winner_changes_total": 15
|
||||
},
|
||||
"site_t0": {
|
||||
"maxjump_f1_per_dt": 0.0,
|
||||
"maxjump_out_per_dt": 0.0,
|
||||
"winner_changes_total": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
{
|
||||
"timejump site dt=0.01": {
|
||||
"q": [
|
||||
2.3,
|
||||
2.8
|
||||
],
|
||||
"t_worst": 9.91,
|
||||
"max_out_step": 0.10827554277384255,
|
||||
"max_f1_step": 0.0028572323710521763
|
||||
},
|
||||
"timejump site dt=0.001": {
|
||||
"q": [
|
||||
2.3,
|
||||
2.8
|
||||
],
|
||||
"t_worst": 9.91,
|
||||
"max_out_step": 0.011248851985623975,
|
||||
"max_f1_step": 0.00028847749905558384
|
||||
},
|
||||
"timejump site dt=0.0001": {
|
||||
"q": [
|
||||
2.3,
|
||||
2.8
|
||||
],
|
||||
"t_worst": 9.91,
|
||||
"max_out_step": 0.00113067110268672,
|
||||
"max_f1_step": 2.8874698390524145e-05
|
||||
},
|
||||
"timejump site dt=1e-05": {
|
||||
"q": [
|
||||
2.3,
|
||||
2.8
|
||||
],
|
||||
"t_worst": 9.91,
|
||||
"max_out_step": 0.00011316362556801352,
|
||||
"max_f1_step": 2.8877387294734547e-06
|
||||
},
|
||||
"timejump qpt dt=0.01": {
|
||||
"q": [
|
||||
5.9,
|
||||
1.9
|
||||
],
|
||||
"t_worst": 16.21,
|
||||
"max_out_step": 0.00461104485268235,
|
||||
"max_f1_step": 0.004042127425204045
|
||||
},
|
||||
"timejump qpt dt=0.001": {
|
||||
"q": [
|
||||
5.9,
|
||||
1.9
|
||||
],
|
||||
"t_worst": 16.21,
|
||||
"max_out_step": 0.0004611240505579539,
|
||||
"max_f1_step": 0.000406360115853599
|
||||
},
|
||||
"timejump qpt dt=0.0001": {
|
||||
"q": [
|
||||
5.9,
|
||||
1.9
|
||||
],
|
||||
"t_worst": 16.21,
|
||||
"max_out_step": 4.611242486959877e-05,
|
||||
"max_f1_step": 4.065726355373389e-05
|
||||
},
|
||||
"timejump qpt dt=1e-05": {
|
||||
"q": [
|
||||
5.9,
|
||||
1.9
|
||||
],
|
||||
"t_worst": 16.21,
|
||||
"max_out_step": 4.611242504171109e-06,
|
||||
"max_f1_step": 4.065938649877587e-06
|
||||
},
|
||||
"timejump cellc dt=0.01": {
|
||||
"q": [
|
||||
5.9,
|
||||
1.9
|
||||
],
|
||||
"t_worst": 0.39,
|
||||
"max_out_step": 0.10604425529368433,
|
||||
"max_f1_step": 0.005110972264514513
|
||||
},
|
||||
"timejump cellc dt=0.001": {
|
||||
"q": [
|
||||
5.9,
|
||||
1.9
|
||||
],
|
||||
"t_worst": 0.39,
|
||||
"max_out_step": 0.020376801353283347,
|
||||
"max_f1_step": 0.0005172341605852449
|
||||
},
|
||||
"timejump cellc dt=0.0001": {
|
||||
"q": [
|
||||
5.9,
|
||||
1.9
|
||||
],
|
||||
"t_worst": 0.39,
|
||||
"max_out_step": 0.002064834070920063,
|
||||
"max_f1_step": 5.178482072565327e-05
|
||||
},
|
||||
"timejump cellc dt=1e-05": {
|
||||
"q": [
|
||||
5.9,
|
||||
1.9
|
||||
],
|
||||
"t_worst": 0.39,
|
||||
"max_out_step": 0.00020688477103125713,
|
||||
"max_f1_step": 5.179096147611695e-06
|
||||
},
|
||||
"shape base": {
|
||||
"mean_cell_pixels_near_wells": 10951.06215310558,
|
||||
"mean_cell_pixels_far": 10515.08654105133,
|
||||
"border_frac_near": 0.3004222385907398,
|
||||
"border_frac_far": 0.28125746844758714
|
||||
},
|
||||
"shape site": {
|
||||
"mean_cell_pixels_near_wells": 9671.733834071645,
|
||||
"mean_cell_pixels_far": 11438.643308839986,
|
||||
"border_frac_near": 0.2704401863244494,
|
||||
"border_frac_far": 0.25280623954417336
|
||||
},
|
||||
"shape qpt": {
|
||||
"mean_cell_pixels_near_wells": 10951.06215310558,
|
||||
"mean_cell_pixels_far": 10515.08654105133,
|
||||
"border_frac_near": 0.1644136900747995,
|
||||
"border_frac_far": 0.2500635410778843
|
||||
},
|
||||
"shape cellc": {
|
||||
"mean_cell_pixels_near_wells": 11562.292900789493,
|
||||
"mean_cell_pixels_far": 12273.640744435888,
|
||||
"border_frac_near": 0.27424759572989177,
|
||||
"border_frac_far": 0.2618651108270203
|
||||
},
|
||||
"shape warp": {
|
||||
"mean_cell_pixels_near_wells": 3446.9220259152826,
|
||||
"mean_cell_pixels_far": 8566.10048594699,
|
||||
"border_frac_near": 0.29084665580811486,
|
||||
"border_frac_far": 0.3016702768114897
|
||||
}
|
||||
}
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
"""Scratch probe: where does a weighted lattice Voronoi cell get its weight?
|
||||
2D, lattice cell = 1 unit, wells with a proximity field, numpy only.
|
||||
Variants of the per-site weight wt_i (multiplies the distance to site i, as the fauna probe's lattice_web):
|
||||
base : wt = hashed .5..1, no field (reference, the unweighted-by-field node)
|
||||
site : wt = base*(1+B*prox(site position, t)) (fauna probe, 4354ef2c)
|
||||
qpt : wt = base*(1+B*prox(query position, t)) (common factor for every candidate site)
|
||||
cellc : wt = base*(1+B*prox(lattice cell centre, t)) (node calls the field at the integer cell id)
|
||||
site_t0: as site, but field read at t=0 (stale time) while wells have moved
|
||||
warp : base weights, query warped q' = q*(1+B*prox(q)) (domain scaling, a different construct)
|
||||
"""
|
||||
import numpy as np, sys, json
|
||||
rng_seed = 7
|
||||
def hash01(ix, iz, k):
|
||||
# integer hash (lowbias32-style), stand-in for pcg3d; deterministic, vectorised
|
||||
x = (ix.astype(np.uint64)*np.uint64(73856093) ^ iz.astype(np.uint64)*np.uint64(19349663) ^ np.uint64(k*83492791+rng_seed)) & np.uint64(0xFFFFFFFF)
|
||||
x ^= x >> np.uint64(16); x = (x*np.uint64(0x7feb352d)) & np.uint64(0xFFFFFFFF)
|
||||
x ^= x >> np.uint64(15); x = (x*np.uint64(0x846ca68b)) & np.uint64(0xFFFFFFFF)
|
||||
x ^= x >> np.uint64(16)
|
||||
return (x >> np.uint64(8)).astype(np.float64)/16777216.0
|
||||
|
||||
W0 = np.array([[2.0,2.5],[5.5,4.0],[3.5,6.0],[6.5,1.5]]); WS = np.array([0.8,1.2,0.6,1.0])
|
||||
OM = np.array([0.30,-0.22,0.17,-0.41]) # well orbit rate, rad per unit t (wells orbit a centre)
|
||||
CEN = np.array([4.0,4.0])
|
||||
def wells(t):
|
||||
r = W0-CEN; c, s = np.cos(OM*t), np.sin(OM*t)
|
||||
return CEN + np.stack([r[:,0]*c-r[:,1]*s, r[:,0]*s+r[:,1]*c], 1)
|
||||
def prox(x, z, t):
|
||||
w = wells(t); out = 0
|
||||
for k in range(len(w)):
|
||||
out = np.maximum(out, np.exp(-(np.hypot(x-w[k,0], z-w[k,1])/WS[k])**2))
|
||||
return out
|
||||
|
||||
KW=.35
|
||||
def node(qx, qz, variant, t=0.0, B=1.6, WIN=2, tfield=None):
|
||||
"""returns f1, f2, winning cell id (ix*10007+iz), soft border value"""
|
||||
qx0, qz0 = qx, qz
|
||||
if variant == 'warp':
|
||||
s = 1+B*prox(qx, qz, t); qx, qz = qx*s, qz*s
|
||||
nx, nz = np.floor(qx), np.floor(qz)
|
||||
m = np.full(qx.shape, 8.0); f1 = m.copy(); f2 = m.copy(); wid = np.zeros(qx.shape)
|
||||
pq = prox(qx0, qz0, t)
|
||||
tf = t if tfield is None else tfield
|
||||
for j in range(-WIN, WIN+1):
|
||||
for i in range(-WIN, WIN+1):
|
||||
cx, cz = nx+i, nz+j
|
||||
ox, oz = hash01(cx, cz, 11), hash01(cx, cz, 12)
|
||||
base = hash01(cx, cz, 13)*.5+.5
|
||||
sx, sz = cx+ox, cz+oz
|
||||
if variant in ('base','warp'): wt = base
|
||||
elif variant == 'site': wt = base*(1+B*prox(sx, sz, tf))
|
||||
elif variant == 'site_t0': wt = base*(1+B*prox(sx, sz, 0.0))
|
||||
elif variant == 'cellc': wt = base*(1+B*prox(cx+.5, cz+.5, tf))
|
||||
elif variant == 'qpt': wt = base*(1+B*pq)
|
||||
d = np.hypot(sx-qx, sz-qz)*wt
|
||||
upd1 = d < f1; upd2 = (~upd1) & (d < f2)
|
||||
wid = np.where(upd1, cx*10007+cz, wid)
|
||||
f2 = np.where(upd1, f1, np.where(upd2, d, f2)); f1 = np.where(upd1, d, f1)
|
||||
h = (lambda u: u*u*(3-2*u))(np.clip(.5+.5*(m-d)/KW, 0, 1))
|
||||
m = m+(d-m)*h-h*(1-h)*KW/(1+3*KW)
|
||||
gap0 = .25*KW/(1+3*KW)
|
||||
soft = np.clip((f1-m)/gap0, 0, 1)
|
||||
out = np.maximum(soft**2, np.exp(-((f2-f1)/.10)**2))
|
||||
return f1, f2, wid, out
|
||||
|
||||
def grid(n=500, lo=1.0, hi=7.0):
|
||||
xs = np.linspace(lo, hi, n); X, Z = np.meshgrid(xs, xs); return X, Z
|
||||
|
||||
def main():
|
||||
R = {}
|
||||
X, Z = grid()
|
||||
for t in (0.0, 8.0):
|
||||
ref = {v: node(X, Z, v, t) for v in ('base','site','qpt','cellc','warp')}
|
||||
ref['site_t0'] = node(X, Z, 'site_t0', t)
|
||||
# large-window truth for 'site' (exact argmin unless window too small)
|
||||
truth = node(X, Z, 'site', t, WIN=5)[2]
|
||||
key = f"t={t}"; R[key] = {}
|
||||
for v, (f1, f2, wid, out) in ref.items():
|
||||
R[key][v] = dict(
|
||||
winner_differs_from_site=float((wid != ref['site'][2]).mean()),
|
||||
winner_differs_from_base=float((wid != ref['base'][2]).mean()),
|
||||
out_absdiff_vs_site_mean=float(np.abs(out-ref['site'][3]).mean()),
|
||||
out_absdiff_vs_site_max=float(np.abs(out-ref['site'][3]).max()),
|
||||
n_cells=int(len(np.unique(wid))))
|
||||
R[key]['site_window2_vs_window5_winner_differs'] = float((ref['site'][2] != truth).mean())
|
||||
# cell count density near wells for site vs base
|
||||
# pop measure: dense line, step 1e-3 units; also jumps in time at fixed q
|
||||
ts = np.linspace(1.0, 7.0, 6001) # 1e-3
|
||||
lines = {'horizontal z=2.6 (near well 0)': (ts, np.full_like(ts, 2.6)),
|
||||
'diagonal': (ts, 1.0+ts*0.9-0.5)}
|
||||
P = {}
|
||||
for ln, (lx, lz) in lines.items():
|
||||
P[ln] = {}
|
||||
for t in (0.0, 8.0):
|
||||
for v in ('base','site','qpt','cellc','warp'):
|
||||
for B, WIN in ((1.6,2),(1.6,1),(6.0,2),(6.0,3)):
|
||||
if v in ('base',) and B != 1.6: continue
|
||||
f1, f2, wid, out = node(lx, lz, v, t, B, WIN)
|
||||
ref = node(lx, lz, v, t, B, 6)
|
||||
P[ln][f"{v} t={t} B={B} WIN={WIN}"] = dict(
|
||||
maxjump_f1_per_step=float(np.abs(np.diff(f1)).max()),
|
||||
maxjump_out_per_step=float(np.abs(np.diff(out)).max()),
|
||||
maxjump_f1_with_window6=float(np.abs(np.diff(ref[0])).max()),
|
||||
f1_err_vs_win6=float(np.abs(f1-ref[0]).max()))
|
||||
# time pop: fixed query points, t stepped at 1e-2; wells move
|
||||
tt = np.linspace(0, 20, 2001)
|
||||
qs = [(2.3,2.8),(4.7,3.9),(3.5,5.5),(5.9,1.9)]
|
||||
T = {}
|
||||
for v in ('base','site','qpt','cellc','site_t0'):
|
||||
mj = 0; mo = 0; changes = 0
|
||||
for (qx, qz) in qs:
|
||||
f1s=[];outs=[];wids=[]
|
||||
for t in tt:
|
||||
a,b,w,o = node(np.array([qx]), np.array([qz]), v, t)
|
||||
f1s.append(a[0]); outs.append(o[0]); wids.append(w[0])
|
||||
mj=max(mj,np.abs(np.diff(f1s)).max()); mo=max(mo,np.abs(np.diff(outs)).max())
|
||||
changes += int((np.diff(wids)!=0).sum())
|
||||
T[v] = dict(maxjump_f1_per_dt=float(mj), maxjump_out_per_dt=float(mo), winner_changes_total=changes)
|
||||
json.dump(dict(grid=R, line_pop=P, time_pop=T), open('probe_results.json','w'), indent=1)
|
||||
print(json.dumps(R, indent=1)); print(json.dumps(T, indent=1))
|
||||
for ln in P:
|
||||
print(ln)
|
||||
for k,v in P[ln].items(): print(' ', k, {a: round(b,5) for a,b in v.items()})
|
||||
main()
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
"""Follow-up: (1) continuity refinement: does the max time-jump of 'site' scale with dt? (2) cell size near vs far from wells,
|
||||
border-pixel fraction per variant."""
|
||||
import numpy as np, json, importlib.util, sys, io, contextlib
|
||||
spec = importlib.util.spec_from_file_location('p', 'siteweight_probe.py')
|
||||
src = open('siteweight_probe.py').read().replace('\nmain()\n', '\n') # import without running main
|
||||
ns = {}; exec(compile(src, 'p', 'exec'), ns)
|
||||
node, prox, grid, wells = ns['node'], ns['prox'], ns['grid'], ns['wells']
|
||||
res = {}
|
||||
# (1) worst time step at dt=.01 for each variant, then refine dt inside +-.02 of it (continuity: step should shrink ~ with dt)
|
||||
qs = [(2.3,2.8),(4.7,3.9),(3.5,5.5),(5.9,1.9)]
|
||||
def series(v, qx, qz, tt):
|
||||
f1 = np.empty(len(tt)); out = np.empty(len(tt))
|
||||
for k, t in enumerate(tt):
|
||||
a,b,w,o = node(np.array([qx]), np.array([qz]), v, t); f1[k]=a[0]; out[k]=o[0]
|
||||
return f1, out
|
||||
for v in ('site','qpt','cellc'):
|
||||
tt = np.arange(0, 20.0001, 0.01); best = (0, None, None)
|
||||
for qx, qz in qs:
|
||||
f1, out = series(v, qx, qz, tt); i = int(np.argmax(np.abs(np.diff(out))))
|
||||
if abs(out[i+1]-out[i]) > best[0]: best = (abs(out[i+1]-out[i]), (qx,qz), tt[i])
|
||||
_, q, t0 = best
|
||||
for dt in (0.01, 0.001, 0.0001, 0.00001):
|
||||
t2 = np.arange(t0-0.02, t0+0.03, dt); f1, out = series(v, q[0], q[1], t2)
|
||||
res[f"timejump {v} dt={dt}"] = dict(q=q, t_worst=float(t0), max_out_step=float(np.abs(np.diff(out)).max()), max_f1_step=float(np.abs(np.diff(f1)).max()))
|
||||
print(v, dt, res[f"timejump {v} dt={dt}"], flush=True)
|
||||
# (2) cell area near vs far wells, border fraction
|
||||
X, Z = grid(600)
|
||||
w = wells(0.0)
|
||||
near = np.min([np.hypot(X-w[k,0], Z-w[k,1])/ns['WS'][k] for k in range(4)], axis=0) < 1.0
|
||||
for v in ('base','site','qpt','cellc','warp'):
|
||||
f1,f2,wid,out = node(X, Z, v, 0.0)
|
||||
ids, cnt = np.unique(wid, return_counts=True); area = dict(zip(ids, cnt))
|
||||
pa = np.array([area[i] for i in wid.ravel()]).reshape(wid.shape)
|
||||
res[f"shape {v}"] = dict(mean_cell_pixels_near_wells=float(pa[near].mean()), mean_cell_pixels_far=float(pa[~near].mean()),
|
||||
border_frac_near=float((out[near]>.5).mean()), border_frac_far=float((out[~near]>.5).mean()))
|
||||
print(v, res[f"shape {v}"])
|
||||
json.dump(res, open('probe_results2.json','w'), indent=1)
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import numpy as np, probe as P
|
||||
g = np.linspace(-3200, 3200, 260); X, Z = np.meshgrid(g, g); q = np.stack([X, Z], -1)
|
||||
rng = np.random.default_rng(3); ang = rng.uniform(0, 6.283, X.shape); nrm = np.stack([np.cos(ang), np.sin(ang)], -1)
|
||||
for boost, S in [(1.6, 1), (1.6, 2), (4, 1), (4, 2), (9, 1)]:
|
||||
ref = P.node(q, 100.0, 9, boost, S, nrm)
|
||||
row = []
|
||||
for W in (2, 3, 4, 5):
|
||||
r = P.node(q, 100.0, W, boost, S, nrm)
|
||||
miss = np.mean((np.abs(r[0]-ref[0])>1e-12)|(np.abs(r[1]-ref[1])>1e-12))
|
||||
row.append(f"W{W}: miss {miss:.5f} dOut {np.abs(r[3]-ref[3]).max():.3f}")
|
||||
print(f"boost {boost} S {S} ratio {2*(1+boost):.1f}x{S}:", "; ".join(row))
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import numpy as np, probe as P
|
||||
g = (np.arange(-12, 12) + .5) * 260; X, Z = np.meshgrid(g, g); c = np.stack([X, Z], -1).reshape(-1, 2)
|
||||
for ex in (False, True):
|
||||
prev = None; worst = 0; big = 0
|
||||
for t in np.arange(0, 300, 1/60):
|
||||
Pw = np.exp(-(P.border(c, P.bodies(t), ex) / P.FALL) ** 2)
|
||||
if prev is not None:
|
||||
s = np.abs(Pw - prev); worst = max(worst, s.max()); big += (s > .01).sum()
|
||||
prev = Pw
|
||||
print("exact" if ex else "first-order", f"cell-centre profile: max step per 1/60 s {worst:.4f}, steps > .01: {big} (576 centres x 18000 ticks)")
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import numpy as np, probe as P
|
||||
rng = np.random.default_rng(9); n = 15000
|
||||
kx = rng.integers(-12, 12, n); z = rng.uniform(-3100, 3100, n); eps = 1e-6
|
||||
qa = np.stack([kx * 260.0 - eps, z], -1)[None]; qb = np.stack([kx * 260.0 + eps, z], -1)[None]
|
||||
for W, fold, boost, S in [(6,"f1kw",1.6,2),(6,"her",1.6,2),(6,"f1kw",4,2)]:
|
||||
ang = rng.uniform(0, 6.283, n); nrm = np.stack([np.cos(ang), np.sin(ang)], -1)[None]
|
||||
a = P.node(qa, 100.0, W, boost, S, nrm, True, fold); b = P.node(qb, 100.0, W, boost, S, nrm, True, fold)
|
||||
j = np.abs(a[3] - b[3]); js = np.abs(a[2]-b[2]); jf = np.abs(a[0]-b[0])
|
||||
print(f"W{W} {fold} B{boost} S{S}: cell-boundary jump out max {j.max():.4f} (>.01: {np.mean(j>.01):.4f}), soft max {js.max():.4f}, F1 max {jf.max():.2e}")
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import numpy as np, probe as P
|
||||
g = np.linspace(-3200, 3200, 200); X, Z = np.meshgrid(g, g); q = np.stack([X, Z], -1)
|
||||
rng = np.random.default_rng(3); ang = rng.uniform(0, 6.283, X.shape); nrm = np.stack([np.cos(ang), np.sin(ang)], -1)
|
||||
wmin = .5
|
||||
for boost, S in [(1.6, 1), (1.6, 2), (4, 2), (9, 1)]:
|
||||
f1, f2, soft, out = P.node(q, 100.0, 6, boost, S, nrm)
|
||||
C = np.maximum(f2, f1 + P.KW)
|
||||
k = np.floor(C / wmin).astype(int) + 1 # first ring whose lower bound (k-1)*wmin >= C is excluded... rings visited 0..k
|
||||
k = np.ceil(C / wmin).astype(int) # rings 0..k visited (ring k+1 has lb k*wmin >= C)
|
||||
cells = (2 * k + 1) ** 2
|
||||
wmax = 1.0 * (1 + boost); Usound = 1.58 * wmax * S
|
||||
Wsound = int(np.ceil((Usound + P.KW) / wmin))
|
||||
print(f"B{boost} S{S}: rings needed p50 {np.median(k):.0f} p99 {np.percentile(k,99):.0f} max {k.max()} -> cells p50 {np.median(cells):.0f} max {cells.max()}; worst-case sound W {Wsound} ({(2*Wsound+1)**2} cells)")
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import numpy as np, probe as P, sys
|
||||
fold = sys.argv[1]; rng = np.random.default_rng(2); jm = js = 0; nsw = 0
|
||||
for k in range(3):
|
||||
o = rng.uniform(-2500, 2500, 2); a = rng.uniform(0, 3.1416); d = np.array([np.cos(a), np.sin(a)])
|
||||
s = np.arange(0, 15 * 260, .5); P.node((o + s[:, None] * d)[None], 100.0, 3, 1.6, 1.0, None, True, fold); a1 = P.node.a1[0].copy()
|
||||
idx = np.nonzero(np.diff(a1))[0][:40]
|
||||
lo = s[idx]; hi = s[idx + 1]; ref = a1[idx]
|
||||
for _ in range(40):
|
||||
mid = .5 * (lo + hi); P.node((o + mid[:, None] * d)[None], 100.0, 3, 1.6, 1.0, None, True, fold)
|
||||
same = P.node.a1[0] == ref; lo = np.where(same, mid, lo); hi = np.where(same, hi, mid)
|
||||
ra = P.node((o + lo[:, None] * d)[None], 100.0, 3, 1.6, 1.0, None, True, fold); ma = P.node.m[0].copy()
|
||||
rb = P.node((o + hi[:, None] * d)[None], 100.0, 3, 1.6, 1.0, None, True, fold); mb = P.node.m[0]
|
||||
jm = max(jm, np.abs(ma - mb).max()); js = max(js, np.abs(ra[2] - rb[2]).max()); nsw += len(idx)
|
||||
print(fold, f"F1 swaps {nsw}: max jump m {jm:.2e}, soft {js:.2e}")
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import numpy as np, probe as P
|
||||
g = np.linspace(-3200, 3200, 200); X, Z = np.meshgrid(g, g); q = np.stack([X, Z], -1)
|
||||
rng = np.random.default_rng(3); ang = rng.uniform(0, 6.283, X.shape); nrm = np.stack([np.cos(ang), np.sin(ang)], -1)
|
||||
for S in (1, 2):
|
||||
a = P.node(q, 100.0, 6, 1.6, S, nrm, True, "her"); b = P.node(q, 100.0, 6, 1.6, S, nrm, True, "f1kw")
|
||||
d = np.abs(a[3] - b[3]); print(f"S{S} look her vs f1kw: mean {d.mean():.4f} p99 {np.percentile(d,99):.3f} max {d.max():.3f}; mean out {a[3].mean():.3f}")
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
"""Weighted lattice cellular probe (Imagination, lattice-weighted-cellular). numpy, float64.
|
||||
Bodies orbit (period = distance s). Cell weight = base(hash)*(1+BOOST*exp(-(border(centre)/FALL)^2)),
|
||||
border over the bodies' power scores (site.w = sigma). Her fold: row-major, m from 8; variant m0f1 starts m at F1."""
|
||||
import numpy as np, sys
|
||||
LAT, FALL, KW, EW, OMEGA = 260.0, 120.0, .35, .10, .05
|
||||
GAP0 = .25 * KW / (1 + 3 * KW)
|
||||
rng = np.random.default_rng(7)
|
||||
NB = 6
|
||||
DIST = np.array([0.0, 700, 1200, 1800, 2400, 3000]); PH = rng.uniform(0, 6.283, NB)
|
||||
SIG = np.array([1500, 300, 450, 350, 900, 600]) / np.sqrt(128.0) * 4 # sigma ratio 5:1, edges well inside
|
||||
def bodies(t):
|
||||
a = PH + np.where(DIST > 0, 2 * np.pi * t / np.maximum(DIST, 1), 0)
|
||||
return np.stack([np.cos(a) * DIST, np.sin(a) * DIST], -1)
|
||||
|
||||
def h32(x):
|
||||
x = x.astype(np.uint32)
|
||||
x ^= x >> 16; x *= np.uint32(0x7feb352d); x ^= x >> 15; x *= np.uint32(0x846ca68b); x ^= x >> 16
|
||||
return x
|
||||
def hash01(cx, cz, s):
|
||||
k = ((cx.astype(np.int64) * 73856093) ^ (cz.astype(np.int64) * 19349663) ^ (s * 83492791)) & 0xffffffff
|
||||
return (h32(h32(k)) >> 8) / 16777216.0
|
||||
|
||||
def border(q, B, exact):
|
||||
"""q: (...,2). Power border distance over bodies B."""
|
||||
sc = ((q[..., None, :] - B) ** 2).sum(-1) - SIG ** 2
|
||||
o = np.argsort(sc, -1); i1 = o[..., 0]; i2 = o[..., 1]
|
||||
s1 = B[i1]
|
||||
if not exact:
|
||||
s2 = B[i2]; return (np.take_along_axis(sc, i2[..., None], -1)[..., 0] - np.take_along_axis(sc, i1[..., None], -1)[..., 0]) / (2 * np.linalg.norm(s2 - s1, axis=-1))
|
||||
best = np.full(q.shape[:-1], 1e30)
|
||||
w1 = SIG[i1]
|
||||
for k in range(NB):
|
||||
d = B[k] - s1; L = np.linalg.norm(d, axis=-1)
|
||||
pl = np.maximum((((s1 - q) * d).sum(-1) + .5 * ((d * d).sum(-1) + w1 ** 2 - SIG[k] ** 2)) / np.where(L > 0, L, 1), 0)
|
||||
best = np.where((L > 0) & (pl < best), pl, best)
|
||||
return best
|
||||
|
||||
def node(q, t, W, boost, S=1.0, nrm=None, exact=True, fold="her"):
|
||||
B = bodies(t); p = q / LAT; n = np.floor(p); f = p - n
|
||||
if nrm is None: nrm = np.zeros_like(q); nrm[..., 0] = 1
|
||||
ds = []
|
||||
for j in range(-W, W + 1):
|
||||
for i in range(-W, W + 1):
|
||||
cx, cz = n[..., 0] + i, n[..., 1] + j
|
||||
base = .5 + .5 * hash01(cx, cz, 1)
|
||||
P = np.exp(-(border(np.stack([(cx + .5) * LAT, (cz + .5) * LAT], -1), B, exact) / FALL) ** 2)
|
||||
wt = base * (1 + boost * P)
|
||||
ox = .5 + .5 * np.sin(OMEGA * t + 6.2831 * hash01(cx, cz, 2)); oz = .5 + .5 * np.sin(OMEGA * t + 6.2831 * hash01(cx, cz, 3))
|
||||
vx, vz = i - f[..., 0] + ox, j - f[..., 1] + oz
|
||||
a = vx * nrm[..., 0] + vz * nrm[..., 1]; c = -vx * nrm[..., 1] + vz * nrm[..., 0]
|
||||
ds.append(np.hypot(S * a, c) * wt)
|
||||
D = np.stack(ds, -1); srt = np.sort(D, -1); f1, f2 = srt[..., 0], srt[..., 1]; a1 = np.argmin(D, -1)
|
||||
m = np.full(f1.shape, 8.0) if fold == "her" else (f1 + KW if fold == "f1kw" else f1.copy())
|
||||
for k in range(D.shape[-1]):
|
||||
d = D[..., k]
|
||||
h = np.clip(.5 + .5 * (m - d) / KW, 0, 1); h = h * h * (3 - 2 * h)
|
||||
mn = m + (d - m) * h - h * (1 - h) * KW / (1 + 3 * KW)
|
||||
m = np.where((fold == "m0f1") & (a1 == k), m, mn)
|
||||
soft = np.clip((f1 - m) / GAP0, 0, 1); node.m = m; node.a1 = a1
|
||||
return f1, f2, soft, np.maximum(soft ** 2, np.exp(-((f2 - f1) / EW) ** 2))
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
# Shader runner probes: the HLSL-to-GLSL ES toolchain, lavapipe on Yggdrasil, native ANGLE on Starfire
|
||||
|
||||
For the cultmath-tapes questions that replace `device-run-driver`: `glsl-es-translation-owner`,
|
||||
`conforming-shader-run` and `real-device-shader-run`. These are body facts measured by
|
||||
imagination-ag4-shader-runner (session self-2026-10-16-ag4) on 2026-10-16, between about 14:10 and
|
||||
15:20 UTC (16:10 to 17:20 CEST). Every probe read CultLib at f697e03f (origin/main). Companion to
|
||||
`docs/research/native-shader-testing.md` (f697e03f) and `docs/research/device-capture-runners.md` (c9d3e672).
|
||||
|
||||
The probe scripts are in this directory under `p/`: `probe.sh` and `lib.sh` (Yggdrasil jobs), `fam.py`
|
||||
(the per-family shader generator and bit judge), `vkrun.c` (an 87-line Vulkan compute host) and
|
||||
`angle_native.py` (a 96-line native ANGLE host). They are throwaway probe code, not a proposed
|
||||
implementation. The logs are `q12-run3.log` (lavapipe) and `lib-run4.log` (library route). The earlier
|
||||
runs are superseded: run1 used a raw-ulp metric, run2 died on CRLF, and lib-run1 to lib-run3 failed on
|
||||
the capability and grep problems recorded below.
|
||||
|
||||
## Metric
|
||||
|
||||
Each fixture family has 256 cases, and the fixture records the C# result bits. "Exact" means all 256
|
||||
cases are bit-identical to the C# fixture. Otherwise the worst error is given in units of
|
||||
2^-23 * max(|expected|, 1) (written "u1" below), the scale the fixture's ulp-bounded checks use. NaN and
|
||||
Inf cases are compared only as NaN-versus-not-NaN. These figures describe the data. They are not the
|
||||
fixture's own judge, which is C# and per family: GlslMirrorTests tolerances, and the `check` text on 10
|
||||
families.
|
||||
|
||||
## The fixture surface (glsl-parity.json at f697e03f)
|
||||
|
||||
- 109 families, each with 256 cases, encoded `args -> result` as hex 32-bit words. There are at most 32
|
||||
words per case.
|
||||
- 105 families use only float, int, vecN, ivecN and mat3 (mat3 appears as a return only). The other 4
|
||||
take or return structs: `CultCellular cultmath_cellular(vec3)`, `CultPhasor cultmath_phacelle(vec3,
|
||||
vec3, float, float)`, and `SpanNoise cultmath_span_fbm/ridged(SpanPath, SpanStage, int, float, float)`.
|
||||
Any runner needs per-struct flatteners in field order for these 4.
|
||||
- Tolerance classes: 89 are `ulp-bounded`, 2 are `exact`, 7 are `platform` (exp, pow, log, asin, acos,
|
||||
atan, and phacelle's sin and cos), and 10 carry a `check` (enclosure).
|
||||
- What a harness needs: generate one shader per family that reads the argument words, bit-casts them to
|
||||
the signature's types, calls the function and writes the result words. The probe generator does this
|
||||
for both HLSL and GLSL in 112 lines of Python, judge included.
|
||||
|
||||
## Q1. Can dxc emit a SPIR-V library that SPIRV-Cross lowers to GLSL ES for CultMath's real surface?
|
||||
|
||||
Yggdrasil, ubuntu:24.04. Tools: dxc v1.9.2607 Linux (`linux_dxc_2026_07_29`, the version get-dxc.ps1
|
||||
pins on Windows), and SPIRV-Cross built from HEAD 300681bb (2026-10-14).
|
||||
|
||||
| Step | Result |
|
||||
|---|---|
|
||||
| `dxc -T lib_6_6 -spirv -fspv-target-env=vulkan1.2 CultMath.hlsl`, no change to the source | fails: "No OpEntryPoint ... only allowed if the Linkage ... capability is being used" |
|
||||
| `-default-linkage external` (lib_6_3, universal1.5), no `export` | fails the same way: no exports in SPIR-V |
|
||||
| `export` added to every top-level `cultmath_` definition, vulkan1.2 | fails: "Capability Linkage is not allowed by Vulkan 1.2" |
|
||||
| same, `-fspv-target-env=universal1.5` | compiles, but declares 14 capabilities, ray query, interlock, quad control and partitioned subgroups among them |
|
||||
| SPIRV-Cross `--es --version 310` on that module | fails: "RayQuery requires Vulkan GLSL 460" |
|
||||
| ray query stripped | fails: "Cannot find mapping for combined sampler parameter". The Texture2D/SamplerState functions were exported |
|
||||
| `export` only on the 173 non-texture definitions, all capabilities but Shader and Linkage stripped, every OpExtension stripped, then `spirv-as` | assembles; SPIRV-Cross `--es --version 310` exits 0 |
|
||||
| SPIRV-Cross `--es --version 300` | fails: "At least ESSL 3.10 required for compute shaders" (a library gets a GLCompute placeholder entry) |
|
||||
| ES 3.10 output, `#version` guarded by `#ifdef SPIRV_CROSS_LIBRARY_HEADER`, placeholder main removed, compiled by glslang as a `#version 300 es` fragment shader | compiles |
|
||||
|
||||
Shape of the output against the committed lowering:
|
||||
|
||||
| | committed (CultMath.glsl, .Cycles, .Phacelle) | dxc + SPIRV-Cross |
|
||||
|---|---|---|
|
||||
| lines | 1035 + Cycles + Phacelle | 3458, one file (the MPL and Apache files are merged) |
|
||||
| function definitions | 173 (121 + 51 + 1) | 127 |
|
||||
| overloads | kept | **46 dropped**: one definition per name survives (for example `cultmath_snoise(vec2)` and `cultmath_iv_snoise_ball(vec2, float)` are gone) |
|
||||
| SSA temporaries `_NNN` | 0 | 5632 |
|
||||
| min and max | as written | NaN-guarded `mix(mix(min(a,b),b,isnan(a)),a,isnan(b))` |
|
||||
|
||||
In `cultmath_snoise(vec3)` as emitted, the body is one temporary per SSA value (`vec3 _1157 = floor(...)`)
|
||||
and constants are spelt to 20 digits.
|
||||
|
||||
**Answer:** yes, but only with three owned workarounds: an `export` annotation on 173 definitions,
|
||||
which FXC (Unity's D3D11 compiler) does not accept, so it would have to be a macro; a capability and
|
||||
extension strip pass; and unique names for 46 overloads, which breaks the public API for both the HLSL
|
||||
and the GLSL consumers. The output is generated text, not a readable library.
|
||||
|
||||
## Q2. Does a conforming Vulkan implementation run the families in a Linux container on Yggdrasil?
|
||||
|
||||
Yggdrasil, ubuntu:24.04, apt packages: mesa-vulkan-drivers 25.2.8 (lavapipe), glslang-tools 15.1.0,
|
||||
spirv-tools 2025.1~rc1, libvulkan-dev, and the same dxc. There is no GPU.
|
||||
|
||||
- Device: `llvmpipe (LLVM 20.1.2, 256 bits)`, Vulkan 1.4. Float controls for f32: denormPreserve 0,
|
||||
flushToZero 0, RTE 1, RTZ 0, signedZeroInfNanPreserve 1.
|
||||
- HLSL route: a generated `cs_6_0` compute shader that includes CultMath.hlsl, through `dxc -spirv
|
||||
-fspv-target-env=vulkan1.2`. 105 of 105 families compile and run. Fma instructions in the SPIR-V: 0.
|
||||
- GLSL route: the committed CultMath.glsl, .Cycles.glsl and .Phacelle.glsl concatenated after `#version
|
||||
310 es` and highp precision, as a compute shader through `glslangValidator -V`. 105 of 105 compile and
|
||||
run.
|
||||
- Library route: the dxc + SPIRV-Cross library from Q1 through glslang. 103 of 105 run; the 2 missing
|
||||
ones are the dropped overloads. Against the fixture: 75 exact, 4 within 4 u1, 7 within 64, 17 above
|
||||
64. Its worst rows are the same figures as the HLSL route's.
|
||||
- Time: all 105 dispatches together take 8 s on a re-run. Each job runs about 4 minutes, almost all of
|
||||
it apt, the dxc download and the SPIRV-Cross build.
|
||||
- Determinism: re-running all 105 SPIR-V modules gave bit-identical outputs.
|
||||
- The HLSL and GLSL routes are bit-identical to each other in 85 of 105 families.
|
||||
- Against the C# fixture, HLSL route: 75 exact, 6 within 4 u1, 7 within 64, 17 above 64. GLSL route:
|
||||
75, 4, 8 and 18. The largest errors are in the `coordinate bands 1e2 to 4e6` families of snoise_grad
|
||||
and snoise_hessian (13e6 and 1.6e8 u1), then fbm_grad and ridged_grad (about 3.5e3 to 5e3 u1), Cycles
|
||||
arcsine (2.7e3) and arccosine (1.0e3), and the other hessian families (1e3 to 2.4e3).
|
||||
|
||||
**Answer:** yes. The image needs nothing beyond distro packages and the pinned dxc. It is
|
||||
deterministic and fast, and it runs both the HLSL and the lowered GLSL on the same implementation.
|
||||
|
||||
## Q3. A real-device run on Starfire without a browser: native ANGLE
|
||||
|
||||
Starfire. The host is `angle_native.py`, Python ctypes over the libEGL.dll and libGLESv2.dll that ship
|
||||
with Docker Desktop 4.71.0 (an Electron app): ANGLE 2.1.26626, git hash a4490148bd3d. Display:
|
||||
`EGL_PLATFORM_ANGLE_TYPE_D3D11`. Context: ES 3.0, optionally `EGL_CONTEXT_WEBGL_COMPATIBILITY_ANGLE`.
|
||||
Each family is a `#version 300 es` fragment shader, with arguments read by texelFetch from an RGBA32UI
|
||||
texture and results written to up to 3 RGBA32UI targets over a 16x16 grid, then read back with
|
||||
glReadPixels. The same RGBA32UI readback is used by the scratchpad browser pages (gpu.js).
|
||||
|
||||
- `GL_RENDERER`: `ANGLE (NVIDIA, NVIDIA GeForce GTX 1070 (0x00001B81) Direct3D11 vs_5_0 ps_5_0, D3D11)`.
|
||||
`GL_VERSION`: `OpenGL ES 3.0 (ANGLE 2.1.26626 git hash: a4490148bd3d)`.
|
||||
- 105 of 105 families compile and run. All 105 together take 6.3 s wall, process start included. The GL
|
||||
error at the end is 0.
|
||||
- The WebGL-compatibility context and the plain ES 3.0 context are bit-identical in 105 of 105 families.
|
||||
- Against the C# fixture: 38 exact, 31 within 4 u1, 17 within 64, 19 above 64.
|
||||
- It agrees with the earlier browser measurements on the same GPU. `cultmath_iv_exp`: 19 of 256 exact,
|
||||
worst 12 ulp; the browser run of finding iv-exp-gpu-not-enclosed measured "up to 12 ulp".
|
||||
`cultmath_iv_frustum_ball`: 213 of 256 exact, so 43 off, by at most 2 ulp; finding
|
||||
frustum-exact-unsound-on-webgl2 measured "43/256 cases by up to 2 ulp".
|
||||
- The dxc + SPIRV-Cross library from Q1 also runs on this ANGLE, as a WebGL-compatibility ES 3.00
|
||||
fragment shader: 103 of 105 families (the 2 dropped overloads fail to compile), with 41 exact. It is
|
||||
bit-identical to the committed lowering's ANGLE output in 71 of 103 families.
|
||||
- Largest differences: the `coordinate bands 1e2 to 4e6` families again. 173 cases of snoise_grad and
|
||||
415 component values of snoise_hessian differ by more than 1e-3 relative, up to 2 and 20. These
|
||||
families exist for the span allowance's device margin (follow-up span-allowance-gpu-margin, cut
|
||||
stage-shaders-device). Their judge is the allowance check, not an ulp bound.
|
||||
|
||||
**Answer:** yes. A pinned ANGLE build, loaded natively, runs the site's real translation path
|
||||
(ES 3.00, then ANGLE, then HLSL, then D3DCompile, then the NVIDIA D3D11 driver) with no browser and no
|
||||
SwiftShader fallback, because the backend is named explicitly and the renderer string is recorded. The
|
||||
result agrees bit for bit with the WebGL-compatibility context, and it reproduces the two browser
|
||||
measurements on record.
|
||||
|
||||
## What was not probed
|
||||
|
||||
- SwiftShader as a second conforming implementation.
|
||||
- Amber as the dispatch host instead of a small owned one.
|
||||
- WARP and native D3D11 HLSL on Starfire, which is the Unity consumer path.
|
||||
- naga with dxc SPIR-V as input.
|
||||
- Chrome's own ANGLE feature flags, which the native host does not reproduce.
|
||||
- Mobile or mediump devices.
|
||||
- An Electron release's ANGLE pinned by download. The probe used the copy Docker Desktop happens to
|
||||
ship.
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
"""Probe: run the GLSL fixture families through a native ANGLE (DLLs shipped with an Electron app), D3D11
|
||||
backend, ES 3.0 context, fragment-shader readback through RGBA32UI targets. No browser.
|
||||
usage: py -3 -I angle_native.py <angle-dll-dir> <fixture.json> <shaders-dir> <outdir> [webgl]"""
|
||||
import ctypes as C, json, os, struct, sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import fam
|
||||
|
||||
dll, fx, sh, out = sys.argv[1:5]; webgl = len(sys.argv) > 5
|
||||
os.add_dll_directory(dll)
|
||||
egl = C.CDLL(os.path.join(dll, 'libEGL.dll')); gl = C.CDLL(os.path.join(dll, 'libGLESv2.dll'))
|
||||
egl.eglGetProcAddress.restype = C.c_void_p
|
||||
for n in ['eglInitialize', 'eglChooseConfig', 'eglCreatePbufferSurface', 'eglCreateContext', 'eglMakeCurrent', 'eglBindAPI', 'eglGetError']:
|
||||
getattr(egl, n).restype = C.c_void_p if n.startswith('eglCreate') else C.c_uint
|
||||
egl.eglCreatePbufferSurface.argtypes = [C.c_void_p, C.c_void_p, C.POINTER(C.c_int)]
|
||||
egl.eglCreateContext.argtypes = [C.c_void_p, C.c_void_p, C.c_void_p, C.POINTER(C.c_int)]
|
||||
egl.eglMakeCurrent.argtypes = [C.c_void_p, C.c_void_p, C.c_void_p, C.c_void_p]
|
||||
egl.eglInitialize.argtypes = [C.c_void_p, C.c_void_p, C.c_void_p]
|
||||
egl.eglChooseConfig.argtypes = [C.c_void_p, C.POINTER(C.c_int), C.POINTER(C.c_void_p), C.c_int, C.POINTER(C.c_int)]
|
||||
GPD = C.CFUNCTYPE(C.c_void_p, C.c_uint, C.c_void_p, C.POINTER(C.c_int))(egl.eglGetProcAddress(b'eglGetPlatformDisplayEXT'))
|
||||
def ia(*v): return (C.c_int * (len(v) + 1))(*v, 0x3038)
|
||||
disp = GPD(0x3202, None, ia(0x3203, 0x3208)) # EGL_PLATFORM_ANGLE_TYPE_ANGLE = D3D11
|
||||
assert egl.eglInitialize(disp, None, None), 'eglInitialize %x' % egl.eglGetError()
|
||||
cfg = C.c_void_p(); ncfg = C.c_int()
|
||||
egl.eglChooseConfig(disp, ia(0x3033, 0x1, 0x3040, 0x40), C.byref(cfg), 1, C.byref(ncfg)) # PBUFFER, ES3
|
||||
surf = egl.eglCreatePbufferSurface(disp, cfg, ia(0x3057, 16, 0x3056, 16))
|
||||
egl.eglBindAPI(0x30A0)
|
||||
ctx_attr = (0x3098, 3) + ((0x33AC, 1) if webgl else ()) # CLIENT_VERSION 3 [, WEBGL_COMPATIBILITY_ANGLE]
|
||||
ctx = egl.eglCreateContext(disp, cfg, None, ia(*ctx_attr))
|
||||
assert ctx and egl.eglMakeCurrent(disp, surf, surf, ctx), 'context %x' % egl.eglGetError()
|
||||
gl.glGetString.restype = C.c_char_p
|
||||
print('GL_RENDERER', gl.glGetString(0x1F01).decode()); print('GL_VERSION', gl.glGetString(0x1F02).decode())
|
||||
print('webgl compatibility context', webgl)
|
||||
|
||||
gl.glCreateShader.restype = C.c_uint; gl.glCreateProgram.restype = C.c_uint
|
||||
def shader(kind, src):
|
||||
s = gl.glCreateShader(kind); b = C.c_char_p(src.encode()); gl.glShaderSource(s, 1, C.byref(b), None); gl.glCompileShader(s)
|
||||
ok = C.c_int(); gl.glGetShaderiv(s, 0x8B81, C.byref(ok))
|
||||
if not ok.value:
|
||||
buf = C.create_string_buffer(4000); gl.glGetShaderInfoLog(s, 4000, None, buf); raise RuntimeError(buf.value.decode()[:300])
|
||||
return s
|
||||
VS = '#version 300 es\nvoid main() { vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2); gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); }\n'
|
||||
lib = open(os.environ['LIBGLSL']).read() if os.environ.get('LIBGLSL') else ''.join(open(os.path.join(sh, f)).read() for f in ['CultMath.glsl', 'CultMath.Cycles.glsl', 'CultMath.Phacelle.glsl'])
|
||||
vao = C.c_uint(); gl.glGenVertexArrays(1, C.byref(vao)); gl.glBindVertexArray(vao)
|
||||
d = json.load(open(fx)); man = []; skipped = 0
|
||||
for idx, f in enumerate(d['functions']):
|
||||
r, name, ps = fam.parse(f['glsl']); rt = fam.ty(r); pts = [fam.ty(p) for p in ps]
|
||||
if rt is None or any(p is None for p in pts): skipped += 1; continue
|
||||
nin = sum(p[1] for p in pts); nout = rt[1]; tw = (nin + 3) // 4; nt = (nout + 3) // 4; tag = 'f%03d' % idx
|
||||
words = [int(x, 16) for c in f['cases'] for x in c.split('->')[0].split()]
|
||||
tex = []
|
||||
for ci in range(256):
|
||||
row = words[ci * nin:(ci + 1) * nin]; tex += row + [0] * (tw * 4 - nin)
|
||||
lines, off = [], 0
|
||||
for j, (p, (ht, n, k)) in enumerate(zip(ps, pts)):
|
||||
src = ', '.join('w[%d]' % (off + t) for t in range(n)); v = ('uvec%d(%s)' % (n, src)) if n > 1 else src
|
||||
conv = {'f': 'uintBitsToFloat', 'i': ('ivec%d' % n if n > 1 else 'int'), 'u': ''}[k]
|
||||
lines.append(' %s a%d = %s(%s);' % (p, j, conv, v)); off += n
|
||||
lines.append(' %s r = %s(%s);' % (r, name, ', '.join('a%d' % j for j in range(len(ps)))))
|
||||
comps = ['r[%d][%d]' % (a, b) for a in range(3) for b in range(3)] if r == 'mat3' else ['r'] if nout == 1 else ['r[%d]' % i for i in range(nout)]
|
||||
for i, c in enumerate(comps):
|
||||
lines.append(' ow[%d] = %s(%s);' % (i, {'f': 'floatBitsToUint', 'i': 'uint', 'u': ''}[rt[2]], c))
|
||||
outs = ''.join('layout(location = %d) out highp uvec4 o%d;\n' % (t, t) for t in range(nt))
|
||||
wr = ''.join(' o%d = uvec4(ow[%d], ow[%d], ow[%d], ow[%d]);\n' % (t, 4 * t, 4 * t + 1, 4 * t + 2, 4 * t + 3) for t in range(nt))
|
||||
FS = ('#version 300 es\nprecision highp float;\nprecision highp int;\n' + lib + '\nuniform highp usampler2D In;\n' + outs +
|
||||
'void main() {\n int i = int(gl_FragCoord.y) * 16 + int(gl_FragCoord.x);\n uint w[%d];\n for (int k = 0; k < %d; k++) w[k] = texelFetch(In, ivec2(k / 4, i), 0)[k %% 4];\n'
|
||||
' uint ow[%d];\n' % (tw * 4, nin, nt * 4) + '\n'.join(lines) + '\n' + ''.join(' ow[%d] = 0u;\n' % z for z in range(nout, nt * 4)) + wr + '}\n')
|
||||
try:
|
||||
prog = gl.glCreateProgram(); gl.glAttachShader(prog, shader(0x8B31, VS)); gl.glAttachShader(prog, shader(0x8B30, FS)); gl.glLinkProgram(prog)
|
||||
except RuntimeError as e:
|
||||
print('compile fail', f['glsl'], str(e).replace('\n', ' ')[:200]); continue
|
||||
gl.glUseProgram(prog)
|
||||
t_in = C.c_uint(); gl.glGenTextures(1, C.byref(t_in)); gl.glActiveTexture(0x84C0); gl.glBindTexture(0x0DE1, t_in)
|
||||
gl.glTexParameteri(0x0DE1, 0x2801, 0x2600); gl.glTexParameteri(0x0DE1, 0x2800, 0x2600)
|
||||
buf = (C.c_uint * len(tex))(*tex); gl.glTexImage2D(0x0DE1, 0, 0x8D70, tw, 256, 0, 0x8D99, 0x1405, buf)
|
||||
gl.glUniform1i(gl.glGetUniformLocation(prog, b'In'), 0)
|
||||
fbo = C.c_uint(); gl.glGenFramebuffers(1, C.byref(fbo)); gl.glBindFramebuffer(0x8D40, fbo)
|
||||
tos = (C.c_uint * nt)(); gl.glGenTextures(nt, tos)
|
||||
for t in range(nt):
|
||||
gl.glBindTexture(0x0DE1, tos[t]); gl.glTexImage2D(0x0DE1, 0, 0x8D70, 16, 16, 0, 0x8D99, 0x1405, None)
|
||||
gl.glFramebufferTexture2D(0x8D40, 0x8CE0 + t, 0x0DE1, tos[t], 0)
|
||||
gl.glDrawBuffers(nt, (C.c_uint * nt)(*[0x8CE0 + t for t in range(nt)]))
|
||||
gl.glActiveTexture(0x84C0); gl.glBindTexture(0x0DE1, t_in)
|
||||
gl.glViewport(0, 0, 16, 16); gl.glDrawArrays(0x0004, 0, 3)
|
||||
res = [[0] * 1024 for _ in range(nt)]
|
||||
for t in range(nt):
|
||||
gl.glReadBuffer(0x8CE0 + t); px = (C.c_uint * 1024)(); gl.glReadPixels(0, 0, 16, 16, 0x8D99, 0x1405, px); res[t] = list(px)
|
||||
flat = []
|
||||
for ci in range(256):
|
||||
allw = sum((res[t][ci * 4:ci * 4 + 4] for t in range(nt)), []); flat += allw[:nout]
|
||||
open(os.path.join(out, tag + '.in'), 'wb').write(b'\0')
|
||||
open(os.path.join(out, tag + '.angle.out'), 'wb').write(struct.pack('<%dI' % len(flat), *flat))
|
||||
man.append({'tag': tag, 'glsl': f['glsl'], 'n': 256, 'nout': nout, 'kind': rt[2], 'tol': f['tolerance'], 'platform': f.get('platform')})
|
||||
gl.glDeleteFramebuffers(1, C.byref(fbo)); gl.glDeleteTextures(nt, tos); gl.glDeleteTextures(1, C.byref(t_in)); gl.glDeleteProgram(prog)
|
||||
print('gl error at end 0x%x' % gl.glGetError(), '; ran', len(man), 'skipped struct', skipped)
|
||||
json.dump(man, open(os.path.join(out, 'manifest.json'), 'w'))
|
||||
fam.cmp(fx, out, 'angle')
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Probe: per fixture family, generate an HLSL and a GLSL ES 3.10 compute shader that call the family's
|
||||
function on the fixture's argument bits, and compare device results with the fixture's result bits.
|
||||
usage: fam.py gen <fixture> <outdir> | fam.py cmp <fixture> <outdir> <tag>"""
|
||||
import json, re, struct, sys, os
|
||||
|
||||
SCAL = {'float': ('float', 1, 'f'), 'int': ('int', 1, 'i'), 'uint': ('uint', 1, 'u')}
|
||||
def ty(t):
|
||||
if t in SCAL: return SCAL[t]
|
||||
m = re.fullmatch(r'(i|u|)vec([234])', t)
|
||||
if m:
|
||||
k = {'': 'f', 'i': 'i', 'u': 'u'}[m.group(1)]; n = int(m.group(2))
|
||||
return ({'f': 'float', 'i': 'int', 'u': 'uint'}[k] + str(n), n, k)
|
||||
if t == 'mat3': return ('float3x3', 9, 'f')
|
||||
return None
|
||||
|
||||
def parse(sig):
|
||||
m = re.fullmatch(r'(\w+)\s+(\w+)\((.*)\)', sig)
|
||||
ps = [p.strip() for p in m.group(3).split(',') if p.strip()]
|
||||
return m.group(1), m.group(2), ps
|
||||
|
||||
def hlsl_read(k, words):
|
||||
return {'f': 'asfloat', 'i': 'asint', 'u': ''}[k] + '(' + words + ')'
|
||||
|
||||
def gen(fx, out):
|
||||
d = json.load(open(fx)); man = []; skipped = []
|
||||
for idx, f in enumerate(d['functions']):
|
||||
r, name, ps = parse(f['glsl'])
|
||||
rt = ty(r); pts = [ty(p) for p in ps]
|
||||
if rt is None or any(p is None for p in pts):
|
||||
skipped.append(f['glsl']); continue
|
||||
nin = sum(p[1] for p in pts); nout = rt[1]
|
||||
tag = 'f%03d' % idx
|
||||
words = []
|
||||
for c in f['cases']:
|
||||
a, b = c.split('->'); aw = a.split(); assert len(aw) == nin, (f['glsl'], len(aw), nin)
|
||||
words += [int(x, 16) for x in aw]
|
||||
open(os.path.join(out, tag + '.in'), 'wb').write(struct.pack('<%dI' % len(words), *words) if words else b'\0\0\0\0')
|
||||
# HLSL
|
||||
hl, gl, off = [], [], 0
|
||||
for j, (p, (ht, n, k)) in enumerate(zip(ps, pts)):
|
||||
idxs = ', '.join('In[b + %d]' % (off + t) for t in range(n))
|
||||
gidx = ', '.join('In.a[b + %du]' % (off + t) for t in range(n))
|
||||
if ht == 'float3x3': raise SystemExit('mat3 arg unsupported')
|
||||
vec = ('uint%d(%s)' % (n, idxs)) if n > 1 else idxs
|
||||
hl.append(' %s a%d = %s;' % (ht, j, hlsl_read(k, vec) if k != 'u' else vec))
|
||||
gvec = ('uvec%d(%s)' % (n, gidx)) if n > 1 else gidx
|
||||
gfun = {'f': 'uintBitsToFloat', 'i': ('ivec%d' % n if n > 1 else 'int'), 'u': ''}[k]
|
||||
gl.append(' %s a%d = %s(%s);' % (p, j, gfun, gvec))
|
||||
off += n
|
||||
args = ', '.join('a%d' % j for j in range(len(ps)))
|
||||
hl.append(' %s r = %s(%s);' % (rt[0], name, args))
|
||||
gl.append(' %s r = %s(%s);' % (r, name, args))
|
||||
if rt[0] == 'float3x3':
|
||||
comps = ['r[%d][%d]' % (i, j) for i in range(3) for j in range(3)]
|
||||
elif rt[1] == 1: comps = ['r']
|
||||
else: comps = ['r[%d]' % i for i in range(rt[1])]
|
||||
for i, c in enumerate(comps):
|
||||
hl.append(' Out[o + %d] = %s;' % (i, {'f': 'asuint', 'i': 'asuint', 'u': ''}[rt[2]] + '(' + c + ')'))
|
||||
gl.append(' Out.a[o + %du] = %s;' % (i, {'f': 'floatBitsToUint', 'i': 'uint', 'u': ''}[rt[2]] + '(' + c + ')'))
|
||||
n = len(f['cases'])
|
||||
open(os.path.join(out, tag + '.hlsl'), 'w').write(
|
||||
'#include "CultMath.hlsl"\n[[vk::binding(0)]] StructuredBuffer<uint> In;\n[[vk::binding(1)]] RWStructuredBuffer<uint> Out;\n'
|
||||
'[numthreads(64, 1, 1)] void main(uint3 id : SV_DispatchThreadID) {\n uint i = id.x; if (i >= %du) return; uint b = i * %du; uint o = i * %du;\n%s\n}\n'
|
||||
% (n, nin, nout, '\n'.join(hl)))
|
||||
open(os.path.join(out, tag + '.body.glsl'), 'w').write(
|
||||
'layout(local_size_x = 64) in;\nlayout(std430, binding = 0) readonly buffer IB { uint a[]; } In;\nlayout(std430, binding = 1) buffer OB { uint a[]; } Out;\n'
|
||||
'void main() {\n uint i = gl_GlobalInvocationID.x; if (i >= %du) return; uint b = i * %du; uint o = i * %du;\n%s\n}\n'
|
||||
% (n, nin, nout, '\n'.join(gl)))
|
||||
man.append({'tag': tag, 'glsl': f['glsl'], 'n': n, 'nout': nout, 'kind': rt[2], 'tol': f['tolerance'], 'platform': f.get('platform')})
|
||||
json.dump(man, open(os.path.join(out, 'manifest.json'), 'w'))
|
||||
print('generated', len(man), 'families; skipped (struct types)', len(skipped), ':', '; '.join(skipped))
|
||||
|
||||
def ordered(u): # float bits -> monotonic int for ulp distance
|
||||
return u if u < 0x80000000 else 0x80000000 - u
|
||||
|
||||
def cmp(fx, out, tag):
|
||||
d = json.load(open(fx)); man = json.load(open(os.path.join(out, 'manifest.json')))
|
||||
rows = []; buckets = {'exact': 0, '<=4': 0, '<=64': 0, '>64': 0, 'missing': 0}
|
||||
for m in man:
|
||||
p = os.path.join(out, m['tag'] + '.' + tag + '.out')
|
||||
if not os.path.exists(p): buckets['missing'] += 1; rows.append((m['glsl'], 'MISSING', '')); continue
|
||||
got = struct.unpack('<%dI' % (m['n'] * m['nout']), open(p, 'rb').read())
|
||||
f = d['functions'][int(m['tag'][1:])]
|
||||
worst = 0; exact = 0; nanmis = 0
|
||||
for ci, c in enumerate(f['cases']):
|
||||
exp = [int(x, 16) for x in c.split('->')[1].split()]
|
||||
g = got[ci * m['nout']:(ci + 1) * m['nout']]
|
||||
if list(g) == exp: exact += 1; continue
|
||||
for e, h in zip(exp, g):
|
||||
if m['kind'] != 'f': worst = max(worst, 1 << 40); continue
|
||||
en = (e & 0x7f800000) == 0x7f800000 and e & 0x7fffff; hn = (h & 0x7f800000) == 0x7f800000 and h & 0x7fffff
|
||||
if en or hn:
|
||||
if not (en and hn): nanmis += 1
|
||||
continue
|
||||
fe = struct.unpack('<f', struct.pack('<I', e))[0]; fh = struct.unpack('<f', struct.pack('<I', h))[0]
|
||||
if fe != fe or fh != fh or abs(fe) == float('inf') or abs(fh) == float('inf'):
|
||||
if not (fe == fh or (fe != fe and fh != fh)): nanmis += 1
|
||||
continue
|
||||
worst = max(worst, abs(fe - fh) / max(abs(fe), 1.0) / 2.0 ** -23)
|
||||
b = 'exact' if exact == m['n'] else '<=4' if worst <= 4 and not nanmis else '<=64' if worst <= 64 and not nanmis else '>64'
|
||||
if worst >= 1 << 40: b = '>64'
|
||||
buckets[b] += 1
|
||||
rows.append((m['glsl'], '%d/%d exact' % (exact, m['n']), 'max err %s ulp(max(|v|,1))%s' % (('%.1f' % worst) if worst < 1 << 40 else 'int-mismatch', ', nan-mismatch %d' % nanmis if nanmis else '')))
|
||||
print(tag, 'buckets', buckets)
|
||||
for r in sorted(rows, key=lambda r: r[1])[:0]: pass
|
||||
rows.sort(key=lambda r: -float(re.search(r'max err ([0-9.]+)', r[2]).group(1)) if re.search(r'max err ([0-9.]+)', r[2]) else -1e18)
|
||||
for r in rows[:int(os.environ.get('CMP_TOP', '25'))]:
|
||||
if not r[1].startswith('256/256'): print(' ', r[0], '|', r[1], '|', r[2])
|
||||
|
||||
if __name__ == '__main__':
|
||||
{'gen': lambda: gen(sys.argv[2], sys.argv[3]), 'cmp': lambda: cmp(sys.argv[2], sys.argv[3], sys.argv[4])}[sys.argv[1]]()
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Probe job, runs in ubuntu:24.04 with /src = CultLib at the probed revision. Q1 library path, Q2 lavapipe runs.
|
||||
set -u
|
||||
P=/tmp/p; W=/tmp/w; mkdir -p $W/fam $W/tools; cd $W
|
||||
step() { echo; echo "=== $*"; }
|
||||
step apt
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq >/dev/null && apt-get install -y -qq --no-install-recommends ca-certificates curl git cmake g++ make python3 \
|
||||
mesa-vulkan-drivers libvulkan-dev vulkan-tools glslang-tools spirv-tools >/dev/null 2>&1; echo "apt exit $?"
|
||||
dpkg-query -W -f='${Package} ${Version}\n' mesa-vulkan-drivers glslang-tools spirv-tools libvulkan-dev
|
||||
step dxc
|
||||
url=$(curl -s https://api.github.com/repos/microsoft/DirectXShaderCompiler/releases/tags/v1.9.2607 | python3 -c "import json,sys;print([a['browser_download_url'] for a in json.load(sys.stdin)['assets'] if 'linux' in a['name'] and a['name'].endswith('.tar.gz')][0])")
|
||||
echo "$url"; curl -sL --fail -o dxc.tgz "$url" && mkdir -p tools/dxc && tar xzf dxc.tgz -C tools/dxc; DXC=$(find tools/dxc -name dxc -type f | head -1); echo "DXC=$DXC"; $DXC --version 2>&1 | head -2
|
||||
step spirv-cross
|
||||
git clone -q --depth 1 https://github.com/KhronosGroup/SPIRV-Cross.git tools/sc && git -C tools/sc log -1 --format='SPIRV-Cross %H %cs' && \
|
||||
cmake -S tools/sc -B tools/scb -DCMAKE_BUILD_TYPE=Release -DSPIRV_CROSS_ENABLE_TESTS=OFF >/dev/null && cmake --build tools/scb --target spirv-cross -j4 >/dev/null 2>&1; SC=tools/scb/spirv-cross; ls -la $SC | cut -c1-120
|
||||
SH=/src/packages/cultmath/shaders
|
||||
python3 - "$SH" <<'PY'
|
||||
import re,sys,os,shutil
|
||||
src=sys.argv[1]; dst='/tmp/w/shx'; shutil.copytree(src,dst,dirs_exist_ok=True); n=0
|
||||
for fn in os.listdir(dst):
|
||||
if fn.endswith('.hlsl'):
|
||||
t=open(os.path.join(dst,fn)).read(); t2,k=re.subn(r'(?m)^(?!static|export)(\w+(?:\dx\d)?\s+cultmath_\w+\s*\(([^)]*)\))', lambda m: m.group(1) if re.search('Texture2D|SamplerState', m.group(2)) else 'export ' + m.group(1), t); k=len(re.findall(r'(?m)^export ', t2)); n+=k; open(os.path.join(dst,fn),'w').write(t2)
|
||||
print('export keywords added', n)
|
||||
PY
|
||||
step "L1 dxc lib_6_3 universal1.5, export on non-texture defs"
|
||||
$DXC -T lib_6_3 -HV 2021 -spirv -fspv-target-env=universal1.5 -I shx shx/CultMath.hlsl -Fo lib.spv 2>&1 | head -3; echo "exit ${PIPESTATUS[0]}"
|
||||
spirv-dis lib.spv -o lib.spvasm; echo "exports $(grep -c 'LinkageAttributes.*Export' lib.spvasm); capabilities $(grep -c OpCapability lib.spvasm)"
|
||||
step "L1b -default-linkage external, no export keyword"
|
||||
$DXC -T lib_6_3 -HV 2021 -spirv -fspv-target-env=universal1.5 -default-linkage external -I $SH $SH/CultMath.hlsl -Fo libd.spv 2>&1 | head -3; echo "exit ${PIPESTATUS[0]}"
|
||||
[ -s libd.spv ] && { spirv-dis libd.spv -o libd.spvasm; echo "exports $(grep -c 'LinkageAttributes.*Export' libd.spvasm)"; }
|
||||
step "L2 strip every capability but Shader and Linkage, every extension; SPIRV-Cross ES 310 and 300"
|
||||
grep -vP '^\s*OpCapability (?!Shader$|Linkage$)|^\s*OpExtension ' lib.spvasm > s.spvasm; spirv-as --target-env spv1.5 s.spvasm -o s.spv; echo "as exit $?"
|
||||
for v in 310 300; do $SC s.spv --es --version $v --output s.$v.glsl 2>&1 | head -2; echo "es$v exit ${PIPESTATUS[0]}"; done
|
||||
G=s.310.glsl; echo "G=$G"; [ -s "$G" ] || { echo PROBE-DONE; exit 0; }
|
||||
step "L3 shape of $G"
|
||||
echo "lines $(wc -l < $G); defs cultmath_: $(grep -cE '^\w+ cultmath_\w+\(' $G); distinct: $(grep -oE '^\w+ cultmath_\w+\(' $G | sed -E 's/^\w+ //' | sort -u | wc -l); renamed _N: $(grep -oE '^\w+ cultmath_\w+_[0-9]+\(' $G | wc -l); _NNN temporaries: $(grep -oE '\b_[0-9]+\b' $G | wc -l); non-cultmath defs: $(grep -cE '^\w+ (?!cultmath_)\w+\(' -P $G)"
|
||||
grep -nE '^#|^layout|precision|^struct|^void main' $G | head -12
|
||||
echo "--- renamed defs sample:"; grep -oE '^\w+ cultmath_\w+_[0-9]+\([^)]*\)' $G | head -6
|
||||
echo "--- snoise(vec3):"; awk '/^(highp )?float cultmath_snoise\((highp )?vec3/{p=1} p{print; if(++n>=22) exit}' $G
|
||||
sed -E '/^#version/d; /^layout\(local_size/d' $G | awk 'BEGIN{s=0} /^void main\(\)/{s=1} s==0{print} s==1&&/^\}/{s=0}' > lib.body.glsl
|
||||
{ printf '#version 300 es\nprecision highp float;\nprecision highp int;\n'; cat lib.body.glsl; printf 'out highp vec4 c;\nvoid main() { c = vec4(cultmath_snoise(gl_FragCoord.xyz)); }\n'; } > es300.frag
|
||||
glslangValidator es300.frag > es300.txt 2>&1; echo "glslang ES 3.00 fragment with the library: exit $? $(grep -m3 ERROR es300.txt | tr '\n' ' ' | cut -c1-300)"
|
||||
step "L4 library through glslang on lavapipe"
|
||||
gcc -O2 -o vkrun $P/vkrun.c -lvulkan 2>/dev/null; python3 $P/fam.py gen /src/packages/cultmath/tests/CultMath.Tests/fixtures/glsl-parity.json fam >/dev/null
|
||||
okl=0; for b in fam/*.body.glsl; do t=${b%.body.glsl}; tag=$(basename $t); n=$(python3 -c "import json;m={x['tag']:x for x in json.load(open('fam/manifest.json'))}['$tag'];print(m['n'],m['nout'])")
|
||||
{ printf '#version 310 es\nprecision highp float;\nprecision highp int;\n'; cat lib.body.glsl $b; } > $t.l.comp
|
||||
glslangValidator -V --target-env vulkan1.2 -o $t.l.spv $t.l.comp >$t.l.log 2>&1 && ./vkrun $t.l.spv $t.in $t.lib.out $n && okl=$((okl+1)); done
|
||||
echo "library path ran $okl of 105"; grep -h -m1 ERROR fam/*.l.log | sed -E 's/[0-9]+:/N:/g' | sort | uniq -c | sort -rn | head -6
|
||||
CMP_TOP=8 python3 $P/fam.py cmp /src/packages/cultmath/tests/CultMath.Tests/fixtures/glsl-parity.json fam lib
|
||||
echo "LIBGZ-BEGIN"; gzip -9c lib.body.glsl | base64 -w0; echo; echo "LIBGZ-END"
|
||||
echo PROBE-DONE
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Probe: by-value parameter lowering on a SPIR-V module. dxc -Od passes every parameter as a Function pointer and the
|
||||
caller copies in; a callee that assigns its parameter then comes out of SPIRV-Cross as `inout`. This pass makes each
|
||||
Function-pointer parameter a value parameter: the old param id becomes a local variable in the entry block, stored from the
|
||||
new value parameter; each call loads the pointer it passed. usage: byvalue.py <in.spv> <out.spv>"""
|
||||
import struct, sys
|
||||
def load(p):
|
||||
b = open(p, 'rb').read(); w = list(struct.unpack('<%dI' % (len(b) // 4), b)); ins = []; i = 5
|
||||
while i < len(w): n = w[i] >> 16; ins.append(w[i:i + n]); i += n
|
||||
return w[:5], ins
|
||||
def op(x): return x[0] & 0xffff
|
||||
def mk(o, *ws): return [((len(ws) + 1) << 16) | o, *ws]
|
||||
h, ins = load(sys.argv[1]); bound = h[3]
|
||||
ptr = {x[1]: (x[2], x[3]) for x in ins if op(x) == 32} # id -> (storage, pointee)
|
||||
ftype = {x[1]: x for x in ins if op(x) == 33}
|
||||
fnparams = {}; cur = None; fntype = {}
|
||||
for x in ins: # analysis: per function, which params are Function pointers
|
||||
if op(x) == 54: cur = x[2]; fnparams[cur] = []; fntype[cur] = x[4]
|
||||
elif op(x) == 55 and cur is not None:
|
||||
t = x[1]; conv = t in ptr and ptr[t][0] == 7
|
||||
fnparams[cur].append((x[2], t, ptr[t][1] if conv else None, bound if conv else None)); bound += conv
|
||||
newtype = {}; typeins = []; sig = {tuple(x[2:]): x[1] for x in ins if op(x) == 33}
|
||||
for f, ps in fnparams.items():
|
||||
if any(p[2] for p in ps):
|
||||
ft = ftype[fntype[f]]; key = (ft[2], *[p[2] if p[2] else p[1] for p in ps])
|
||||
if key not in sig: bound += 1; sig[key] = bound; typeins.append(mk(33, bound, *key))
|
||||
newtype[f] = sig[key]
|
||||
names = {x[1]: x for x in ins if op(x) == 5}
|
||||
out = []; cur = None; pend = []; need_stores = False; first = True; pc = 0
|
||||
for x in ins:
|
||||
o = op(x)
|
||||
if o == 54 and first: out += typeins; first = False
|
||||
if need_stores and o != 59:
|
||||
out += [mk(62, p, q) for p, _, q in pend]; pend = []; need_stores = False
|
||||
if o == 54:
|
||||
cur = x[2]; pc = 0
|
||||
if cur in newtype: x = x[:4] + [newtype[cur]] + x[5:]
|
||||
elif o == 55:
|
||||
_, t, pointee, q = fnparams[cur][pc]; pc += 1
|
||||
if pointee: out.append(mk(55, pointee, q)); pend.append((x[2], t, q)); continue
|
||||
elif o == 248 and pend:
|
||||
out.append(x); out += [mk(59, t, p, 7) for p, t, _ in pend]; need_stores = True; continue
|
||||
elif o == 57:
|
||||
callee = x[3]; args = x[4:]; new = []
|
||||
for a, (_, _, pointee, _) in zip(args, fnparams[callee]):
|
||||
if pointee: bound += 1; out.append(mk(61, pointee, bound, a)); new.append(bound)
|
||||
else: new.append(a)
|
||||
x = x[:4] + new; x[0] = (len(x) << 16) | 57
|
||||
out.append(x)
|
||||
if o == 5 and x[1] in [p[0] for ps in fnparams.values() for p in ps if p[2]]:
|
||||
q = next(p[3] for ps in fnparams.values() for p in ps if p[0] == x[1]); out.append([x[0], q] + x[2:])
|
||||
h[3] = bound + 1; w = h + [v for x in out for v in x]; open(sys.argv[2], 'wb').write(struct.pack('<%dI' % len(w), *w))
|
||||
print('byvalue: %d functions changed, %d params' % (len(newtype), sum(1 for ps in fnparams.values() for p in ps if p[2])))
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Imagination probe: what dxc -Od leaves in the SPIRV-Cross GLSL and what cleans it. ubuntu:24.04, /src = CultLib, /cache persists tools.
|
||||
set -u; P=/src/docs/research/native-shader-probes; O=$P/overload-wrappers; W=/tmp/w; mkdir -p $W /cache; cd $W
|
||||
step() { echo; echo "=== $*"; }
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq >/dev/null && apt-get install -y -qq --no-install-recommends ca-certificates curl git cmake g++ make python3 gcc \
|
||||
mesa-vulkan-drivers libvulkan-dev glslang-tools spirv-tools >/dev/null 2>&1; echo "apt exit $?"
|
||||
dpkg-query -W -f='${Package} ${Version}\n' spirv-tools glslang-tools mesa-vulkan-drivers
|
||||
if [ ! -x /cache/sc/spirv-cross ]; then
|
||||
git clone -q --depth 1 --branch vulkan-sdk-1.4.363.0 https://github.com/KhronosGroup/SPIRV-Cross.git /cache/scsrc
|
||||
cmake -S /cache/scsrc -B /cache/scb -DCMAKE_BUILD_TYPE=Release -DSPIRV_CROSS_ENABLE_TESTS=OFF -DSPIRV_CROSS_CLI=ON >/dev/null && cmake --build /cache/scb --target spirv-cross -j8 >/dev/null 2>&1
|
||||
mkdir -p /cache/sc; cp /cache/scb/spirv-cross /cache/sc/
|
||||
fi
|
||||
if [ ! -x /cache/dxc/bin/dxc ]; then
|
||||
curl -sL --fail -o /cache/dxc.tgz https://github.com/microsoft/DirectXShaderCompiler/releases/download/v1.9.2607/linux_dxc_2026_07_29.x86_x64.tar.gz
|
||||
mkdir -p /cache/dxc && tar xzf /cache/dxc.tgz -C /cache/dxc
|
||||
fi
|
||||
DXC=$(find /cache/dxc -name dxc -type f | head -1); SC=/cache/sc/spirv-cross; export LD_LIBRARY_PATH=$(dirname $DXC)/../lib:${LD_LIBRARY_PATH:-}
|
||||
if [ ! -x /cache/st/spirv-opt ]; then
|
||||
git clone -q --depth 1 --branch vulkan-sdk-1.4.363.0 https://github.com/KhronosGroup/SPIRV-Tools.git /cache/stsrc
|
||||
git clone -q --depth 1 --branch vulkan-sdk-1.4.363.0 https://github.com/KhronosGroup/SPIRV-Headers.git /cache/stsrc/external/spirv-headers
|
||||
cmake -S /cache/stsrc -B /cache/stb -DCMAKE_BUILD_TYPE=Release -DSPIRV_SKIP_TESTS=ON -DSPIRV_WERROR=OFF >/dev/null && cmake --build /cache/stb --target spirv-opt spirv-val spirv-dis spirv-as -j8 >/dev/null 2>&1
|
||||
mkdir -p /cache/st; cp /cache/stb/tools/spirv-* /cache/st/
|
||||
fi
|
||||
export PATH=/cache/st:$PATH; echo "spirv-opt from $(which spirv-opt)"
|
||||
echo "dxc $DXC"; $DXC --version 2>&1|head -2; spirv-opt --version | head -1
|
||||
SH=/src/packages/cultmath/shaders; FX=/src/packages/cultmath/tests/CultMath.Tests/fixtures/glsl-parity.json
|
||||
step "1 build the -Od library as in Pass 9b"
|
||||
python3 $O/wrapgen.py $SH shw
|
||||
$DXC -T lib_6_3 -HV 2021 -spirv -fspv-target-env=universal1.5 -Od -I shw shw/cm_wrappers.hlsl -Fo w.spv 2>&1 | head -3
|
||||
python3 $O/spvedit.py edit w.spv e.spv shw/wrappers.tsv export; spirv-val --target-env spv1.5 e.spv && echo "val ok"
|
||||
nf() { spirv-dis "$1" | grep -c ' = OpFunction '; }; echo "functions in e.spv: $(nf e.spv)"
|
||||
step "2 variants: by-value lowering, then spirv-opt (never an inliner)"
|
||||
cp e.spv v0.spv
|
||||
python3 $P/od-temporaries/byvalue.py e.spv b0.spv; spirv-val --target-env spv1.5 b0.spv && echo "b0 val ok"
|
||||
spirv-opt --target-env=spv1.5 --ssa-rewrite --eliminate-dead-code-aggressive --simplify-instructions e.spv -o v2.spv
|
||||
SSA="--ssa-rewrite --eliminate-dead-code-aggressive"
|
||||
declare -A VP=([b1]="--eliminate-local-single-store --eliminate-dead-code-aggressive" [b2]="$SSA" [b3]="$SSA --simplify-instructions" [b4]="--eliminate-local-single-block --eliminate-local-single-store --eliminate-local-multi-store --eliminate-dead-code-aggressive --merge-blocks")
|
||||
for v in b1 b2 b3 b4; do spirv-opt --target-env=spv1.5 ${VP[$v]} b0.spv -o $v.spv 2>$v.err; echo "$v opt exit $? functions $(nf $v.spv 2>/dev/null) $(head -c 160 $v.err)"; done
|
||||
cp b1.spv b1r.spv; VS="v0 b0 b1 b1r b3"
|
||||
for v in $VS; do [ -s $v.spv ] || continue
|
||||
spirv-val --target-env spv1.5 $v.spv >$v.val 2>&1; vx=$?
|
||||
$SC $v.spv --es --version 310 $([ $v = b1r ] && echo --relax-nan-checks) --output $v.glsl >$v.sc 2>&1; sx=$?
|
||||
[ -s $v.glsl ] || { echo "$v val $vx sc exit $sx"; head -3 $v.val $v.sc; continue; }
|
||||
python3 - $v $vx $sx <<'PY'
|
||||
import re,sys
|
||||
v,vx,sx=sys.argv[1:4]; g=open(f'/tmp/w/{v}.glsl').read()
|
||||
defs=re.findall(r'(?m)^(?:\w+ )*?(\w+) (cultmath_\w+)\(([^)]*)\)\n\{',g)
|
||||
ids=re.findall(r'\b_\d+\b',g)
|
||||
print(f'{v}: val={vx} sc={sx} lines={g.count(chr(10))} bytes={len(g)} cultmath_defs={len(defs)} distinct={len(set(d[1] for d in defs))} id_tokens={len(ids)} distinct_ids={len(set(ids))} param_var={g.count("param_var_")} inout={len(re.findall(r"inout",g))} suffixed={len(re.findall(r"cultmath_\w+_\d+\(",g))}')
|
||||
PY
|
||||
done
|
||||
mkdir -p /cache/out; cp *.glsl *.spv /cache/out/ 2>/dev/null
|
||||
step "3 SPIRV-Cross options"
|
||||
for opt in "--remove-unused-variables"; do for v in v0 b2; do $SC $v.spv --es --version 310 $opt --output $v.o.glsl >/dev/null 2>&1; echo "$v + $opt: lines $(wc -l < $v.o.glsl)"; done; done
|
||||
step "4 lerp(vec3) as emitted: v0 vs b2, vs committed"
|
||||
for v in v0 b2; do echo "--- $v lerp(vec3)"; awk '/^(highp )?vec3 cultmath_lerp\((highp )?vec3/{p=1} p{print} p&&/^}/{exit}' $v.glsl; done
|
||||
echo "--- committed lerp(vec3)"; grep -n -A3 -E '^(highp )?vec3 cultmath_lerp\(' $SH/CultMath.glsl | head -6
|
||||
for v in v0 b2; do echo "--- $v snoise(vec3) head"; awk '/^(highp )?float cultmath_snoise\((highp )?vec3/{p=1} p{print; if(++n>=40) exit}' $v.glsl; done
|
||||
step "5 conformance on lavapipe, every family, per variant vs committed GLSL and HLSL"
|
||||
mkdir -p fam; gcc -O2 -o vkrun $P/vkrun.c -lvulkan && python3 $P/fam.py gen $FX fam >/dev/null
|
||||
cat $SH/CultMath.glsl $SH/CultMath.Cycles.glsl $SH/CultMath.Phacelle.glsl > all.glsl
|
||||
body() { sed -E '/^#version/d; /^#ifdef SPIRV_CROSS/d; /^#endif/d; /^layout\(local_size/d' $1 | awk 'BEGIN{s=0} /^void main\(\)/{s=1} s==0{print} s==1&&/^\}/{s=0}'; }
|
||||
for v in $VS; do [ -s $v.glsl ] && body $v.glsl > $v.body; done
|
||||
run() { local t=fam/$1 n; n=$(python3 -c "import json;m={x['tag']:x for x in json.load(open('fam/manifest.json'))}['$1'];print(m['n'],m['nout'])")
|
||||
{ printf '#version 310 es\nprecision highp float;\nprecision highp int;\n'; cat $3 $t.body.glsl; } > $t.$2.comp
|
||||
glslangValidator -V --target-env vulkan1.2 -o $t.$2.spv $t.$2.comp >$t.$2.log 2>&1 && ./vkrun $t.$2.spv $t.in $t.$2.out $n; }
|
||||
for b in fam/*.body.glsl; do tag=$(basename ${b%.body.glsl})
|
||||
n=$(python3 -c "import json;m={x['tag']:x for x in json.load(open('fam/manifest.json'))}['$tag'];print(m['n'],m['nout'])")
|
||||
$DXC -T cs_6_0 -E main -HV 2021 -spirv -fspv-target-env=vulkan1.2 -I $SH fam/$tag.hlsl -Fo fam/$tag.h.spv >/dev/null 2>&1 && ./vkrun fam/$tag.h.spv fam/$tag.in fam/$tag.h.out $n
|
||||
run $tag g all.glsl; for v in $VS; do [ -s $v.body ] && run $tag $v $v.body; done; done
|
||||
export VS; python3 - <<'PY'
|
||||
import os,filecmp
|
||||
d='/tmp/w/fam'; tags=sorted(f[:-10] for f in os.listdir(d) if f.endswith('.body.glsl'))
|
||||
def same(a,b): return [t for t in tags if os.path.exists(f'{d}/{t}.{a}.out') and os.path.exists(f'{d}/{t}.{b}.out') and filecmp.cmp(f'{d}/{t}.{a}.out',f'{d}/{t}.{b}.out',shallow=False)]
|
||||
print('families',len(tags),'hlsl ran',len([t for t in tags if os.path.exists(f'{d}/{t}.h.out')]),'committed glsl ran',len([t for t in tags if os.path.exists(f'{d}/{t}.g.out')]))
|
||||
for v in os.environ['VS'].split():
|
||||
ran=[t for t in tags if os.path.exists(f'{d}/{t}.{v}.out')]
|
||||
sg=same(v,'g'); print(v,'ran',len(ran),'bit-identical to committed glsl',len(sg),'to hlsl',len(same(v,'h')),'| differs from g:',[t for t in ran if t not in sg][:8])
|
||||
print('committed vs hlsl',len(same('g','h')),'of',len(tags))
|
||||
PY
|
||||
for v in v0 b2; do { printf '#version 300 es\nprecision highp float;\nprecision highp int;\n'; cat $v.body; printf 'out highp vec4 c;\nvoid main() { vec3 p = gl_FragCoord.xyz; c = vec4(cultmath_lerp(p, p.yzx, vec3(0.5)), cultmath_lerp(1.0, 2.0, p.x)) + cultmath_saturate(vec4(p, 1.0)) + vec4(cultmath_snoise(p)); }\n'; } > $v.es300.frag; glslangValidator $v.es300.frag >$v.es300.txt 2>&1; echo "$v ES 3.00 glslang exit $? $(grep -m2 ERROR $v.es300.txt | tr '\n' ' ' | cut -c1-200)"; done
|
||||
for t in g $VS; do echo "--- cmp vs C# fixture: $t"; CMP_TOP=3 python3 $P/fam.py cmp $FX fam $t 2>&1 | grep -v "^ *$" | head -8; done
|
||||
echo PROBE-DONE
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Imagination probe, lavapipe leg: every fixture family through three routes on lavapipe.
|
||||
# h = HLSL by dxc cs_6_0, g = committed GLSL by glslang, r = restored overloaded library GLSL (lib.body.glsl) by glslang.
|
||||
set -u; P=/tmp/p; W=/tmp/w; cd $W; SH=/src/packages/cultmath/shaders; FX=/src/packages/cultmath/tests/CultMath.Tests/fixtures/glsl-parity.json
|
||||
echo; echo "=== 2b lavapipe: same computation"
|
||||
mkdir -p fam; gcc -O2 -o vkrun $P/vkrun.c -lvulkan && python3 $P/fam.py gen $FX fam >/dev/null
|
||||
cat $SH/CultMath.glsl $SH/CultMath.Cycles.glsl $SH/CultMath.Phacelle.glsl > all.glsl
|
||||
ok=(0 0 0); first=1
|
||||
for b in fam/*.body.glsl; do t=${b%.body.glsl}; tag=$(basename $t)
|
||||
n=$(python3 -c "import json;m={x['tag']:x for x in json.load(open('fam/manifest.json'))}['$tag'];print(m['n'],m['nout'])")
|
||||
$DXC_PATH -T cs_6_0 -E main -HV 2021 -spirv -fspv-target-env=vulkan1.2 -I $SH $t.hlsl -Fo $t.h.spv >$t.h.log 2>&1 && ./vkrun $t.h.spv $t.in $t.h.out $n && ok[0]=$((ok[0]+1))
|
||||
for r in g r; do src=all.glsl; [ $r = r ] && src=lib.body.glsl
|
||||
{ printf '#version 310 es\nprecision highp float;\nprecision highp int;\n'; cat $src $b; } > $t.$r.comp
|
||||
glslangValidator -V --target-env vulkan1.2 -o $t.$r.spv $t.$r.comp >$t.$r.log 2>&1 && ./vkrun $t.$r.spv $t.in $t.$r.out $n && { [ $r = g ] && ok[1]=$((ok[1]+1)) || ok[2]=$((ok[2]+1)); }
|
||||
done
|
||||
done
|
||||
echo "ran on lavapipe: hlsl ${ok[0]}, committed glsl ${ok[1]}, restored glsl ${ok[2]} (of $(ls fam/*.body.glsl | wc -l))"
|
||||
grep -h -m1 ERROR fam/*.r.log | sed -E 's/[0-9]+:/N:/g' | sort | uniq -c | sort -rn | head -5
|
||||
python3 - <<'PY'
|
||||
import os,filecmp
|
||||
d='/tmp/w/fam'; tags=sorted(f[:-10] for f in os.listdir(d) if f.endswith('.body.glsl'))
|
||||
def same(a,b): return [t for t in tags if os.path.exists(f'{d}/{t}.{a}.out') and os.path.exists(f'{d}/{t}.{b}.out') and filecmp.cmp(f'{d}/{t}.{a}.out',f'{d}/{t}.{b}.out',shallow=False)]
|
||||
rg=same('r','g'); rh=same('r','h'); gh=same('g','h')
|
||||
print('bit-identical: restored vs committed glsl',len(rg),'| restored vs hlsl',len(rh),'| committed glsl vs hlsl',len(gh),'of',len(tags))
|
||||
print('restored differs from committed glsl in:',[t for t in tags if t not in rg][:12])
|
||||
print('restored differs from hlsl but committed glsl matches hlsl:',[t for t in gh if t not in rh][:12])
|
||||
PY
|
||||
|
||||
CMP_TOP=10 python3 $P/fam.py cmp $FX fam r 2>&1 | head -16
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import re,sys,os,collections
|
||||
sh,g,out=sys.argv[1:4]; os.makedirs(out,exist_ok=True)
|
||||
origin={}; owner={'CultMath.hlsl':'core','CultMath.Interval.hlsl':'core','CultMath.Affine.hlsl':'core','CultMath.Cycles.hlsl':'cycles','CultMath.Phacelle.hlsl':'phacelle'}
|
||||
for fn,o in owner.items():
|
||||
t=open(os.path.join(sh,fn)).read()
|
||||
for m in re.finditer(r'(?m)^(?:export\s+)?\w+\s+(cultmath_\w+)\s*\(',t): origin.setdefault(m.group(1),o)
|
||||
for m in re.finditer(r'(?m)^struct\s+(\w+)',t): origin.setdefault(m.group(1),o)
|
||||
text=open(g).read()
|
||||
# top-level chunks: struct blocks and function definitions; everything else is prologue
|
||||
lines=text.split('\n'); chunks=[]; i=0; pro=[]
|
||||
while i<len(lines):
|
||||
l=lines[i]
|
||||
m=re.match(r'^struct (\w+)$',l) or re.match(r'^\w+ (\w+)\(.*\)$',l)
|
||||
if m and m.group(1)!='main':
|
||||
j=i
|
||||
while lines[j]!='};' and lines[j]!='}': j+=1
|
||||
chunks.append((m.group(1),'\n'.join(lines[i:j+1]))); i=j+1
|
||||
elif m:
|
||||
j=i
|
||||
while lines[j]!='}': j+=1
|
||||
i=j+1
|
||||
else:
|
||||
if l.strip(): pro.append(l)
|
||||
i+=1
|
||||
print('prologue lines',len(pro),':',' | '.join(pro[:6])[:300])
|
||||
cnt=collections.Counter(); unknown=[]; bad=collections.Counter(); files=collections.defaultdict(list)
|
||||
rank={'core':0,'cycles':1,'phacelle':1}
|
||||
for name,body in chunks:
|
||||
o=origin.get(name)
|
||||
if o is None: unknown.append(name); o='core'
|
||||
cnt[o]+=1; files[o].append(body)
|
||||
for callee in set(re.findall(r'\b(cultmath_\w+|[A-Z]\w+)\b',body))-{name}:
|
||||
co=origin.get(callee)
|
||||
if co and co!=o and not (rank[co]==0): bad[(o,co,name,callee)]+=1
|
||||
print('chunks',len(chunks),dict(cnt),'unknown-origin',len(unknown),unknown[:8])
|
||||
print('cross-licence calls (non-core callee):',len(bad),list(bad)[:6])
|
||||
seen=collections.Counter(n for n,_ in chunks); print('duplicate chunk names',[n for n,c in seen.items() if c>1][:6])
|
||||
for o,b in files.items(): open(os.path.join(out,o+'.glsl'),'w').write('\n'.join(b)+'\n')
|
||||
print('split sizes', {o:len('\n'.join(b).split('\n')) for o,b in files.items()})
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Probe: instruction-level SPIR-V edit (binary, no text regex).
|
||||
stats <spv> <wrappers.tsv> how each wrapper looks after dxc
|
||||
edit <in> <out> <wrappers.tsv> <mode> mode drop: strip caps (keep Shader, Linkage) and extensions, delete
|
||||
wrapper functions and their names/decorations, set each callee's OpName to the overloaded source name.
|
||||
mode export: as drop, but each wrapper's Export decoration moves to its callee instead of being deleted."""
|
||||
import struct, sys, collections
|
||||
def load(p):
|
||||
b = open(p, 'rb').read(); w = list(struct.unpack('<%dI' % (len(b) // 4), b)); ins = []; i = 5
|
||||
while i < len(w): n = w[i] >> 16; ins.append(w[i:i + n]); i += n
|
||||
return w[:5], ins
|
||||
def s_dec(ws):
|
||||
b = struct.pack('<%dI' % len(ws), *ws); return b[:b.index(0)].decode()
|
||||
def s_enc(s):
|
||||
b = s.encode() + b'\0'; b += b'\0' * (-len(b) % 4); return list(struct.unpack('<%dI' % (len(b) // 4), b))
|
||||
def op(x): return x[0] & 0xffff
|
||||
def funcs(ins):
|
||||
fs = {}; cur = None
|
||||
for k, x in enumerate(ins):
|
||||
if op(x) == 54: cur = x[2]; fs[cur] = [k, None, [], set()]
|
||||
if cur is not None:
|
||||
if op(x) == 57: fs[cur][2].append(x[3])
|
||||
o = op(x)
|
||||
if o == 248: fs[cur][3].add(x[1])
|
||||
elif o not in (8, 317, 62, 246, 247, 249, 250, 253, 254, 252) and len(x) > 2: fs[cur][3].add(x[2])
|
||||
if op(x) == 56: fs[cur][1] = k; cur = None
|
||||
return fs
|
||||
def names(ins): return {x[1]: s_dec(x[2:]) for x in ins if op(x) == 5}
|
||||
def wrappers(tsv): return [l.split('\t') for l in open(tsv).read().splitlines() if l]
|
||||
def analyse(ins, tsv):
|
||||
nm = names(ins); fs = funcs(ins); byname = {v: k for k, v in nm.items() if k in fs}; res = []
|
||||
for wn, name, fn, ps in wrappers(tsv):
|
||||
fid = byname.get(wn); callees = fs[fid][2] if fid else None
|
||||
res.append((wn, name, fid, callees))
|
||||
return nm, fs, res
|
||||
if sys.argv[1] == 'stats':
|
||||
h, ins = load(sys.argv[2]); nm, fs, res = analyse(ins, sys.argv[3])
|
||||
missing = [r[0] for r in res if r[2] is None]; inl = [r[0] for r in res if r[2] and not r[3]]
|
||||
multi = [r[0] for r in res if r[3] and len(r[3]) != 1]
|
||||
callee = collections.Counter(r[3][0] for r in res if r[3] and len(r[3]) == 1)
|
||||
wrong = [(r[0], nm.get(r[3][0])) for r in res if r[3] and len(r[3]) == 1 and nm.get(r[3][0]) != r[1]]
|
||||
print('functions %d, wrappers found %d of %d, missing %d, inlined (no call) %d, not-one-call %d' % (len(fs), len(res) - len(missing), len(res), len(missing), len(inl), len(multi)))
|
||||
print('distinct callees %d, callee shared by >1 wrapper %d' % (len(callee), sum(1 for c in callee.values() if c > 1)))
|
||||
print('callee OpName != source name: %d %s' % (len(wrong), wrong[:4]))
|
||||
calls = sum(len(f[2]) for f in fs.values()); print('total OpFunctionCall %d; non-wrapper functions %d' % (calls, len(fs) - len(res) + len(missing)))
|
||||
print('sample', [(r[0], nm.get(r[3][0]) if r[3] else None) for r in res[:3]], 'missing sample', missing[:3], 'inlined sample', inl[:3])
|
||||
sys.exit(0)
|
||||
_, src, dst, tsv, mode = sys.argv[1:6]
|
||||
h, ins = load(src); nm, fs, res = analyse(ins, tsv)
|
||||
drop_k = set(); drop_ids = set(); setname = {}; retarget = {}
|
||||
for wn, name, fid, callees in res:
|
||||
if fid is None or not callees or len(callees) != 1: sys.exit('refuse: wrapper %s is not one call' % wn)
|
||||
a, b, _, ids = fs[fid]; drop_k.update(range(a, b + 1)); drop_ids |= ids | {fid}
|
||||
setname[callees[0]] = name; retarget[fid] = callees[0]
|
||||
drop_ids |= set(retarget)
|
||||
outi = []; named = set()
|
||||
for k, x in enumerate(ins):
|
||||
if k in drop_k: continue
|
||||
o = op(x)
|
||||
if o == 17 and x[1] not in (1, 5): continue
|
||||
if o == 10: continue
|
||||
if o == 5 and x[1] in setname: x = [0] + [x[1]] + s_enc(setname[x[1]]); x[0] = (len(x) << 16) | 5; named.add(x[1])
|
||||
elif o in (5, 6) and x[1] in drop_ids: continue
|
||||
if o == 71 and x[1] in drop_ids:
|
||||
if mode == 'export' and x[1] in retarget and x[2] == 41: x = [x[0], retarget[x[1]]] + x[2:]
|
||||
else: continue
|
||||
outi.append(x)
|
||||
for fid, name in setname.items():
|
||||
if fid not in named: outi.insert(next(i for i, x in enumerate(outi) if op(x) == 5), [((2 + len(s_enc(name))) << 16) | 5, fid] + s_enc(name))
|
||||
w = h + [v for x in outi for v in x]; open(dst, 'wb').write(struct.pack('<%dI' % len(w), *w))
|
||||
print('edit %s: dropped %d wrapper functions, renamed %d callees, kept %d instructions' % (mode, len(res), len(setname), len(outi)))
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Imagination probe: build-only export wrappers -> dxc lib_6_3 -spirv -> SPIR-V edit -> SPIRV-Cross ES. /src = CultLib.
|
||||
set -u; P=/tmp/p; W=/tmp/w; mkdir -p $W/tools; cd $W
|
||||
step() { echo; echo "=== $*"; }
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq >/dev/null && apt-get install -y -qq --no-install-recommends ca-certificates curl git cmake g++ make python3 \
|
||||
mesa-vulkan-drivers libvulkan-dev glslang-tools spirv-tools >/dev/null 2>&1; echo "apt exit $?"
|
||||
curl -sL --fail -o dxc.tgz https://github.com/microsoft/DirectXShaderCompiler/releases/download/v1.9.2607/linux_dxc_2026_07_29.x86_x64.tar.gz
|
||||
echo "dxc sha ok: $(sha256sum dxc.tgz | cut -c1-64 | grep -c 55665c87824051ed4774ff3280a79ccbbb7d39243b9736ca5e98222134112d54)"
|
||||
mkdir -p tools/dxc && tar xzf dxc.tgz -C tools/dxc; DXC=$(find tools/dxc -name dxc -type f | head -1)
|
||||
git clone -q --depth 1 --branch vulkan-sdk-1.4.363.0 https://github.com/KhronosGroup/SPIRV-Cross.git tools/sc
|
||||
cmake -S tools/sc -B tools/scb -DCMAKE_BUILD_TYPE=Release -DSPIRV_CROSS_ENABLE_TESTS=OFF -DSPIRV_CROSS_CLI=ON >/dev/null && cmake --build tools/scb --target spirv-cross -j4 >/dev/null 2>&1; SC=tools/scb/spirv-cross; ls $SC >/dev/null && echo "spirv-cross built"
|
||||
SH=/src/packages/cultmath/shaders
|
||||
step "1 wrappers"
|
||||
python3 $P/wrapgen.py $SH shw; head -3 shw/cm_wrappers.hlsl | cut -c1-160; grep -c '' shw/wrappers.tsv
|
||||
echo "source defs per overloaded name: $(cut -f2 shw/wrappers.tsv | sort | uniq -c | awk '$1>1{n++; d+=$1} END{print n" names, "d" defs"}')"
|
||||
step "2 dxc lib_6_3 on source plus wrappers"
|
||||
for v in default Od; do f=""; [ $v = Od ] && f="-Od"
|
||||
$DXC -T lib_6_3 -HV 2021 -spirv -fspv-target-env=universal1.5 $f -I shw shw/cm_wrappers.hlsl -Fo w.$v.spv 2>&1 | head -3; echo "[$v] dxc exit ${PIPESTATUS[0]}"
|
||||
[ -s w.$v.spv ] && python3 $P/spvedit.py stats w.$v.spv shw/wrappers.tsv; done
|
||||
V=default; python3 $P/spvedit.py stats w.default.spv shw/wrappers.tsv | grep -q 'inlined (no call) 0,' || V=Od; echo "using variant $V"
|
||||
step "3 SPIR-V edit, validate, SPIRV-Cross ES 310"
|
||||
for m in drop export; do python3 $P/spvedit.py edit w.$V.spv e.$m.spv shw/wrappers.tsv $m
|
||||
spirv-val --target-env spv1.5 e.$m.spv 2>&1 | head -2; echo "[$m] val exit ${PIPESTATUS[0]}"
|
||||
$SC e.$m.spv --es --version 310 --output e.$m.glsl 2>&1 | head -2; echo "[$m] spirv-cross exit ${PIPESTATUS[0]}, lines $(wc -l < e.$m.glsl 2>/dev/null)"
|
||||
spirv-dis e.$m.spv | grep -E 'OpCapability|OpExtension' | tr -s ' ' | sort | uniq -c | tr '\n' ' '; echo; done
|
||||
cmp -s e.drop.glsl e.export.glsl && echo "drop and export outputs identical" || echo "drop and export outputs DIFFER"
|
||||
for m in drop export; do G=e.$m.glsl; [ -s $G ] || continue; python3 - $G shw/wrappers.tsv <<'PY'
|
||||
import re,sys,collections
|
||||
g=open(sys.argv[1]).read(); rows=[l.split('\t') for l in open(sys.argv[2]).read().splitlines() if l]
|
||||
src=collections.Counter(r[1] for r in rows)
|
||||
defs=re.findall(r'(?m)^(?:\w+ )*?(\w+) (\w+)\(([^)]*)\)\n\{',g); out=collections.Counter(d[1] for d in defs)
|
||||
print(sys.argv[1],'defs',len(defs),'cultmath_ defs',sum(v for k,v in out.items() if k.startswith('cultmath_')),'source defs',sum(src.values()))
|
||||
print(' names whose def count differs from source:',[(k,src[k],out.get(k,0)) for k in src if src[k]!=out.get(k,0)][:8])
|
||||
print(' non-source names emitted:',[k for k in out if k not in src][:10],' cmw in text:',g.count('cmw'),' suffixed _N cultmath names:',len(re.findall(r'\bcultmath_\w+_\d+\(',g)))
|
||||
print(' prologue:',' | '.join(l for l in g.split('\n')[:6] if l.startswith('#'))[:200], ' main:', g.count('void main'))
|
||||
PY
|
||||
done
|
||||
G=e.export.glsl; echo "--- cultmath_lerp and cultmath_saturate as emitted:"; grep -E '^\w+ cultmath_(lerp|saturate)\(' $G
|
||||
step "3b glslang ES 3.00 fragment, call sites resolve per overload"
|
||||
sed -E '/^#version/d; /^#ifdef SPIRV_CROSS/d; /^#endif/d; /^layout\(local_size/d' $G | awk 'BEGIN{s=0} /^void main\(\)/{s=1} s==0{print} s==1&&/^\}/{s=0}' > lib.body.glsl
|
||||
{ printf '#version 300 es\nprecision highp float;\nprecision highp int;\n'; cat lib.body.glsl; printf 'out highp vec4 c;\nvoid main() { vec3 p = gl_FragCoord.xyz; float a = cultmath_lerp(1.0, 2.0, p.x); vec3 b = cultmath_lerp(p, p.yzx, vec3(0.5)); vec4 s = cultmath_saturate(vec4(p, a)); c = vec4(b, cultmath_saturate(a)) + s + vec4(cultmath_snoise(p)); }\n'; } > es300.frag
|
||||
glslangValidator es300.frag > es300.txt 2>&1; echo "glslang ES 3.00 fragment: exit $? $(grep -m3 ERROR es300.txt | tr '\n' ' ' | cut -c1-300)"
|
||||
sed -i 's/cultmath_saturate(a))/cultmath_saturate(ivec2(1)))/' es300.frag; glslangValidator es300.frag > neg.txt 2>&1; echo "negative (no ivec2 overload) refused: exit $? $(grep -m1 ERROR neg.txt | cut -c1-160)"
|
||||
step "4 split, constants, capability strip"
|
||||
python3 $P/split.py $SH $G $W/split 2>&1 | head -6
|
||||
for combo in "core" "core cycles" "core phacelle" "core cycles phacelle"; do f=$W/combo.frag; { printf '#version 300 es\nprecision highp float;\nprecision highp int;\n'; for c in $combo; do cat $W/split/$c.glsl; done; printf 'out highp vec4 c;\nvoid main() { c = vec4(cultmath_snoise(gl_FragCoord.xyz)) + vec4(cultmath_lerp(gl_FragCoord.xyz, vec3(1.0), vec3(0.5)), 1.0); }\n'; } > $f; glslangValidator $f > $W/c.txt 2>&1; echo "glslang [$combo]: exit $? $(grep -m2 ERROR $W/c.txt | tr '\n' ' ' | cut -c1-200)"; done
|
||||
echo "static const lines: $(grep -hcE '^static const' $SH/*.hlsl | awk '{s+=$1}END{print s}'); of form 'static const float CULTMATH_X = lit;': $(grep -hcE '^static const float CULTMATH_\w+ = [-0-9.eEfx]+;' $SH/*.hlsl | awk '{s+=$1}END{print s}'); CULTMATH_ in output $(grep -c 'CULTMATH_' $G); in committed glsl $(cat $SH/*.glsl | grep -cE '^(const )?\w* ?float CULTMATH_')"
|
||||
DXC_PATH=$DXC bash $P/lava.sh
|
||||
echo PROBE-DONE
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Probe: build-only export wrappers. One exported, uniquely named wrapper per top-level cultmath_ definition
|
||||
(texture-taking ones skipped). Source is copied unchanged. usage: wrapgen.py <shaders> <outdir>
|
||||
Writes <outdir>/cm_wrappers.hlsl and <outdir>/wrappers.tsv (wrapper, name, file, params)."""
|
||||
import os, re, sys, shutil
|
||||
sh, out = sys.argv[1:3]; shutil.copytree(sh, out, dirs_exist_ok=True)
|
||||
SIG = re.compile(r'(?m)^(\w+)\s+(cultmath_\w+)\s*\(([^)]*)\)')
|
||||
files = ['CultMath.hlsl', 'CultMath.Interval.hlsl', 'CultMath.Affine.hlsl', 'CultMath.Cycles.hlsl', 'CultMath.Phacelle.hlsl']
|
||||
w = ['#include "CultMath.hlsl"']; rows = []; k = 0
|
||||
for fn in files:
|
||||
for m in SIG.finditer(open(os.path.join(sh, fn)).read()):
|
||||
ret, name, ps = m.group(1), m.group(2), m.group(3)
|
||||
if re.search(r'Texture|Sampler', ps): continue
|
||||
params = [p.strip() for p in ps.split(',') if p.strip()]
|
||||
args = [re.split(r'\s+', p)[-1] for p in params]
|
||||
wn = 'cmw%03d_%s' % (k, name); k += 1
|
||||
call = '%s(%s)' % (name, ', '.join(args))
|
||||
w.append('export %s %s(%s) { %s%s; }' % (ret, wn, ', '.join(params), '' if ret == 'void' else 'return ', call))
|
||||
rows.append('\t'.join([wn, name, fn, ', '.join(params)]))
|
||||
open(os.path.join(out, 'cm_wrappers.hlsl'), 'w').write('\n'.join(w) + '\n')
|
||||
open(os.path.join(out, 'wrappers.tsv'), 'w').write('\n'.join(rows) + '\n')
|
||||
print('wrappers', k)
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Probe job, runs in ubuntu:24.04 with /src = CultLib at the probed revision. Q1 library path, Q2 lavapipe runs.
|
||||
set -u
|
||||
P=/tmp/p; W=/tmp/w; mkdir -p $W/fam $W/tools; cd $W
|
||||
step() { echo; echo "=== $*"; }
|
||||
step apt
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq >/dev/null && apt-get install -y -qq --no-install-recommends ca-certificates curl git cmake g++ make python3 \
|
||||
mesa-vulkan-drivers libvulkan-dev vulkan-tools glslang-tools spirv-tools >/dev/null 2>&1; echo "apt exit $?"
|
||||
dpkg-query -W -f='${Package} ${Version}\n' mesa-vulkan-drivers glslang-tools spirv-tools libvulkan-dev
|
||||
step dxc
|
||||
url=$(curl -s https://api.github.com/repos/microsoft/DirectXShaderCompiler/releases/tags/v1.9.2607 | python3 -c "import json,sys;print([a['browser_download_url'] for a in json.load(sys.stdin)['assets'] if 'linux' in a['name'] and a['name'].endswith('.tar.gz')][0])")
|
||||
echo "$url"; curl -sL --fail -o dxc.tgz "$url" && mkdir -p tools/dxc && tar xzf dxc.tgz -C tools/dxc; DXC=$(find tools/dxc -name dxc -type f | head -1); echo "DXC=$DXC"; $DXC --version 2>&1 | head -2
|
||||
step spirv-cross
|
||||
git clone -q --depth 1 https://github.com/KhronosGroup/SPIRV-Cross.git tools/sc && git -C tools/sc log -1 --format='SPIRV-Cross %H %cs' && \
|
||||
cmake -S tools/sc -B tools/scb -DCMAKE_BUILD_TYPE=Release -DSPIRV_CROSS_ENABLE_TESTS=OFF >/dev/null && cmake --build tools/scb --target spirv-cross -j4 >/dev/null 2>&1; SC=tools/scb/spirv-cross; ls -la $SC | cut -c1-120
|
||||
SH=/src/packages/cultmath/shaders
|
||||
step "Q1a dxc library SPIR-V, no wrapper (lib_6_6, vulkan1.2)"
|
||||
$DXC -T lib_6_6 -HV 2021 -spirv -fspv-target-env=vulkan1.2 -I $SH $SH/CultMath.hlsl -Fo lib.spv 2>&1 | head -8; echo "dxc exit ${PIPESTATUS[0]}"
|
||||
[ -f lib.spv ] && { spirv-dis lib.spv -o lib.spvasm; echo "functions: $(grep -c ' = OpFunction ' lib.spvasm) exports: $(grep -c 'LinkageAttributes.*Export' lib.spvasm) entrypoints: $(grep -c OpEntryPoint lib.spvasm)"; }
|
||||
step "Q1b dxc library with 'export' added to every top-level cultmath_ definition"
|
||||
python3 - "$SH" <<'PY'
|
||||
import re,sys,os,shutil
|
||||
src=sys.argv[1]; dst='/tmp/w/shx'; shutil.copytree(src,dst,dirs_exist_ok=True)
|
||||
n=0
|
||||
for fn in os.listdir(dst):
|
||||
if not fn.endswith('.hlsl'): continue
|
||||
t=open(os.path.join(dst,fn)).read()
|
||||
t2,k=re.subn(r'(?m)^(?!static|export)(\w+(?:\dx\d)?\s+cultmath_\w+\s*\()', r'export \1', t); n+=k
|
||||
open(os.path.join(dst,fn),'w').write(t2)
|
||||
print('export keywords added', n)
|
||||
PY
|
||||
for v in "universal1.5" "vulkan1.2 -Vd" "vulkan1.3 -Vd"; do echo "--- variant: -fspv-target-env=$v"; rm -f libx.spv
|
||||
$DXC -T lib_6_6 -HV 2021 -spirv -fspv-target-env=$v -I shx shx/CultMath.hlsl -Fo libx.spv 2>&1 | head -4; echo "dxc exit ${PIPESTATUS[0]}"
|
||||
[ -s libx.spv ] && { spirv-dis libx.spv -o libx.spvasm; echo "functions: $(grep -c ' = OpFunction ' libx.spvasm) exports: $(grep -c 'LinkageAttributes.*Export' libx.spvasm) OpName cultmath_: $(grep -c 'OpName.*"cultmath_' libx.spvasm) Fma: $(grep -c ' Fma ' libx.spvasm) NoContraction: $(grep -c NoContraction libx.spvasm)"; spirv-val --target-env vulkan1.2 libx.spv 2>&1 | head -2; break; }
|
||||
done
|
||||
LIB=libx.spv
|
||||
step "Q1c SPIRV-Cross ES output of $LIB"
|
||||
for v in 300 310; do $SC $LIB --es --version $v --output lib$v.glsl 2>&1 | head -3; echo "spirv-cross --es --version $v exit ${PIPESTATUS[0]}"; done
|
||||
for v in 300 310; do [ -s lib$v.glsl ] || continue
|
||||
echo "lib$v.glsl: $(wc -l < lib$v.glsl) lines; defs named cultmath_: $(grep -cE '^\w+ cultmath_\w+\(' lib$v.glsl); distinct names: $(grep -oE '^\w+ cultmath_\w+\(' lib$v.glsl | sed -E 's/^\w+ //' | sort -u | wc -l); suffixed _N names: $(grep -oE '^\w+ cultmath_\w+_[0-9]+\(' lib$v.glsl | wc -l); _NNN temporaries: $(grep -oE '\b_[0-9]+\b' lib$v.glsl | wc -l); precision lines: $(grep -c precision lib$v.glsl); main: $(grep -c 'void main' lib$v.glsl)"; done
|
||||
echo "committed CultMath.glsl: $(wc -l < $SH/CultMath.glsl) lines; defs $(grep -cE '^\w+ cultmath_\w+\(' $SH/CultMath.glsl); distinct names $(grep -oE '^\w+ cultmath_\w+\(' $SH/CultMath.glsl | sed -E 's/^\w+ //' | sort -u | wc -l)"
|
||||
F=$(ls lib310.glsl lib300.glsl 2>/dev/null | head -1); [ -n "$F" ] && { echo "--- head of $F"; head -12 $F; echo "--- cultmath_snoise(vec3) as emitted (first 25 lines)"; awk '/^float cultmath_snoise\(/{p=1} p{print; if(++n>=25) exit}' $F; }
|
||||
for v in 300 310; do [ -s lib$v.glsl ] && { glslangValidator -S comp lib$v.glsl >gv$v.txt 2>&1; echo "glslang -S comp lib$v.glsl exit $? : $(head -3 gv$v.txt | tr '\n' ' ' | cut -c1-300)"; }; done
|
||||
step "Q2 lavapipe runner"
|
||||
gcc -O2 -o vkrun $P/vkrun.c -lvulkan && echo "vkrun built"
|
||||
python3 $P/fam.py gen /src/packages/cultmath/tests/CultMath.Tests/fixtures/glsl-parity.json fam
|
||||
cat $SH/CultMath.glsl $SH/CultMath.Cycles.glsl $SH/CultMath.Phacelle.glsl > all.glsl
|
||||
python3 - <<'PY'
|
||||
import json; print('families', len(json.load(open('/tmp/w/fam/manifest.json'))))
|
||||
PY
|
||||
ok_h=0; ok_g=0; fail=0; first=1
|
||||
for h in fam/*.hlsl; do t=${h%.hlsl}; tag=$(basename $t)
|
||||
n=$(python3 -c "import json;m={x['tag']:x for x in json.load(open('fam/manifest.json'))}['$tag'];print(m['n'],m['nout'])")
|
||||
if $DXC -T cs_6_0 -E main -HV 2021 -spirv -fspv-target-env=vulkan1.2 -I $SH $h -Fo $t.h.spv >$t.h.log 2>&1; then
|
||||
if [ $first = 1 ]; then VKRUN_INFO=1 ./vkrun $t.h.spv $t.in $t.hlsl.out $n; first=0; else ./vkrun $t.h.spv $t.in $t.hlsl.out $n; fi && ok_h=$((ok_h+1)) || echo "run fail hlsl $tag"
|
||||
else fail=$((fail+1)); echo "dxc fail $tag: $(head -2 $t.h.log | tr '\n' ' ' | cut -c1-200)"; fi
|
||||
{ printf '#version 310 es\nprecision highp float;\nprecision highp int;\n'; cat all.glsl $t.body.glsl; } > $t.comp
|
||||
if glslangValidator -V --target-env vulkan1.2 -o $t.g.spv $t.comp >$t.g.log 2>&1; then ./vkrun $t.g.spv $t.in $t.glsl.out $n && ok_g=$((ok_g+1)) || echo "run fail glsl $tag"
|
||||
else echo "glslang fail $tag: $(grep -m2 ERROR $t.g.log | tr '\n' ' ' | cut -c1-200)"; fi
|
||||
done
|
||||
echo "ran: hlsl->dxc->lavapipe $ok_h, committed glsl->glslang->lavapipe $ok_g, dxc compile failures $fail"
|
||||
echo "dxc-compute Fma total: $(for s in fam/*.h.spv; do spirv-dis $s | grep -c ' Fma '; done | python3 -c "import sys;print(sum(int(l) for l in sys.stdin))")"
|
||||
python3 $P/fam.py cmp /src/packages/cultmath/tests/CultMath.Tests/fixtures/glsl-parity.json fam hlsl
|
||||
python3 $P/fam.py cmp /src/packages/cultmath/tests/CultMath.Tests/fixtures/glsl-parity.json fam glsl
|
||||
python3 - <<'PY2'
|
||||
import os,filecmp
|
||||
fs=sorted(f[:-9] for f in os.listdir('/tmp/w/fam') if f.endswith('.hlsl.out'))
|
||||
same=sum(filecmp.cmp('/tmp/w/fam/%s.hlsl.out'%f,'/tmp/w/fam/%s.glsl.out'%f,shallow=False) for f in fs if os.path.exists('/tmp/w/fam/%s.glsl.out'%f))
|
||||
print('lavapipe: HLSL-path and GLSL-path outputs bit-identical in',same,'of',len(fs),'families')
|
||||
PY2
|
||||
t0=$(date +%s); for h in fam/*.h.spv; do t=${h%.h.spv}; n=$(python3 -c "import json;m={x['tag']:x for x in json.load(open('fam/manifest.json'))}['$(basename $t)'];print(m['n'],m['nout'])"); ./vkrun $h $t.in $t.rerun.out $n; cmp -s $t.rerun.out $t.hlsl.out || echo "NONDETERMINISTIC $(basename $t)"; done; echo "rerun of 105 SPIR-V modules on lavapipe: $(( $(date +%s) - t0 ))s, determinism check done"
|
||||
step "Q2b SPIRV-Cross ES 3.10 library through glslang on lavapipe (only if Q1 produced one)"
|
||||
if [ -s lib310.glsl ]; then sed -E '/^#version/d; /^void main\(\)/,/^\}/d' lib310.glsl > lib.body.glsl; okl=0
|
||||
for b in fam/*.body.glsl; do t=${b%.body.glsl}; tag=$(basename $t); n=$(python3 -c "import json;m={x['tag']:x for x in json.load(open('fam/manifest.json'))}['$tag'];print(m['n'],m['nout'])")
|
||||
{ printf '#version 310 es\nprecision highp float;\nprecision highp int;\n'; cat lib.body.glsl $b; } > $t.l.comp
|
||||
glslangValidator -V --target-env vulkan1.2 -o $t.l.spv $t.l.comp >$t.l.log 2>&1 && ./vkrun $t.l.spv $t.in $t.lib.out $n && okl=$((okl+1))
|
||||
done; echo "lib path ran $okl"; grep -h -m1 ERROR fam/*.l.log | sort | uniq -c | sort -rn | head -5
|
||||
python3 $P/fam.py cmp /src/packages/cultmath/tests/CultMath.Tests/fixtures/glsl-parity.json fam lib | head -15
|
||||
fi
|
||||
echo PROBE-DONE
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue