Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c43eb54962 | ||
|
|
2b9143d303 | ||
|
|
f21b5ff1aa | ||
|
|
fec39c2010 | ||
|
|
0e9def0ebc | ||
|
|
ef11427e24 | ||
|
|
f40bbaac13 | ||
|
|
ee62fec98a | ||
|
|
eaefed1e7d | ||
|
|
56496c7108 | ||
|
|
aa56736841 | ||
|
|
f503c85484 | ||
|
|
e0344ff5af | ||
|
|
e2924bbc50 | ||
|
|
73f26a5a32 | ||
|
|
1d75f14578 | ||
|
|
ee9e18a745 | ||
|
|
771a79dde2 | ||
|
|
fca3ec1a3a | ||
|
|
e2c3511cbd | ||
|
|
824a4dea15 | ||
|
|
4741d50898 | ||
|
|
f90d408466 | ||
|
|
9ccc9fd4f2 | ||
|
|
dac9686576 | ||
|
|
d1111f4c4f | ||
|
|
9308c7d043 | ||
|
|
9747957649 | ||
|
|
fe06efcf26 | ||
|
|
b3699908f7 | ||
|
|
6d84c2b57b | ||
|
|
03b04011b7 | ||
|
|
311c4bfaed | ||
|
|
8c6a01f2ad | ||
|
|
c3f0e3789c | ||
|
|
ef7299b673 | ||
|
|
a501009210 | ||
|
|
b5b964c234 | ||
|
|
2dabb85009 | ||
|
|
706d0c3792 | ||
|
|
5f2e629c32 | ||
|
|
d1883e9973 | ||
|
|
92275cbe7c | ||
|
|
2a888da955 | ||
|
|
5cf005eb2c | ||
|
|
b5dc76dd75 | ||
|
|
61042670d1 | ||
|
|
cb1be44f92 | ||
|
|
9bf7932573 | ||
|
|
db12c26b31 | ||
|
|
2b495e1f33 | ||
|
|
adb8cbff2b | ||
|
|
158e8f9f3d | ||
|
|
278033b90e | ||
|
|
be7d9eec75 | ||
|
|
5277973afb | ||
|
|
06a8373fbd | ||
|
|
06e30dc921 | ||
|
|
097bf02f1e | ||
|
|
fc3f9fd668 | ||
|
|
cef6436971 | ||
|
|
2daa80e159 | ||
|
|
4eff650755 | ||
|
|
e854cf3671 | ||
|
|
8f6b552e7a | ||
|
|
9afbdc879e | ||
|
|
42e71d89d5 | ||
|
|
677eb1b3a8 | ||
|
|
3eb59068c7 | ||
|
|
b5edc05b1f | ||
|
|
84b8e3d6d5 | ||
|
|
c3e16a57c2 | ||
|
|
f167683e8d | ||
|
|
308169ac51 | ||
|
|
4126c83eff | ||
|
|
ecff1ac25b | ||
|
|
e651d9e863 | ||
|
|
2c5eb95da3 | ||
|
|
ee174c68ce | ||
|
|
1afd942be3 | ||
|
|
c750e21c2d | ||
|
|
e085286126 | ||
|
|
525666f79b | ||
|
|
b8465e50d3 | ||
|
|
8c9b88b519 | ||
|
|
327cbe3c5e | ||
|
|
bd8dc756d9 | ||
|
|
fbd8bdb8ca | ||
|
|
e5ae6fe7af | ||
|
|
c11e8a16ba | ||
|
|
2e9905f42f | ||
|
|
f74dec6675 | ||
|
|
e28bf3fd55 | ||
|
|
b2447e4f99 | ||
|
|
8d263bb60e | ||
|
|
b0f57a009b | ||
|
|
74f3b7011f | ||
|
|
b3d5843e71 | ||
|
|
aef08ce3a6 | ||
|
|
f178684391 | ||
|
|
831f10458e | ||
|
|
0d28508180 | ||
|
|
dd80e29484 | ||
|
|
7262e72a0d | ||
|
|
8912dde49f | ||
|
|
90c5e3b337 | ||
|
|
1645f4a972 | ||
|
|
f89be06a33 | ||
|
|
efa7d5cff2 | ||
|
|
367e2eb50f | ||
|
|
9f2904ffb8 | ||
|
|
ea8538761b | ||
|
|
4f92840fe7 | ||
|
|
8ca2c5556c | ||
|
|
82a0c1b4f0 | ||
|
|
5b6796d1e6 | ||
|
|
0c0e1c73db | ||
|
|
c47a0785c9 | ||
|
|
d79a96ede4 | ||
|
|
82a500eedb | ||
|
|
b64b61c345 | ||
|
|
189d1d3c2b | ||
|
|
97e39f48fd | ||
|
|
3ee4ab8e88 | ||
|
|
29efabe4d8 | ||
|
|
5a876bf615 | ||
|
|
6db5d0873e | ||
|
|
1e6da32ad3 | ||
|
|
f0adcc61c5 | ||
|
|
e1c2632f03 | ||
|
|
aa18e28925 | ||
|
|
26f533e8f4 | ||
|
|
5cb2ecbb31 | ||
|
|
64071de685 | ||
|
|
d3f8975d3d | ||
|
|
424a20e64b | ||
|
|
9de9e53a5f | ||
|
|
57b16df286 | ||
|
|
72a98d8468 | ||
|
|
2b6e8c7826 | ||
|
|
447471825b | ||
|
|
86977e51f7 | ||
|
|
1f85d1c4d7 | ||
|
|
ea91b33946 | ||
|
|
a7ed853fcd | ||
|
|
90ec569d53 | ||
|
|
62dcc6c5b3 | ||
|
|
058c070a42 | ||
|
|
26623d6d2f | ||
|
|
ece28506e6 | ||
|
|
1b31bfc399 | ||
|
|
af7e544830 | ||
|
|
c4bc865573 |
543 changed files with 14464 additions and 105196 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -1,11 +1,5 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
node_modules/
|
||||
web/dist/
|
||||
sidecar/claude-sdk/dist/
|
||||
state/scratch-compaction-*.md
|
||||
experiments/images/
|
||||
target/
|
||||
|
||||
# Disposable local scaffolding. These accumulated to ~87MB of untracked debris
|
||||
|
|
|
|||
3
.gitmodules
vendored
3
.gitmodules
vendored
|
|
@ -1,3 +0,0 @@
|
|||
[submodule "vendor/eve"]
|
||||
path = vendor/eve
|
||||
url = https://github.com/GameCult/Eve.git
|
||||
305
AGENTS.md
305
AGENTS.md
|
|
@ -2,233 +2,106 @@
|
|||
|
||||
## Project Purpose
|
||||
|
||||
Ghostlight is a research and implementation home for socially persistent
|
||||
generative agents. The core problem is not "make an LLM sound like a person."
|
||||
The core problem is building an architecture where personality, culture,
|
||||
memory, masks, perception, and incentives can actually survive contact with
|
||||
time, relationships, routine, tonal variation, and pressure.
|
||||
Ghostlight is a Rust world kernel and the play daemon built on it. Models
|
||||
propose; the kernel admits or refuses. The same kernel is meant to run the
|
||||
simulation behind Aetheria's public story, a persistent cast and storylets, as
|
||||
well as Ghostlight Dungeon, the single-player DM-led client (parked at
|
||||
tag `parked/ghostlight-eve-surface`). `README.md` says
|
||||
what exists today and what does not.
|
||||
|
||||
This repo exists so those organs can live somewhere cleaner than a side chamber
|
||||
inside another project.
|
||||
## Where State Lives
|
||||
|
||||
## Canonical State
|
||||
- Campaign state (targets, rulings, cut specs, reports, verdicts, findings,
|
||||
follow-ups) lives in the Eureka mind, not in this repo. The current campaign
|
||||
is `ghostlight-verse`; query it through the `eureka-state` tools.
|
||||
- `notes/ghostlight-verse-target.md` and `notes/ghostlight-verse-map.md` are the
|
||||
campaign's prose target and map. Read the map for probed body facts.
|
||||
- This repo has no state directory, no state CLI, and no evidence ledger. Do not
|
||||
create one. History belongs in git; durable decisions belong in the mind.
|
||||
- `notes/fresh-workspace-handoff.md` is a pointer: what runs, how it deploys,
|
||||
where open work is queried.
|
||||
- Dated cut documents and postmortems (`docs/architecture/*-cut.md`,
|
||||
`*-postmortem.md`) are history. Do not edit them, including their old paths.
|
||||
|
||||
- Treat `state/ghostlight-state.cultcache.jsonl` as the CultCache-backed
|
||||
persistence spine for machine-managed state records that have migrated.
|
||||
- Treat `state/map.yaml` as the canonical human-readable project map.
|
||||
- Treat `state/scratch.md` as disposable working memory for one bounded
|
||||
subgoal.
|
||||
- Treat `state/evidence.jsonl` and `state/branches.json` as readable export
|
||||
surfaces during the persistence migration, not as the long-term direct-write
|
||||
API. A direct write to either is silently reverted: every `add-evidence`
|
||||
regenerates `evidence.jsonl` from the CultCache evidence document, so edits
|
||||
and deletions that do not go through `tools/ghostlight_state_store.py` look
|
||||
applied until the next append restores what you removed. Compaction means
|
||||
replacing the store's evidence document through
|
||||
`ghostlight_state.py compact-evidence --replacement <distilled.jsonl>`,
|
||||
which archives every current record verbatim to
|
||||
`state/evidence.archive.jsonl` first; the export follows.
|
||||
- Treat `state/evidence.jsonl` as the distilled durable ledger of what was
|
||||
learned, verified, rejected, or accepted.
|
||||
- Treat `notes/ghostlight-implementation-plan.md` as the current implementation
|
||||
plan for the larger organs.
|
||||
- Update `state/map.yaml` when project understanding changes.
|
||||
- Add evidence after meaningful research, implementation, verification, or
|
||||
rejected paths, but keep it distilled. Routine "I just did this" proof
|
||||
belongs in git history, commits, smoke artifacts, or targeted logs unless it
|
||||
changes what the next session should believe.
|
||||
- Do not turn notes, map, handoff, and evidence into four versions of the same
|
||||
brain. Distinct jobs or bust.
|
||||
## Invariants
|
||||
|
||||
## Important Paths
|
||||
- **The kernel decides.** The kernel owns world state through its closed
|
||||
operation set. Models, tools, controllers, and consumers produce proposals
|
||||
only. No code outside the kernel commits world state, and no compatibility
|
||||
writer exists.
|
||||
- **The library is sealed.** `crates/ghostlight` exposes create/open,
|
||||
immutable snapshots, command submission, typed receipts, and the runner and
|
||||
port entry points. Do not publish mutable world types, an ID issuer, a reducer
|
||||
entry, or a journal handle for the convenience of tests or adapters. The seal
|
||||
is proven by the `compile_fail` doc-tests in `crates/ghostlight/src/lib.rs`
|
||||
and by `crates/ghostlight/tests/external_admission.rs`; keep both passing and
|
||||
extend them when the public surface changes.
|
||||
- **Every commit is a command.** Everything enters through the mailbox
|
||||
(`crates/ghostlight/src/mailbox.rs`) as a command. Do not append a commit when
|
||||
reduction produces no canonical mutation.
|
||||
- **A Persona never sees structured state.** Persona turns are prose; an
|
||||
Interpreter lowers them to typed proposals, and anything it cannot lower
|
||||
becomes a recorded gap, not invented state.
|
||||
- **Typed state.** Runtime documents are CultCache `.cc`. JSON is for schema
|
||||
publication and boundaries with other systems.
|
||||
- **Credentials.** Ghostlight never reads, copies, forwards, or logs a model
|
||||
credential. A consumer never restates a foreign owner's document shape as its
|
||||
own strict struct; read the owner's published contract and test against the
|
||||
real counterpart.
|
||||
- **Canon flows one way.** Simulation output never writes back into AetheriaLore
|
||||
or Aetheria's catalog without operator review.
|
||||
|
||||
- Project root: `F:\Projects\Ghostlight`
|
||||
- Canonical map: `F:\Projects\Ghostlight\state\map.yaml`
|
||||
- Scratch surface: `F:\Projects\Ghostlight\state\scratch.md`
|
||||
- Distilled evidence ledger: `F:\Projects\Ghostlight\state\evidence.jsonl`
|
||||
- Branch ledger: `F:\Projects\Ghostlight\state\branches.json`
|
||||
- Handoff summary: `F:\Projects\Ghostlight\notes\fresh-workspace-handoff.md`
|
||||
- System map: `F:\Projects\Ghostlight\notes\ghostlight-current-system-map.md`
|
||||
- Implementation plan: `F:\Projects\Ghostlight\notes\ghostlight-implementation-plan.md`
|
||||
- Architecture rationale: `F:\Projects\Ghostlight\notes\architecture-rationale.md`
|
||||
- State CLI: `F:\Projects\Ghostlight\tools\ghostlight_state.py`
|
||||
- CultCache state seam: `F:\Projects\Ghostlight\tools\ghostlight_state_store.py`
|
||||
- CultCache-Py dependency: pinned `cultcache-py==0.2.0` from PyPI, declared in
|
||||
`F:\Projects\Ghostlight\tools\requirements.txt`
|
||||
- CultCache state store: `F:\Projects\Ghostlight\state\ghostlight-state.cultcache.jsonl`
|
||||
- Pre-compaction helper: `F:\Projects\Ghostlight\tools\ghostlight_prepare_compaction.py`
|
||||
## Where Things Are
|
||||
|
||||
## Useful Commands
|
||||
- Kernel: `crates/ghostlight/src/`. Its vocabulary and current mechanism:
|
||||
`docs/architecture/ghostlight-world-ontology.md`. The consumer contract:
|
||||
`docs/architecture/ghostlight-world-consumer-api.md`.
|
||||
- Parked Dungeon (daemon, play table, Eve surface, browser host, Idunn recipe):
|
||||
tag `parked/ghostlight-eve-surface`; not on main.
|
||||
- Persona membrane: `crates/ghostlight-persona-projection/src/lib.rs`.
|
||||
- Claude SDK sidecar: `sidecar/claude-sdk/`.
|
||||
- Architecture rationale (the parked Dungeon's): `docs/architecture/ghostlight-dungeon-mvp.md`.
|
||||
`notes/ghostlight-implementation-plan.md` is the historical plan of the 2026-09
|
||||
rebuild, not the current plan.
|
||||
|
||||
Use the bundled Python runtime if `python` is not on PATH. `npm run state:status`
|
||||
and `npm run state:prepare-compaction` install the pinned `cultcache-py`
|
||||
dependency automatically (a `pre*` npm hook runs `state:bootstrap` first, an
|
||||
idempotent `pip install -r tools/requirements.txt`). A direct
|
||||
`ghostlight_state.py` invocation does not go through npm, so run
|
||||
`state:bootstrap` once per session before the first direct call:
|
||||
## Build, Verify, Deploy
|
||||
|
||||
```powershell
|
||||
npm run state:bootstrap
|
||||
npm run state:status
|
||||
npm run state:prepare-compaction
|
||||
& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\ghostlight_state.py' add-evidence --type research --status ok --note '...'
|
||||
& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\ghostlight_state.py' add-branch --id branch-id --hypothesis '...'
|
||||
& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\ghostlight_state.py' compact-evidence --replacement '.\distilled.jsonl'
|
||||
```
|
||||
Nothing deploys from this repo: the Idunn target `ghostlight` is braked and stopped (deployment and
|
||||
lifecycle brakes engaged, unit stopped; retirement waits on
|
||||
`ghostlight-verse:follow_up:idunn-v2-target-retirement`) and no recipe exists on main. Kernel and verse tests run on Linux on Yggdrasil (the
|
||||
`ygg-verify` stopgap), not on the workstation; a Windows compile is not evidence
|
||||
about Linux, so say which host you used. Do not run `idunn up ghostlight`.
|
||||
|
||||
## Build And Deploy Target
|
||||
|
||||
Ghostlight Dungeon ships to Linux on Yggdrasil. Yggdrasil is its build, test,
|
||||
and production body: Idunn freezes an exact commit, runs the locked native
|
||||
Linux tests, builds the daemon and web projection in pinned Linux containers,
|
||||
and seals the release. Owning runbook:
|
||||
`gamecult-ops/runbooks/ghostlight-dungeon-yggdrasil.md`.
|
||||
|
||||
You are almost certainly reading this on a Windows workstation. That is where
|
||||
the shell is, not where the artifact runs. A Windows compile of a commit is not
|
||||
evidence about the Linux release — toolchain, linker, filesystem semantics and
|
||||
path handling all differ — and it is not the build the deploy path will use.
|
||||
Build for the target, or let the deploy path's own builder do it, and say which
|
||||
one you used.
|
||||
|
||||
## Session Bootstrap And Re-entry Protocol
|
||||
|
||||
On fresh session load, do this before wandering off into implementation:
|
||||
|
||||
1. read:
|
||||
- `state/map.yaml`
|
||||
- `notes/fresh-workspace-handoff.md`
|
||||
- `notes/ghostlight-current-system-map.md`
|
||||
- `notes/ghostlight-implementation-plan.md`
|
||||
2. run:
|
||||
- `npm run state:bootstrap` (installs the pinned `cultcache-py` dependency;
|
||||
idempotent, safe to rerun)
|
||||
- `npm run state:status`
|
||||
3. restate the current next action from the persisted state before starting
|
||||
edits
|
||||
4. if the user only asked to rehydrate or reorient, stop after orientation and
|
||||
wait for an explicit continue instruction instead of treating the persisted
|
||||
next action as permission to start coding
|
||||
|
||||
After compaction, resume, or suspicious continuity loss:
|
||||
|
||||
1. rerun `npm run state:status`
|
||||
2. reread `state/map.yaml` and `notes/fresh-workspace-handoff.md`
|
||||
3. treat the persisted next action as authoritative unless fresh repo evidence
|
||||
contradicts it
|
||||
|
||||
When context pressure is clearly rising:
|
||||
|
||||
1. stop broad exploration
|
||||
2. narrow the active move to one bounded organ
|
||||
3. persist map or handoff updates, plus distilled evidence only when the lesson
|
||||
changes future belief, before the blackout lands
|
||||
|
||||
When the user says to prepare for imminent compaction:
|
||||
|
||||
1. immediately write hot live context from memory into a new
|
||||
`state/scratch-compaction-<guid>.md` file before reading files, running
|
||||
status checks, or doing tidy persistence work
|
||||
2. run `tools/ghostlight_prepare_compaction.py` before editing persistence
|
||||
surfaces
|
||||
3. use its warnings as the checklist for map, handoff, scratch, evidence, and
|
||||
git hygiene
|
||||
4. update only the state that actually needs to change
|
||||
5. after hot context has been folded into canonical persisted state, delete the
|
||||
`state/scratch-compaction-<guid>.md` file so stale emergency memory does not
|
||||
linger
|
||||
6. run `tools/ghostlight_prepare_compaction.py` again after edits and scratch
|
||||
cleanup
|
||||
7. fix errors, address warnings, and commit the completed persistence pass
|
||||
unless the work is deliberately mid-surgery
|
||||
For infrastructure, SSH, Idunn, Odin, or Heimdall work, consult
|
||||
`F:\Projects\gamecult-ops` before acting.
|
||||
|
||||
## Operating Discipline
|
||||
|
||||
- Steward persistent state as part of the work. If a lesson changes future
|
||||
behavior, update the smallest correct memory surface before moving on; if a
|
||||
memory surface is stale, duplicated, or making re-entry slower, cut or
|
||||
consolidate it.
|
||||
- Before substantial edits, restate the current mechanism and intended change.
|
||||
- Prefer one clear hypothesis per iteration.
|
||||
- Verify with checks that reflect the real goal, not just proxy success.
|
||||
- Revert or discard changes that do not clearly improve the target.
|
||||
- If the diff grows while understanding shrinks, stop implementation and switch
|
||||
to diagnosis.
|
||||
- Keep maps and prose together; do not replace useful maps with prose-only
|
||||
explanations.
|
||||
- Commit completed work before it rots in the worktree unless the task is
|
||||
deliberately mid-surgery or the user asked to leave changes uncommitted.
|
||||
- Push after major completed passes when a remote exists, unless the user asked
|
||||
not to or there is a concrete reason to keep the commit local for a moment.
|
||||
- Before handoff, compaction, or phase boundaries, sync `state/map.yaml`, add
|
||||
distilled evidence when the lesson changes future belief, refresh
|
||||
`notes/fresh-workspace-handoff.md`, and make the next action explicit.
|
||||
- Treat `state/map.yaml` as a working memory map, not an archive. Preserve
|
||||
current mission, live seams, boundaries, and next action; let git history,
|
||||
evidence, and artifact files carry chronology.
|
||||
- In documentation, describe the live system and current rule directly. Do not
|
||||
write victory laps, scar tours, clever autopsies, or contrastive little
|
||||
boasts about what the project no longer does. If a sentence congratulates the
|
||||
design for not being a previous wrong design, cut it or rewrite it as a plain
|
||||
current rule. If rejected history matters, keep it short, explicit, and
|
||||
decision-relevant; otherwise cut it. The docs are not a trophy case for
|
||||
yesterday's wrong machine.
|
||||
- Restate the current mechanism and intended change before substantial edits.
|
||||
Prefer one clear hypothesis per iteration.
|
||||
- Verify with checks that reflect the real goal, not proxy success. Revert
|
||||
changes that do not clearly improve the target.
|
||||
- If the diff grows while understanding shrinks, stop and diagnose.
|
||||
- Delete obsolete authority before adding a new path. Do not keep an old writer
|
||||
alive behind a shim.
|
||||
- Commit completed work and push it. Merge, confirm the tip by SHA, then delete
|
||||
the branch; push only after reading the test result line.
|
||||
- In documentation, describe the live system and the current rule directly. No
|
||||
claim of capability without a file that implements it. Keep rejected history
|
||||
short and decision-relevant, or leave it in git.
|
||||
|
||||
## Aetheria Grounding Doctrine
|
||||
## Aetheria Grounding
|
||||
|
||||
- Before writing or generating Aetheria scenes, check AetheriaLore for the
|
||||
factions, movements, institutions, species or body types, location, and time
|
||||
period involved.
|
||||
- Before drafting story prose, choose an intentional tonal mode or blend for the
|
||||
scene. Aetheria should support wit-with-stakes, warmth, absurdity, domestic
|
||||
texture, ritual quiet, wonder, horror, noir, dour poetry, and dry systems
|
||||
prose as needed. Do not default to constant crisis or procedural severity.
|
||||
- Use Adams/Pratchett-style comic precision and humane levity as a useful
|
||||
default touchstone when no stronger scene-specific style is called for:
|
||||
jokes and absurdity should reveal stakes, not dissolve them.
|
||||
- Establish ordinary life before interruption when the fixture calls for it:
|
||||
routines, work texture, relationships, private jokes, rituals, small desires,
|
||||
and local accommodations are part of worldbuilding, not filler.
|
||||
- For illustrated Ink fixtures, segment prose into click-through visual
|
||||
sections. Distinct places, focal areas, object closeups, arrivals, alarms,
|
||||
pivotal one-sentence beats, and aftermath states need stable visual scene ids
|
||||
and imagegen-ready prompts.
|
||||
- Treat visual artifacts as illustrated replay and website collateral, not core
|
||||
social-model training signal. Visual review judges the playable Ink
|
||||
experience plus the visual plan, not clean-run prose receipts.
|
||||
- Write visual prompts for a general image model, not a lore expert. Explain
|
||||
fictional objects and body affordances in visible terms: geometry, materials,
|
||||
lighting, stance, interfaces, tools, supports, cables, fluids, screens, and
|
||||
crowd positions.
|
||||
- Do the same for environments. A location label like "manifold line," "cavity
|
||||
yard," "supervisor glass," or "anchor rail" is not a drawable scene until the
|
||||
prompt describes its visible geometry, materials, interfaces, lights, and
|
||||
scale.
|
||||
- If a fixture has a target visual style, store that style cue once in the
|
||||
sidecar and include it in every assembled image prompt. Do not rely on memory
|
||||
or unstated website context to carry the look.
|
||||
- Do not treat a character name as a visual description. Illustrated fixtures
|
||||
need stable character visual refs for every recurring named character, and
|
||||
scene prompts should either include those refs during prompt assembly or carry
|
||||
enough appearance detail to stand alone.
|
||||
- Include visual character refs only for characters actually visible in the
|
||||
rendered frame. Do not feed imagegen refs for absent, off-frame, merely
|
||||
mentioned, or object-represented characters.
|
||||
- Treat visual continuity notes as tool/reviewer constraints, not prompt text.
|
||||
If imagegen needs the fact, rewrite it as concrete visible description in the
|
||||
base prompt, character ref, or branch/state modifier.
|
||||
- Treat generated environment images as concept and material reference, not
|
||||
spatial truth. Multi-angle or branch-consistent illustrated IF needs a durable
|
||||
scene-set source such as a hand-built blockout, procedural scene file, layout
|
||||
asset, or camera map. Imagegen can paint over camera-specific visual input;
|
||||
it should not be asked to preserve room topology from a prior generated
|
||||
image or text memory.
|
||||
- Ground scene writing in the source's material details: habitat form,
|
||||
infrastructure, law, class pressure, money, route access, surveillance,
|
||||
communication channels, body affordances, and local failure costs.
|
||||
- If the source is too vague for a concrete scene detail, do not bluff. Mark
|
||||
the lore gap, patch the AetheriaLore source with a narrow elaboration, then
|
||||
regenerate or revise the Ghostlight artifact from that patched source.
|
||||
- Treat this as part of data generation. Ghostlight is expected to stress-test
|
||||
Aetheria at room scale, find missing granularity, and push useful
|
||||
elaborations back into the lore vault.
|
||||
- Before writing or generating Aetheria material, check AetheriaLore for the
|
||||
factions, institutions, species or body types, location, and time period
|
||||
involved. Ground detail in the source's material facts: habitat, law, money,
|
||||
route access, surveillance, communication channels, and local failure costs.
|
||||
- If the source is too vague for a concrete detail, do not bluff. Mark the gap
|
||||
and propose a narrow elaboration for AetheriaLore, subject to operator review.
|
||||
- Choose a tonal mode on purpose. Aetheria supports wit with stakes, warmth,
|
||||
absurdity, domestic texture, ritual quiet, wonder, horror, noir, and dry
|
||||
systems prose; do not default to constant crisis. Comic precision in the
|
||||
Adams/Pratchett manner is a good default, where jokes reveal stakes instead of
|
||||
dissolving them.
|
||||
- Establish ordinary life before interruption: routines, work, relationships,
|
||||
small desires, and local accommodations are worldbuilding, not filler.
|
||||
|
|
|
|||
702
Cargo.lock
generated
702
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
13
Cargo.toml
13
Cargo.toml
|
|
@ -1,8 +1,8 @@
|
|||
[workspace]
|
||||
members = [
|
||||
"crates/ghostlight",
|
||||
"crates/ghostlight-dungeon",
|
||||
"crates/ghostlight-persona-projection",
|
||||
"crates/ghostlight-verse",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
|
@ -13,19 +13,10 @@ version = "0.1.0"
|
|||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1"
|
||||
aes-gcm = "0.10"
|
||||
async-trait = "0.1"
|
||||
async-stream = "0.3"
|
||||
axum = { version = "0.8", features = ["macros"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
base64 = "0.22"
|
||||
codex-connector = { git = "https://github.com/GameCult/CodexConnector.git", rev = "68fe94b9475823de92ed766a97ea962d48723708" }
|
||||
cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "85f7024a35ea42cf9f0a0199a064526427150191", package = "cultcache-rs" }
|
||||
cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "85f7024a35ea42cf9f0a0199a064526427150191", package = "cultnet-rs" }
|
||||
cultmesh-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "85f7024a35ea42cf9f0a0199a064526427150191", package = "cultmesh-rs" }
|
||||
jsonwebtoken = "9"
|
||||
hmac = "0.12"
|
||||
rand = "0.9"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||
rmp-serde = "1.3"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
|
@ -33,9 +24,7 @@ serde_json = "1"
|
|||
sha2 = "0.10"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tower-http = { version = "0.6", features = ["fs", "trace"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
unicode-segmentation = "1"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
zeroize = "1.8"
|
||||
|
|
|
|||
276
README.md
276
README.md
|
|
@ -1,197 +1,121 @@
|
|||
# Ghostlight
|
||||
|
||||
Ghostlight builds persistent generative people and worlds. Its active product is
|
||||
[Ghostlight Dungeon](https://ghostlight.gamecult.org): a Vault-grounded narrative
|
||||
simulation in which places, knowledge, relationships, institutions, clocks, and
|
||||
consequences survive beyond the model's current context.
|
||||
**Ghostlight is a world kernel: typed world state that a language model can
|
||||
propose changes to and can never write.** The valuable part is
|
||||
`crates/ghostlight`. The Dungeon game, one client of it, is parked (below).
|
||||
|
||||
The project began as a research and fixture pipeline for socially persistent
|
||||
Aetheria agents. That material remains useful regression evidence. The live
|
||||
machine is now a Rust runtime with typed campaign state, one canonical world
|
||||
authority, bounded Persona projection, fiction-first action resolution, and a
|
||||
multiresolution agency graph for keeping large settings active at finite cost.
|
||||
**The thesis.** World state is typed documents. Every change to it, and every
|
||||
narrative dependency on it, is an operation in a closed, predefined algebra.
|
||||
Models propose operations; the kernel checks each against the state (references,
|
||||
preconditions, authority, conservation, who knows what) and admits it or refuses
|
||||
it with the complete list of mismatches. Narration describes only what was
|
||||
committed. A model-as-game-master holds the world in a context window and
|
||||
drifts; here the world is a digest-chained journal that survives restarts and
|
||||
cannot be talked into contradicting itself.
|
||||
|
||||
## What Ghostlight Owns
|
||||
**Use it as a structured state backend.** Do not judge it by the parked Dungeon's
|
||||
prose. The kernel is a library; prose comes from whichever model you plug into
|
||||
its ports.
|
||||
|
||||
- `WorldKernel` is the sole owner of committed campaign state and revisions.
|
||||
- Every player command, NPC action, strategic tick, wait, import, and reload
|
||||
enters the same validated campaign mailbox and atomic CultCache commit path.
|
||||
- Foreground actions, NPC reactions, strategic Gestalt activity, waits, travel,
|
||||
and population fission lower into one closed semantic world-mutation algebra.
|
||||
Means and intended effects remain proposals; only an admitted mutation batch
|
||||
can change canonical components. There is no model-authored JSON Patch path.
|
||||
- Vault evidence constrains world compilation without pretending every playable
|
||||
route, procedure, or local institution is already written down. The compiler
|
||||
synthesizes the smallest compatible branch elaboration, exposes it for
|
||||
approval, and keeps it distinct from canon. Details that must not vary belong
|
||||
in the Vault.
|
||||
- Hosted lore Vaults are planned as Git-synchronized, Obsidian-compatible
|
||||
Markdown hierarchies. The Vault service owns their indexes; campaigns retain
|
||||
only manifest bindings and exact evidence receipts.
|
||||
- Projector → Persona → Interpreter turns produce proposals. Models never
|
||||
commit world state.
|
||||
- Knowledge, perception, capability, location, custody, and authority are
|
||||
validated against the exact actor proposing an action.
|
||||
- Fiction-first d20 resolution treats player text as an attempt rather than an
|
||||
accomplished fact.
|
||||
- Multiresolution Gestalts keep people, institutions, and factions active under
|
||||
a bounded Persona-cell budget without merging rivals into one mind or erasing
|
||||
named individuals.
|
||||
- Away-time simulation advances clocks and remote agency without puppeting or
|
||||
directly harming an absent player character.
|
||||
- CultMesh publishes typed service and Eve/CultUI state; the browser lowers that
|
||||
surface and does not become a second state authority.
|
||||
- Odin receives Ghostlight's advertised boundary schemas and typed projections
|
||||
over ACK-bounded RUDP publication. Internal runtime schemas are not treated as
|
||||
discovery contracts merely because Ghostlight owns them.
|
||||
- `crates/ghostlight`: the sealed library. A consumer gets create/open
|
||||
(`WorldMailbox::open`, `create`), immutable snapshots (`snapshot`), command
|
||||
submission through role-scoped ports (`PlayPort`, `ConsumerPort`,
|
||||
`ControllerPort`, `SeedPort`), typed receipts, and the runner and inference-port
|
||||
entry points (`ControllerRunner`, `ElaborationRunner`, `SeedRunner`,
|
||||
`InferencePort`). Mutable state, ID issuers, the reducer and the journal
|
||||
writer are not public. `compile_fail` doc-tests in `crates/ghostlight/src/lib.rs`
|
||||
and `crates/ghostlight/tests/external_admission.rs` prove the seal.
|
||||
- `docs/architecture/ghostlight-world-ontology.md`: the vocabulary (subjects,
|
||||
places, routes, holdings, knowledge, commitments, pressures), the operation
|
||||
algebra, and what the reducer checks.
|
||||
- `docs/architecture/ghostlight-world-consumer-api.md`: the contract for a
|
||||
program that reads a world and proposes changes to the characters it controls
|
||||
(`POST /cultnet/world-patch`; no process on main serves that door, a host serves
|
||||
`ghostlight::admit_document`).
|
||||
|
||||
## Eve-native interface
|
||||
Start with those two documents, then `cargo doc -p ghostlight --open`.
|
||||
|
||||
Ghostlight publishes one stable logical surface, `ghostlight.play`, for
|
||||
anonymous entry, Session Zero, Contract Review, and campaign play. The browser
|
||||
contains Eve's canonical provider host and lowering package plus Heimdall's
|
||||
access-plugin adapter. It does not contain Ghostlight-specific screen renderers
|
||||
or domain API routing.
|
||||
**Populate the algebra first.** The kernel owns the component operations; a world owns its
|
||||
affordance catalog, the verbs its actors can attempt. A fresh world knows one: the built-in
|
||||
Speak, which records an utterance and changes nothing. Until your world declares affordances
|
||||
(at genesis, by patch, or through elaborators) for moving, trading, taking, deciding and so on,
|
||||
every intent a model voices lowers to speech or to a recorded gap, and the prose built on that
|
||||
will be thin no matter how good the model is. The Aetheria verse genesis
|
||||
(`crates/ghostlight/src/verse/genesis.rs`) declares its grammar's verbs, and today they still
|
||||
only record the act; their effects are the next phase.
|
||||
|
||||
Editable Eve bindings are the input model. Text, number, and choice controls
|
||||
bind either provider-owned typed state or renderer-local draft state; an
|
||||
operation captures named bindings and submits one canonical Eve command.
|
||||
Rejected or stale commands preserve local drafts, while an accepted receipt may
|
||||
clear named bindings before the host refetches the authoritative surface. A
|
||||
browser may lower this interaction to an HTML form for accessibility, but forms
|
||||
are not part of Eve's state or command ontology.
|
||||
## Elaborators
|
||||
|
||||
Heimdall authentication is composed as the required
|
||||
`gamecult.heimdall.access` Eve plugin. Heimdall owns OAuth attempts, provider
|
||||
callbacks, claims, entitlements, and single-use completion redemption.
|
||||
Ghostlight owns only its hashed HttpOnly app session and derives campaign
|
||||
authority from canonical membership. The browser sees an opaque attempt handle,
|
||||
never a claim, refresh credential, account hash, member ID, or actor authority.
|
||||
See
|
||||
[`docs/architecture/ghostlight-eve-native-interface.md`](docs/architecture/ghostlight-eve-native-interface.md).
|
||||
An elaborator is a model session, confined to one jurisdiction of the world, that
|
||||
adds structure where the world is thin: it answers a derived causal boundary
|
||||
(a destination nobody has described yet) or a scale deficit with typed
|
||||
declarations, and the kernel verifies the result like any other patch. Sessions
|
||||
are resumable and checkpointed against committed history. Eight stock lenses
|
||||
(`crates/ghostlight/src/lens.rs`: patina, charter, ledger, hearth, tangle, veil,
|
||||
ember, numen) give each session a role-specific emphasis, for example customs and
|
||||
local jokes, or money and ledgers. A lens changes the prompt only, never what the
|
||||
kernel admits. Because boundaries are derived from state, elaborators can be
|
||||
dispatched as the player approaches a place or a social structure, instead of
|
||||
authoring the world up front.
|
||||
|
||||
## Current Status
|
||||
## Direction (planned, not built)
|
||||
|
||||
Ghostlight Dungeon is implemented and deployed as a native Yggdrasil,
|
||||
Heimdall-gated playtest harness. Idunn owns daemon continuity, Odin owns Verse
|
||||
discovery, and the application remains the sole owner of campaign truth.
|
||||
Authentication commands resolve Heimdall's redacted private boundary through
|
||||
Odin; no direct route is browser- or unit-owned, and valid local sessions do
|
||||
not phone home on routine commands. The
|
||||
current acceptance surface covers:
|
||||
Use the kernel to simulate a whole world with many kinds of actor: Aetheria's
|
||||
factions and a persistent cast whose members keep identity, memory and
|
||||
commitments across sessions. Newspaper-staff simulations before the 2026-08
|
||||
teardown are the earlier proof of the idea; that code is in git history, not in
|
||||
the tree. Running this at scale is gated by inference cost, not by the design.
|
||||
`crates/ghostlight-verse` is the first slice: `ghostlight-verse genesis` creates
|
||||
an Aetheria verse world from the Aetheria catalog and `ghostlight-verse run`
|
||||
advances it a number of ticks under a token cap, reporting per tick what each
|
||||
faction did and what it cost. Nothing loads AetheriaLore prose, models
|
||||
storylets, or produces episodes yet. Targets: `notes/ghostlight-verse-target.md`.
|
||||
|
||||
- persistent DM-led Session Zero with shared/private channels, typed contracts,
|
||||
private boundaries, character bargains, digest-bound unanimous approval, and
|
||||
atomic publication;
|
||||
- source-constrained world compilation from an approved brief: canon evidence
|
||||
pins what must remain true, compatible game-scale connective tissue becomes
|
||||
disclosed branch-local state, and genuine premise conflicts return to
|
||||
negotiation instead of borrowing a nearby story;
|
||||
- selectable Aetheria and Kalsa Vaults, with Kalsa's player-safe `Public` and
|
||||
GM-only `Spoilers` evidence lanes preserved through exact receipts;
|
||||
- persistent campaigns, forks, resets, exports, and Heimdall-account-isolated sessions;
|
||||
- parallel affected-character Projector/Persona/Interpreter waves;
|
||||
- impossible-action refusal, assessed stakes, server-side rolls, and receipted
|
||||
commits;
|
||||
- strategic clocks, away-time catch-up, institution activity, migration, and
|
||||
information-channel-aware news;
|
||||
- bounded shared-scene co-op for up to eight Heimdall members, with exact
|
||||
member→actor authority, actor-filtered surfaces, unanimous time/budget
|
||||
governance, and no player puppeting or PvP mutation;
|
||||
- connected cohesive and arena simulation covers at budgets from 1 to 128;
|
||||
- Gestalt materialisation, folding, member deltas, migration, and later
|
||||
rematerialisation of the same person;
|
||||
- atomic rejection of malformed, stale, or semantically invalid model waves;
|
||||
- one kernel-owned mutation reducer for foreground, reaction, strategic, time,
|
||||
travel, population-fission, and bounded region-expansion consequences, with
|
||||
exact component versions and mutation proof receipts;
|
||||
- provider, token, cache, latency, validation, state-version, and build receipts;
|
||||
- provider-neutral inference through either direct DeepSeek/OpenRouter
|
||||
boundaries or the independent CodexConnector daemon's encrypted loopback
|
||||
CultNet boundary, while every prompt, schema, retry, interpretation, and world
|
||||
commit remains Ghostlight's;
|
||||
- exact-build deployment, state migration, public cutover, and restart
|
||||
verification on Yggdrasil.
|
||||
## Ghostlight Dungeon (parked)
|
||||
|
||||
Initial compiler seed publication is a bounded empty-store creation
|
||||
transaction. Named-person materialisation and folding are resolution
|
||||
transactions that preserve individual deltas without rewriting their Gestalt.
|
||||
The remaining forge gates are expansion and review of the agency corpus beyond
|
||||
its current candidate seed, removal of legacy model effect schemas,
|
||||
multi-account human pressure testing of Session Zero privacy/publication and
|
||||
bounded co-op, and continued multiresolution Gestalt agency pressure. The
|
||||
public site is provisional; checkout is not live.
|
||||
The single-player, DM-led client and its daemon, Eve play surface and browser
|
||||
host are parked at tag `parked/ghostlight-eve-surface`. Nothing is deployed and
|
||||
nothing on main serves HTTP. The Dungeon returns when Thing 2 gives it a UI
|
||||
(follow-up `dungeon-ui-on-thing2`); the rebuild starts from the tag.
|
||||
|
||||
## Bounded Co-op
|
||||
## Repo map
|
||||
|
||||
Campaign creation now supports one to eight authenticated players. One
|
||||
`SessionZeroKernel` owns negotiation; after unanimous approval, one
|
||||
`WorldKernel` owns the shared campaign. Membership binds each account to one
|
||||
exact actor, and every player receives an actor-filtered Eve projection.
|
||||
| Path | What it is |
|
||||
|---|---|
|
||||
| `crates/ghostlight/` | The sealed kernel library. |
|
||||
| `crates/ghostlight-persona-projection/` | Membrane between structured state and a prose-only Persona. |
|
||||
| `crates/ghostlight-verse/` | The `ghostlight-verse` operator command line (`genesis`, `run`). |
|
||||
| `sidecar/claude-sdk/` | Node process serving `claude`-prefixed models to the kernel; a stopgap for a direct Messages API port. |
|
||||
| `AGENTS.md` | Invariants and where things are. |
|
||||
| `docs/architecture/ghostlight-dungeon-mvp.md` | The parked Dungeon's authority architecture (design record). |
|
||||
| `notes/` | The verse campaign's target and map, the handoff pointer, local smoke notes. |
|
||||
|
||||
This milestone is intentionally one shared scene with sequential public
|
||||
actions. PvP, split parties, private in-play actions, delegation, late joining,
|
||||
and simultaneous declarations remain closed until their consent and governance
|
||||
contracts exist. See
|
||||
[`docs/architecture/ghostlight-dungeon-session-zero.md`](docs/architecture/ghostlight-dungeon-session-zero.md)
|
||||
and the longer-term
|
||||
[`multiplayer intention`](docs/architecture/ghostlight-dungeon-multiplayer-intention.md).
|
||||
`docs/public-architecture/`, `docs/articles/` and `docs/product/` are design
|
||||
essays and proposals from before the rebuild. They describe organs (campaigns,
|
||||
co-op, Nemesis, newspapers) that are not in `crates/`; they do not describe the
|
||||
running machine. Pages ending in `-cut.md` or `-postmortem.md` are dated history.
|
||||
|
||||
## Lore Vault Product Intention
|
||||
## Build and test
|
||||
|
||||
The hosted product will accept ordinary Obsidian-compatible lore Vaults synced
|
||||
through Git. Contributor and Private plans provision one active custom Vault;
|
||||
their Plus variants provision three, subject to combined indexed-source and
|
||||
import allowances. The entitlement, accounting rules, authority boundary, and
|
||||
security gates are recorded in
|
||||
[`docs/product/lore-vault-entitlements.md`](docs/product/lore-vault-entitlements.md).
|
||||
Custom tenant import is not yet part of the deployed tester harness.
|
||||
Nothing deploys from this repo. Linux verification runs on Yggdrasil, not on a
|
||||
workstation:
|
||||
|
||||
## Architecture
|
||||
|
||||
The shortest reliable re-entry path is:
|
||||
|
||||
- [`docs/architecture/ghostlight-dungeon-mvp.md`](docs/architecture/ghostlight-dungeon-mvp.md): runtime authority, compiler, action loop, persistence, hosting, and security;
|
||||
- [`docs/architecture/ghostlight-dungeon-session-zero.md`](docs/architecture/ghostlight-dungeon-session-zero.md): campaign negotiation, privacy, publication, membership, and bounded co-op;
|
||||
- [`docs/architecture/ghostlight-multiresolution-agency.md`](docs/architecture/ghostlight-multiresolution-agency.md): dynamic Gestalt partitioning, cohesive and arena cells, fairness, and atomic strategic waves;
|
||||
- [`docs/architecture/ghostlight-eve-native-interface.md`](docs/architecture/ghostlight-eve-native-interface.md): Eve bindings, the stable provider surface, private Heimdall command plane, app-session custody, and browser cut line;
|
||||
- [`docs/architecture/ghostlight-transition-algebra.md`](docs/architecture/ghostlight-transition-algebra.md): canonical subjects and components, semantic mutations, admission envelopes, atomic reduction, and the remaining writer migration;
|
||||
- [`notes/ghostlight-current-system-map.md`](notes/ghostlight-current-system-map.md): current implemented pipeline;
|
||||
- [`notes/ghostlight-implementation-plan.md`](notes/ghostlight-implementation-plan.md): live sequence and next pressure tests;
|
||||
- [`state/map.yaml`](state/map.yaml): canonical human-readable project state.
|
||||
|
||||
## Repository Shape
|
||||
|
||||
- `crates/ghostlight/`: the library crate — world kernel, ontology and reducer,
|
||||
journal, mailbox, controllers, elaboration, cover, clock, consumer admission,
|
||||
inference ports, and evidence sources;
|
||||
- `crates/ghostlight-dungeon/`: Rust daemon consuming that library — HTTP
|
||||
routes, Eve projection/command ingress, Heimdall and app sessions, CultMesh
|
||||
publication, Idunn health, and acceptance harnesses;
|
||||
- `crates/ghostlight-persona-projection/`: generalized projection membrane owned
|
||||
by Ghostlight and consumed by Epiphany;
|
||||
- `docs/architecture/`: durable contracts and authority maps;
|
||||
- `docs/articles/`: accessible public explanations;
|
||||
- `notes/`: implementation planning, handoff, and current-system maps;
|
||||
- `schemas/`: published JSON Schemas for typed boundary documents;
|
||||
- `examples/`: earlier fixtures and regression material;
|
||||
- `state/`: human-readable project memory plus older research-pipeline state;
|
||||
- `tools/`: state, validation, and fixture helpers.
|
||||
- `web/`: thin Eve browser host, Ghostlight transport, and Heimdall plugin
|
||||
adapter; product state and product-specific rendering remain server-owned.
|
||||
|
||||
Runtime documents and campaign exports use MessagePack-backed CultCache `.cc`.
|
||||
JSON exists at schema publication, browser, MCP, model-provider, and diagnostic
|
||||
boundaries; it is not the canonical hosted state store.
|
||||
|
||||
## Useful Commands
|
||||
|
||||
```powershell
|
||||
cargo test --workspace
|
||||
npm run state:status
|
||||
npm run state:prepare-compaction
|
||||
npm run schema:validate
|
||||
```sh
|
||||
cargo build --locked --workspace
|
||||
cargo test --locked -p ghostlight-persona-projection
|
||||
cargo test --locked -p ghostlight
|
||||
cargo test --locked -p ghostlight-verse
|
||||
```
|
||||
|
||||
For Codex-driven work, read `AGENTS.md` before changing the repository. It owns
|
||||
the persistence, grounding, verification, and handoff discipline.
|
||||
The kernel alone needs only `cargo test -p ghostlight`. The sidecar has
|
||||
`npm run sdk:build` and `npm run sdk:test`. The acceptance test
|
||||
`controllers::tests::real_local_model_cognition_modes_commit_speech` is ignored
|
||||
by default and needs a real model.
|
||||
|
||||
Inference is routed by model name: `local/` goes to an OpenAI-compatible
|
||||
loopback endpoint, `claude` to the SDK sidecar, and the verse runner's `hosted/`
|
||||
to an https endpoint. Ghostlight never reads, stores or forwards a model
|
||||
credential beyond a key file named at run time.
|
||||
|
||||
Runtime documents are MessagePack-backed CultCache. JSON appears only at schema
|
||||
publication, model-provider and diagnostic boundaries.
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
Beyond the Confluence, approach and admit a politically inhabited elven forest realm whose watershed law, living-landscape institutions, rival offices, settlements, and public routes respond independently to the deep crisis.
|
||||
Beyond the Confluence, approach and admit a politically inhabited human rail-capital realm with a government, opposition, university, mage interests, labor constituencies, dependent infrastructure, surrounding settlements, and public routes.
|
||||
Beyond the Confluence, approach and admit a politically inhabited orcish oathland realm with several councils and communities, guardian-species obligations, contested enforcement, trade interests, dissent, and public routes.
|
||||
Beyond the Confluence, approach and admit a politically inhabited halfling grain-and-river realm with farms, market towns, tenants, cooperatives, water authorities, insurers, faiths, political movements, and public routes.
|
||||
Beyond the Confluence, approach and admit a politically inhabited goblin deep-route realm with distinct settlements, route guilds, kin networks, custodians, brokers, radicals, discounted expertise, and public routes.
|
||||
Beyond the Confluence, approach and admit a second politically inhabited human polity: a tidal mercantile republic with elected harbor offices, hereditary creditors, dock unions, displaced fisher wards, rival courts, naval contracts, and public routes independent of the rail capital.
|
||||
Beyond the Confluence, approach and admit a mixed border federation whose member towns, mobile communities, species, faiths, and trade leagues retain local authority while sharing delegates, mutual-aid stores, contested asylum law, watershed obligations, and public routes.
|
||||
Beyond the Confluence, approach and admit an autonomous deep-ecology polity of dungeon organs, custodial communities, symbiotic species, and negotiated human-scale envoys, with legible law, dissent, material means, territorial claims, redress, and public routes; do not make it a single mystical will.
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
# DELVE/HOLD Outside World — Full-World Acceptance Seed v1
|
||||
|
||||
The Greathold is a sovereign dwarven civilization along the mountain spine. It
|
||||
possesses the world's deepest inherited routes, the principal international
|
||||
adventuring threshold, mature guild and civic institutions, and a rapidly
|
||||
industrializing system of standardized runes, core farming, pumps, rail,
|
||||
weapons, and mana distribution. It is controlled outside this simulation. The
|
||||
world beyond may observe it, depend on it, bargain with it, fear it, and act
|
||||
against it, but may not invent its choices, quantities, prices, contracts, or
|
||||
internal political outcomes.
|
||||
|
||||
Elven cultures understand landscapes as nested societies of persons: forests,
|
||||
groves, trees, rivers, animal communities, and seasons retain identities within
|
||||
larger living wholes. Elven realms are politically plural. Courts, communes,
|
||||
temples, scientific delegations, trade interests, militant defenders, and rival
|
||||
legal traditions disagree over whether core farming is husbandry, mutilation,
|
||||
or surgery upon a sleeping god. Their names, exact borders, officeholders,
|
||||
selection systems, revenues, redress, internal factions, and rival claims are
|
||||
intentionally unspecified and must emerge as grounded institutions.
|
||||
|
||||
Human societies include kingdoms, republics, city-states, merchant leagues,
|
||||
principalities, and empires. Human cities buy dwarven pumps, lights, rails,
|
||||
weapons, and power systems while local mage guilds lose work and governments
|
||||
acquire dangerous foreign dependencies. Universities arbitrate disputed
|
||||
evidence amid company restrictions, elven models, municipal interests, and
|
||||
political funding. No single human authority or position exists; invent several
|
||||
distinct polities with credible institutions, constituencies, opposition, and
|
||||
cross-border interests.
|
||||
|
||||
Orcish societies vary, but many organize ancestry, territory, oath, rhythm, and
|
||||
reciprocal obligation. Some maintain old agreements with local dungeon systems
|
||||
that outsiders dismiss as monster worship. Industrial disturbance displaces
|
||||
protected species, breaks inherited obligations, and forces clans, councils,
|
||||
war leaders, ritual authorities, traders, and younger dissidents to decide who
|
||||
owes repair and what enforcement is legitimate. Their politics are not a
|
||||
single tribal will.
|
||||
|
||||
Halfling regions are agricultural, riverine, commercial, and locally varied.
|
||||
Mana contamination brings crop mutation, magical pests, livestock deformity,
|
||||
untimely storms, and localized ecological collapse. Farms and towns depend on
|
||||
dwarven pumps and rail even as those systems transmit the crisis. Invent
|
||||
landholders, tenants, cooperatives, market towns, water authorities, faiths,
|
||||
insurers, and political movements with conflicting exposure to that dependence.
|
||||
|
||||
Goblin communities possess practical knowledge of living tunnels and dungeon
|
||||
moods accumulated through work, travel, salvage, habitation, and negotiated
|
||||
coexistence. Their expertise is routinely used while their jurisdiction and
|
||||
testimony are discounted. Invent distinct settlements, route guilds, kin
|
||||
networks, councils, brokers, radicals, and custodians; do not reduce them to a
|
||||
shared instinct or a service population.
|
||||
|
||||
The deep magical ecology crosses every border. Young dungeon cores behave like
|
||||
organs; old cores remember; ancient systems may become persons, polities, or
|
||||
gods. Dwarven extraction produces scattered mine failures, shifting veins,
|
||||
strange migration, coordinated dungeon architecture, mana storms, altered
|
||||
rivers, crop failures, and foreign political pressure. Mining companies call
|
||||
this deep mana instability, engineers call it adaptive geomantic feedback,
|
||||
militaries call it subterranean hostile activity, elven observers call it
|
||||
injury and immune response, and goblin guides say the tunnels know what the
|
||||
machines are for. None of these interpretations is established as complete.
|
||||
|
||||
The world begins as a coarse international skeleton. Invent at least one
|
||||
politically inhabited seed locality for several peoples rather than treating a
|
||||
species as a state. Establish concrete routes, dependencies, public channels,
|
||||
institutions, offices, revenues, succession or selection, redress, opposed
|
||||
interests, and named figures. Preserve ordinary life alongside crisis. Later
|
||||
elaboration and strategic cells may deepen these facts but may not replace this
|
||||
ontology or turn the Greathold into an autonomous character.
|
||||
|
||||
## Frozen run profile
|
||||
|
||||
- World name: `The Deep World Beyond the Greathold`
|
||||
- Boundary observer: `The player-controlled Greathold, present only as an
|
||||
externally sovereign boundary observer with no Persona, vote, office, or
|
||||
autonomous strategic turn.`
|
||||
- Place scope: `the politically inhabited world beyond the Greathold: elven,
|
||||
human, orcish, halfling, and goblin realms joined by trade, rivers, deep
|
||||
routes, migration, scholarship, and the living dungeon ecology`
|
||||
- Time scope: `the first international season in which coordinated deep
|
||||
ecological injury can no longer be dismissed as isolated industrial failure`
|
||||
- Public channel: `the world post and treaty-wire carried through named local
|
||||
presses, couriers, universities, councils, guild halls, and border exchanges`
|
||||
- Strategic pressure: `The Greathold has opened the Crownshaft Array and
|
||||
reversed three deep mana currents to supply rune works through winter. River
|
||||
pumps fail across foreign grain country; a migrating dungeon organ surfaces
|
||||
beneath a human rail capital; an orcish oath-route closes after its guardian
|
||||
species arrives blind and burned; goblin route custodians produce work songs
|
||||
that predict collapses before dwarven instruments; and five elven delegations
|
||||
publish mutually incompatible claims of jurisdiction over the same wounded
|
||||
watershed. Dwarven power exports continue under signed contracts while
|
||||
insurers suspend cover, armies secure infrastructure, displaced families
|
||||
cross borders, and an unknown party circulates proof that several foreign
|
||||
governments secretly financed the extraction they now condemn.`
|
||||
- Ordered outer-realm requests:
|
||||
`acceptance/full-world-delvehold-regions-v2.txt` (eight requests, one per
|
||||
line; their order is part of the acceptance input)
|
||||
- World locality elaborations: `8` (one per ordered outer-realm request)
|
||||
- World elaboration parallelism: `8`
|
||||
- Active cell budget: `240`
|
||||
- Target active-cover basis points: `2000` (exactly `1200` qualified subjects)
|
||||
- World complexity parallelism: `8`
|
||||
- Maximum world complexity rounds: `128`
|
||||
- Strategic waves: `1`
|
||||
- Maximum rejected pulses per wave: `4`
|
||||
- Provider routing: Luna fast, Terra balanced, Sol capable through the existing
|
||||
Codex connector
|
||||
|
||||
Acceptance requires a single persistent campaign with multiple politically
|
||||
inhabited realms, named figures and institutions acting across realm boundaries,
|
||||
causal consequences that survive later waves, no autonomous Greathold action,
|
||||
complete per-wave and final publication artifacts, exact source/model receipts,
|
||||
immutable archive hashes, and unchanged live-service process identities.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 81 KiB |
|
|
@ -1,15 +0,0 @@
|
|||
# Boundary Rail Exchange woodcut
|
||||
|
||||
Generation mode: built-in ImageGen, fresh generation from text.
|
||||
|
||||
```text
|
||||
Use case: illustration-story
|
||||
Asset type: printable wordless woodcut for an early hand-press fantasy broadsheet
|
||||
Primary request: a stark Boundary Rail Exchange breach: two heavy parallel iron rails and timber sleepers cross a cramped masonry exchange; thick living roots bend toward exposed mana conduits; in the immediate foreground lie several unmistakable severed gloved elven hands, each palm marked by a different simple burned court seal; a rough warning cordon and marked withdrawal path remain secondary
|
||||
Style/medium: authentic late-15th-century relief woodcut printed in BLACK INK ON WHITE PAPER, bold carved black contours, irregular crosshatching, white negative space between marks, uneven hand-pressed ink, archaic perspective
|
||||
Composition/framing: horizontal rectangular block, centered subject, roots and severed hands clearly readable at 3-inch newspaper width, balanced white margins
|
||||
Color palette: black marks on a bright white background only
|
||||
Constraints: the page must be predominantly white, never a black or dark background; wordless; no caption, letters, numerals, frame, logo, watermark, gradients, gray wash, glowing white lines, photorealism, modern comic style, or vector cleanliness; macabre but not wet or photorealistically gory
|
||||
```
|
||||
|
||||
Press preparation: converted to grayscale, thresholded at 72%, and trimmed without semantic edits.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 76 KiB |
|
|
@ -1,15 +0,0 @@
|
|||
# Sinkroot Gauge Gallery woodcut
|
||||
|
||||
Generation mode: built-in ImageGen, fresh generation from text.
|
||||
|
||||
```text
|
||||
Use case: illustration-story
|
||||
Asset type: printable wordless woodcut for an early hand-press fantasy broadsheet
|
||||
Primary request: Sinkroot Gauge Gallery during a deep-current dispute: one enormous round calibrated pressure gauge with a violently deflected needle dominates the center; branching iron pipes, rootlike conduits, worn shutoff wheels, workers' measuring rods, and a bound petition sit below it; carved wave and vibration marks inside the pipes imply turbulent current
|
||||
Style/medium: authentic late-15th-century relief woodcut printed in BLACK INK ON WHITE PAPER, bold carved black outlines, rhythmic crosshatching, ample white negative space, uneven hand-pressed ink, archaic technical illustration without labels
|
||||
Composition/framing: horizontal rectangular block, central dial unmistakable and fully visible, petition and tools secondary, readable at 3-inch newspaper width, balanced white margins
|
||||
Color palette: black marks on a bright white background only
|
||||
Constraints: the page must be predominantly white, never a black or dark background; wordless; no caption, letters, numerals, frame, logo, watermark, gradients, gray wash, glowing white lines, photorealism, modern vector style, or steampunk clutter
|
||||
```
|
||||
|
||||
Press preparation: converted to grayscale, thresholded at 72%, and trimmed without semantic edits.
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
[package]
|
||||
name = "ghostlight-dungeon"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
autobins = false
|
||||
|
||||
[[bin]]
|
||||
name = "ghostlight-dungeon"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
aes-gcm.workspace = true
|
||||
async-trait.workspace = true
|
||||
async-stream.workspace = true
|
||||
axum.workspace = true
|
||||
chrono.workspace = true
|
||||
base64.workspace = true
|
||||
codex-connector.workspace = true
|
||||
cultcache-rs.workspace = true
|
||||
cultnet-rs.workspace = true
|
||||
cultmesh-rs.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
libc = "0.2"
|
||||
hmac.workspace = true
|
||||
ghostlight = { path = "../ghostlight" }
|
||||
ghostlight-persona-projection = { path = "../ghostlight-persona-projection" }
|
||||
rand.workspace = true
|
||||
reqwest.workspace = true
|
||||
rmp-serde.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_bytes = "0.11"
|
||||
sha2.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
tower-http.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
uuid.workspace = true
|
||||
zeroize.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
use std::{env, path::Path, process::Command};
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-env-changed=GHOSTLIGHT_BUILD_COMMIT");
|
||||
let manifest_dir =
|
||||
env::var("CARGO_MANIFEST_DIR").expect("Cargo must supply CARGO_MANIFEST_DIR");
|
||||
let repo_root = Path::new(&manifest_dir).join("../..");
|
||||
emit_git_rerun_paths(&repo_root);
|
||||
// Release tooling injects the clean-tree commit. Git discovery is only a
|
||||
// convenience for local builds. The exact ref and reflog dependencies above
|
||||
// keep that convenience from silently publishing stale provenance.
|
||||
let commit = env::var("GHOSTLIGHT_BUILD_COMMIT")
|
||||
.ok()
|
||||
.filter(|value| is_commit(value))
|
||||
.or_else(|| git_commit(&repo_root))
|
||||
.expect("GhostlightDungeon builds require an exact 40-hex source commit");
|
||||
println!("cargo:rustc-env=GHOSTLIGHT_BUILD_COMMIT={commit}");
|
||||
}
|
||||
|
||||
fn emit_git_rerun_paths(repo_root: &Path) {
|
||||
for git_path in ["HEAD", "logs/HEAD", "packed-refs"] {
|
||||
if let Some(path) = git_output(repo_root, &["rev-parse", "--git-path", git_path]) {
|
||||
emit_rerun_path(repo_root, &path);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(reference) = git_output(repo_root, &["symbolic-ref", "-q", "HEAD"])
|
||||
&& let Some(path) = git_output(repo_root, &["rev-parse", "--git-path", &reference])
|
||||
{
|
||||
emit_rerun_path(repo_root, &path);
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_rerun_path(repo_root: &Path, git_path: &str) {
|
||||
let path = Path::new(git_path);
|
||||
let resolved = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
repo_root.join(path)
|
||||
};
|
||||
println!("cargo:rerun-if-changed={}", resolved.display());
|
||||
}
|
||||
|
||||
fn git_commit(repo_root: &Path) -> Option<String> {
|
||||
git_output(repo_root, &["rev-parse", "HEAD"]).filter(|value| is_commit(value))
|
||||
}
|
||||
|
||||
fn git_output(repo_root: &Path, args: &[&str]) -> Option<String> {
|
||||
let output = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(repo_root)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8(output.stdout)
|
||||
.ok()
|
||||
.map(|value| value.trim().to_owned())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn is_commit(value: &str) -> bool {
|
||||
value.len() == 40 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}
|
||||
|
|
@ -1,869 +0,0 @@
|
|||
//! Local custody for authenticated Heimdall sessions.
|
||||
//!
|
||||
//! This store owns only browser-session continuity. It does not own world
|
||||
//! selection, commands, idempotency, exports, or fictional state.
|
||||
|
||||
use crate::heimdall::{VerifiedSessionAdmission, VerifiedSessionRefresh};
|
||||
use ghostlight::VerifiedPrincipalEvidence;
|
||||
use aes_gcm::{Aes256Gcm, KeyInit, aead::Aead};
|
||||
use anyhow::{Context, bail};
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use chrono::{DateTime, Utc};
|
||||
use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, OwnedRedbMessagePackBackingStore};
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{collections::BTreeMap, path::Path};
|
||||
|
||||
const STORE_TYPE: &str = "ghostlight.app_session_store.v2";
|
||||
/// `pub(crate)` so the recipe-drift test in `idunn_health.rs` can check the
|
||||
/// `app-sessions` state slot's schema against this constant.
|
||||
pub(crate) const STORE_SCHEMA: &str = "ghostlight.app_session_store.v2";
|
||||
const STORE_KEY: &str = "primary";
|
||||
const MAX_SESSION_RECORDS: usize = 1_024;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct AppSessionState {
|
||||
schema: String,
|
||||
sessions: BTreeMap<String, AppSession>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct AppSession {
|
||||
schema: String,
|
||||
cookie_hash: String,
|
||||
account_subject_hash: String,
|
||||
heimdall_session_id: String,
|
||||
access_revision: u64,
|
||||
verified_capabilities: Vec<String>,
|
||||
access_expires_at: DateTime<Utc>,
|
||||
refresh_expires_at: DateTime<Utc>,
|
||||
encrypted_refresh_claim: String,
|
||||
created_at: DateTime<Utc>,
|
||||
last_refresh_at: DateTime<Utc>,
|
||||
revoked_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
pub(crate) struct RefreshCandidate {
|
||||
pub(crate) cookie_hash: String,
|
||||
pub(crate) account_subject_hash: String,
|
||||
pub(crate) heimdall_session_id: String,
|
||||
pub(crate) access_revision: u64,
|
||||
pub(crate) refresh_claim: String,
|
||||
}
|
||||
|
||||
pub(crate) struct AppSessionOwner {
|
||||
store: OwnedRedbMessagePackBackingStore,
|
||||
row: CultCacheEnvelope,
|
||||
state: AppSessionState,
|
||||
wrapping_key: [u8; 32],
|
||||
healthy: bool,
|
||||
}
|
||||
|
||||
impl AppSessionOwner {
|
||||
pub(crate) fn open(
|
||||
store_path: impl AsRef<Path>,
|
||||
wrapping_key_path: impl AsRef<Path>,
|
||||
) -> anyhow::Result<Self> {
|
||||
let wrapping_key = read_wrapping_key(wrapping_key_path.as_ref())?;
|
||||
let store = OwnedRedbMessagePackBackingStore::new(store_path.as_ref())?;
|
||||
store
|
||||
.validate_path_identity()
|
||||
.context("app-session store path identity is invalid")?;
|
||||
let rows = store.pull_all()?;
|
||||
let (row, state) = match rows.as_slice() {
|
||||
[] => {
|
||||
let state = AppSessionState {
|
||||
schema: STORE_SCHEMA.into(),
|
||||
sessions: BTreeMap::new(),
|
||||
};
|
||||
let row = envelope(&state)?;
|
||||
if !store.compare_and_swap_batch(&[], vec![row.clone()])? {
|
||||
bail!("app-session store changed during initialization");
|
||||
}
|
||||
(row, state)
|
||||
}
|
||||
[row]
|
||||
if row.r#type == STORE_TYPE
|
||||
&& row.key == STORE_KEY
|
||||
&& row.schema_id.as_deref() == Some(STORE_SCHEMA) =>
|
||||
{
|
||||
let state: AppSessionState =
|
||||
rmp_serde::from_slice(&row.payload).context("app-session state is corrupt")?;
|
||||
if rmp_serde::to_vec_named(&state)? != row.payload {
|
||||
bail!("app-session state is not canonical");
|
||||
}
|
||||
if state.schema != STORE_SCHEMA {
|
||||
bail!("app-session state schema is invalid");
|
||||
}
|
||||
validate_state(&state, &wrapping_key)?;
|
||||
(row.clone(), state)
|
||||
}
|
||||
_ => bail!(
|
||||
"app-session v2 store contains foreign or legacy records; start with a fresh store"
|
||||
),
|
||||
};
|
||||
Ok(Self {
|
||||
store,
|
||||
row,
|
||||
state,
|
||||
wrapping_key,
|
||||
healthy: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn create_session(
|
||||
&mut self,
|
||||
input: VerifiedSessionAdmission,
|
||||
) -> anyhow::Result<String> {
|
||||
if !input
|
||||
.capabilities()
|
||||
.iter()
|
||||
.any(|value| value == "app_access")
|
||||
{
|
||||
bail!("Heimdall session lacks app_access");
|
||||
}
|
||||
if input.account_id().trim().is_empty()
|
||||
|| input.heimdall_session_id().trim().is_empty()
|
||||
|| input.refresh_claim().is_empty()
|
||||
|| input.refresh_expires_at() <= input.access_expires_at()
|
||||
{
|
||||
bail!("Heimdall session material is invalid");
|
||||
}
|
||||
let mut cookie = [0_u8; 32];
|
||||
rand::rng().fill_bytes(&mut cookie);
|
||||
let raw_cookie = URL_SAFE_NO_PAD.encode(cookie);
|
||||
let cookie_hash = secret_hash(&raw_cookie);
|
||||
let account_subject_hash = secret_hash(&format!("heimdall-account:{}", input.account_id()));
|
||||
let now = Utc::now();
|
||||
let session = AppSession {
|
||||
schema: "ghostlight.app_session.v2".into(),
|
||||
cookie_hash: cookie_hash.clone(),
|
||||
account_subject_hash: account_subject_hash.clone(),
|
||||
heimdall_session_id: input.heimdall_session_id().to_owned(),
|
||||
access_revision: input.access_revision(),
|
||||
verified_capabilities: input.capabilities().to_vec(),
|
||||
access_expires_at: input.access_expires_at(),
|
||||
refresh_expires_at: input.refresh_expires_at(),
|
||||
encrypted_refresh_claim: wrap_refresh(
|
||||
&self.wrapping_key,
|
||||
&account_subject_hash,
|
||||
input.heimdall_session_id(),
|
||||
input.refresh_claim(),
|
||||
)?,
|
||||
created_at: now,
|
||||
last_refresh_at: now,
|
||||
revoked_at: None,
|
||||
};
|
||||
let mut next = self.state.clone();
|
||||
for previous in next.sessions.values_mut().filter(|previous| {
|
||||
previous.revoked_at.is_none()
|
||||
&& previous.heimdall_session_id == input.heimdall_session_id()
|
||||
}) {
|
||||
previous.revoked_at = Some(now);
|
||||
}
|
||||
next.sessions.insert(cookie_hash, session);
|
||||
self.commit(next)?;
|
||||
Ok(raw_cookie)
|
||||
}
|
||||
|
||||
pub(crate) fn is_healthy(&self) -> bool {
|
||||
self.healthy
|
||||
}
|
||||
|
||||
pub(crate) fn validate_custody(&mut self) -> anyhow::Result<()> {
|
||||
self.ensure_owned()
|
||||
}
|
||||
|
||||
pub(crate) fn account_for_cookie(
|
||||
&mut self,
|
||||
raw_cookie: &str,
|
||||
now: DateTime<Utc>,
|
||||
) -> anyhow::Result<Option<VerifiedPrincipalEvidence>> {
|
||||
self.ensure_owned()?;
|
||||
let Some(session) = self.state.sessions.get(&secret_hash(raw_cookie)) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok((session.revoked_at.is_none()
|
||||
&& session.access_expires_at > now
|
||||
&& session.refresh_expires_at > now
|
||||
&& session
|
||||
.verified_capabilities
|
||||
.iter()
|
||||
.any(|value| value == "app_access"))
|
||||
.then(|| {
|
||||
VerifiedPrincipalEvidence::new(
|
||||
session.account_subject_hash.clone(),
|
||||
session.access_expires_at,
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn revoke_cookie(&mut self, raw_cookie: &str) -> anyhow::Result<bool> {
|
||||
self.ensure_owned()?;
|
||||
let cookie_hash = secret_hash(raw_cookie);
|
||||
let mut next = self.state.clone();
|
||||
let Some(session) = next.sessions.get_mut(&cookie_hash) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if session.revoked_at.is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
session.revoked_at = Some(Utc::now());
|
||||
self.commit(next)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(crate) fn revoke_cookie_hash(&mut self, cookie_hash: &str) -> anyhow::Result<bool> {
|
||||
self.ensure_owned()?;
|
||||
let mut next = self.state.clone();
|
||||
let Some(session) = next.sessions.get_mut(cookie_hash) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if session.revoked_at.is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
session.revoked_at = Some(Utc::now());
|
||||
self.commit(next)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(crate) fn session_for_logout(
|
||||
&mut self,
|
||||
raw_cookie: &str,
|
||||
) -> anyhow::Result<Option<RefreshCandidate>> {
|
||||
self.ensure_owned()?;
|
||||
let Some(session) = self.state.sessions.get(&secret_hash(raw_cookie)) else {
|
||||
return Ok(None);
|
||||
};
|
||||
match self.refresh_candidate(session) {
|
||||
Ok(candidate) => Ok(Some(candidate)),
|
||||
Err(error) => self.poison(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn sessions_due_for_refresh(
|
||||
&mut self,
|
||||
now: DateTime<Utc>,
|
||||
refresh_before: chrono::Duration,
|
||||
) -> anyhow::Result<Vec<RefreshCandidate>> {
|
||||
self.ensure_owned()?;
|
||||
let candidates = self
|
||||
.state
|
||||
.sessions
|
||||
.values()
|
||||
.filter(|session| {
|
||||
session.revoked_at.is_none()
|
||||
&& session.refresh_expires_at > now
|
||||
&& session.access_expires_at <= now + refresh_before
|
||||
})
|
||||
.map(|session| self.refresh_candidate(session))
|
||||
.collect::<anyhow::Result<Vec<_>>>();
|
||||
match candidates {
|
||||
Ok(candidates) => Ok(candidates),
|
||||
Err(error) => self.poison(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply_refresh(
|
||||
&mut self,
|
||||
cookie_hash: &str,
|
||||
expected_access_revision: u64,
|
||||
input: VerifiedSessionRefresh,
|
||||
) -> anyhow::Result<()> {
|
||||
if !input
|
||||
.capabilities()
|
||||
.iter()
|
||||
.any(|value| value == "app_access")
|
||||
{
|
||||
bail!("refreshed Heimdall session lacks app_access");
|
||||
}
|
||||
if input.refresh_claim().is_empty()
|
||||
|| input.refresh_expires_at() <= input.access_expires_at()
|
||||
{
|
||||
bail!("refreshed Heimdall session material is invalid");
|
||||
}
|
||||
let mut next = self.state.clone();
|
||||
let session = next
|
||||
.sessions
|
||||
.get_mut(cookie_hash)
|
||||
.context("local session vanished during refresh")?;
|
||||
if session.revoked_at.is_some()
|
||||
|| session.access_revision != expected_access_revision
|
||||
|| input.access_revision() <= expected_access_revision
|
||||
|| session.heimdall_session_id != input.heimdall_session_id()
|
||||
|| session.account_subject_hash
|
||||
!= secret_hash(&format!("heimdall-account:{}", input.account_id()))
|
||||
{
|
||||
bail!("local session changed during refresh");
|
||||
}
|
||||
session.access_revision = input.access_revision();
|
||||
session.verified_capabilities = input.capabilities().to_vec();
|
||||
session.access_expires_at = input.access_expires_at();
|
||||
session.refresh_expires_at = input.refresh_expires_at();
|
||||
session.encrypted_refresh_claim = wrap_refresh(
|
||||
&self.wrapping_key,
|
||||
&session.account_subject_hash,
|
||||
&session.heimdall_session_id,
|
||||
input.refresh_claim(),
|
||||
)?;
|
||||
session.last_refresh_at = Utc::now();
|
||||
self.commit(next)
|
||||
}
|
||||
|
||||
fn refresh_candidate(&self, session: &AppSession) -> anyhow::Result<RefreshCandidate> {
|
||||
Ok(RefreshCandidate {
|
||||
cookie_hash: session.cookie_hash.clone(),
|
||||
account_subject_hash: session.account_subject_hash.clone(),
|
||||
heimdall_session_id: session.heimdall_session_id.clone(),
|
||||
access_revision: session.access_revision,
|
||||
refresh_claim: unwrap_refresh(
|
||||
&self.wrapping_key,
|
||||
&session.account_subject_hash,
|
||||
&session.heimdall_session_id,
|
||||
&session.encrypted_refresh_claim,
|
||||
)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn commit(&mut self, mut next: AppSessionState) -> anyhow::Result<()> {
|
||||
self.ensure_owned()?;
|
||||
prune_sessions(&mut next, Utc::now());
|
||||
let next_row = envelope(&next)?;
|
||||
let swapped = self
|
||||
.store
|
||||
.compare_and_swap_batch(std::slice::from_ref(&self.row), vec![next_row.clone()]);
|
||||
if !matches!(swapped, Ok(true)) {
|
||||
return self.poison(anyhow::anyhow!(
|
||||
"app-session commit outcome is uncertain; sole ownership was lost"
|
||||
));
|
||||
}
|
||||
if self.store.validate_path_identity().is_err() {
|
||||
return self.poison(anyhow::anyhow!(
|
||||
"app-session store path changed after durable commit"
|
||||
));
|
||||
}
|
||||
self.row = next_row;
|
||||
self.state = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_owned(&mut self) -> anyhow::Result<()> {
|
||||
if !self.healthy {
|
||||
bail!("app-session custody is poisoned; reopen the owner");
|
||||
}
|
||||
if self.store.validate_path_identity().is_err() {
|
||||
self.healthy = false;
|
||||
bail!("app-session store ownership was lost");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn poison<T>(&mut self, error: anyhow::Error) -> anyhow::Result<T> {
|
||||
self.healthy = false;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn secret_hash(value: &str) -> String {
|
||||
format!("sha256:{:x}", Sha256::digest(value.as_bytes()))
|
||||
}
|
||||
|
||||
fn envelope(state: &AppSessionState) -> anyhow::Result<CultCacheEnvelope> {
|
||||
Ok(CultCacheEnvelope {
|
||||
key: STORE_KEY.into(),
|
||||
r#type: STORE_TYPE.into(),
|
||||
payload: rmp_serde::to_vec_named(state)?,
|
||||
stored_at: Utc::now().to_rfc3339(),
|
||||
schema_id: Some(STORE_SCHEMA.into()),
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_state(state: &AppSessionState, wrapping_key: &[u8; 32]) -> anyhow::Result<()> {
|
||||
if state.schema != STORE_SCHEMA {
|
||||
bail!("app-session state schema is invalid");
|
||||
}
|
||||
for (key, session) in &state.sessions {
|
||||
if key != &session.cookie_hash
|
||||
|| !is_secret_hash(key)
|
||||
|| !is_secret_hash(&session.account_subject_hash)
|
||||
|| session.schema != "ghostlight.app_session.v2"
|
||||
|| session.heimdall_session_id.trim().is_empty()
|
||||
|| session.heimdall_session_id.trim() != session.heimdall_session_id
|
||||
|| !session
|
||||
.verified_capabilities
|
||||
.iter()
|
||||
.any(|value| value == "app_access")
|
||||
|| session.refresh_expires_at <= session.access_expires_at
|
||||
|| session.last_refresh_at < session.created_at
|
||||
|| session
|
||||
.revoked_at
|
||||
.is_some_and(|revoked_at| revoked_at < session.created_at)
|
||||
{
|
||||
bail!("app-session state contains a noncanonical session");
|
||||
}
|
||||
unwrap_refresh(
|
||||
wrapping_key,
|
||||
&session.account_subject_hash,
|
||||
&session.heimdall_session_id,
|
||||
&session.encrypted_refresh_claim,
|
||||
)
|
||||
.context("app-session state contains an unreadable refresh claim")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_secret_hash(value: &str) -> bool {
|
||||
value.len() == 71
|
||||
&& value.strip_prefix("sha256:").is_some_and(|hex| {
|
||||
hex.bytes()
|
||||
.all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
|
||||
})
|
||||
}
|
||||
|
||||
fn prune_sessions(state: &mut AppSessionState, now: DateTime<Utc>) {
|
||||
let retention_floor = now - chrono::Duration::days(7);
|
||||
state.sessions.retain(|_, session| {
|
||||
session.refresh_expires_at > retention_floor
|
||||
&& session
|
||||
.revoked_at
|
||||
.is_none_or(|revoked_at| revoked_at > retention_floor)
|
||||
});
|
||||
if state.sessions.len() <= MAX_SESSION_RECORDS {
|
||||
return;
|
||||
}
|
||||
let mut oldest = state
|
||||
.sessions
|
||||
.iter()
|
||||
.map(|(key, session)| {
|
||||
let live = session.revoked_at.is_none() && session.refresh_expires_at > now;
|
||||
(live, session.last_refresh_at, key.clone())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
oldest.sort();
|
||||
for (_, _, key) in oldest
|
||||
.into_iter()
|
||||
.take(state.sessions.len() - MAX_SESSION_RECORDS)
|
||||
{
|
||||
state.sessions.remove(&key);
|
||||
}
|
||||
}
|
||||
|
||||
fn read_wrapping_key(path: &Path) -> anyhow::Result<[u8; 32]> {
|
||||
let source = std::fs::read(path)
|
||||
.with_context(|| format!("failed to read session wrapping key {}", path.display()))?;
|
||||
let decoded = if source.len() == 32 {
|
||||
source
|
||||
} else {
|
||||
URL_SAFE_NO_PAD
|
||||
.decode(std::str::from_utf8(&source)?.trim())
|
||||
.context("session wrapping key must be 32 raw bytes or base64url")?
|
||||
};
|
||||
decoded
|
||||
.try_into()
|
||||
.map_err(|_| anyhow::anyhow!("session wrapping key must be exactly 32 bytes"))
|
||||
}
|
||||
|
||||
fn wrap_refresh(
|
||||
key: &[u8; 32],
|
||||
account_hash: &str,
|
||||
session_id: &str,
|
||||
claim: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
let mut iv = [0_u8; 12];
|
||||
rand::rng().fill_bytes(&mut iv);
|
||||
let aad = format!("ghostlight.app_session.refresh.v2\n{account_hash}\n{session_id}");
|
||||
let ciphertext = Aes256Gcm::new_from_slice(key)?
|
||||
.encrypt(
|
||||
(&iv).into(),
|
||||
aes_gcm::aead::Payload {
|
||||
msg: claim.as_bytes(),
|
||||
aad: aad.as_bytes(),
|
||||
},
|
||||
)
|
||||
.map_err(|_| anyhow::anyhow!("failed to wrap Heimdall refresh claim"))?;
|
||||
Ok(format!(
|
||||
"{}.{}",
|
||||
URL_SAFE_NO_PAD.encode(iv),
|
||||
URL_SAFE_NO_PAD.encode(ciphertext)
|
||||
))
|
||||
}
|
||||
|
||||
fn unwrap_refresh(
|
||||
key: &[u8; 32],
|
||||
account_hash: &str,
|
||||
session_id: &str,
|
||||
wrapped: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
let (iv, ciphertext) = wrapped
|
||||
.split_once('.')
|
||||
.context("wrapped refresh claim is malformed")?;
|
||||
let iv: [u8; 12] = URL_SAFE_NO_PAD
|
||||
.decode(iv)?
|
||||
.try_into()
|
||||
.map_err(|_| anyhow::anyhow!("wrapped refresh IV is invalid"))?;
|
||||
let ciphertext = URL_SAFE_NO_PAD.decode(ciphertext)?;
|
||||
let aad = format!("ghostlight.app_session.refresh.v2\n{account_hash}\n{session_id}");
|
||||
let plaintext = Aes256Gcm::new_from_slice(key)?
|
||||
.decrypt(
|
||||
(&iv).into(),
|
||||
aes_gcm::aead::Payload {
|
||||
msg: &ciphertext,
|
||||
aad: aad.as_bytes(),
|
||||
},
|
||||
)
|
||||
.map_err(|_| anyhow::anyhow!("failed to unwrap Heimdall refresh claim"))?;
|
||||
String::from_utf8(plaintext).context("Heimdall refresh claim is not UTF-8")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every `.rs` file under `src`, subdirectories included, with everything
|
||||
/// a `#[cfg(test)]` attribute guards removed — whatever that item is, and
|
||||
/// whatever a test module is called.
|
||||
///
|
||||
/// The item's extent is read from the layout: an attribute at indent `N`
|
||||
/// guards lines until the first later line at indent `N` that closes a
|
||||
/// block, or, for an item with no body, until the first line at indent `N`
|
||||
/// that ends in `;` or `,`. This is a claim about rustfmt's output, not
|
||||
/// about Rust's grammar, and it is the reason the scan does not try to
|
||||
/// balance braces: braces inside string and character literals are not
|
||||
/// block structure, and a scanner that counts them reads the end of a test
|
||||
/// module in the wrong place.
|
||||
fn production_sources() -> Vec<(std::path::PathBuf, String)> {
|
||||
fn indent(line: &str) -> usize {
|
||||
line.len() - line.trim_start().len()
|
||||
}
|
||||
|
||||
fn strip_cfg_test(text: &str) -> String {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let mut kept: Vec<&str> = Vec::new();
|
||||
let mut index = 0;
|
||||
while index < lines.len() {
|
||||
let line = lines[index];
|
||||
if line.trim() != "#[cfg(test)]" {
|
||||
kept.push(line);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
let guard = indent(line);
|
||||
index += 1;
|
||||
// A body opens on the attribute's own item; without one, the
|
||||
// item ends at its first terminator.
|
||||
let mut opened = lines
|
||||
.get(index)
|
||||
.is_some_and(|next| next.trim_end().ends_with('{'));
|
||||
while index < lines.len() {
|
||||
let body = lines[index];
|
||||
index += 1;
|
||||
let trimmed = body.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if opened {
|
||||
if indent(body) == guard
|
||||
&& (trimmed == "}" || trimmed == "};" || trimmed == "},")
|
||||
{
|
||||
break;
|
||||
}
|
||||
} else if indent(body) == guard
|
||||
&& (trimmed.ends_with(';') || trimmed.ends_with(','))
|
||||
{
|
||||
break;
|
||||
} else if indent(body) == guard && trimmed.ends_with('{') {
|
||||
opened = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
kept.join("\n")
|
||||
}
|
||||
|
||||
let mut sources = Vec::new();
|
||||
let mut pending = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")];
|
||||
while let Some(directory) = pending.pop() {
|
||||
for entry in std::fs::read_dir(&directory).expect("the source tree reads") {
|
||||
let path = entry.expect("a readable entry").path();
|
||||
if path.is_dir() {
|
||||
pending.push(path);
|
||||
continue;
|
||||
}
|
||||
if path.extension().and_then(|value| value.to_str()) != Some("rs") {
|
||||
continue;
|
||||
}
|
||||
let text = std::fs::read_to_string(&path)
|
||||
.expect("the source reads")
|
||||
.replace("\r\n", "\n");
|
||||
sources.push((path, strip_cfg_test(&text)));
|
||||
}
|
||||
}
|
||||
sources
|
||||
}
|
||||
|
||||
/// True when the text immediately following the type's name calls its
|
||||
/// `new`. Written as a scan rather than one literal so the qualified
|
||||
/// `<Type>::new(` form and any whitespace the formatter may introduce
|
||||
/// count the same as the plain path.
|
||||
fn calls_new(tail: &str) -> bool {
|
||||
let tail = tail.trim_start();
|
||||
let tail = tail.strip_prefix('>').unwrap_or(tail).trim_start();
|
||||
let Some(tail) = tail.strip_prefix("::") else {
|
||||
return false;
|
||||
};
|
||||
let Some(tail) = tail.trim_start().strip_prefix("new") else {
|
||||
return false;
|
||||
};
|
||||
tail.trim_start().starts_with('(')
|
||||
}
|
||||
|
||||
/// The library owns `VerifiedPrincipalEvidence` and gives it a public
|
||||
/// constructor, so the single-minter rule its private fields used to
|
||||
/// enforce is this one: production mints it in `account_for_cookie` and
|
||||
/// nowhere else, from a live cookie this owner holds custody of. A second
|
||||
/// minter anywhere in Dungeon's production source is a second authority
|
||||
/// over who the kernel believes is speaking.
|
||||
///
|
||||
/// The type is followed by name, so production may not give it a second
|
||||
/// name: an `as` import or a type alias is refused outright rather than
|
||||
/// counted, because a renamed minter is one this test cannot follow. What
|
||||
/// it still cannot see is a call that never spells the type — a macro
|
||||
/// expansion, or a generic instantiated elsewhere.
|
||||
#[test]
|
||||
fn soul_exactly_one_production_site_mints_verified_principal_evidence() {
|
||||
// Assembled from halves so this test's own source is never a match,
|
||||
// whatever the scan above decides about this module's extent.
|
||||
let name = format!("VerifiedPrincipal{}", "Evidence");
|
||||
let mut sites = Vec::new();
|
||||
for (path, production) in production_sources() {
|
||||
for (at, _) in production.match_indices(name.as_str()) {
|
||||
let tail = &production[at + name.len()..];
|
||||
assert!(
|
||||
!tail.trim_start().starts_with("as "),
|
||||
"{} renames {name} on import",
|
||||
path.display()
|
||||
);
|
||||
if calls_new(tail) {
|
||||
sites.push(path.clone());
|
||||
}
|
||||
}
|
||||
for line in production.lines() {
|
||||
assert!(
|
||||
!(line.trim_start().starts_with("type ") && line.contains(name.as_str())),
|
||||
"{} aliases {name} to another name: {line}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
sites.len(),
|
||||
1,
|
||||
"production mints {name} in exactly one place: {sites:?}"
|
||||
);
|
||||
assert!(
|
||||
sites[0].ends_with("app_session.rs"),
|
||||
"the one minter is not this owner: {:?}",
|
||||
sites[0]
|
||||
);
|
||||
}
|
||||
|
||||
fn session(claim: &str) -> VerifiedSessionAdmission {
|
||||
VerifiedSessionAdmission::fixture(
|
||||
"account-1",
|
||||
"heimdall-session-1",
|
||||
3,
|
||||
Utc::now() + chrono::Duration::minutes(5),
|
||||
Utc::now() + chrono::Duration::days(7),
|
||||
claim,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_rejects_an_alternate_messagepack_shape() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let key = directory.path().join("session.key");
|
||||
std::fs::write(&key, [5_u8; 32]).unwrap();
|
||||
let store_path = directory.path().join("app-sessions-v2.cc");
|
||||
let store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap();
|
||||
let state = AppSessionState {
|
||||
schema: STORE_SCHEMA.into(),
|
||||
sessions: BTreeMap::new(),
|
||||
};
|
||||
let row = CultCacheEnvelope {
|
||||
key: STORE_KEY.into(),
|
||||
r#type: STORE_TYPE.into(),
|
||||
payload: rmp_serde::to_vec(&state).unwrap(),
|
||||
stored_at: Utc::now().to_rfc3339(),
|
||||
schema_id: Some(STORE_SCHEMA.into()),
|
||||
};
|
||||
assert!(store.compare_and_swap_batch(&[], vec![row]).unwrap());
|
||||
drop(store);
|
||||
assert!(AppSessionOwner::open(&store_path, &key).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stores_only_session_custody_and_reopens() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let key = directory.path().join("session.key");
|
||||
std::fs::write(&key, [7_u8; 32]).unwrap();
|
||||
let store = directory.path().join("app-sessions-v2.cc");
|
||||
let mut owner = AppSessionOwner::open(&store, &key).unwrap();
|
||||
let cookie = owner
|
||||
.create_session(session("private-refresh-claim"))
|
||||
.unwrap();
|
||||
let account = owner
|
||||
.account_for_cookie(&cookie, Utc::now())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let account_hash = account.account_subject_hash().to_owned();
|
||||
drop(owner);
|
||||
|
||||
let mut reopened = AppSessionOwner::open(&store, &key).unwrap();
|
||||
assert_eq!(
|
||||
reopened
|
||||
.account_for_cookie(&cookie, Utc::now())
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.account_subject_hash(),
|
||||
account_hash
|
||||
);
|
||||
assert!(reopened.revoke_cookie(&cookie).unwrap());
|
||||
assert_eq!(
|
||||
reopened.account_for_cookie(&cookie, Utc::now()).unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_heimdall_adoption_retry_rotates_the_only_live_cookie() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let key = directory.path().join("session.key");
|
||||
std::fs::write(&key, [9_u8; 32]).unwrap();
|
||||
let store = directory.path().join("app-sessions-v2.cc");
|
||||
let mut owner = AppSessionOwner::open(&store, &key).unwrap();
|
||||
let first = owner
|
||||
.create_session(session("same-refresh-receipt"))
|
||||
.unwrap();
|
||||
let second = owner
|
||||
.create_session(session("same-refresh-receipt"))
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(first, second);
|
||||
assert_eq!(owner.account_for_cookie(&first, Utc::now()).unwrap(), None);
|
||||
assert!(
|
||||
owner
|
||||
.account_for_cookie(&second, Utc::now())
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
assert_eq!(
|
||||
owner
|
||||
.state
|
||||
.sessions
|
||||
.values()
|
||||
.filter(|session| session.revoked_at.is_none())
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_legacy_or_foreign_store_instead_of_migrating_it() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let key = directory.path().join("session.key");
|
||||
std::fs::write(&key, [3_u8; 32]).unwrap();
|
||||
let store_path = directory.path().join("app-sessions-v2.cc");
|
||||
let store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap();
|
||||
let foreign = CultCacheEnvelope {
|
||||
key: "primary".into(),
|
||||
r#type: "app_session_store.v1".into(),
|
||||
payload: vec![],
|
||||
stored_at: Utc::now().to_rfc3339(),
|
||||
schema_id: Some("ghostlight.app_session_store.v1".into()),
|
||||
};
|
||||
assert!(store.compare_and_swap_batch(&[], vec![foreign]).unwrap());
|
||||
assert!(AppSessionOwner::open(&store_path, &key).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_claim_is_encrypted_and_exactly_bound() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let key = directory.path().join("session.key");
|
||||
std::fs::write(&key, [11_u8; 32]).unwrap();
|
||||
let store = directory.path().join("app-sessions-v2.cc");
|
||||
let mut owner = AppSessionOwner::open(&store, &key).unwrap();
|
||||
let cookie = owner
|
||||
.create_session(session("needle-private-refresh"))
|
||||
.unwrap();
|
||||
let candidate = owner.session_for_logout(&cookie).unwrap().unwrap();
|
||||
assert_eq!(candidate.refresh_claim, "needle-private-refresh");
|
||||
drop(owner);
|
||||
let bytes = std::fs::read(store).unwrap();
|
||||
assert!(
|
||||
!bytes
|
||||
.windows("needle-private-refresh".len())
|
||||
.any(|window| window == b"needle-private-refresh")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_the_store_path_revokes_session_authentication_until_reopen() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let key = directory.path().join("session.key");
|
||||
std::fs::write(&key, [19_u8; 32]).unwrap();
|
||||
let path = directory.path().join("app-sessions-v2.cc");
|
||||
let displaced = directory.path().join("displaced.cc");
|
||||
let mut owner = AppSessionOwner::open(&path, &key).unwrap();
|
||||
let cookie = owner
|
||||
.create_session(session("private-refresh-claim"))
|
||||
.unwrap();
|
||||
|
||||
std::fs::rename(&path, &displaced).unwrap();
|
||||
std::fs::File::create(&path).unwrap();
|
||||
assert!(owner.account_for_cookie(&cookie, Utc::now()).is_err());
|
||||
assert!(!owner.is_healthy());
|
||||
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
std::fs::rename(&displaced, &path).unwrap();
|
||||
assert!(owner.account_for_cookie(&cookie, Utc::now()).is_err());
|
||||
drop(owner);
|
||||
|
||||
let mut reopened = AppSessionOwner::open(&path, &key).unwrap();
|
||||
assert!(
|
||||
reopened
|
||||
.account_for_cookie(&cookie, Utc::now())
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopen_rejects_corrupt_inner_session_custody() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let key = directory.path().join("session.key");
|
||||
std::fs::write(&key, [23_u8; 32]).unwrap();
|
||||
let path = directory.path().join("app-sessions-v2.cc");
|
||||
let mut owner = AppSessionOwner::open(&path, &key).unwrap();
|
||||
owner
|
||||
.create_session(session("private-refresh-claim"))
|
||||
.unwrap();
|
||||
drop(owner);
|
||||
|
||||
let store = OwnedRedbMessagePackBackingStore::new(&path).unwrap();
|
||||
let rows = store.pull_all().unwrap();
|
||||
let mut state: AppSessionState = rmp_serde::from_slice(&rows[0].payload).unwrap();
|
||||
state
|
||||
.sessions
|
||||
.values_mut()
|
||||
.next()
|
||||
.unwrap()
|
||||
.encrypted_refresh_claim = "not-an-encrypted-claim".into();
|
||||
let corrupt = envelope(&state).unwrap();
|
||||
assert!(store.compare_and_swap_batch(&rows, vec![corrupt]).unwrap());
|
||||
drop(store);
|
||||
|
||||
assert!(AppSessionOwner::open(&path, &key).is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,980 +0,0 @@
|
|||
//! Eve/CultUI projection for the one live world owner.
|
||||
|
||||
use crate::mesh::{COMMAND_BOUNDARY, COMMAND_RECEIPT_SCHEMA, COMMAND_RESULT_SCHEMA, OWNER_REPO, PROVIDER_ID, SURFACE_ID};
|
||||
use crate::play::{PlayTurnState, PlayTurnView};
|
||||
use ghostlight::{JurisdictionKey, WorldPhase, WorldSnapshot};
|
||||
use anyhow::{Context, bail};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct EveCommandInvocation {
|
||||
pub(crate) schema: String,
|
||||
#[serde(rename = "providerId")]
|
||||
pub(crate) provider_id: String,
|
||||
#[serde(rename = "surfaceId")]
|
||||
pub(crate) surface_id: String,
|
||||
pub(crate) operation: EveOperation,
|
||||
pub(crate) payload: Value,
|
||||
#[serde(rename = "issuedAt")]
|
||||
pub(crate) issued_at: String,
|
||||
#[serde(rename = "clientId")]
|
||||
pub(crate) client_id: String,
|
||||
#[serde(rename = "commandBoundary")]
|
||||
pub(crate) command_boundary: String,
|
||||
#[serde(rename = "receiptSchema")]
|
||||
pub(crate) receipt_schema: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub(crate) struct EveOperation {
|
||||
pub(crate) operation_id: String,
|
||||
pub(crate) schema_id: Option<String>,
|
||||
pub(crate) idempotency_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) route_hint: EveRouteHint,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub(crate) struct EveRouteHint {
|
||||
pub(crate) source_version: Option<u64>,
|
||||
pub(crate) transport: Option<String>,
|
||||
}
|
||||
|
||||
/// The `world_create.v4` payload, in one place, so the button that captures it
|
||||
/// and the descriptor that advertises it cannot drift.
|
||||
const CREATE_BINDINGS: [&str; 8] = [
|
||||
"title",
|
||||
"brief",
|
||||
"subject_label",
|
||||
"narrative_persona_label",
|
||||
"operational_agent_label",
|
||||
"targets",
|
||||
"jurisdictions",
|
||||
"lens_weights",
|
||||
];
|
||||
|
||||
const SEED_BINDINGS: [&str; 2] = ["vault_scope", "brief"];
|
||||
|
||||
pub(crate) fn surface_version(snapshot: Option<&WorldSnapshot>) -> u64 {
|
||||
snapshot
|
||||
.map(|world| world.revision.saturating_add(1))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The wake signal `publish_projection` sends over the `revisions` broadcast
|
||||
/// channel and `/api/eve/events` relays — never a value a client feeds back
|
||||
/// as `routeHint.sourceVersion`, and never the surface document's own
|
||||
/// `version` field (PA.f148/PA.f160), which names `surface_version` alone so
|
||||
/// the kernel's `expected_revision = source_version - 1` derivation in
|
||||
/// `runtime.rs` stays sound. It folds `surface_version` with the play row's
|
||||
/// own `revision`, when a play view is available, so a play-row-only change
|
||||
/// (a fresh refusal, a newly open question, a closed turn's narration, none
|
||||
/// of which necessarily commit anything to the world) still wakes a watching
|
||||
/// client even though `surface_version` alone would not move. Additive, not
|
||||
/// paired: both counters only ever grow, so the sum only ever grows. No play
|
||||
/// view (no turn has opened yet, or the table is unavailable) contributes
|
||||
/// nothing, matching `surface_version`'s own reach when no play card renders
|
||||
/// at all.
|
||||
pub(crate) fn authenticated_wake_version(
|
||||
snapshot: Option<&WorldSnapshot>,
|
||||
play: Option<&PlayTurnView>,
|
||||
) -> u64 {
|
||||
surface_version(snapshot).saturating_add(play.map_or(0, |view| view.revision))
|
||||
}
|
||||
|
||||
pub(crate) fn world_state(snapshot: Option<&WorldSnapshot>) -> &'static str {
|
||||
match snapshot.map(|world| world.phase) {
|
||||
None => "empty",
|
||||
Some(WorldPhase::Draft) => "draft",
|
||||
Some(WorldPhase::Active) => "active",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn anonymous_surface() -> Value {
|
||||
anonymous_surface_at(0)
|
||||
}
|
||||
|
||||
pub(crate) fn mesh_surface(snapshot: Option<&WorldSnapshot>) -> Value {
|
||||
anonymous_surface_at(surface_version(snapshot))
|
||||
}
|
||||
|
||||
fn anonymous_surface_at(version: u64) -> Value {
|
||||
surface_document(
|
||||
version,
|
||||
"Ghostlight Dungeon",
|
||||
vec![
|
||||
json!({
|
||||
"id":"ghostlight.access",
|
||||
"kind":"heimdall.access_gate",
|
||||
"props":{
|
||||
"state":"anonymous",
|
||||
"title":"Enter Ghostlight",
|
||||
"detail":"Sign in with Discord to reach the world owner."
|
||||
},
|
||||
"children":[]
|
||||
}),
|
||||
command_button(
|
||||
"ghostlight.auth.begin",
|
||||
"Continue with Discord",
|
||||
"heimdall.auth.begin",
|
||||
json!({}),
|
||||
&[],
|
||||
),
|
||||
],
|
||||
vec![command_descriptor(
|
||||
"heimdall.auth.begin",
|
||||
"heimdall.auth_begin_command.v1",
|
||||
&[],
|
||||
"Heimdall",
|
||||
)],
|
||||
)
|
||||
}
|
||||
|
||||
/// What the seed lane has done and what it will answer next. Everything here is
|
||||
/// derived from `scale_deficit` and `revision`: in Draft every commit is a patch
|
||||
/// admission, so `revision - 1` is exactly the seed patches committed since
|
||||
/// genesis, and "next shortfall" calls the runner's own `select_row` so the card
|
||||
/// and the runner cannot disagree about what happens next.
|
||||
///
|
||||
/// It shows no in-flight session and no last outcome. Both would need a second
|
||||
/// reader of the controller-work store from here and a status cache in
|
||||
/// `AppState`; the last outcome is already in the invocation's own
|
||||
/// `command_result` receipt, which is where a command's result belongs.
|
||||
fn seed_card(world: &WorldSnapshot) -> Value {
|
||||
let next = ghostlight::select_row(world).map_or_else(
|
||||
|| "Nothing short.".to_owned(),
|
||||
|row| {
|
||||
format!(
|
||||
"{} · {:?} · short {}",
|
||||
jurisdiction_label(world, row.jurisdiction),
|
||||
row.kind,
|
||||
row.deficit
|
||||
)
|
||||
},
|
||||
);
|
||||
let rows = world
|
||||
.scale_deficit
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, row)| {
|
||||
json!({
|
||||
"id":format!("world.seed.row.{index}"),
|
||||
"kind":"text",
|
||||
"props":{"value":format!(
|
||||
"{} · {:?} · target {} · alive {} · short {}",
|
||||
jurisdiction_label(world, row.jurisdiction),
|
||||
row.kind,
|
||||
row.target,
|
||||
row.qualified,
|
||||
row.deficit
|
||||
)},
|
||||
"children":[]
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
json!({
|
||||
"id":"world.seed.card",
|
||||
"kind":"card",
|
||||
"props":{
|
||||
"title":"Seed",
|
||||
"detail":format!("Draft patches committed: {}", world.revision.saturating_sub(1)),
|
||||
"nextShortfall":next
|
||||
},
|
||||
"children":rows
|
||||
})
|
||||
}
|
||||
|
||||
fn jurisdiction_label(world: &WorldSnapshot, jurisdiction: JurisdictionKey) -> String {
|
||||
match jurisdiction {
|
||||
JurisdictionKey::PlaceSubtree(root) => world
|
||||
.places
|
||||
.iter()
|
||||
.find(|entry| entry.id == root)
|
||||
.map_or_else(
|
||||
|| "an unnamed place subtree".to_owned(),
|
||||
|entry| entry.label.clone(),
|
||||
),
|
||||
JurisdictionKey::Uncovered => "everything no declared root covers".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `play` arrives beside the snapshot rather than inside it: `WorldSnapshot`
|
||||
/// carries no play-turn state of its own. The play card below reads `play`
|
||||
/// alone, never the operator's own unscoped log of every committed event:
|
||||
/// invariant 8's projection, question, and refusal, nothing else.
|
||||
pub(crate) fn authenticated_surface(
|
||||
account: &str,
|
||||
snapshot: Option<&WorldSnapshot>,
|
||||
play: Option<&PlayTurnView>,
|
||||
) -> anyhow::Result<Value> {
|
||||
// The document's own `version` names the world's `surface_version`
|
||||
// alone — the one meaning `routeHint.sourceVersion` and the kernel's
|
||||
// compare-and-swap require of it (PA.f148/PA.f160). The play row's own
|
||||
// revision moves independently of it and is never folded in; it has no
|
||||
// wire reader of its own (PA.f165) and stays internal to
|
||||
// `eve::authenticated_wake_version`'s SSE wake computation.
|
||||
let version = surface_version(snapshot);
|
||||
let mut children = vec![json!({
|
||||
"id":"ghostlight.identity",
|
||||
"kind":"heimdall.identity",
|
||||
"props":{
|
||||
"state":"authenticated",
|
||||
"title":"Authenticated",
|
||||
"detail":"Heimdall identity is bound server-side.",
|
||||
"displayName":short_principal(account)
|
||||
},
|
||||
"children":[]
|
||||
})];
|
||||
let mut commands = Vec::new();
|
||||
|
||||
match snapshot {
|
||||
None => {
|
||||
// Dungeon policy, editable before submission; the library holds no
|
||||
// default weights.
|
||||
let default_lens_weights =
|
||||
serde_json::to_string(&crate::runtime::uniform_lens_weights())
|
||||
.context("the default lens weights encode")?;
|
||||
children.extend([
|
||||
json!({
|
||||
"id":"world.create.title",
|
||||
"kind":"control.input.text",
|
||||
"props":{"label":"World title","placeholder":"A name for this world"},
|
||||
"stateBindings":[local_draft("title", "string")],
|
||||
"children":[]
|
||||
}),
|
||||
// Cut 10 (PA.f149): `CREATE_BINDINGS` has always advertised
|
||||
// `brief` — `world_create.v4`'s `CreatePayload.brief` is
|
||||
// required, not `#[serde(default)]` — but no control ever
|
||||
// captured it. Driving `world.create` with the real
|
||||
// vendored lowering surfaced this: nothing the lowering
|
||||
// could ever submit through this form would satisfy
|
||||
// `world_create.v4`, hand-built test payloads notwithstanding.
|
||||
json!({
|
||||
"id":"world.create.brief",
|
||||
"kind":"control.input.textarea",
|
||||
// PA.f164: `brief` is documented "required, may be empty"
|
||||
// (`CreatePayload::brief`), the same deliberate-empty
|
||||
// shape as `targets`/`jurisdictions` below — an authored
|
||||
// `value` so a form left untouched here still submits.
|
||||
"props":{"label":"Brief","rows":2,"value":"","placeholder":"One sentence of what this world is for"},
|
||||
"stateBindings":[local_draft("brief", "string")],
|
||||
"children":[]
|
||||
}),
|
||||
json!({
|
||||
"id":"world.create.subject",
|
||||
"kind":"control.input.text",
|
||||
"props":{"label":"Your name","placeholder":"The first person in the world"},
|
||||
"stateBindings":[local_draft("subject_label", "string")],
|
||||
"children":[]
|
||||
}),
|
||||
json!({
|
||||
"id":"world.create.narrative_persona",
|
||||
"kind":"control.input.text",
|
||||
"props":{"label":"Narrative persona (optional)","placeholder":"A person who lives the story in prose"},
|
||||
"stateBindings":[local_draft("narrative_persona_label", "string")],
|
||||
"children":[]
|
||||
}),
|
||||
json!({
|
||||
"id":"world.create.operational_agent",
|
||||
"kind":"control.input.text",
|
||||
"props":{"label":"Operational agent (optional)","placeholder":"An institution or operator-shaped mind"},
|
||||
"stateBindings":[local_draft("operational_agent_label", "string")],
|
||||
"children":[]
|
||||
}),
|
||||
json!({
|
||||
"id":"world.create.targets",
|
||||
"kind":"control.input.textarea",
|
||||
// PA.f164: an authored `value`, not only a `placeholder` —
|
||||
// `findAuthoredBindingValue` (the vendored lowering) only
|
||||
// ever captures a control's `value`, never its
|
||||
// `placeholder`, so a form submitted without editing this
|
||||
// field would otherwise be refused as missing. `{}` is a
|
||||
// legitimate `targets` (a world with no scale target is a
|
||||
// deliberate choice, `CreatePayload`'s own doc comment).
|
||||
"props":{"label":"Scale target","rows":2,"value":"{}","placeholder":"{\"person\": 12, \"institution\": 3}"},
|
||||
"stateBindings":[local_draft("targets", "string")],
|
||||
"children":[]
|
||||
}),
|
||||
json!({
|
||||
"id":"world.create.jurisdictions",
|
||||
"kind":"control.input.textarea",
|
||||
// PA.f164: same gap, `[]` is a legitimate empty root list.
|
||||
"props":{"label":"Jurisdiction roots","rows":3,"value":"[]","placeholder":"[{\"handle\":\"low_sere\",\"label\":\"The Low Sere\",\"permille\":700}]"},
|
||||
"stateBindings":[local_draft("jurisdictions", "string")],
|
||||
"children":[]
|
||||
}),
|
||||
json!({
|
||||
"id":"world.create.lens_weights",
|
||||
"kind":"control.input.textarea",
|
||||
"props":{"label":"Lens weights","rows":2,"value":default_lens_weights,"placeholder":default_lens_weights},
|
||||
"stateBindings":[local_draft("lens_weights", "string")],
|
||||
"children":[]
|
||||
}),
|
||||
command_button(
|
||||
"world.create",
|
||||
"Create world",
|
||||
"world.create",
|
||||
json!({}),
|
||||
&CREATE_BINDINGS,
|
||||
),
|
||||
]);
|
||||
commands.push(command_descriptor(
|
||||
"world.create",
|
||||
"ghostlight.world_create.v4",
|
||||
&CREATE_BINDINGS,
|
||||
"WorldMailbox",
|
||||
));
|
||||
}
|
||||
Some(world) => {
|
||||
// Cut 10 (PA.f159): phase and clock stay; the subject census is
|
||||
// gone — nothing about who else is in the world is the player's
|
||||
// own projection, invariant 8's own "nothing else."
|
||||
children.push(json!({
|
||||
"id":"world.summary",
|
||||
"kind":"card",
|
||||
"props":{"title":world.title,"subtitle":format!("{:?} · revision {} · minute {}", world.phase, world.revision, world.now.0)},
|
||||
"children":[]
|
||||
}));
|
||||
match world.phase {
|
||||
WorldPhase::Draft => {
|
||||
if world
|
||||
.required_approvers
|
||||
.contains(&ghostlight::PrincipalId::new(account))
|
||||
&& !world
|
||||
.draft_approvals
|
||||
.contains(&ghostlight::PrincipalId::new(account))
|
||||
{
|
||||
children.push(command_button(
|
||||
"world.approve",
|
||||
"Approve draft",
|
||||
"world.approve",
|
||||
json!({}),
|
||||
&[],
|
||||
));
|
||||
commands.push(command_descriptor(
|
||||
"world.approve",
|
||||
"ghostlight.world_approve.v0",
|
||||
&[],
|
||||
"WorldMailbox",
|
||||
));
|
||||
}
|
||||
if world.owner == ghostlight::PrincipalId::new(account) {
|
||||
children.extend([
|
||||
json!({
|
||||
"id":"world.seed.vault_scope",
|
||||
"kind":"control.input.text",
|
||||
"props":{"label":"Vault scope (optional)","placeholder":"A subdirectory of the configured vault"},
|
||||
"stateBindings":[local_draft("vault_scope", "string")],
|
||||
"children":[]
|
||||
}),
|
||||
json!({
|
||||
"id":"world.seed.brief",
|
||||
"kind":"control.input.textarea",
|
||||
"props":{"label":"Brief (optional)","rows":2,"placeholder":"One sentence of what this world is for"},
|
||||
"stateBindings":[local_draft("brief", "string")],
|
||||
"children":[]
|
||||
}),
|
||||
command_button(
|
||||
"world.seed",
|
||||
"Seed the world",
|
||||
"world.seed",
|
||||
json!({}),
|
||||
&SEED_BINDINGS,
|
||||
),
|
||||
seed_card(world),
|
||||
]);
|
||||
commands.push(command_descriptor(
|
||||
"world.seed",
|
||||
"ghostlight.world_seed.v1",
|
||||
&SEED_BINDINGS,
|
||||
"SeedPort",
|
||||
));
|
||||
}
|
||||
if world.owner == ghostlight::PrincipalId::new(account)
|
||||
&& world.draft_approvals == world.required_approvers
|
||||
{
|
||||
children.push(command_button(
|
||||
"world.activate",
|
||||
"Activate world",
|
||||
"world.activate",
|
||||
json!({}),
|
||||
&[],
|
||||
));
|
||||
commands.push(command_descriptor(
|
||||
"world.activate",
|
||||
"ghostlight.world_activate.v0",
|
||||
&[],
|
||||
"WorldMailbox",
|
||||
));
|
||||
}
|
||||
}
|
||||
WorldPhase::Active => {
|
||||
if world.owner == ghostlight::PrincipalId::new(account) {
|
||||
children.extend([
|
||||
json!({
|
||||
"id":"world.advance_time.minutes",
|
||||
"kind":"control.input.number",
|
||||
"props":{"label":"Advance minutes","min":1,"max":525_600},
|
||||
"stateBindings":[local_draft("minutes", "number")],
|
||||
"children":[]
|
||||
}),
|
||||
command_button(
|
||||
"world.advance_time",
|
||||
"Advance time",
|
||||
"world.advance_time",
|
||||
json!({}),
|
||||
&["minutes"],
|
||||
),
|
||||
]);
|
||||
commands.push(command_descriptor(
|
||||
"world.advance_time",
|
||||
"ghostlight.world_advance_time.v0",
|
||||
&["minutes"],
|
||||
"WorldMailbox",
|
||||
));
|
||||
}
|
||||
// Cut 10 (PA.f152): owner-gated, like `world.advance_time`
|
||||
// and `world.seed` above — the play card, its controls,
|
||||
// and the `world.play` descriptor are the owner's own
|
||||
// play authority over their own world, not a public
|
||||
// affordance.
|
||||
if world.owner == ghostlight::PrincipalId::new(account) {
|
||||
// Cut 9: the play card. Invariant 8 — the player sees
|
||||
// a projection, the question, and the refusal of
|
||||
// their own act, nothing else — so this reads `play`
|
||||
// alone, never the operator's own unscoped event log:
|
||||
// no id, no other subject's state, no speech the
|
||||
// player did not perceive. Always rendered here, with
|
||||
// empty rows before any turn has ever opened.
|
||||
let mut play_rows = Vec::new();
|
||||
if let Some(narration) = play.and_then(|view| view.narration.as_deref()) {
|
||||
play_rows.push(json!({
|
||||
"id":"world.play.narration",
|
||||
"kind":"text",
|
||||
"props":{"value":narration},
|
||||
"children":[]
|
||||
}));
|
||||
}
|
||||
if let Some(question) = play.and_then(|view| view.question.as_ref()) {
|
||||
play_rows.push(json!({
|
||||
"id":"world.play.question",
|
||||
"kind":"text",
|
||||
"props":{"value":question.text.as_str()},
|
||||
"children":[]
|
||||
}));
|
||||
}
|
||||
if let Some(refusal) = play.and_then(|view| view.refusal.as_deref()) {
|
||||
play_rows.push(json!({
|
||||
"id":"world.play.refusal",
|
||||
"kind":"text",
|
||||
"props":{"value":refusal},
|
||||
"children":[]
|
||||
}));
|
||||
}
|
||||
// PA.f193-D ("the interrupted turn"; renumbered from
|
||||
// the PA.f188 duplicate this file's own comment used
|
||||
// to carry — PA.f188 names only `f295a3a`'s
|
||||
// construction-site coverage): a turn a process died
|
||||
// mid-round leaves `Running` forever — nothing
|
||||
// resumes it on its own, and the only way forward is
|
||||
// submitting an empty message (`play.rs`'s own
|
||||
// `Some(existing) if existing.state ==
|
||||
// PlayTurnState::Running` admit arm: empty text
|
||||
// continues it, non-empty text is refused as
|
||||
// `TurnStillRunning`). Before this the card showed
|
||||
// nothing at all in that state — no narration, no
|
||||
// question, no refusal, since none of those are set
|
||||
// on a turn that never got to close, ask, or record a
|
||||
// refused act since it last opened — so a player had
|
||||
// no way to learn the empty-submit continue exists at
|
||||
// all. This is a control hint about the player's own
|
||||
// turn, not world truth, so it stays within
|
||||
// invariant 8 the same way the narration/question/
|
||||
// refusal rows above already do.
|
||||
//
|
||||
// `&& !view.run_in_progress`: `state == Running`
|
||||
// alone is not "nothing is happening" — it is also
|
||||
// true for the seconds a healthy turn is genuinely
|
||||
// running its own round loop. In that window `admit`
|
||||
// fails `run_lock`'s own `try_lock_owned` before
|
||||
// inspecting anything and refuses as `TurnBusy`,
|
||||
// contradicting a hint that told the player an empty
|
||||
// continue would be accepted. `run_in_progress` is
|
||||
// `current_turn_view`'s own non-blocking peek at that
|
||||
// same lock, so the hint now renders only for a
|
||||
// `Running` turn nothing is actively working on.
|
||||
if play.is_some_and(|view| view.state == PlayTurnState::Running && !view.run_in_progress) {
|
||||
play_rows.push(json!({
|
||||
"id":"world.play.running",
|
||||
"kind":"text",
|
||||
"props":{"value":"Your last turn did not finish. If nothing arrives, \
|
||||
submit an empty message to continue it."},
|
||||
"children":[]
|
||||
}));
|
||||
}
|
||||
// PA.f194-D: with the hint above correctly suppressed
|
||||
// while a round is genuinely in flight
|
||||
// (`run_in_progress`), the card had nothing left to
|
||||
// render for the whole span between submitting a turn
|
||||
// and its own narration/question/refusal landing —
|
||||
// narration/question/refusal are all still whatever
|
||||
// the *previous* turn left behind (or unset, on a
|
||||
// fresh table), and the round in progress does not
|
||||
// touch any of them until it closes or opens a
|
||||
// question. A player who submits and sees an
|
||||
// unchanged or empty card has no sign anything is
|
||||
// happening. This row renders only while this
|
||||
// player's own turn is actively being worked on —
|
||||
// `run_in_progress`, not world truth, and stays
|
||||
// within invariant 8 the same way the hint above
|
||||
// does.
|
||||
if play.is_some_and(|view| view.run_in_progress) {
|
||||
play_rows.push(json!({
|
||||
"id":"world.play.resolving",
|
||||
"kind":"text",
|
||||
"props":{"value":"Resolving your turn…"},
|
||||
"children":[]
|
||||
}));
|
||||
}
|
||||
children.push(json!({
|
||||
"id":"world.play.card",
|
||||
"kind":"card",
|
||||
"props":{"title":"Play"},
|
||||
"children":play_rows
|
||||
}));
|
||||
|
||||
// Cut 10 (PA.f151): no `answers`/question-id control
|
||||
// any more. The lowering has no authored, non-editable
|
||||
// binding value (`hidden` is not a prop any renderer
|
||||
// reads — Soul's own probe found it rendered as a
|
||||
// plain, editable, visible text box holding the raw
|
||||
// id), so the server resolves the open question
|
||||
// itself instead of trusting a client-supplied id at
|
||||
// all (`runtime.rs::execute_world`'s `world.play`
|
||||
// arm). PA.f170's guarantee now holds by an opaque
|
||||
// per-question token rather than PA.f161's version
|
||||
// comparison (see `play::PlayTurn::question_token`'s
|
||||
// own doc comment for why a version alone cannot tell
|
||||
// two questions in the same turn apart): the button's
|
||||
// own `props.action` carries it DOM-invisibly, the
|
||||
// way `props.action.command` already does — a real
|
||||
// client spreads a button's own `action` fields
|
||||
// straight into the payload beside `bindings`
|
||||
// (`commandPayload`,
|
||||
// `vendor/eve/packages/eve-browser-lowering/src/index.ts:2101`),
|
||||
// never through a renderer that could show or edit
|
||||
// it. `None` while no question is open: the button's
|
||||
// own action then carries no token at all, matching a
|
||||
// plain continue/opening, which needs none.
|
||||
// PA.f179: `question.token` is `Option<String>` — a
|
||||
// `None` here (a question open whose own token was,
|
||||
// for whatever reason, never minted) serializes as a
|
||||
// JSON `null`, which `runtime.rs`'s own
|
||||
// `answer_token_admits` gate refuses unconditionally,
|
||||
// the same as any other mismatch. It is never
|
||||
// rendered as an empty string a forged answer could
|
||||
// match.
|
||||
let play_action = play
|
||||
.and_then(|view| view.question.as_ref())
|
||||
.map(|question| json!({"answerToken": question.token}))
|
||||
.unwrap_or_else(|| json!({}));
|
||||
children.extend([
|
||||
json!({
|
||||
"id":"world.play.text",
|
||||
"kind":"control.input.textarea",
|
||||
"props":{"label":"What do you do?","rows":3,"placeholder":"Write freely"},
|
||||
"stateBindings":[local_draft("text", "string")],
|
||||
"children":[]
|
||||
}),
|
||||
command_button(
|
||||
"world.play",
|
||||
"Play",
|
||||
"world.play",
|
||||
play_action,
|
||||
&["text"],
|
||||
),
|
||||
]);
|
||||
commands.push(command_descriptor(
|
||||
"world.play",
|
||||
"ghostlight.world_play.v0",
|
||||
&["text"],
|
||||
"PlayTable",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
children.push(command_button(
|
||||
"app.auth.logout",
|
||||
"Sign out",
|
||||
"app.auth.logout",
|
||||
json!({}),
|
||||
&[],
|
||||
));
|
||||
commands.push(command_descriptor(
|
||||
"app.auth.logout",
|
||||
"ghostlight.app_logout.v2",
|
||||
&[],
|
||||
"AppSessionOwner",
|
||||
));
|
||||
Ok(surface_document(
|
||||
version,
|
||||
snapshot
|
||||
.map(|world| world.title.as_str())
|
||||
.unwrap_or("Ghostlight Dungeon"),
|
||||
children,
|
||||
commands,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn validate_invocation(
|
||||
invocation: &EveCommandInvocation,
|
||||
expected_transport: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
if invocation.schema != "gamecult.eve.command_invocation.v1"
|
||||
|| invocation.provider_id != PROVIDER_ID
|
||||
|| invocation.surface_id != SURFACE_ID
|
||||
|| invocation.command_boundary != COMMAND_BOUNDARY
|
||||
|| invocation.receipt_schema != COMMAND_RESULT_SCHEMA
|
||||
{
|
||||
bail!("invocation does not match the Ghostlight Eve boundary");
|
||||
}
|
||||
if invocation.operation.operation_id.trim().is_empty()
|
||||
|| invocation
|
||||
.operation
|
||||
.idempotency_key
|
||||
.as_deref()
|
||||
.is_none_or(str::is_empty)
|
||||
|| invocation
|
||||
.operation
|
||||
.schema_id
|
||||
.as_deref()
|
||||
.is_none_or(str::is_empty)
|
||||
|| invocation.client_id.trim().is_empty()
|
||||
|| invocation.issued_at.parse::<DateTime<Utc>>().is_err()
|
||||
|| invocation.operation.route_hint.transport.as_deref() != Some(expected_transport)
|
||||
{
|
||||
bail!("invocation metadata is incomplete");
|
||||
}
|
||||
let expected = operation_schema(&invocation.operation.operation_id)
|
||||
.context("Eve operation is not advertised by Ghostlight")?;
|
||||
if invocation.operation.schema_id.as_deref() != Some(expected) {
|
||||
bail!("operation payload schema does not match its command descriptor");
|
||||
}
|
||||
if invocation.payload.get("caller").is_some()
|
||||
|| invocation.payload.get("caller_id").is_some()
|
||||
|| invocation.payload.get("callerId").is_some()
|
||||
{
|
||||
bail!("payload may not supply caller authority");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn operation_schema(operation: &str) -> Option<&'static str> {
|
||||
Some(match operation {
|
||||
"heimdall.auth.begin" => "heimdall.auth_begin_command.v1",
|
||||
"heimdall.auth.complete" => "heimdall.auth_complete_command.v1",
|
||||
"app.auth.logout" => "ghostlight.app_logout.v2",
|
||||
"world.create" => "ghostlight.world_create.v4",
|
||||
"world.approve" => "ghostlight.world_approve.v0",
|
||||
"world.activate" => "ghostlight.world_activate.v0",
|
||||
"world.advance_time" => "ghostlight.world_advance_time.v0",
|
||||
"world.seed" => "ghostlight.world_seed.v1",
|
||||
"world.play" => "ghostlight.world_play.v0",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// One command result in the shape `gamecult.eve.command_result.v1` actually
|
||||
/// admits: `additionalProperties: false` over exactly `schema`, `receipt`,
|
||||
/// `transientProjection`, `pluginPayload` and `draftDirective`, with the
|
||||
/// per-command detail (state, message, source version, ids) inside the
|
||||
/// receipt, which is `gamecult.eve.command_receipt.v1` and does allow its own
|
||||
/// extra fields.
|
||||
///
|
||||
/// The browser validates this envelope before it looks at anything in it, so
|
||||
/// a flat result is not a lenient dialect of the contract — it is rejected
|
||||
/// whole, with the client reporting "invalid Eve command result: data must
|
||||
/// not have additional properties" and no command of any kind completing.
|
||||
pub(crate) fn command_result(
|
||||
invocation: &EveCommandInvocation,
|
||||
state: &str,
|
||||
message: impl Into<String>,
|
||||
source_version: Option<u64>,
|
||||
plugin_payload: Option<Value>,
|
||||
receipt_extra: Option<Value>,
|
||||
) -> Value {
|
||||
let message = message.into();
|
||||
// A refusal is otherwise visible only to the browser that received it.
|
||||
// The operator log gets the operation and the refusal's own message,
|
||||
// never the payload.
|
||||
if !matches!(state, "accepted" | "completed" | "pending") {
|
||||
tracing::warn!(
|
||||
operation = %invocation.operation.operation_id,
|
||||
state,
|
||||
message = %message,
|
||||
"Eve command not accepted"
|
||||
);
|
||||
}
|
||||
let mut receipt = json!({
|
||||
"schema":COMMAND_RECEIPT_SCHEMA,
|
||||
"receiptId":Uuid::new_v4().to_string(),
|
||||
"commandId":invocation.operation.idempotency_key.clone().unwrap_or_default(),
|
||||
"command":invocation.operation.operation_id,
|
||||
"state":state,
|
||||
"ownerRepo":OWNER_REPO,
|
||||
"authority":PROVIDER_ID,
|
||||
"providerId":PROVIDER_ID,
|
||||
"surfaceId":SURFACE_ID,
|
||||
"message":message,
|
||||
// The contract requires a source version and refuses a null one. A
|
||||
// command that reports no version reports the surface it was decided
|
||||
// against, which is 0 before any world exists.
|
||||
"sourceVersion":source_version.unwrap_or(0),
|
||||
"issuedAtUtc":Utc::now().to_rfc3339()
|
||||
});
|
||||
if let (Some(map), Some(extra)) = (receipt.as_object_mut(), receipt_extra) {
|
||||
if let Some(extra) = extra.as_object() {
|
||||
for (key, value) in extra {
|
||||
map.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut result = json!({
|
||||
"schema":COMMAND_RESULT_SCHEMA,
|
||||
"receipt":receipt
|
||||
});
|
||||
if let (Some(map), Some(payload)) = (result.as_object_mut(), plugin_payload) {
|
||||
map.insert("pluginPayload".into(), payload);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn surface_document(
|
||||
version: u64,
|
||||
title: &str,
|
||||
children: Vec<Value>,
|
||||
commands: Vec<Value>,
|
||||
) -> Value {
|
||||
json!({
|
||||
"type":"surface-state",
|
||||
"schema":"gamecult.eve.surface.v1",
|
||||
"providerId":PROVIDER_ID,
|
||||
"providerKind":"narrative.simulation",
|
||||
"title":title,
|
||||
// The one meaning `routeHint.sourceVersion` and the kernel's
|
||||
// compare-and-swap require of it (PA.f148/PA.f160): `surface_version`
|
||||
// alone. The play row's own revision has no wire reader (PA.f165)
|
||||
// and is never carried here.
|
||||
"version":version,
|
||||
"updatedAtUtc":Utc::now().to_rfc3339(),
|
||||
"surface":{
|
||||
"id":SURFACE_ID,
|
||||
"root":{"id":"ghostlight.root","kind":"surface","props":{},"children":children},
|
||||
"styles":{"tokens":{
|
||||
"colorBackground":"#0c1110",
|
||||
"colorPanel":"#17201d",
|
||||
"colorText":"#e8e1cf",
|
||||
"colorMuted":"#9aa69f",
|
||||
"colorAccent":"#d49b58"
|
||||
}}
|
||||
},
|
||||
"commands":commands
|
||||
})
|
||||
}
|
||||
|
||||
fn command_button(id: &str, label: &str, command: &str, action: Value, bindings: &[&str]) -> Value {
|
||||
let mut action = action.as_object().cloned().unwrap_or_default();
|
||||
action.insert("command".into(), Value::String(command.into()));
|
||||
json!({
|
||||
"id":id,
|
||||
"kind":"control.button",
|
||||
"props":{"label":label,"command":command,"action":action,"captureBindings":bindings},
|
||||
"children":[]
|
||||
})
|
||||
}
|
||||
|
||||
fn command_descriptor(command: &str, schema: &str, bindings: &[&str], authority: &str) -> Value {
|
||||
json!({
|
||||
"schema":"gamecult.eve.command.v1",
|
||||
"command":command,
|
||||
"payloadSchema":schema,
|
||||
"captureBindings":bindings,
|
||||
"transport":"https-json",
|
||||
"authority":authority
|
||||
})
|
||||
}
|
||||
|
||||
/// A `captureBindings` entry the vendored browser lowering
|
||||
/// (`@gamecult/eve-browser-lowering`, `EveStateBindingDescriptor`) actually
|
||||
/// resolves, field-for-field the same shape its own test suite
|
||||
/// (`test/host-isolation.test.mjs`) builds for its own `composer.message`
|
||||
/// local-draft binding: `targetProp`, `pointerId`, `sourceId`, `schemaId`,
|
||||
/// `routeKind`, `bindingName`, `valueKind`, `accessMode`, `authority`.
|
||||
///
|
||||
/// Two fields are load-bearing. `bindingName` must equal `key`, since `key`
|
||||
/// is also the exact string Dungeon's own `captureBindings` array and
|
||||
/// `command_descriptor` advertise — `findAuthoredBindingValue` and
|
||||
/// `editableBindingContext` both match a binding by this field (falling back
|
||||
/// to `pointerId`, then the control's own node id, only when it is absent).
|
||||
/// `accessMode` must stay `"local-draft"`: `editableBindingContext`
|
||||
/// (`src/index.ts:2198`) defaults an absent `accessMode` to `"read"` whenever
|
||||
/// a binding is present at all, and a `"read"` control is disabled
|
||||
/// (`:2205`) — an editable field with no `accessMode` here would render
|
||||
/// disabled, not merely miscaptured. The remaining fields
|
||||
/// (`targetProp`, `pointerId`, `sourceId`, `schemaId`, `routeKind`,
|
||||
/// `valueKind`, `authority`) carry no runtime behavior in the current
|
||||
/// lowering (grep confirms `valueKind` is read nowhere), but are still worth
|
||||
/// stating correctly rather than leaving absent.
|
||||
///
|
||||
/// `value_kind` must be one of the lowering's own `valueKind` union
|
||||
/// (`"string" | "number" | "boolean" | "choice" | "string-list"`); Dungeon's
|
||||
/// own JSON-textarea fields (`targets`, `jurisdictions`, `lens_weights`) are,
|
||||
/// client-side, plain text controls like any other — there is no JSON
|
||||
/// `valueKind` — so callers pass `"string"` for those too, and the server
|
||||
/// side parses the captured text as JSON (`deserialize_json_capture` in
|
||||
/// `runtime.rs`).
|
||||
fn local_draft(key: &str, value_kind: &str) -> Value {
|
||||
json!({
|
||||
"targetProp":"value",
|
||||
"pointerId":format!("ghostlight.local.{key}"),
|
||||
"sourceId":"eve.browser.local",
|
||||
"schemaId":"gamecult.eve.local_draft.v1",
|
||||
"routeKind":"in-process",
|
||||
"bindingName":key,
|
||||
"valueKind":value_kind,
|
||||
"accessMode":"local-draft",
|
||||
"authority":"eve.browser"
|
||||
})
|
||||
}
|
||||
|
||||
fn short_principal(principal: &str) -> String {
|
||||
principal.chars().take(20).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn invocation(operation: &str, schema: &str, payload: Value) -> EveCommandInvocation {
|
||||
EveCommandInvocation {
|
||||
schema: "gamecult.eve.command_invocation.v1".into(),
|
||||
provider_id: PROVIDER_ID.into(),
|
||||
surface_id: SURFACE_ID.into(),
|
||||
operation: EveOperation {
|
||||
operation_id: operation.into(),
|
||||
schema_id: Some(schema.into()),
|
||||
idempotency_key: Some(uuid::Uuid::new_v4().to_string()),
|
||||
route_hint: EveRouteHint {
|
||||
source_version: Some(0),
|
||||
transport: Some("https-json".into()),
|
||||
},
|
||||
},
|
||||
payload,
|
||||
issued_at: Utc::now().to_rfc3339(),
|
||||
client_id: "fixture".into(),
|
||||
command_boundary: COMMAND_BOUNDARY.into(),
|
||||
receipt_schema: COMMAND_RESULT_SCHEMA.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anonymous_surface_advertises_only_authentication() {
|
||||
let surface = anonymous_surface();
|
||||
let commands = surface["commands"].as_array().unwrap();
|
||||
assert_eq!(commands.len(), 1);
|
||||
assert_eq!(commands[0]["command"], "heimdall.auth.begin");
|
||||
assert_eq!(surface["version"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_authenticated_surface_has_create_without_session_zero() {
|
||||
let surface = authenticated_surface("sha256:owner", None, None).unwrap();
|
||||
let encoded = serde_json::to_string(&surface).unwrap();
|
||||
assert!(encoded.contains("world.create"));
|
||||
assert!(encoded.contains("narrative_persona_label"));
|
||||
assert!(encoded.contains("operational_agent_label"));
|
||||
assert!(!encoded.contains("session_zero"));
|
||||
assert!(!encoded.contains("campaign"));
|
||||
assert_eq!(surface["version"], 0);
|
||||
|
||||
// The lens control's default is Dungeon's uniform policy, every stock
|
||||
// lens named, and it decodes as the weights the payload will carry.
|
||||
fn find<'a>(node: &'a Value, id: &str) -> Option<&'a Value> {
|
||||
if node["id"] == id {
|
||||
return Some(node);
|
||||
}
|
||||
node.as_object()?
|
||||
.values()
|
||||
.flat_map(|value| match value {
|
||||
Value::Array(items) => items.iter().collect::<Vec<_>>(),
|
||||
other => vec![other],
|
||||
})
|
||||
.find_map(|child| find(child, id))
|
||||
}
|
||||
let control = find(&surface, "world.create.lens_weights").expect("a lens weights control");
|
||||
let default = control["props"]["value"].as_str().expect("a default value");
|
||||
let decoded: ghostlight::LensWeights = serde_json::from_str(default).unwrap();
|
||||
assert_eq!(decoded, crate::runtime::uniform_lens_weights());
|
||||
assert_eq!(decoded.iter().count(), ghostlight::Lens::ALL.len());
|
||||
assert!(decoded.iter().all(|(_, weight)| weight == 1));
|
||||
}
|
||||
|
||||
/// `world.play`'s own operation id resolves to its schema through
|
||||
/// `operation_schema`, so an invocation whose payload matches it
|
||||
/// validates, and a payload that tries to claim authority is refused —
|
||||
/// the same claim this test once made for the now-deleted speak
|
||||
/// operation, PA.f146's own precedent (Cut 8b's schema existed here
|
||||
/// before Cut 9 published `world.play`'s own descriptor and button,
|
||||
/// below).
|
||||
#[test]
|
||||
fn payload_cannot_claim_authority() {
|
||||
assert_eq!(operation_schema("world.play"), Some("ghostlight.world_play.v0"));
|
||||
|
||||
let bound = invocation(
|
||||
"world.play",
|
||||
"ghostlight.world_play.v0",
|
||||
json!({"text":"I look around.", "answers": null}),
|
||||
);
|
||||
assert!(validate_invocation(&bound, "https-json").is_ok());
|
||||
|
||||
let forged = invocation(
|
||||
"world.play",
|
||||
"ghostlight.world_play.v0",
|
||||
json!({"text":"I look around.", "answers": null, "caller":"owner"}),
|
||||
);
|
||||
assert!(validate_invocation(&forged, "https-json").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invocation_metadata_rejects_legacy_authority_extensions() {
|
||||
let exact = invocation(
|
||||
"world.play",
|
||||
"ghostlight.world_play.v0",
|
||||
json!({"text":"I look around.", "answers": null}),
|
||||
);
|
||||
let mut outer = serde_json::to_value(&exact).unwrap();
|
||||
outer["callerId"] = json!("legacy-owner");
|
||||
assert!(serde_json::from_value::<EveCommandInvocation>(outer).is_err());
|
||||
|
||||
let mut route = serde_json::to_value(&exact).unwrap();
|
||||
route["operation"]["routeHint"]["caller"] = json!("legacy-owner");
|
||||
assert!(serde_json::from_value::<EveCommandInvocation>(route).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_operation_is_unknown() {
|
||||
assert!(operation_schema("session_zero.begin").is_none());
|
||||
assert!(operation_schema("world.assess").is_none());
|
||||
assert!(operation_schema("governance.time.propose").is_none());
|
||||
assert!(operation_schema("world.speak").is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
{
|
||||
"schema": "ghostlight.eve_client_bridge_fixture.v1",
|
||||
"steps": [
|
||||
{
|
||||
"intent": {
|
||||
"clientId": "eve.bridge.test",
|
||||
"commandBoundary": "ghostlight.eve.commands",
|
||||
"issuedAt": "2026-09-22T23:30:03.348Z",
|
||||
"operation": {
|
||||
"idempotencyKey": "0af70fc4-d2bf-4f9b-9914-696a27580065",
|
||||
"operationId": "world.create",
|
||||
"routeHint": {
|
||||
"sourceVersion": 0,
|
||||
"transport": "https-json"
|
||||
},
|
||||
"schemaId": "ghostlight.world_create.v4"
|
||||
},
|
||||
"payload": {
|
||||
"bindings": {
|
||||
"brief": "A world captured for the committed Eve client bridge fixture.",
|
||||
"jurisdictions": "[]",
|
||||
"lens_weights": "{\"patina\":1,\"charter\":1,\"ledger\":1,\"hearth\":1,\"tangle\":1,\"veil\":1,\"ember\":1,\"numen\":1}",
|
||||
"subject_label": "The Fixture Operator",
|
||||
"targets": "{}",
|
||||
"title": "Bridge Fixture World"
|
||||
}
|
||||
},
|
||||
"providerId": "gamecult.ghostlight.dungeon",
|
||||
"receiptSchema": "gamecult.eve.command_result.v1",
|
||||
"schema": "gamecult.eve.command_invocation.v1",
|
||||
"surfaceId": "ghostlight.play"
|
||||
},
|
||||
"label": "world.create"
|
||||
},
|
||||
{
|
||||
"intent": {
|
||||
"clientId": "eve.bridge.test",
|
||||
"commandBoundary": "ghostlight.eve.commands",
|
||||
"issuedAt": "2026-09-22T23:30:05.143Z",
|
||||
"operation": {
|
||||
"idempotencyKey": "99503a87-9e15-45b7-8776-765a8dc4216b",
|
||||
"operationId": "world.approve",
|
||||
"routeHint": {
|
||||
"sourceVersion": 1,
|
||||
"transport": "https-json"
|
||||
},
|
||||
"schemaId": "ghostlight.world_approve.v0"
|
||||
},
|
||||
"payload": {},
|
||||
"providerId": "gamecult.ghostlight.dungeon",
|
||||
"receiptSchema": "gamecult.eve.command_result.v1",
|
||||
"schema": "gamecult.eve.command_invocation.v1",
|
||||
"surfaceId": "ghostlight.play"
|
||||
},
|
||||
"label": "world.approve"
|
||||
},
|
||||
{
|
||||
"intent": {
|
||||
"clientId": "eve.bridge.test",
|
||||
"commandBoundary": "ghostlight.eve.commands",
|
||||
"issuedAt": "2026-09-22T23:30:07.195Z",
|
||||
"operation": {
|
||||
"idempotencyKey": "1d6a441e-f173-41af-a798-a9123039d5e0",
|
||||
"operationId": "world.seed",
|
||||
"routeHint": {
|
||||
"sourceVersion": 2,
|
||||
"transport": "https-json"
|
||||
},
|
||||
"schemaId": "ghostlight.world_seed.v1"
|
||||
},
|
||||
"payload": {
|
||||
"bindings": {}
|
||||
},
|
||||
"providerId": "gamecult.ghostlight.dungeon",
|
||||
"receiptSchema": "gamecult.eve.command_result.v1",
|
||||
"schema": "gamecult.eve.command_invocation.v1",
|
||||
"surfaceId": "ghostlight.play"
|
||||
},
|
||||
"label": "world.seed"
|
||||
},
|
||||
{
|
||||
"intent": {
|
||||
"clientId": "eve.bridge.test",
|
||||
"commandBoundary": "ghostlight.eve.commands",
|
||||
"issuedAt": "2026-09-22T23:30:09.010Z",
|
||||
"operation": {
|
||||
"idempotencyKey": "20d01b36-cdfa-4e59-b5f3-31be54d94bc6",
|
||||
"operationId": "world.activate",
|
||||
"routeHint": {
|
||||
"sourceVersion": 2,
|
||||
"transport": "https-json"
|
||||
},
|
||||
"schemaId": "ghostlight.world_activate.v0"
|
||||
},
|
||||
"payload": {},
|
||||
"providerId": "gamecult.ghostlight.dungeon",
|
||||
"receiptSchema": "gamecult.eve.command_result.v1",
|
||||
"schema": "gamecult.eve.command_invocation.v1",
|
||||
"surfaceId": "ghostlight.play"
|
||||
},
|
||||
"label": "world.activate"
|
||||
},
|
||||
{
|
||||
"intent": {
|
||||
"clientId": "eve.bridge.test",
|
||||
"commandBoundary": "ghostlight.eve.commands",
|
||||
"issuedAt": "2026-09-22T23:30:11.040Z",
|
||||
"operation": {
|
||||
"idempotencyKey": "2301df6e-397b-4d5b-afa3-ef965e57b49a",
|
||||
"operationId": "world.play",
|
||||
"routeHint": {
|
||||
"sourceVersion": 3,
|
||||
"transport": "https-json"
|
||||
},
|
||||
"schemaId": "ghostlight.world_play.v0"
|
||||
},
|
||||
"payload": {
|
||||
"bindings": {
|
||||
"text": "The new owner looks around."
|
||||
}
|
||||
},
|
||||
"providerId": "gamecult.ghostlight.dungeon",
|
||||
"receiptSchema": "gamecult.eve.command_result.v1",
|
||||
"schema": "gamecult.eve.command_invocation.v1",
|
||||
"surfaceId": "ghostlight.play"
|
||||
},
|
||||
"label": "world.play"
|
||||
}
|
||||
],
|
||||
"vendorEveSubmoduleRevision": "672c0c1e9fc828b63752345385727b06c50491d0"
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,21 +0,0 @@
|
|||
mod app_session;
|
||||
mod eve;
|
||||
mod heimdall;
|
||||
mod idunn_health;
|
||||
mod mesh;
|
||||
mod play;
|
||||
mod runtime;
|
||||
|
||||
use anyhow::bail;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let args = std::env::args_os().skip(1).collect::<Vec<_>>();
|
||||
let state_root = match args.as_slice() {
|
||||
[flag, path] if flag == "--state-root" => Some(PathBuf::from(path)),
|
||||
[] => None,
|
||||
_ => bail!("usage: ghostlight-dungeon [--state-root PATH]"),
|
||||
};
|
||||
runtime::run(state_root).await
|
||||
}
|
||||
|
|
@ -1,464 +0,0 @@
|
|||
//! Derived CultMesh and Eve projection.
|
||||
//!
|
||||
//! The mesh publishes the world owner's current surface. It never pulls or
|
||||
//! repairs world state and it has no command authority.
|
||||
|
||||
use crate::eve;
|
||||
use ghostlight::{CONSUMER_PATCH_SCHEMA, CONSUMER_RECEIPT_SCHEMA, STATE_SCHEMA, WorldSnapshot};
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
use cultcache_rs::DatabaseEntry;
|
||||
use cultmesh_rs::{
|
||||
CultMesh, CultMeshNode, CultMeshNodeOptions, CultMeshRudpDocumentPublishOptions,
|
||||
publish_cultnet_messages_to_rudp_catalog,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
net::SocketAddr,
|
||||
path::Path,
|
||||
sync::{
|
||||
Arc, Condvar, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
thread,
|
||||
};
|
||||
|
||||
pub(crate) const PROVIDER_ID: &str = "gamecult.ghostlight.dungeon";
|
||||
pub(crate) const SURFACE_ID: &str = "ghostlight.play";
|
||||
pub(crate) const COMMAND_BOUNDARY: &str = "ghostlight.eve.commands";
|
||||
pub(crate) const COMMAND_RESULT_SCHEMA: &str = "gamecult.eve.command_result.v1";
|
||||
pub(crate) const COMMAND_RECEIPT_SCHEMA: &str = "gamecult.eve.command_receipt.v1";
|
||||
/// The repository that owns this surface, as the command receipt contract
|
||||
/// requires of every result.
|
||||
pub(crate) const OWNER_REPO: &str = "GameCult/Ghostlight";
|
||||
|
||||
const HEALTH_KEY: &str = "ghostlight:dungeon:health";
|
||||
const ADVERTISEMENT_KEY: &str = "eve:provider:gamecult.ghostlight.dungeon";
|
||||
const SURFACE_KEY: &str = "eve:surface:ghostlight.play";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
|
||||
#[cultcache(type = "gamecult.eve.surface", schema = "gamecult.eve.surface.v1")]
|
||||
struct EveSurfaceRecord {
|
||||
#[cultcache(key = 0)]
|
||||
value: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
|
||||
#[cultcache(
|
||||
type = "gamecult.eve.provider_advertisement",
|
||||
schema = "gamecult.eve.provider_advertisement.v1"
|
||||
)]
|
||||
struct EveProviderAdvertisementRecord {
|
||||
#[cultcache(key = 0)]
|
||||
value: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
|
||||
#[cultcache(
|
||||
type = "ghostlight.service_health",
|
||||
schema = "ghostlight.service_health.v2"
|
||||
)]
|
||||
struct ServiceHealthRecord {
|
||||
#[cultcache(key = 0)]
|
||||
value: Value,
|
||||
}
|
||||
|
||||
cultmesh_rs::cultmesh_documents!(GhostlightDocuments {
|
||||
EveSurfaceRecord => "gamecult.eve.surface.v1",
|
||||
EveProviderAdvertisementRecord => "gamecult.eve.provider_advertisement.v1",
|
||||
ServiceHealthRecord => "ghostlight.service_health.v2",
|
||||
});
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct MeshRuntimeIdentity {
|
||||
pub(crate) runtime_id: String,
|
||||
pub(crate) service_id: String,
|
||||
pub(crate) located_service: String,
|
||||
}
|
||||
|
||||
impl Default for MeshRuntimeIdentity {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
runtime_id: "ghostlight-dungeon".into(),
|
||||
service_id: "ghostlight-dungeon".into(),
|
||||
located_service: "local".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct MeshPublisher {
|
||||
node: Arc<Mutex<CultMeshNode>>,
|
||||
remote: Option<RemoteReplication>,
|
||||
identity: MeshRuntimeIdentity,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct RemoteReplication {
|
||||
target: SocketAddr,
|
||||
runtime_id: String,
|
||||
pending: Arc<(Mutex<Option<Vec<cultnet_rs::CultNetMessage>>>, Condvar)>,
|
||||
alive: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl MeshPublisher {
|
||||
pub(crate) fn open(
|
||||
store_path: impl AsRef<Path>,
|
||||
rudp_target: Option<SocketAddr>,
|
||||
identity: MeshRuntimeIdentity,
|
||||
) -> Result<Self> {
|
||||
if let Some(parent) = store_path.as_ref().parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let node = CultMesh::create_node(
|
||||
store_path,
|
||||
GhostlightDocuments,
|
||||
CultMeshNodeOptions {
|
||||
runtime_id: identity.runtime_id.clone(),
|
||||
// Ghostlight derives its own interface from the local owner.
|
||||
// Stale remote surfaces must never become startup authority.
|
||||
pull_on_start: false,
|
||||
},
|
||||
)?;
|
||||
let remote = rudp_target
|
||||
.map(|target| RemoteReplication::start(target, identity.runtime_id.clone()))
|
||||
.transpose()?;
|
||||
Ok(Self {
|
||||
node: Arc::new(Mutex::new(node)),
|
||||
remote,
|
||||
identity,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn provider_advertisement(&self, updated_at: &str) -> Value {
|
||||
provider_advertisement(&self.identity, updated_at)
|
||||
}
|
||||
|
||||
pub(crate) fn publish(&self, snapshot: Option<&WorldSnapshot>) -> Result<Value> {
|
||||
let surface = eve::mesh_surface(snapshot);
|
||||
let world_state = eve::world_state(snapshot);
|
||||
let version = eve::surface_version(snapshot);
|
||||
let updated_at = Utc::now().to_rfc3339();
|
||||
let health = json!({
|
||||
"schema":"ghostlight.service_health.v2",
|
||||
"status":"ok",
|
||||
"worldState":world_state,
|
||||
"surfaceVersion":version,
|
||||
"updatedAtUtc":updated_at,
|
||||
"runtime":self.identity.runtime_id,
|
||||
"commit":option_env!("GHOSTLIGHT_BUILD_COMMIT").unwrap_or("development")
|
||||
});
|
||||
let advertisement = provider_advertisement(&self.identity, &updated_at);
|
||||
let mut node = self
|
||||
.node
|
||||
.lock()
|
||||
.map_err(|_| anyhow::anyhow!("mesh lock poisoned"))?;
|
||||
let mut remote_messages = Vec::new();
|
||||
self.put_and_stage(
|
||||
&mut node,
|
||||
ADVERTISEMENT_KEY,
|
||||
&EveProviderAdvertisementRecord {
|
||||
value: advertisement,
|
||||
},
|
||||
&mut remote_messages,
|
||||
)?;
|
||||
self.put_and_stage(
|
||||
&mut node,
|
||||
SURFACE_KEY,
|
||||
&EveSurfaceRecord { value: surface },
|
||||
&mut remote_messages,
|
||||
)?;
|
||||
// Health is the completion marker for this derived projection. A
|
||||
// failed advertisement or surface write must leave the prior version
|
||||
// visibly stale rather than publishing green first.
|
||||
self.put_and_stage(
|
||||
&mut node,
|
||||
HEALTH_KEY,
|
||||
&ServiceHealthRecord {
|
||||
value: health.clone(),
|
||||
},
|
||||
&mut remote_messages,
|
||||
)?;
|
||||
drop(node);
|
||||
if let Some(remote) = &self.remote {
|
||||
remote.enqueue(remote_messages);
|
||||
}
|
||||
Ok(health)
|
||||
}
|
||||
|
||||
pub(crate) fn health(&self) -> Result<Value> {
|
||||
if self
|
||||
.remote
|
||||
.as_ref()
|
||||
.is_some_and(|remote| !remote.is_alive())
|
||||
{
|
||||
anyhow::bail!("Odin projection worker stopped");
|
||||
}
|
||||
self.node
|
||||
.try_lock()
|
||||
.map_err(|_| anyhow::anyhow!("mesh projection is busy or its lock is poisoned"))?
|
||||
.get_required::<ServiceHealthRecord>(HEALTH_KEY)
|
||||
.map(|record| record.value)
|
||||
}
|
||||
|
||||
fn put_and_stage<T>(
|
||||
&self,
|
||||
node: &mut CultMeshNode,
|
||||
key: impl Into<String>,
|
||||
value: &T,
|
||||
remote_messages: &mut Vec<cultnet_rs::CultNetMessage>,
|
||||
) -> Result<()>
|
||||
where
|
||||
T: DatabaseEntry + serde::Serialize,
|
||||
{
|
||||
let key = key.into();
|
||||
node.put(&key, value)?;
|
||||
if let Some(remote) = &self.remote {
|
||||
remote_messages.push(node.create_rudp_document_message(
|
||||
&key,
|
||||
value,
|
||||
&remote.options(),
|
||||
)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn provider_advertisement(identity: &MeshRuntimeIdentity, updated_at: &str) -> Value {
|
||||
json!({
|
||||
"schema":"gamecult.eve.provider_advertisement.v1",
|
||||
"providerId":PROVIDER_ID,
|
||||
"serviceId":identity.service_id,
|
||||
"verseId":"gamecult.private",
|
||||
"rootVerse":"gamecult",
|
||||
"canonicalService":"ghostlight-dungeon",
|
||||
"locatedService":identity.located_service,
|
||||
"cultMeshAddress":"cultmesh://gamecult.private/ghostlight/providers/dungeon",
|
||||
"title":"Ghostlight Dungeon",
|
||||
"kind":"narrative.simulation",
|
||||
"updatedAtUtc":updated_at,
|
||||
"freshness":{"state":"live","lastSeenAtUtc":updated_at,"maxAgeMs":360000},
|
||||
"schemas":[
|
||||
"gamecult.eve.surface.v1",
|
||||
"gamecult.eve.command_invocation.v1",
|
||||
COMMAND_RESULT_SCHEMA,
|
||||
"heimdall.access_gate_state.v1",
|
||||
STATE_SCHEMA,
|
||||
CONSUMER_PATCH_SCHEMA,
|
||||
CONSUMER_RECEIPT_SCHEMA
|
||||
],
|
||||
"witnesses":[{
|
||||
"kind":"source-commit",
|
||||
"ref":option_env!("GHOSTLIGHT_BUILD_COMMIT").unwrap_or("development")
|
||||
}],
|
||||
"surfaces":[{
|
||||
"surfaceId":SURFACE_ID,
|
||||
"schema":"gamecult.eve.surface.v1",
|
||||
"url":"/api/eve/surfaces/ghostlight.play",
|
||||
"transport":"https-json",
|
||||
"status":"available",
|
||||
"surfaceKind":"subject-scoped-narrative",
|
||||
"interactionModel":"typed-command-receipts",
|
||||
"worldInteraction":{
|
||||
"projectionKind":"owner-derived",
|
||||
"stateSchemas":[STATE_SCHEMA],
|
||||
"commandBoundary":COMMAND_BOUNDARY,
|
||||
"receiptSchema":COMMAND_RESULT_SCHEMA,
|
||||
"loweringTargets":["browser","eve-native","tui"],
|
||||
"ownership":"One Ghostlight WorldMailbox owns state; Eve lowers derived projections and commands."
|
||||
},
|
||||
"requiresPlugins":[{
|
||||
"pluginId":"gamecult.heimdall.access",
|
||||
"versionRange":"^1.0.0",
|
||||
"availability":"required",
|
||||
"requiredCapabilities":["auth.gate","auth.begin","auth.complete","auth.logout"]
|
||||
}]
|
||||
}],
|
||||
"commands":[{
|
||||
"command":COMMAND_BOUNDARY,
|
||||
"transport":"https-json",
|
||||
"summary":"Canonical Ghostlight Eve command boundary."
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
impl RemoteReplication {
|
||||
fn start(target: SocketAddr, runtime_id: String) -> Result<Self> {
|
||||
let pending = Arc::new((
|
||||
Mutex::new(None::<Vec<cultnet_rs::CultNetMessage>>),
|
||||
Condvar::new(),
|
||||
));
|
||||
let worker_pending = pending.clone();
|
||||
let worker_runtime_id = runtime_id.clone();
|
||||
let alive = Arc::new(AtomicBool::new(true));
|
||||
let worker_alive = alive.clone();
|
||||
thread::Builder::new()
|
||||
.name("ghostlight-odin-rudp".into())
|
||||
.spawn(move || {
|
||||
struct Liveness(Arc<AtomicBool>);
|
||||
impl Drop for Liveness {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
let _liveness = Liveness(worker_alive);
|
||||
loop {
|
||||
let messages = {
|
||||
let (lock, ready) = &*worker_pending;
|
||||
let mut pending = lock
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
while pending.is_none() {
|
||||
pending = ready
|
||||
.wait(pending)
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
}
|
||||
pending.take().unwrap_or_default()
|
||||
};
|
||||
if messages.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let options = remote_options(target, &worker_runtime_id);
|
||||
if let Err(error) =
|
||||
publish_cultnet_messages_to_rudp_catalog(&messages, options)
|
||||
{
|
||||
tracing::warn!(
|
||||
document_count = messages.len(),
|
||||
%target,
|
||||
%error,
|
||||
"Odin publication failed; retaining the newest derived projection for retry"
|
||||
);
|
||||
let (lock, ready) = &*worker_pending;
|
||||
let mut pending = lock
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if pending.is_none() {
|
||||
*pending = Some(messages);
|
||||
}
|
||||
let _ = ready
|
||||
.wait_timeout(pending, std::time::Duration::from_secs(2))
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(Self {
|
||||
target,
|
||||
runtime_id,
|
||||
pending,
|
||||
alive,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_alive(&self) -> bool {
|
||||
self.alive.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
fn options(&self) -> CultMeshRudpDocumentPublishOptions {
|
||||
remote_options(self.target, &self.runtime_id)
|
||||
}
|
||||
|
||||
fn enqueue(&self, messages: Vec<cultnet_rs::CultNetMessage>) {
|
||||
let (lock, ready) = &*self.pending;
|
||||
let mut pending = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
*pending = Some(messages);
|
||||
ready.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_options(target: SocketAddr, runtime_id: &str) -> CultMeshRudpDocumentPublishOptions {
|
||||
let mut options = CultMeshRudpDocumentPublishOptions::odin(target, runtime_id);
|
||||
options.source_agent_id = Some(PROVIDER_ID.into());
|
||||
options.source_role = Some("narrative-simulation".into());
|
||||
options.tags = vec!["ghostlight".into(), "eve".into(), "private".into()];
|
||||
options
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn publishes_only_owner_derived_surface_and_health() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let publisher = MeshPublisher::open(
|
||||
directory.path().join("mesh-v2.cc"),
|
||||
None,
|
||||
MeshRuntimeIdentity::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let health = publisher.publish(None).unwrap();
|
||||
assert_eq!(health["worldState"], "empty");
|
||||
assert_eq!(health["surfaceVersion"], 0);
|
||||
assert_eq!(publisher.health().unwrap(), health);
|
||||
let node = publisher.node.lock().unwrap();
|
||||
let surface = node
|
||||
.get_required::<EveSurfaceRecord>(SURFACE_KEY)
|
||||
.unwrap()
|
||||
.value;
|
||||
assert_eq!(surface["type"], "surface-state");
|
||||
assert_eq!(surface["schema"], "gamecult.eve.surface.v1");
|
||||
assert_eq!(surface["providerId"], PROVIDER_ID);
|
||||
assert_eq!(surface["version"], 0);
|
||||
assert!(
|
||||
surface["updatedAtUtc"]
|
||||
.as_str()
|
||||
.is_some_and(|value| value.parse::<DateTime<Utc>>().is_ok())
|
||||
);
|
||||
assert_eq!(surface["surface"], eve::mesh_surface(None)["surface"]);
|
||||
assert_eq!(surface["commands"], eve::mesh_surface(None)["commands"]);
|
||||
assert!(
|
||||
node.get_required::<EveProviderAdvertisementRecord>(ADVERTISEMENT_KEY)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(node.get_required::<ServiceHealthRecord>(HEALTH_KEY).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_odin_cannot_block_local_publication() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let publisher = MeshPublisher::open(
|
||||
directory.path().join("mesh-v2.cc"),
|
||||
Some("127.0.0.1:1".parse().unwrap()),
|
||||
MeshRuntimeIdentity::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let started = std::time::Instant::now();
|
||||
publisher.publish(None).unwrap();
|
||||
assert!(started.elapsed() < std::time::Duration::from_millis(500));
|
||||
assert_eq!(publisher.health().unwrap()["worldState"], "empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopped_odin_worker_makes_runtime_health_non_green() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let publisher = MeshPublisher::open(
|
||||
directory.path().join("mesh-v2.cc"),
|
||||
Some("127.0.0.1:1".parse().unwrap()),
|
||||
MeshRuntimeIdentity::default(),
|
||||
)
|
||||
.unwrap();
|
||||
publisher.publish(None).unwrap();
|
||||
publisher
|
||||
.remote
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.alive
|
||||
.store(false, Ordering::Release);
|
||||
assert!(publisher.health().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advertisement_has_one_surface_and_one_command_boundary() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let publisher = MeshPublisher::open(
|
||||
directory.path().join("mesh-v2.cc"),
|
||||
None,
|
||||
MeshRuntimeIdentity::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let advertisement = publisher.provider_advertisement("2026-09-01T00:00:00Z");
|
||||
assert_eq!(advertisement["surfaces"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(advertisement["commands"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(advertisement["surfaces"][0]["surfaceId"], SURFACE_ID);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,27 +0,0 @@
|
|||
use std::{fs, path::Path, process::Command};
|
||||
|
||||
#[test]
|
||||
fn embedded_commit_matches_checkout_head() {
|
||||
let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let output = Command::new("git")
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.current_dir(&repo_root)
|
||||
.output()
|
||||
.expect("git must be available for the provenance regression test");
|
||||
let checkout_head = if output.status.success() {
|
||||
String::from_utf8(output.stdout)
|
||||
.expect("git commit must be UTF-8")
|
||||
.trim()
|
||||
.to_owned()
|
||||
} else {
|
||||
fs::read_to_string(repo_root.join(".ghostlight-source-commit"))
|
||||
.expect("frozen source archive must carry its exact commit witness")
|
||||
.trim()
|
||||
.to_owned()
|
||||
};
|
||||
assert_eq!(
|
||||
env!("GHOSTLIGHT_BUILD_COMMIT"),
|
||||
checkout_head,
|
||||
"the binary embedded stale Git provenance"
|
||||
);
|
||||
}
|
||||
15
crates/ghostlight-verse/Cargo.toml
Normal file
15
crates/ghostlight-verse/Cargo.toml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
[package]
|
||||
name = "ghostlight-verse"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
ghostlight = { path = "../ghostlight" }
|
||||
tokio.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
tempfile = "3"
|
||||
346
crates/ghostlight-verse/src/main.rs
Normal file
346
crates/ghostlight-verse/src/main.rs
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
//! The verse runner's operator command line.
|
||||
//!
|
||||
//! `genesis` creates the canonical Aetheria verse world in a state root, and
|
||||
//! `run` advances it a given number of ticks under a token cap, printing per
|
||||
//! tick what each faction did and what it cost. A run starts only when this
|
||||
//! command is invoked, and ends when it has done what it was asked.
|
||||
//!
|
||||
//! The state root holds `world.cc`, `controller-work.cc`, `runs.cc` and
|
||||
//! `calls.cc`. The provider key is read from a file at run time and appears
|
||||
//! nowhere else.
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use chrono::{Duration, Utc};
|
||||
use ghostlight::{
|
||||
CommandBody, CommandId, ControllerModels, ControllerOpenError, ControllerRunner, DEFAULT_HOSTED_MODEL_PREFIX,
|
||||
DEFAULT_LOCAL_MODEL_PREFIX, CallRecordStore, CoverBudget, CreateVerseWorldIntent,
|
||||
HostedBinding, Lens, LensWeights, LocalBinding, PrincipalCommandIntent, RecordingInferencePort,
|
||||
RunStart, RunStatus, TickMinutes, VerifiedPrincipalEvidence, VerseRunConfig, VerseRunStore,
|
||||
WorldMailbox, call_run_id, id_text, open_controller_work, open_inference,
|
||||
read_aetheria_catalog, run_manual,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::ops::ControlFlow;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// What a manual run spends per tick on cognition cells. The operator's lever
|
||||
/// on cost is the token cap; the partition always covers every faction.
|
||||
const COVER: CoverBudget = CoverBudget {
|
||||
cells: 8,
|
||||
constituent_cap: 8,
|
||||
urgency_slots: 2,
|
||||
};
|
||||
|
||||
const RUNTIME_ID: &str = "ghostlight-verse";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
let arguments: Vec<String> = std::env::args().skip(1).collect();
|
||||
match command(arguments).await {
|
||||
Ok(code) => code,
|
||||
Err(error) => {
|
||||
eprintln!("ghostlight-verse: {error:#}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn command(arguments: Vec<String>) -> Result<ExitCode> {
|
||||
let Some((name, rest)) = arguments.split_first() else {
|
||||
bail!("usage: ghostlight-verse genesis|run [flags]");
|
||||
};
|
||||
match name.as_str() {
|
||||
"genesis" => genesis(Flags::parse(rest, &GENESIS_FLAGS)?).await,
|
||||
"run" => run(Flags::parse(rest, &RUN_FLAGS)?).await,
|
||||
_ => bail!("unknown command, expected genesis or run"),
|
||||
}
|
||||
}
|
||||
|
||||
const GENESIS_FLAGS: [&str; 4] = ["catalog", "aetheria-commit", "state", "owner-hash"];
|
||||
const RUN_FLAGS: [&str; 7] = [
|
||||
"state",
|
||||
"ticks",
|
||||
"token-cap",
|
||||
"tick-span-minutes",
|
||||
"model",
|
||||
"base-url",
|
||||
"key-file",
|
||||
];
|
||||
/// Flags a command may omit.
|
||||
const OPTIONAL: [&str; 1] = ["key-file"];
|
||||
|
||||
/// `--name value` pairs, each named once and from the command's own list.
|
||||
struct Flags(BTreeMap<&'static str, String>);
|
||||
|
||||
impl Flags {
|
||||
fn parse(arguments: &[String], allowed: &[&'static str]) -> Result<Self> {
|
||||
let mut seen = BTreeMap::new();
|
||||
let mut rest = arguments.iter();
|
||||
while let Some(flag) = rest.next() {
|
||||
let Some(name) = flag.strip_prefix("--") else {
|
||||
bail!("expected a --flag, found a bare argument");
|
||||
};
|
||||
let Some(&known) = allowed.iter().find(|candidate| **candidate == name) else {
|
||||
bail!("a flag this command does not take was given");
|
||||
};
|
||||
let Some(value) = rest.next() else {
|
||||
bail!("--{known} needs a value");
|
||||
};
|
||||
if seen.insert(known, value.clone()).is_some() {
|
||||
bail!("--{known} was given twice");
|
||||
}
|
||||
}
|
||||
for &required in allowed {
|
||||
if !OPTIONAL.contains(&required) && !seen.contains_key(required) {
|
||||
bail!("--{required} is required");
|
||||
}
|
||||
}
|
||||
Ok(Self(seen))
|
||||
}
|
||||
|
||||
fn text(&self, name: &str) -> &str {
|
||||
self.0.get(name).map_or("", String::as_str)
|
||||
}
|
||||
|
||||
fn path(&self, name: &str) -> PathBuf {
|
||||
PathBuf::from(self.text(name))
|
||||
}
|
||||
|
||||
fn number<T: std::str::FromStr + PartialOrd + Default>(&self, name: &str) -> Result<T> {
|
||||
match self.text(name).parse::<T>() {
|
||||
Ok(value) if value > T::default() => Ok(value),
|
||||
_ => bail!("--{name} is not a positive integer"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn genesis(flags: Flags) -> Result<ExitCode> {
|
||||
let state = flags.path("state");
|
||||
std::fs::create_dir_all(&state).context("the state directory cannot be created")?;
|
||||
let catalog = read_aetheria_catalog(flags.path("catalog"))
|
||||
.context("the Aetheria catalog cannot be read")?;
|
||||
let factions = catalog.factions.len();
|
||||
let principal = VerifiedPrincipalEvidence::new(flags.text("owner-hash"), Utc::now() + Duration::hours(1));
|
||||
|
||||
let (mailbox, task) = WorldMailbox::open(state.join("world.cc"))
|
||||
.map_err(store_fault("the world file in --state cannot be opened"))?;
|
||||
let receipt = mailbox
|
||||
.create_verse(
|
||||
CreateVerseWorldIntent {
|
||||
id: CommandId::new(),
|
||||
title: "The Aetheria Verse".into(),
|
||||
brief: "The Aetheria verse, simulated from its catalog.".into(),
|
||||
catalog,
|
||||
aetheria_commit: flags.text("aetheria-commit").into(),
|
||||
lens_weights: LensWeights::new(Lens::ALL.into_iter().map(|lens| (lens, 1)).collect()),
|
||||
targets: BTreeMap::new(),
|
||||
},
|
||||
&principal,
|
||||
)
|
||||
.await
|
||||
.context("the verse world cannot be created")?;
|
||||
// A created world is a draft: the owner approves and activates it, so the
|
||||
// factions hold opportunities.
|
||||
for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] {
|
||||
let revision = mailbox.snapshot().await.context("the world cannot be read")?.revision;
|
||||
mailbox
|
||||
.submit_principal(
|
||||
PrincipalCommandIntent {
|
||||
id: CommandId::new(),
|
||||
world_id: receipt.world_id,
|
||||
expected_revision: revision,
|
||||
body,
|
||||
},
|
||||
&principal,
|
||||
)
|
||||
.await
|
||||
.context("the world cannot be activated")?;
|
||||
}
|
||||
drop(mailbox);
|
||||
task.await.context("the world file did not close cleanly")?;
|
||||
println!("world {}", id_text(receipt.world_id));
|
||||
println!("factions {factions}");
|
||||
println!("phase Active");
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
async fn run(flags: Flags) -> Result<ExitCode> {
|
||||
let state = flags.path("state");
|
||||
let ticks: u32 = flags.number("ticks")?;
|
||||
let cap: u64 = flags.number("token-cap")?;
|
||||
let span: u32 = flags.number("tick-span-minutes")?;
|
||||
let tick_span = TickMinutes::new(span).context("--tick-span-minutes is outside the clock's span")?;
|
||||
if !state.join("world.cc").is_file() {
|
||||
bail!("the state directory holds no world; run genesis first");
|
||||
}
|
||||
|
||||
let model = flags.text("model").to_owned();
|
||||
let models = ControllerModels {
|
||||
projector: model.clone(),
|
||||
persona: model.clone(),
|
||||
interpreter: model.clone(),
|
||||
operational_agent: model.clone(),
|
||||
elaborator: model.clone(),
|
||||
};
|
||||
let inner = open_lane(&flags, &model, &models)?;
|
||||
|
||||
let (mailbox, task) = WorldMailbox::open(state.join("world.cc"))
|
||||
.map_err(store_fault("the world file in --state cannot be opened"))?;
|
||||
let world = mailbox.snapshot().await.context("the world cannot be read")?;
|
||||
let calls = Arc::new(CallRecordStore::open(state.join("calls.cc")).map_err(store_fault("the call records in --state cannot be opened"))?);
|
||||
let runs = VerseRunStore::open(state.join("runs.cc")).map_err(store_fault("the run records in --state cannot be opened"))?;
|
||||
let work = open_controller_work(state.join("controller-work.cc"))
|
||||
.map_err(store_fault("the controller work store in --state cannot be opened"))?;
|
||||
let config = VerseRunConfig {
|
||||
ticks,
|
||||
tick_span,
|
||||
cover: COVER,
|
||||
};
|
||||
let claim = runs
|
||||
.claim(&world, cap, &config)
|
||||
.context("the run cannot be claimed")?;
|
||||
let sequence = claim.sequence();
|
||||
// A start that fails after the claim ends the claimed run, so it binds
|
||||
// nothing: the next claim takes the next number.
|
||||
let started = RecordingInferencePort::open(
|
||||
inner,
|
||||
calls,
|
||||
call_run_id(sequence),
|
||||
cap,
|
||||
&RunStart::of(&world),
|
||||
)
|
||||
.context("the run's call records cannot be opened")
|
||||
.and_then(|port| {
|
||||
let port = Arc::new(port);
|
||||
ControllerRunner::open(mailbox.clone(), port.clone(), work, models)
|
||||
.map(|runner| (port, runner))
|
||||
.context("the controller organ cannot open")
|
||||
});
|
||||
let (port, runner) = match started {
|
||||
Ok(started) => started,
|
||||
Err(error) => {
|
||||
return Err(match runs.abandon(claim, "start_refused") {
|
||||
Ok(()) => error,
|
||||
Err(_) => error.context("the refused run's record could not be ended"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
println!(
|
||||
"run {sequence}: {ticks} ticks of {span} minutes, cap {cap} tokens, model {model}"
|
||||
);
|
||||
let interrupted = Arc::new(AtomicBool::new(false));
|
||||
{
|
||||
let interrupted = interrupted.clone();
|
||||
tokio::spawn(async move {
|
||||
if tokio::signal::ctrl_c().await.is_ok() {
|
||||
interrupted.store(true, Ordering::SeqCst);
|
||||
}
|
||||
});
|
||||
}
|
||||
let outcome = run_manual(
|
||||
&mailbox,
|
||||
&runner,
|
||||
&port,
|
||||
&runs,
|
||||
claim,
|
||||
&config,
|
||||
|report| {
|
||||
print!("{report}");
|
||||
if interrupted.load(Ordering::SeqCst) {
|
||||
ControlFlow::Break(())
|
||||
} else {
|
||||
ControlFlow::Continue(())
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.context("the run could not be recorded")?;
|
||||
println!(
|
||||
"status: {}; {} of {} ticks closed",
|
||||
outcome.record.status,
|
||||
outcome.record.ticks_closed(),
|
||||
outcome.record.ticks_requested
|
||||
);
|
||||
drop(runner);
|
||||
drop(port);
|
||||
drop(mailbox);
|
||||
task.await.context("the world file did not close cleanly")?;
|
||||
Ok(match outcome.record.status {
|
||||
RunStatus::Failed { .. } => ExitCode::FAILURE,
|
||||
_ => ExitCode::SUCCESS,
|
||||
})
|
||||
}
|
||||
|
||||
/// The inference lane the model's prefix names. A hosted model needs an https
|
||||
/// origin and a key file; a local one reaches a loopback server and needs
|
||||
/// neither, so a key file beside it is refused.
|
||||
fn open_lane(
|
||||
flags: &Flags,
|
||||
model: &str,
|
||||
models: &ControllerModels,
|
||||
) -> Result<Arc<dyn ghostlight::InferencePort>> {
|
||||
let (mut hosted, mut local) = (None, None);
|
||||
if model.starts_with(DEFAULT_HOSTED_MODEL_PREFIX) {
|
||||
let Some(key) = flags.0.get("key-file") else {
|
||||
bail!("a hosted model needs --key-file");
|
||||
};
|
||||
hosted = Some(HostedBinding {
|
||||
base_url: flags.text("base-url").to_owned(),
|
||||
key_path: Path::new(key).to_owned(),
|
||||
model_prefix: DEFAULT_HOSTED_MODEL_PREFIX.into(),
|
||||
caller_runtime_id: RUNTIME_ID.into(),
|
||||
});
|
||||
} else if model.starts_with(DEFAULT_LOCAL_MODEL_PREFIX) {
|
||||
if flags.0.contains_key("key-file") {
|
||||
bail!("a local model takes no --key-file");
|
||||
}
|
||||
let endpoint: SocketAddr = flags
|
||||
.text("base-url")
|
||||
.strip_prefix("http://")
|
||||
.and_then(|address| address.trim_end_matches('/').parse().ok())
|
||||
.context("--base-url for a local model is not http://<address>:<port>")?;
|
||||
local = Some(LocalBinding {
|
||||
endpoint,
|
||||
model_prefix: DEFAULT_LOCAL_MODEL_PREFIX.into(),
|
||||
caller_runtime_id: RUNTIME_ID.into(),
|
||||
});
|
||||
} else {
|
||||
bail!("--model names no inference lane (expected a hosted/ or local/ prefix)");
|
||||
}
|
||||
open_inference(None, None, local, hosted, &models.each())
|
||||
.map_err(|error| anyhow!(lane_fault(&error)))
|
||||
}
|
||||
|
||||
/// A store that cannot open says which one and that it is under `--state`,
|
||||
/// never the path or the store's own text, which carries the path.
|
||||
fn store_fault<E>(message: &'static str) -> impl FnOnce(E) -> anyhow::Error {
|
||||
move |_| anyhow!(message)
|
||||
}
|
||||
|
||||
/// What a lane that cannot open says: the flag or the kind of fault, never a
|
||||
/// value the operator passed.
|
||||
fn lane_fault(error: &ControllerOpenError) -> &'static str {
|
||||
match error {
|
||||
ControllerOpenError::InvalidModels => "--model is not an exact nonempty identifier",
|
||||
ControllerOpenError::Connector(_) => "the connector lane cannot open",
|
||||
ControllerOpenError::WorkStore(_) => "the controller work journal cannot open",
|
||||
ControllerOpenError::UnroutableModel { .. } => "--model is claimed by no inference lane",
|
||||
ControllerOpenError::SdkSidecarMissing { .. } => "the SDK sidecar entry is not a file",
|
||||
ControllerOpenError::LocalEndpointNotLoopback { .. } => {
|
||||
"--base-url for a local model must be a loopback address"
|
||||
}
|
||||
ControllerOpenError::HostedEndpointNotHttps => {
|
||||
"--base-url for a hosted model must be an https URL"
|
||||
}
|
||||
ControllerOpenError::HostedKeyUnreadable => {
|
||||
"--key-file is unreadable, empty, or not one run of visible ASCII"
|
||||
}
|
||||
ControllerOpenError::SharedModelPrefix { .. } => "two inference lanes claim one model prefix",
|
||||
ControllerOpenError::EmptyModelPrefix { .. } => "an inference lane's model prefix is empty",
|
||||
}
|
||||
}
|
||||
445
crates/ghostlight-verse/tests/verse_binary.rs
Normal file
445
crates/ghostlight-verse/tests/verse_binary.rs
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
//! The `ghostlight-verse` command against a loopback scripted responder. No
|
||||
//! real model is called and no key file is read: the responder answers the
|
||||
//! OpenAI-compatible chat-completions request the local lane sends.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{SocketAddr, TcpListener, TcpStream};
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Output};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
const BINARY: &str = env!("CARGO_BIN_EXE_ghostlight-verse");
|
||||
const CATALOG: &str = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../ghostlight/tests/fixtures/aetheria-verse-catalog.cc"
|
||||
);
|
||||
const COMMIT: &str = "92b82e9aa5bb422c568c4bfc85a8f641426a41fc";
|
||||
/// What the responder reports each call cost: 120 prompt and 30 completion.
|
||||
const PER_CALL: u64 = 150;
|
||||
|
||||
fn verse(arguments: &[&str]) -> Output {
|
||||
Command::new(BINARY).args(arguments).output().unwrap()
|
||||
}
|
||||
|
||||
fn stdout(output: &Output) -> String {
|
||||
String::from_utf8_lossy(&output.stdout).into_owned()
|
||||
}
|
||||
|
||||
fn stderr(output: &Output) -> String {
|
||||
String::from_utf8_lossy(&output.stderr).into_owned()
|
||||
}
|
||||
|
||||
fn genesis(state: &Path) -> Output {
|
||||
verse(&[
|
||||
"genesis",
|
||||
"--catalog",
|
||||
CATALOG,
|
||||
"--aetheria-commit",
|
||||
COMMIT,
|
||||
"--state",
|
||||
state.to_str().unwrap(),
|
||||
"--owner-hash",
|
||||
"verse-owner",
|
||||
])
|
||||
}
|
||||
|
||||
/// Answers every chat-completions request with one tool call: the faction the
|
||||
/// prompt names speaks its own line. Counts the requests it served.
|
||||
struct Responder {
|
||||
address: SocketAddr,
|
||||
served: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl Responder {
|
||||
fn start() -> Self {
|
||||
Self::answering(200)
|
||||
}
|
||||
|
||||
/// A provider that refuses every call with this status.
|
||||
fn refusing(status: u16) -> Self {
|
||||
Self::answering(status)
|
||||
}
|
||||
|
||||
fn answering(status: u16) -> Self {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let served = Arc::new(AtomicUsize::new(0));
|
||||
let counter = served.clone();
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
let Ok(stream) = stream else { return };
|
||||
let counter = counter.clone();
|
||||
std::thread::spawn(move || serve(stream, &counter, status));
|
||||
}
|
||||
});
|
||||
Self { address, served }
|
||||
}
|
||||
|
||||
fn url(&self) -> String {
|
||||
format!("http://{}", self.address)
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(mut stream: TcpStream, served: &AtomicUsize, status: u16) {
|
||||
let mut raw = Vec::new();
|
||||
let mut chunk = [0u8; 4096];
|
||||
let (head_end, length) = loop {
|
||||
let read = stream.read(&mut chunk).unwrap_or(0);
|
||||
if read == 0 {
|
||||
return;
|
||||
}
|
||||
raw.extend_from_slice(&chunk[..read]);
|
||||
if let Some(end) = raw.windows(4).position(|window| window == b"\r\n\r\n") {
|
||||
let head = String::from_utf8_lossy(&raw[..end]).to_ascii_lowercase();
|
||||
let length = head
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("content-length:"))
|
||||
.and_then(|value| value.trim().parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
break (end + 4, length);
|
||||
}
|
||||
};
|
||||
while raw.len() < head_end + length {
|
||||
let read = stream.read(&mut chunk).unwrap_or(0);
|
||||
if read == 0 {
|
||||
return;
|
||||
}
|
||||
raw.extend_from_slice(&chunk[..read]);
|
||||
}
|
||||
let request: Value = serde_json::from_slice(&raw[head_end..head_end + length]).unwrap();
|
||||
let text = request.to_string();
|
||||
let faction = ["Adrasteia", "Brannoch"]
|
||||
.into_iter()
|
||||
.find(|name| text.contains(&format!("Decision owner:\\n{name}\\n")))
|
||||
.expect("a request names its faction");
|
||||
let sequence = served.fetch_add(1, Ordering::SeqCst);
|
||||
let reply = json!({
|
||||
"id": format!("reply-{sequence}"),
|
||||
"choices": [{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"tool_calls": [{
|
||||
"id": format!("call-{sequence}"),
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "speak",
|
||||
"arguments": json!({ "text": format!("{faction} holds the line.") }).to_string(),
|
||||
},
|
||||
}],
|
||||
},
|
||||
"finish_reason": "tool_calls",
|
||||
}],
|
||||
"usage": { "prompt_tokens": 120, "completion_tokens": 30 },
|
||||
})
|
||||
.to_string();
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status} Scripted\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{reply}",
|
||||
reply.len()
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
}
|
||||
|
||||
fn run(state: &Path, responder: &Responder, ticks: &str, cap: &str) -> Output {
|
||||
verse(&[
|
||||
"run",
|
||||
"--state",
|
||||
state.to_str().unwrap(),
|
||||
"--ticks",
|
||||
ticks,
|
||||
"--token-cap",
|
||||
cap,
|
||||
"--tick-span-minutes",
|
||||
"60",
|
||||
"--model",
|
||||
"local/scripted",
|
||||
"--base-url",
|
||||
&responder.url(),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_run_prints_acts_and_spend() {
|
||||
let state = tempfile::tempdir().unwrap();
|
||||
let made = genesis(state.path());
|
||||
assert!(made.status.success(), "{}", stderr(&made));
|
||||
let made = stdout(&made);
|
||||
assert!(made.contains("factions 2\n") && made.contains("phase Active\n"), "{made}");
|
||||
assert!(state.path().join("world.cc").is_file());
|
||||
|
||||
let responder = Responder::start();
|
||||
let output = run(state.path(), &responder, "2", "1000000");
|
||||
assert!(output.status.success(), "{}", stderr(&output));
|
||||
let text = stdout(&output);
|
||||
|
||||
// Two ticks, each with both factions named by verb, and the spend as it
|
||||
// accrues against the cap.
|
||||
assert!(text.starts_with("run 1: 2 ticks of 60 minutes, cap 1000000 tokens"), "{text}");
|
||||
for tick in 0..2u64 {
|
||||
let spent = PER_CALL * 2 * (tick + 1);
|
||||
assert!(
|
||||
text.contains(&format!("tick {tick} closed: 2 cells, 2 acts, 0 refused\n")),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains(&format!(
|
||||
" tokens: {} this tick, {spent} spent, {} remaining\n",
|
||||
PER_CALL * 2,
|
||||
1_000_000 - spent
|
||||
)),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
for faction in ["Adrasteia", "Brannoch"] {
|
||||
assert_eq!(
|
||||
text.matches(&format!(" {faction} speak [")).count(),
|
||||
2,
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains(&format!("says \"{faction} holds the line.\"")), "{text}");
|
||||
}
|
||||
assert!(text.ends_with("status: Completed; 2 of 2 ticks closed\n"), "{text}");
|
||||
assert_eq!(responder.served.load(Ordering::SeqCst), 4);
|
||||
|
||||
// The state root holds each store the run wrote.
|
||||
for file in ["world.cc", "controller-work.cc", "runs.cc", "calls.cc"] {
|
||||
assert!(state.path().join(file).is_file(), "{file} was not written");
|
||||
}
|
||||
|
||||
// A second run is the next in the world and carries on from its clock.
|
||||
let again = run(state.path(), &responder, "1", "1000000");
|
||||
assert!(again.status.success(), "{}", stderr(&again));
|
||||
let text = stdout(&again);
|
||||
assert!(text.starts_with("run 2: 1 ticks of 60 minutes"), "{text}");
|
||||
assert!(text.contains("tick 2 closed: 2 cells, 2 acts, 0 refused\n"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cap_the_first_call_could_pass_ends_the_run_with_nothing_spent() {
|
||||
let state = tempfile::tempdir().unwrap();
|
||||
assert!(genesis(state.path()).status.success());
|
||||
let responder = Responder::start();
|
||||
let output = run(state.path(), &responder, "3", "1000");
|
||||
assert!(output.status.success(), "{}", stderr(&output));
|
||||
let text = stdout(&output);
|
||||
assert!(text.contains("tick 0 open: "), "{text}");
|
||||
assert!(text.contains("tokens: 0 this tick, 0 spent, 1000 remaining"), "{text}");
|
||||
assert!(text.ends_with("status: BudgetExhausted; 0 of 3 ticks closed\n"), "{text}");
|
||||
assert_eq!(responder.served.load(Ordering::SeqCst), 0, "no call was made");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_that_fails_says_so_and_exits_nonzero() {
|
||||
let state = tempfile::tempdir().unwrap();
|
||||
assert!(genesis(state.path()).status.success());
|
||||
let responder = Responder::refusing(500);
|
||||
let output = run(state.path(), &responder, "2", "1000000");
|
||||
assert!(!output.status.success());
|
||||
let text = stdout(&output);
|
||||
assert!(text.contains("tick 0 open: "), "{text}");
|
||||
assert!(
|
||||
text.ends_with("status: Failed (inference_recovery_required); 0 of 2 ticks closed\n"),
|
||||
"{text}"
|
||||
);
|
||||
assert_eq!(
|
||||
responder.served.load(Ordering::SeqCst),
|
||||
1,
|
||||
"the run stopped at the first refusal"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_command_refuses_what_it_cannot_run_and_never_echoes_a_value() {
|
||||
let state = tempfile::tempdir().unwrap();
|
||||
let responder = Responder::start();
|
||||
|
||||
// No world yet.
|
||||
let output = run(state.path(), &responder, "1", "1000");
|
||||
assert!(!output.status.success());
|
||||
assert!(stderr(&output).contains("run genesis first"), "{}", stderr(&output));
|
||||
|
||||
assert!(genesis(state.path()).status.success());
|
||||
// A second genesis does not overwrite the first.
|
||||
assert!(!genesis(state.path()).status.success());
|
||||
|
||||
let secret = "sk-not-a-real-key-0000";
|
||||
let key = state.path().join(secret);
|
||||
let cases: [(Vec<&str>, &str); 9] = [
|
||||
(vec![], "usage"),
|
||||
(vec!["fly"], "unknown command"),
|
||||
(
|
||||
vec!["run", "--state", state.path().to_str().unwrap(), "--ticks", "0"],
|
||||
"--token-cap is required",
|
||||
),
|
||||
(
|
||||
vec![
|
||||
"run", "--state", state.path().to_str().unwrap(), "--ticks", "many",
|
||||
"--token-cap", "1", "--tick-span-minutes", "60", "--model", "local/x",
|
||||
"--base-url", "http://127.0.0.1:1",
|
||||
],
|
||||
"--ticks is not a positive integer",
|
||||
),
|
||||
(
|
||||
vec![
|
||||
"run", "--state", state.path().to_str().unwrap(), "--ticks", "0",
|
||||
"--token-cap", "1", "--tick-span-minutes", "60", "--model", "local/x",
|
||||
"--base-url", "http://127.0.0.1:1",
|
||||
],
|
||||
"--ticks is not a positive integer",
|
||||
),
|
||||
(
|
||||
vec![
|
||||
"run", "--state", state.path().to_str().unwrap(), "--ticks", "1",
|
||||
"--token-cap", "0", "--tick-span-minutes", "60", "--model", "local/x",
|
||||
"--base-url", "http://127.0.0.1:1",
|
||||
],
|
||||
"--token-cap is not a positive integer",
|
||||
),
|
||||
(
|
||||
vec![
|
||||
"run", "--state", state.path().to_str().unwrap(), "--ticks", "1",
|
||||
"--token-cap", "1", "--tick-span-minutes", "60", "--model", "gpt-4",
|
||||
"--base-url", "http://127.0.0.1:1",
|
||||
],
|
||||
"--model names no inference lane",
|
||||
),
|
||||
(
|
||||
// A hosted model needs its key file; a missing one is named by flag.
|
||||
vec![
|
||||
"run", "--state", state.path().to_str().unwrap(), "--ticks", "1",
|
||||
"--token-cap", "1", "--tick-span-minutes", "60", "--model", "hosted/x",
|
||||
"--base-url", "https://api.example.test", "--key-file", key.to_str().unwrap(),
|
||||
],
|
||||
"--key-file is unreadable",
|
||||
),
|
||||
(
|
||||
vec![
|
||||
"run", "--state", state.path().to_str().unwrap(), "--ticks", "1",
|
||||
"--token-cap", "1", "--tick-span-minutes", "60", "--model", "local/x",
|
||||
"--base-url", "http://127.0.0.1:1", "--key-file", key.to_str().unwrap(),
|
||||
],
|
||||
"a local model takes no --key-file",
|
||||
),
|
||||
];
|
||||
for (arguments, expected) in cases {
|
||||
let output = verse(&arguments);
|
||||
assert!(!output.status.success(), "{arguments:?} succeeded");
|
||||
let error = stderr(&output);
|
||||
assert!(error.contains(expected), "{arguments:?}: {error}");
|
||||
assert!(!error.contains(secret), "{arguments:?} echoed a path: {error}");
|
||||
assert!(!error.contains("many"), "{arguments:?} echoed a value: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_loopback_local_endpoint_is_refused_without_echoing_it() {
|
||||
let state = tempfile::tempdir().unwrap();
|
||||
assert!(genesis(state.path()).status.success());
|
||||
let output = verse(&[
|
||||
"run",
|
||||
"--state",
|
||||
state.path().to_str().unwrap(),
|
||||
"--ticks",
|
||||
"1",
|
||||
"--token-cap",
|
||||
"1000",
|
||||
"--tick-span-minutes",
|
||||
"60",
|
||||
"--model",
|
||||
"local/x",
|
||||
"--base-url",
|
||||
"http://10.77.0.99:4242",
|
||||
]);
|
||||
assert!(!output.status.success());
|
||||
let error = stderr(&output);
|
||||
assert!(error.contains("--base-url"), "{error}");
|
||||
assert!(!error.contains("10.77.0.99"), "the endpoint was echoed: {error}");
|
||||
// The lane failed before any run was claimed.
|
||||
assert!(!stdout(&output).contains("run 1"), "{}", stdout(&output));
|
||||
}
|
||||
|
||||
/// A start refused after its claim ends the claimed record Failed, so the run
|
||||
/// binds nothing. Reached from the command by removing the run records while
|
||||
/// the call ledger still holds run 1 under another cap.
|
||||
#[test]
|
||||
fn a_start_the_ledger_refuses_ends_its_claimed_record_failed() {
|
||||
let parent = tempfile::tempdir().unwrap();
|
||||
let state = parent.path().join("state");
|
||||
std::fs::create_dir_all(&state).unwrap();
|
||||
assert!(genesis(&state).status.success());
|
||||
let responder = Responder::start();
|
||||
let first = run(&state, &responder, "1", "1000000");
|
||||
assert!(first.status.success(), "{}", stderr(&first));
|
||||
std::fs::remove_file(state.join("runs.cc")).unwrap();
|
||||
|
||||
let refused = run(&state, &responder, "1", "500000");
|
||||
assert!(!refused.status.success());
|
||||
|
||||
let world = {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let _entered = runtime.enter();
|
||||
let (mailbox, task) = ghostlight::WorldMailbox::open(state.join("world.cc")).unwrap();
|
||||
let world = runtime.block_on(mailbox.snapshot()).unwrap().world_id;
|
||||
drop(mailbox);
|
||||
runtime.block_on(task).unwrap();
|
||||
world
|
||||
};
|
||||
let runs = ghostlight::VerseRunStore::open(state.join("runs.cc"))
|
||||
.unwrap()
|
||||
.runs(world)
|
||||
.unwrap();
|
||||
assert_eq!(runs.len(), 1);
|
||||
assert_eq!(
|
||||
runs[0].status,
|
||||
ghostlight::RunStatus::Failed {
|
||||
reason: "start_refused".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// A store that cannot open names the flag and the fault, never the path.
|
||||
#[test]
|
||||
fn a_store_that_cannot_open_never_echoes_the_state_path() {
|
||||
let responder = Responder::start();
|
||||
for file in ["world.cc", "controller-work.cc"] {
|
||||
for held in [false, true] {
|
||||
let parent = tempfile::tempdir().unwrap();
|
||||
let state = parent.path().join("distinctivestatename");
|
||||
std::fs::create_dir_all(&state).unwrap();
|
||||
assert!(genesis(&state).status.success());
|
||||
let first = run(&state, &responder, "1", "1000000");
|
||||
assert!(first.status.success(), "{}", stderr(&first));
|
||||
|
||||
let target = state.join(file);
|
||||
let _guard: Box<dyn std::any::Any> = if !held {
|
||||
std::fs::write(&target, b"not a database").unwrap();
|
||||
Box::new(())
|
||||
} else if file == "world.cc" {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let entered = runtime.enter();
|
||||
let opened = ghostlight::WorldMailbox::open(&target).unwrap();
|
||||
drop(entered);
|
||||
Box::new((runtime, opened))
|
||||
} else {
|
||||
Box::new(ghostlight::open_controller_work(&target).unwrap())
|
||||
};
|
||||
|
||||
let output = run(&state, &responder, "1", "1000000");
|
||||
assert!(!output.status.success(), "{file} held={held}");
|
||||
let error = stderr(&output);
|
||||
assert!(error.contains("--state"), "{file} held={held}: {error}");
|
||||
assert!(
|
||||
!error.contains("distinctivestatename"),
|
||||
"{file} held={held} echoed the path: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ cultcache-rs.workspace = true
|
|||
ghostlight-persona-projection = { path = "../ghostlight-persona-projection" }
|
||||
reqwest.workspace = true
|
||||
rmp-serde.workspace = true
|
||||
rmpv = "1.3"
|
||||
serde.workspace = true
|
||||
serde_bytes = "0.11"
|
||||
serde_json.workspace = true
|
||||
|
|
@ -19,8 +20,15 @@ sha2.workspace = true
|
|||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
unicode-normalization = "0.1.25"
|
||||
unicode-properties = "0.1.4"
|
||||
uuid.workspace = true
|
||||
zeroize.workspace = true
|
||||
|
||||
[features]
|
||||
# Exposes `open_local_port_with_timeout` to a consumer's tests. Production
|
||||
# builds never enable it.
|
||||
test-support = []
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
|
|||
297
crates/ghostlight/src/aetheria.rs
Normal file
297
crates/ghostlight/src/aetheria.rs
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
//! Aetheria's catalog file, read once into the two things Ghostlight takes from
|
||||
//! it: the verse grammar, as a `GrammarBinding`, and the factions, as records
|
||||
//! keyed for import.
|
||||
//!
|
||||
//! The catalog is Aetheria's: this reader opens it read-only and never writes
|
||||
//! it, and it ignores every schema it does not mirror. The mirrors below decode
|
||||
//! `aetheria.verse_grammar` v1, `aetheria.verse_verb` v1 and `aetheria.faction`
|
||||
//! v1 by integer key: a MessagePack-keyed object is an array indexed by key,
|
||||
//! nil in the gaps, so a field Aetheria adds later at a higher key is ignored
|
||||
//! and a field it drops is an error naming the record and the key. A record
|
||||
//! that does not decode, an enum value outside the mirror and a verb that
|
||||
//! breaks the grammar's name or role rules are errors, never skipped rows.
|
||||
|
||||
use crate::ImportKey;
|
||||
use crate::grammar::{
|
||||
GrammarBinding, GrammarError, GrammarReferentKind, GrammarRole, GrammarVerb, RenderPath,
|
||||
};
|
||||
use cultcache_rs::SingleFileMessagePackBackingStore;
|
||||
use rmpv::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
const GRAMMAR_SCHEMA: &str = "aetheria.verse_grammar";
|
||||
const VERB_SCHEMA: &str = "aetheria.verse_verb";
|
||||
const FACTION_SCHEMA: &str = "aetheria.faction";
|
||||
|
||||
const VERB_NAME_MAX: usize = 48;
|
||||
const ROLE_NAME_MAX: usize = 32;
|
||||
/// The invoking subject, never a role.
|
||||
const ACTOR_ROLE: &str = "actor";
|
||||
|
||||
/// What Ghostlight takes from Aetheria's catalog.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct AetheriaCatalog {
|
||||
pub grammar: GrammarBinding,
|
||||
/// In record-key order.
|
||||
pub factions: Vec<AetheriaFaction>,
|
||||
}
|
||||
|
||||
/// One `aetheria.faction` record. `allegiance` is carried and read by no
|
||||
/// consumer yet.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct AetheriaFaction {
|
||||
pub key: ImportKey,
|
||||
pub name: String,
|
||||
pub short_name: String,
|
||||
pub description: String,
|
||||
pub allegiance: BTreeMap<ImportKey, f32>,
|
||||
}
|
||||
|
||||
/// Which of the grammar's name or role rules a verb broke.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum VerbRule {
|
||||
#[error("verb name is not canonical")]
|
||||
Name,
|
||||
#[error("role name is not canonical")]
|
||||
RoleName,
|
||||
#[error("role name is reserved for the invoking subject")]
|
||||
ReservedRole,
|
||||
#[error("role name is declared twice")]
|
||||
DuplicateRole,
|
||||
}
|
||||
|
||||
/// Why a catalog could not be read. Records are named by record key and fields
|
||||
/// by integer key; no error carries a value the catalog held.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum CatalogReadError {
|
||||
#[error("the catalog file cannot be read")]
|
||||
Unreadable,
|
||||
#[error("the catalog holds no aetheria.verse_grammar global")]
|
||||
NoGrammar,
|
||||
#[error("the catalog holds more than one aetheria.verse_grammar record")]
|
||||
MultipleGrammars,
|
||||
#[error("record {record:?} field {field} is missing or not the mirrored type")]
|
||||
MalformedRecord { record: String, field: u8 },
|
||||
#[error("record {record:?} field {field} is not a canonical import key")]
|
||||
NonCanonicalKey { record: String, field: u8 },
|
||||
#[error("record {record:?} is a second aetheria.faction under one record key")]
|
||||
DuplicateFaction { record: String },
|
||||
#[error("record {record:?} field {field} is not a known render path")]
|
||||
UnknownRenderPath { record: String, field: u8 },
|
||||
#[error("record {record:?} field {field} is not a known referent kind")]
|
||||
UnknownReferentKind { record: String, field: u8 },
|
||||
#[error("verb record {record:?} breaks a grammar rule: {rule}")]
|
||||
InvalidVerb { record: String, rule: VerbRule },
|
||||
#[error("the verbs do not bind a grammar: {0}")]
|
||||
Grammar(#[from] GrammarError),
|
||||
}
|
||||
|
||||
/// Reads the catalog at `path` without writing it. An absent file is
|
||||
/// `Unreadable`, not an empty catalog.
|
||||
pub fn read_aetheria_catalog(path: impl AsRef<Path>) -> Result<AetheriaCatalog, CatalogReadError> {
|
||||
let path = path.as_ref();
|
||||
std::fs::metadata(path).map_err(|_| CatalogReadError::Unreadable)?;
|
||||
let mut rows = SingleFileMessagePackBackingStore::new(path)
|
||||
.pull_all_read_only_snapshot()
|
||||
.map_err(|_| CatalogReadError::Unreadable)?;
|
||||
rows.sort_by(|a, b| a.key.cmp(&b.key));
|
||||
|
||||
let mut revision = None;
|
||||
let mut verbs = Vec::new();
|
||||
let mut factions = Vec::new();
|
||||
for row in &rows {
|
||||
match row.r#type.as_str() {
|
||||
GRAMMAR_SCHEMA => {
|
||||
if revision.is_some() {
|
||||
return Err(CatalogReadError::MultipleGrammars);
|
||||
}
|
||||
let cells = Cells::of(&row.key, &row.payload)?;
|
||||
revision = Some(u32::try_from(cells.int(1)?).map_err(|_| cells.malformed(1))?);
|
||||
}
|
||||
VERB_SCHEMA => verbs.push(read_verb(&Cells::of(&row.key, &row.payload)?)?),
|
||||
FACTION_SCHEMA => {
|
||||
let faction = read_faction(&row.key, &Cells::of(&row.key, &row.payload)?)?;
|
||||
if factions
|
||||
.last()
|
||||
.is_some_and(|seen: &AetheriaFaction| seen.key == faction.key)
|
||||
{
|
||||
return Err(CatalogReadError::DuplicateFaction {
|
||||
record: row.key.clone(),
|
||||
});
|
||||
}
|
||||
factions.push(faction);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let revision = revision.ok_or(CatalogReadError::NoGrammar)?;
|
||||
Ok(AetheriaCatalog {
|
||||
grammar: GrammarBinding::new(GRAMMAR_SCHEMA, revision, verbs)?,
|
||||
factions,
|
||||
})
|
||||
}
|
||||
|
||||
/// A record's payload: the positional array of its keyed fields.
|
||||
struct Cells<'a> {
|
||||
record: &'a str,
|
||||
cells: Vec<Value>,
|
||||
}
|
||||
|
||||
impl<'a> Cells<'a> {
|
||||
fn of(record: &'a str, payload: &[u8]) -> Result<Self, CatalogReadError> {
|
||||
let mut payload = payload;
|
||||
match rmpv::decode::read_value(&mut payload) {
|
||||
Ok(Value::Array(cells)) => Ok(Self { record, cells }),
|
||||
_ => Err(CatalogReadError::MalformedRecord {
|
||||
record: record.to_owned(),
|
||||
field: 0,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn malformed(&self, field: u8) -> CatalogReadError {
|
||||
CatalogReadError::MalformedRecord {
|
||||
record: self.record.to_owned(),
|
||||
field,
|
||||
}
|
||||
}
|
||||
|
||||
fn non_canonical(&self, field: u8) -> CatalogReadError {
|
||||
CatalogReadError::NonCanonicalKey {
|
||||
record: self.record.to_owned(),
|
||||
field,
|
||||
}
|
||||
}
|
||||
|
||||
fn cell(&self, field: u8) -> Result<&Value, CatalogReadError> {
|
||||
self.cells
|
||||
.get(usize::from(field))
|
||||
.ok_or_else(|| self.malformed(field))
|
||||
}
|
||||
|
||||
fn int(&self, field: u8) -> Result<i64, CatalogReadError> {
|
||||
self.cell(field)?
|
||||
.as_i64()
|
||||
.ok_or_else(|| self.malformed(field))
|
||||
}
|
||||
|
||||
fn text(&self, field: u8) -> Result<String, CatalogReadError> {
|
||||
self.cell(field)?
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| self.malformed(field))
|
||||
}
|
||||
|
||||
/// A string Aetheria may leave null; null reads as empty.
|
||||
fn text_or_empty(&self, field: u8) -> Result<String, CatalogReadError> {
|
||||
match self.cell(field)? {
|
||||
Value::Nil => Ok(String::new()),
|
||||
Value::String(text) => text
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| self.malformed(field)),
|
||||
_ => Err(self.malformed(field)),
|
||||
}
|
||||
}
|
||||
|
||||
fn array(&self, field: u8) -> Result<&Vec<Value>, CatalogReadError> {
|
||||
self.cell(field)?
|
||||
.as_array()
|
||||
.ok_or_else(|| self.malformed(field))
|
||||
}
|
||||
}
|
||||
|
||||
fn read_verb(cells: &Cells) -> Result<(String, GrammarVerb), CatalogReadError> {
|
||||
let invalid = |rule| CatalogReadError::InvalidVerb {
|
||||
record: cells.record.to_owned(),
|
||||
rule,
|
||||
};
|
||||
let name = cells.text(1)?;
|
||||
if !canonical_name(&name, VERB_NAME_MAX) {
|
||||
return Err(invalid(VerbRule::Name));
|
||||
}
|
||||
let render_path = match cells.int(2)? {
|
||||
1 => RenderPath::ShipAction,
|
||||
2 => RenderPath::Conversation,
|
||||
_ => {
|
||||
return Err(CatalogReadError::UnknownRenderPath {
|
||||
record: cells.record.to_owned(),
|
||||
field: 2,
|
||||
});
|
||||
}
|
||||
};
|
||||
let mut roles: Vec<GrammarRole> = Vec::new();
|
||||
for role in cells.array(3)? {
|
||||
let role = Cells {
|
||||
record: cells.record,
|
||||
cells: role.as_array().cloned().ok_or_else(|| cells.malformed(3))?,
|
||||
};
|
||||
let role_name = role.text(1).map_err(|_| cells.malformed(3))?;
|
||||
if !canonical_name(&role_name, ROLE_NAME_MAX) {
|
||||
return Err(invalid(VerbRule::RoleName));
|
||||
}
|
||||
if role_name == ACTOR_ROLE {
|
||||
return Err(invalid(VerbRule::ReservedRole));
|
||||
}
|
||||
if roles.iter().any(|seen| seen.role == role_name) {
|
||||
return Err(invalid(VerbRule::DuplicateRole));
|
||||
}
|
||||
let referent = match role.int(2).map_err(|_| cells.malformed(3))? {
|
||||
1 => GrammarReferentKind::Person,
|
||||
2 => GrammarReferentKind::Faction,
|
||||
3 => GrammarReferentKind::Population,
|
||||
4 => GrammarReferentKind::Place,
|
||||
5 => GrammarReferentKind::Cargo,
|
||||
_ => {
|
||||
return Err(CatalogReadError::UnknownReferentKind {
|
||||
record: cells.record.to_owned(),
|
||||
field: 3,
|
||||
});
|
||||
}
|
||||
};
|
||||
roles.push(GrammarRole {
|
||||
role: role_name,
|
||||
referent,
|
||||
});
|
||||
}
|
||||
Ok((name, GrammarVerb { render_path, roles }))
|
||||
}
|
||||
|
||||
/// A lower-case letter, then lower-case letters, digits and underscores.
|
||||
fn canonical_name(name: &str, max_len: usize) -> bool {
|
||||
let mut chars = name.chars();
|
||||
name.len() <= max_len
|
||||
&& chars.next().is_some_and(|c| c.is_ascii_lowercase())
|
||||
&& chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
|
||||
}
|
||||
|
||||
fn read_faction(key: &str, cells: &Cells) -> Result<AetheriaFaction, CatalogReadError> {
|
||||
let own = ImportKey::new(FACTION_SCHEMA, key);
|
||||
if !own.is_canonical() {
|
||||
return Err(cells.non_canonical(0));
|
||||
}
|
||||
let Some(allegiance) = cells.cell(12)?.as_map() else {
|
||||
return Err(cells.malformed(12));
|
||||
};
|
||||
let mut standing = BTreeMap::new();
|
||||
for (other, weight) in allegiance {
|
||||
let other = other.as_str().ok_or_else(|| cells.malformed(12))?;
|
||||
let weight = weight.as_f64().ok_or_else(|| cells.malformed(12))?;
|
||||
let other = ImportKey::new(FACTION_SCHEMA, other);
|
||||
if !other.is_canonical() {
|
||||
return Err(cells.non_canonical(12));
|
||||
}
|
||||
standing.insert(other, weight as f32);
|
||||
}
|
||||
Ok(AetheriaFaction {
|
||||
key: own,
|
||||
name: cells.text(1)?,
|
||||
short_name: cells.text_or_empty(2)?,
|
||||
description: cells.text_or_empty(3)?,
|
||||
allegiance: standing,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
589
crates/ghostlight/src/aetheria/tests.rs
Normal file
589
crates/ghostlight/src/aetheria/tests.rs
Normal file
|
|
@ -0,0 +1,589 @@
|
|||
use super::*;
|
||||
use cultcache_rs::{CacheBackingStore, CultCacheEnvelope};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Written by Aetheria's own fixture writer (`VerseGrammarTests.WriteFixture`)
|
||||
/// at Aetheria master 92b82e9a; never edited. See `tests/fixtures/aetheria-verse-catalog.provenance.txt`.
|
||||
const FIXTURE: &str = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/fixtures/aetheria-verse-catalog.cc"
|
||||
);
|
||||
|
||||
fn fixture_rows() -> Vec<CultCacheEnvelope> {
|
||||
SingleFileMessagePackBackingStore::new(FIXTURE)
|
||||
.pull_all_read_only_snapshot()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn row_of<'a>(rows: &'a mut [CultCacheEnvelope], key: &str) -> &'a mut CultCacheEnvelope {
|
||||
rows.iter_mut().find(|row| row.key == key).unwrap()
|
||||
}
|
||||
|
||||
/// Re-encodes one record's positional payload after `edit`.
|
||||
fn edit(rows: &mut [CultCacheEnvelope], key: &str, change: impl FnOnce(&mut Vec<Value>)) {
|
||||
let row = row_of(rows, key);
|
||||
let Value::Array(mut cells) = rmp_serde::from_slice(&row.payload).unwrap() else {
|
||||
panic!("{key} is not a positional record");
|
||||
};
|
||||
change(&mut cells);
|
||||
row.payload = rmp_serde::to_vec(&Value::Array(cells)).unwrap();
|
||||
}
|
||||
|
||||
fn catalog_of(rows: &[CultCacheEnvelope]) -> (tempfile::TempDir, std::path::PathBuf) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("catalog.cc");
|
||||
let mut store = SingleFileMessagePackBackingStore::new(&path);
|
||||
for row in rows {
|
||||
store.push(row).unwrap();
|
||||
}
|
||||
(dir, path)
|
||||
}
|
||||
|
||||
fn read(rows: &[CultCacheEnvelope]) -> Result<AetheriaCatalog, CatalogReadError> {
|
||||
let (_dir, path) = catalog_of(rows);
|
||||
read_aetheria_catalog(&path)
|
||||
}
|
||||
|
||||
fn verb(path: RenderPath, roles: &[(&str, GrammarReferentKind)]) -> GrammarVerb {
|
||||
GrammarVerb {
|
||||
render_path: path,
|
||||
roles: roles
|
||||
.iter()
|
||||
.map(|(role, referent)| GrammarRole {
|
||||
role: (*role).into(),
|
||||
referent: *referent,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// What Aetheria's fixture writer authored, spelled out by hand.
|
||||
fn authored_verbs() -> Vec<(String, GrammarVerb)> {
|
||||
vec![
|
||||
(
|
||||
"haul".into(),
|
||||
verb(
|
||||
RenderPath::ShipAction,
|
||||
&[
|
||||
("cargo", GrammarReferentKind::Cargo),
|
||||
("destination", GrammarReferentKind::Place),
|
||||
],
|
||||
),
|
||||
),
|
||||
(
|
||||
"attack".into(),
|
||||
verb(
|
||||
RenderPath::ShipAction,
|
||||
&[("target", GrammarReferentKind::Faction)],
|
||||
),
|
||||
),
|
||||
("speak".into(), verb(RenderPath::Conversation, &[])),
|
||||
]
|
||||
}
|
||||
|
||||
fn faction_key(record: &str) -> ImportKey {
|
||||
ImportKey::new("aetheria.faction", record)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_aetheria_written_fixture() {
|
||||
let catalog = read_aetheria_catalog(FIXTURE).unwrap();
|
||||
|
||||
let identity = catalog.grammar.identity();
|
||||
assert_eq!(identity.schema_id, "aetheria.verse_grammar");
|
||||
assert_eq!(identity.revision, 1);
|
||||
for (kind, expected) in authored_verbs() {
|
||||
assert_eq!(catalog.grammar.verb(&kind), Some(&expected), "{kind}");
|
||||
}
|
||||
assert!(catalog.grammar.verb("mine").is_none());
|
||||
|
||||
assert_eq!(
|
||||
catalog.factions,
|
||||
vec![
|
||||
AetheriaFaction {
|
||||
key: faction_key("faction-adrasteia"),
|
||||
name: "Adrasteia".into(),
|
||||
short_name: "ADR".into(),
|
||||
description: "First.".into(),
|
||||
allegiance: BTreeMap::from([(faction_key("faction-brannoch"), 0.75)]),
|
||||
},
|
||||
AetheriaFaction {
|
||||
key: faction_key("faction-brannoch"),
|
||||
name: "Brannoch".into(),
|
||||
short_name: "BRN".into(),
|
||||
description: "Second.".into(),
|
||||
allegiance: BTreeMap::from([(faction_key("faction-adrasteia"), 0.25)]),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grammar_digest_matches_a_binding_built_by_hand() {
|
||||
let by_hand = GrammarBinding::new("aetheria.verse_grammar", 1, authored_verbs()).unwrap();
|
||||
let read = read_aetheria_catalog(FIXTURE).unwrap().grammar;
|
||||
assert_eq!(read.identity().digest, by_hand.identity().digest);
|
||||
assert_eq!(read, by_hand);
|
||||
|
||||
// The digest follows the verbs: a role the file drops is a different grammar.
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "verb-haul", |cells| {
|
||||
cells[3].as_array_mut().unwrap().pop();
|
||||
});
|
||||
assert_ne!(
|
||||
read_catalog_digest(&rows),
|
||||
by_hand.identity().digest,
|
||||
"dropping a role did not move the digest"
|
||||
);
|
||||
}
|
||||
|
||||
fn read_catalog_digest(rows: &[CultCacheEnvelope]) -> String {
|
||||
read(rows).unwrap().grammar.identity().digest
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_catalog_without_a_grammar() {
|
||||
let mut rows = fixture_rows();
|
||||
rows.retain(|row| row.r#type != "aetheria.verse_grammar");
|
||||
assert_eq!(read(&rows), Err(CatalogReadError::NoGrammar));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_second_grammar_record() {
|
||||
let mut rows = fixture_rows();
|
||||
let mut second = rows
|
||||
.iter()
|
||||
.find(|row| row.r#type == "aetheria.verse_grammar")
|
||||
.unwrap()
|
||||
.clone();
|
||||
second.key.push_str("-again");
|
||||
rows.push(second);
|
||||
assert_eq!(read(&rows), Err(CatalogReadError::MultipleGrammars));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_an_unknown_render_path() {
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "verb-haul", |cells| cells[2] = json!(3));
|
||||
let error = read(&rows).unwrap_err();
|
||||
assert_eq!(
|
||||
error,
|
||||
CatalogReadError::UnknownRenderPath {
|
||||
record: "verb-haul".into(),
|
||||
field: 2
|
||||
}
|
||||
);
|
||||
assert!(!error.to_string().contains('3'), "the value was echoed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_an_unknown_referent_kind() {
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "verb-attack", |cells| {
|
||||
cells[3][0][2] = json!(9);
|
||||
});
|
||||
assert_eq!(
|
||||
read(&rows),
|
||||
Err(CatalogReadError::UnknownReferentKind {
|
||||
record: "verb-attack".into(),
|
||||
field: 3
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_verb_breaking_the_name_or_role_rules_is_refused_by_key() {
|
||||
let broken = |mutate: fn(&mut Vec<Value>)| {
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "verb-haul", mutate);
|
||||
read(&rows).unwrap_err()
|
||||
};
|
||||
let invalid = |rule| CatalogReadError::InvalidVerb {
|
||||
record: "verb-haul".into(),
|
||||
rule,
|
||||
};
|
||||
assert_eq!(
|
||||
broken(|cells| cells[1] = json!("Haul")),
|
||||
invalid(VerbRule::Name)
|
||||
);
|
||||
assert_eq!(
|
||||
broken(|cells| cells[1] = json!("1haul")),
|
||||
invalid(VerbRule::Name)
|
||||
);
|
||||
assert_eq!(
|
||||
broken(|cells| cells[1] = json!("")),
|
||||
invalid(VerbRule::Name)
|
||||
);
|
||||
assert_eq!(
|
||||
broken(|cells| cells[1] = json!("h".repeat(49))),
|
||||
invalid(VerbRule::Name)
|
||||
);
|
||||
assert_eq!(
|
||||
broken(|cells| cells[3][0][1] = json!("Cargo")),
|
||||
invalid(VerbRule::RoleName)
|
||||
);
|
||||
assert_eq!(
|
||||
broken(|cells| cells[3][0][1] = json!("c".repeat(33))),
|
||||
invalid(VerbRule::RoleName)
|
||||
);
|
||||
assert_eq!(
|
||||
broken(|cells| cells[3][0][1] = json!("actor")),
|
||||
invalid(VerbRule::ReservedRole)
|
||||
);
|
||||
assert_eq!(
|
||||
broken(|cells| cells[3][1][1] = json!("cargo")),
|
||||
invalid(VerbRule::DuplicateRole)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_longest_canonical_names_are_admitted() {
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "verb-haul", |cells| {
|
||||
cells[1] = json!(format!("h{}9", "_9".repeat(23)));
|
||||
cells[3][0][1] = json!(format!("c{}", "z".repeat(31)));
|
||||
});
|
||||
let catalog = read(&rows).unwrap();
|
||||
assert_eq!(catalog.grammar.identity().revision, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_verbs_with_one_name_do_not_bind() {
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "verb-haul", |cells| cells[1] = json!("attack"));
|
||||
assert_eq!(
|
||||
read(&rows),
|
||||
Err(CatalogReadError::Grammar(GrammarError::DuplicateVerb))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_record_that_is_not_the_mirrored_shape_is_named_by_key_and_field() {
|
||||
let malformed = |record: &str, field| CatalogReadError::MalformedRecord {
|
||||
record: record.into(),
|
||||
field,
|
||||
};
|
||||
|
||||
let mut rows = fixture_rows();
|
||||
row_of(&mut rows, "faction-brannoch").payload =
|
||||
rmp_serde::to_vec(&json!("not an array")).unwrap();
|
||||
assert_eq!(read(&rows), Err(malformed("faction-brannoch", 0)));
|
||||
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "faction-brannoch", |cells| {
|
||||
cells[1] = Value::Null
|
||||
});
|
||||
assert_eq!(read(&rows), Err(malformed("faction-brannoch", 1)));
|
||||
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "faction-brannoch", |cells| {
|
||||
cells[12] = json!({"faction-adrasteia": "heavy"});
|
||||
});
|
||||
assert_eq!(read(&rows), Err(malformed("faction-brannoch", 12)));
|
||||
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "faction-brannoch", |cells| cells.truncate(12));
|
||||
assert_eq!(read(&rows), Err(malformed("faction-brannoch", 12)));
|
||||
|
||||
let mut rows = fixture_rows();
|
||||
let grammar = rows
|
||||
.iter()
|
||||
.find(|row| row.r#type == "aetheria.verse_grammar")
|
||||
.unwrap()
|
||||
.key
|
||||
.clone();
|
||||
edit(&mut rows, &grammar, |cells| cells[1] = json!(-1));
|
||||
assert_eq!(read(&rows), Err(malformed(&grammar, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fields_aetheria_adds_and_schemas_it_does_not_mirror_are_ignored() {
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "verb-speak", |cells| cells.push(json!("later")));
|
||||
edit(&mut rows, "faction-adrasteia", |cells| cells.push(json!(7)));
|
||||
let mut stranger = rows[0].clone();
|
||||
stranger.key = "ship-1".into();
|
||||
stranger.r#type = "aetheria.ship".into();
|
||||
stranger.schema_id = Some("sha256:not-mirrored".into());
|
||||
stranger.payload = vec![0xc1];
|
||||
rows.push(stranger);
|
||||
|
||||
assert_eq!(
|
||||
read(&rows).unwrap(),
|
||||
read_aetheria_catalog(FIXTURE).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_null_short_name_or_description_reads_as_empty() {
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "faction-adrasteia", |cells| {
|
||||
cells[2] = Value::Null;
|
||||
cells[3] = Value::Null;
|
||||
});
|
||||
let catalog = read(&rows).unwrap();
|
||||
assert_eq!(catalog.factions[0].short_name, "");
|
||||
assert_eq!(catalog.factions[0].description, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_absent_file_is_unreadable_not_an_empty_catalog() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert_eq!(
|
||||
read_aetheria_catalog(dir.path().join("missing.cc")),
|
||||
Err(CatalogReadError::Unreadable)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_leaves_the_catalog_and_its_directory_untouched() {
|
||||
let (dir, path) = catalog_of(&fixture_rows());
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let listing = |dir: &std::path::Path| {
|
||||
let mut names: Vec<_> = std::fs::read_dir(dir)
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().file_name())
|
||||
.collect();
|
||||
names.sort();
|
||||
names
|
||||
};
|
||||
let before = listing(dir.path());
|
||||
read_aetheria_catalog(&path).unwrap();
|
||||
read_aetheria_catalog(&path).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||
assert_eq!(listing(dir.path()), before, "the reader created a file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_referent_code_maps_to_its_own_kind() {
|
||||
// The fixture binds only Faction, Cargo and Place; Person and Population
|
||||
// are authored here at the role's integer key, each read back as itself.
|
||||
for (code, expected) in [
|
||||
(1, GrammarReferentKind::Person),
|
||||
(2, GrammarReferentKind::Faction),
|
||||
(3, GrammarReferentKind::Population),
|
||||
(4, GrammarReferentKind::Place),
|
||||
(5, GrammarReferentKind::Cargo),
|
||||
] {
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "verb-attack", |cells| {
|
||||
cells[3][0][2] = json!(code)
|
||||
});
|
||||
let catalog = read(&rows).unwrap();
|
||||
assert_eq!(
|
||||
catalog.grammar.verb("attack"),
|
||||
Some(&verb(RenderPath::ShipAction, &[("target", expected)])),
|
||||
"code {code}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn faction_with_key(record: &str) -> Vec<CultCacheEnvelope> {
|
||||
let mut rows = fixture_rows();
|
||||
row_of(&mut rows, "faction-brannoch").key = record.into();
|
||||
rows
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_faction_record_key_that_is_not_canonical_text_is_refused_by_key_and_field() {
|
||||
for bad in [
|
||||
"faction-cafe\u{301}",
|
||||
"faction-brannoch ",
|
||||
"faction-\u{202e}brannoch",
|
||||
"",
|
||||
] {
|
||||
assert_eq!(
|
||||
read(&faction_with_key(bad)),
|
||||
Err(CatalogReadError::NonCanonicalKey {
|
||||
record: bad.into(),
|
||||
field: 0
|
||||
}),
|
||||
"{bad:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_allegiance_key_that_is_not_canonical_text_is_refused_by_key_and_field() {
|
||||
for bad in ["faction-cafe\u{301}", "faction-adrasteia ", "\u{202e}"] {
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "faction-brannoch", |cells| {
|
||||
cells[12] = json!({ bad: 0.5 });
|
||||
});
|
||||
assert_eq!(
|
||||
read(&rows),
|
||||
Err(CatalogReadError::NonCanonicalKey {
|
||||
record: "faction-brannoch".into(),
|
||||
field: 12
|
||||
}),
|
||||
"{bad:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The fixture's snapshot bytes with the record rows changed by `change`, for
|
||||
/// what the store's own writer will not produce: it keeps one row per record
|
||||
/// key and stores rows in key order.
|
||||
fn spliced_catalog(
|
||||
change: impl FnOnce(&mut Vec<rmpv::Value>),
|
||||
) -> (tempfile::TempDir, std::path::PathBuf) {
|
||||
let bytes = std::fs::read(FIXTURE).unwrap();
|
||||
let rmpv::Value::Array(mut snapshot) = rmpv::decode::read_value(&mut bytes.as_slice()).unwrap()
|
||||
else {
|
||||
panic!("snapshot is not a tuple");
|
||||
};
|
||||
let rmpv::Value::Array(records) = &mut snapshot[2] else {
|
||||
panic!("snapshot holds no records");
|
||||
};
|
||||
change(records);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("catalog.cc");
|
||||
let mut spliced = Vec::new();
|
||||
rmpv::encode::write_value(&mut spliced, &rmpv::Value::Array(snapshot)).unwrap();
|
||||
std::fs::write(&path, spliced).unwrap();
|
||||
(dir, path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_faction_rows_under_one_record_key_are_refused() {
|
||||
let (_dir, path) = spliced_catalog(|records| {
|
||||
let twin = records
|
||||
.iter()
|
||||
.find(|record| record[0].as_str() == Some("faction-adrasteia"))
|
||||
.unwrap()
|
||||
.clone();
|
||||
records.push(twin);
|
||||
});
|
||||
assert_eq!(
|
||||
read_aetheria_catalog(&path),
|
||||
Err(CatalogReadError::DuplicateFaction {
|
||||
record: "faction-adrasteia".into()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factions_are_read_in_record_key_order_whatever_the_file_order() {
|
||||
let (_dir, path) = spliced_catalog(|records| {
|
||||
records.sort_by(|a, b| b[0].as_str().cmp(&a[0].as_str()));
|
||||
assert_eq!(records[0][0].as_str(), Some("verb-speak"));
|
||||
});
|
||||
let names: Vec<_> = read_aetheria_catalog(&path)
|
||||
.unwrap()
|
||||
.factions
|
||||
.into_iter()
|
||||
.map(|faction| faction.name)
|
||||
.collect();
|
||||
assert_eq!(names, ["Adrasteia", "Brannoch"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_allegiance_that_is_not_a_map_is_malformed_not_empty() {
|
||||
for not_a_map in [
|
||||
json!([]),
|
||||
json!(["faction-adrasteia"]),
|
||||
Value::Null,
|
||||
json!(1),
|
||||
] {
|
||||
let mut rows = fixture_rows();
|
||||
let shape = not_a_map.clone();
|
||||
edit(&mut rows, "faction-brannoch", |cells| cells[12] = shape);
|
||||
assert_eq!(
|
||||
read(&rows),
|
||||
Err(CatalogReadError::MalformedRecord {
|
||||
record: "faction-brannoch".into(),
|
||||
field: 12
|
||||
}),
|
||||
"{not_a_map}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends cells to a faction as Aetheria's C# CultCache writes a `byte[]`
|
||||
/// (MessagePack bin) and a `DateTime` (MessagePack timestamp, ext -1).
|
||||
#[test]
|
||||
fn bin_and_ext_fields_aetheria_adds_at_higher_keys_are_ignored() {
|
||||
let mut rows = fixture_rows();
|
||||
let row = row_of(&mut rows, "faction-adrasteia");
|
||||
let rmpv::Value::Array(mut cells) =
|
||||
rmpv::decode::read_value(&mut row.payload.as_slice()).unwrap()
|
||||
else {
|
||||
panic!("not positional");
|
||||
};
|
||||
cells.push(rmpv::Value::Binary(vec![1, 0, 7]));
|
||||
cells.push(rmpv::Value::Ext(
|
||||
-1,
|
||||
vec![0, 0, 0, 0, 0x65, 0x1f, 0x2a, 0x80],
|
||||
));
|
||||
let mut payload = Vec::new();
|
||||
rmpv::encode::write_value(&mut payload, &rmpv::Value::Array(cells)).unwrap();
|
||||
row.payload = payload;
|
||||
|
||||
assert_eq!(
|
||||
read(&rows).unwrap(),
|
||||
read_aetheria_catalog(FIXTURE).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
/// A faction record re-encoded with `change` applied to its decoded cells, for
|
||||
/// shapes `serde_json` cannot spell: a non-string map key, raw invalid bytes.
|
||||
fn with_cells(key: &str, change: impl FnOnce(&mut Vec<rmpv::Value>)) -> Vec<CultCacheEnvelope> {
|
||||
let mut rows = fixture_rows();
|
||||
let row = row_of(&mut rows, key);
|
||||
let rmpv::Value::Array(mut cells) =
|
||||
rmpv::decode::read_value(&mut row.payload.as_slice()).unwrap()
|
||||
else {
|
||||
panic!("not positional");
|
||||
};
|
||||
change(&mut cells);
|
||||
let mut payload = Vec::new();
|
||||
rmpv::encode::write_value(&mut payload, &rmpv::Value::Array(cells)).unwrap();
|
||||
row.payload = payload;
|
||||
rows
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_allegiance_key_that_is_not_a_string_is_malformed_by_key_and_field() {
|
||||
for key in [
|
||||
rmpv::Value::from(5),
|
||||
rmpv::Value::Nil,
|
||||
rmpv::Value::Binary(b"faction-adrasteia".to_vec()),
|
||||
] {
|
||||
let rows = with_cells("faction-brannoch", |cells| {
|
||||
cells[12] = rmpv::Value::Map(vec![(key, rmpv::Value::F64(0.5))]);
|
||||
});
|
||||
assert_eq!(
|
||||
read(&rows),
|
||||
Err(CatalogReadError::MalformedRecord {
|
||||
record: "faction-brannoch".into(),
|
||||
field: 12
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_that_is_not_utf8_is_malformed_by_key_and_field() {
|
||||
// The marker is overwritten in the encoded bytes with an invalid sequence of
|
||||
// the same length, since a decoded string cannot hold one.
|
||||
const MARKER: &str = "ZZZZ";
|
||||
for field in [1usize, 3] {
|
||||
let mut rows = with_cells("faction-brannoch", |cells| {
|
||||
cells[field] = rmpv::Value::from(MARKER);
|
||||
});
|
||||
let payload = &mut row_of(&mut rows, "faction-brannoch").payload;
|
||||
let at = payload
|
||||
.windows(MARKER.len())
|
||||
.position(|window| window == MARKER.as_bytes())
|
||||
.unwrap();
|
||||
payload[at..at + MARKER.len()].copy_from_slice(&[0xff, 0xfe, 0xfd, 0xfc]);
|
||||
assert_eq!(
|
||||
read(&rows),
|
||||
Err(CatalogReadError::MalformedRecord {
|
||||
record: "faction-brannoch".into(),
|
||||
field: field as u8
|
||||
}),
|
||||
"field {field}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -469,7 +469,7 @@ mod tests {
|
|||
AuthorityTargetRef, CallerId, CausalBoundary, CommandBody, CommandEnvelope, CommitmentKind,
|
||||
ComponentOp, CoverBudget, DecisionOpportunity, DecisionScope, Declaration, DraftHandle,
|
||||
EntityDeclaration, EntityId, EntityKind, EvidenceRef, FactDeclaration, FactStandingRef,
|
||||
FictionalMinutes, JurisdictionKey, NewController, PatchGround, Quantity, Ref,
|
||||
FictionalMinutes, JurisdictionKey, NewController, PatchGround, Quantity, Ref, TickIndex,
|
||||
RouteDeclaration, Statement, SubjectDeclaration, SubjectId, SubjectKind, SystemCapability,
|
||||
WorldEffect, WorldId, WorldKernel, WorldMailbox, WorldScaleIntentRef, agency_graph,
|
||||
commit_digest, derive_boundaries, derive_cover, derive_opportunities,
|
||||
|
|
@ -518,6 +518,7 @@ mod tests {
|
|||
},
|
||||
controller,
|
||||
position: Some(Ref::Existing(commons)),
|
||||
import_key: None,
|
||||
})
|
||||
};
|
||||
WorldPatch {
|
||||
|
|
@ -735,8 +736,7 @@ mod tests {
|
|||
assert!(!graph.subjects.contains(&mirror.first));
|
||||
let cover = derive_cover(
|
||||
snapshot.world_id,
|
||||
snapshot.now,
|
||||
30,
|
||||
TickIndex(0),
|
||||
&opportunities,
|
||||
&graph,
|
||||
CoverBudget {
|
||||
|
|
@ -1195,6 +1195,7 @@ mod tests {
|
|||
controller: NewController::External { consumer },
|
||||
affordances: BTreeSet::new(),
|
||||
position: Some(Ref::Existing(commons)),
|
||||
import_key: None,
|
||||
})])
|
||||
};
|
||||
// A binding to another consumer is outside its ground.
|
||||
|
|
@ -1573,6 +1574,7 @@ mod tests {
|
|||
controller,
|
||||
affordances,
|
||||
position: Some(Ref::Existing(mirror.commons)),
|
||||
import_key: None,
|
||||
})])
|
||||
};
|
||||
let pairing = |result: Result<SubmitReceipt, KernelError>| {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -11,7 +11,6 @@
|
|||
//! The cover is a partition, never a filter: every active subject is in exactly
|
||||
//! one cell every tick. The budget decides at what resolution, not whether.
|
||||
|
||||
use super::clock::FictionalMinutes;
|
||||
use super::{ControllerMode, DecisionOpportunity, SubjectId, WorldId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
|
@ -29,25 +28,18 @@ pub(crate) const AGENCY_STAR_THRESHOLD: usize = 16;
|
|||
/// the name, not by a second namespace.
|
||||
const COVER_NAMESPACE: &str = "ghostlight.cover.v1";
|
||||
|
||||
/// Monotone tick index. Derived from the world clock, never stored. It is
|
||||
/// threaded into every derived command id, so a resumed tick over the same
|
||||
/// snapshot re-derives the same ids and the kernel's idempotency ledger answers
|
||||
/// with the original receipts instead of double-committing.
|
||||
/// A tick's ordinal: the number of clock advances the world had committed when
|
||||
/// the tick opened. The driver reads it from the world and freezes it in the
|
||||
/// tick document; it is never derived from `now`, so it does not depend on the
|
||||
/// span. It is threaded into every derived command id, so a resumed tick
|
||||
/// re-submits the same ids and the kernel's idempotency ledger answers with the
|
||||
/// original receipts instead of double-committing.
|
||||
#[derive(
|
||||
Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default,
|
||||
)]
|
||||
#[serde(transparent)]
|
||||
pub struct TickIndex(pub u64);
|
||||
|
||||
impl TickIndex {
|
||||
/// Floor division by the span one `AdvanceTime` carries. The driver submits
|
||||
/// the clock *after* the cells run, so every cell in one tick sees the same
|
||||
/// `now` and therefore the same index.
|
||||
pub(crate) fn of(now: FictionalMinutes, tick_minutes: u32) -> Self {
|
||||
Self(now.0 / u64::from(tick_minutes.max(1)))
|
||||
}
|
||||
}
|
||||
|
||||
/// How a subject was represented this tick. Derived from the cell, carried
|
||||
/// nowhere in world state: representation is a compute budget, not world truth.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -254,7 +246,7 @@ impl AgencyGraph {
|
|||
}
|
||||
}
|
||||
|
||||
/// The partition. Pure over `(world, now, opportunities, graph, budget)`.
|
||||
/// The partition. Pure over `(world, tick, opportunities, graph, budget)`.
|
||||
///
|
||||
/// `opportunities` arrives in the kernel's one attention order (pressure, then
|
||||
/// attention debt, then id) and is never re-sorted and never filtered here: a
|
||||
|
|
@ -262,13 +254,11 @@ impl AgencyGraph {
|
|||
/// is a fact about cognition rather than a scheduling preference.
|
||||
pub fn derive_cover(
|
||||
world: WorldId,
|
||||
now: FictionalMinutes,
|
||||
tick_minutes: u32,
|
||||
tick: TickIndex,
|
||||
opportunities: &[DecisionOpportunity],
|
||||
graph: &AgencyGraph,
|
||||
budget: CoverBudget,
|
||||
) -> Cover {
|
||||
let tick = TickIndex::of(now, tick_minutes);
|
||||
let active: Vec<&DecisionOpportunity> = opportunities
|
||||
.iter()
|
||||
.filter(|opportunity| opportunity.controller_mode != ControllerMode::Human)
|
||||
|
|
@ -601,8 +591,7 @@ mod tests {
|
|||
) -> Cover {
|
||||
derive_cover(
|
||||
WorldId::nil_for_test(),
|
||||
FictionalMinutes(tick * 60),
|
||||
60,
|
||||
TickIndex(tick),
|
||||
opportunities,
|
||||
graph,
|
||||
budget,
|
||||
|
|
|
|||
|
|
@ -2158,6 +2158,7 @@ mod tests {
|
|||
})
|
||||
.collect(),
|
||||
receipt_digest: "sha256:receipt".into(),
|
||||
usage: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
209
crates/ghostlight/src/grammar.rs
Normal file
209
crates/ghostlight/src/grammar.rs
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
//! The action grammar a world is bound to at creation.
|
||||
//!
|
||||
//! A grammar is the closed set of affordance kinds a world may declare, each
|
||||
//! with its role signature and the one render path its acts take. The grammar's
|
||||
//! home is the Aetheria repo; the kernel holds only this binding: the verbs it
|
||||
//! enforces, and a content digest that names exactly which grammar that was.
|
||||
//! The digest is derived here and nowhere else, so a binding cannot claim a
|
||||
//! digest its verbs do not have.
|
||||
|
||||
use crate::SubjectKind;
|
||||
use crate::patch::{AffordanceKindName, EntityKind, RefKind, Role, RoleSpec, kernel_speak_entry};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// How an act of one verb reaches the player: filmed in the engine as a ship
|
||||
/// action, or shown as Aetheria's portrait-and-choices conversation.
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RenderPath {
|
||||
ShipAction,
|
||||
Conversation,
|
||||
}
|
||||
|
||||
/// What a grammar role may be bound to, in the grammar's own vocabulary. Each
|
||||
/// lowers to exactly one kernel `RefKind`.
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GrammarReferentKind {
|
||||
Person,
|
||||
Faction,
|
||||
Population,
|
||||
Place,
|
||||
Cargo,
|
||||
}
|
||||
|
||||
impl GrammarReferentKind {
|
||||
fn ref_kind(self) -> RefKind {
|
||||
match self {
|
||||
Self::Person => RefKind::Subject(Some(SubjectKind::Person)),
|
||||
Self::Faction => RefKind::Subject(Some(SubjectKind::Institution)),
|
||||
Self::Population => RefKind::Subject(Some(SubjectKind::Population)),
|
||||
Self::Place => RefKind::Entity(EntityKind::Place),
|
||||
Self::Cargo => RefKind::Entity(EntityKind::Resource),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct GrammarRole {
|
||||
pub role: String,
|
||||
pub referent: GrammarReferentKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct GrammarVerb {
|
||||
pub render_path: RenderPath,
|
||||
/// In signature order: the order is part of the verb.
|
||||
pub roles: Vec<GrammarRole>,
|
||||
}
|
||||
|
||||
impl GrammarVerb {
|
||||
/// Whether a declared role list is this verb's signature: same names, same
|
||||
/// referent kinds, same order.
|
||||
pub(crate) fn admits_roles(&self, roles: &[RoleSpec]) -> bool {
|
||||
self.role_specs() == roles
|
||||
}
|
||||
|
||||
/// This verb's signature as the kernel declares it: the one lowering of a
|
||||
/// grammar role, so declaring a verb and admitting a declaration cannot
|
||||
/// disagree.
|
||||
pub(crate) fn role_specs(&self) -> Vec<RoleSpec> {
|
||||
self.roles
|
||||
.iter()
|
||||
.map(|role| RoleSpec {
|
||||
role: Role(role.role.clone()),
|
||||
kind: role.referent.ref_kind(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a bound grammar refuses an affordance entry.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum GrammarRefusal {
|
||||
KindNotInGrammar,
|
||||
RolesDisagree,
|
||||
SpeakNotConversation,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum GrammarError {
|
||||
#[error("grammar schema id is empty")]
|
||||
EmptySchemaId,
|
||||
#[error("grammar declares one verb kind twice")]
|
||||
DuplicateVerb,
|
||||
#[error("grammar digest does not match its verbs")]
|
||||
DigestMismatch,
|
||||
}
|
||||
|
||||
/// Which grammar a world is bound to: its schema id, revision and content
|
||||
/// digest. What a consumer reads; the verb table stays in the binding.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct GrammarIdentity {
|
||||
pub schema_id: String,
|
||||
pub revision: u32,
|
||||
pub digest: String,
|
||||
}
|
||||
|
||||
/// The grammar a world was created under. Immutable: the only constructor
|
||||
/// derives the digest, and deserialisation re-derives it and refuses a mismatch.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(try_from = "RawBinding")]
|
||||
pub struct GrammarBinding {
|
||||
schema_id: String,
|
||||
revision: u32,
|
||||
digest: String,
|
||||
verbs: BTreeMap<String, GrammarVerb>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawBinding {
|
||||
schema_id: String,
|
||||
revision: u32,
|
||||
digest: String,
|
||||
verbs: BTreeMap<String, GrammarVerb>,
|
||||
}
|
||||
|
||||
impl TryFrom<RawBinding> for GrammarBinding {
|
||||
type Error = GrammarError;
|
||||
|
||||
fn try_from(raw: RawBinding) -> Result<Self, GrammarError> {
|
||||
let bound = Self::new(raw.schema_id, raw.revision, raw.verbs)?;
|
||||
if bound.digest != raw.digest {
|
||||
return Err(GrammarError::DigestMismatch);
|
||||
}
|
||||
Ok(bound)
|
||||
}
|
||||
}
|
||||
|
||||
impl GrammarBinding {
|
||||
/// The one constructor. Verbs arrive in any order and are keyed by kind;
|
||||
/// the digest is the sha256 of the canonical encoding of the kind-sorted
|
||||
/// verbs, so the same verbs bind to the same digest however they were listed.
|
||||
pub fn new(
|
||||
schema_id: impl Into<String>,
|
||||
revision: u32,
|
||||
verbs: impl IntoIterator<Item = (String, GrammarVerb)>,
|
||||
) -> Result<Self, GrammarError> {
|
||||
let schema_id = schema_id.into();
|
||||
if schema_id.is_empty() {
|
||||
return Err(GrammarError::EmptySchemaId);
|
||||
}
|
||||
let mut table = BTreeMap::new();
|
||||
for (kind, verb) in verbs {
|
||||
if table.insert(kind, verb).is_some() {
|
||||
return Err(GrammarError::DuplicateVerb);
|
||||
}
|
||||
}
|
||||
let encoded = rmp_serde::to_vec_named(&table).expect("a verb table encodes");
|
||||
let digest = format!("sha256:{:x}", Sha256::digest(encoded));
|
||||
Ok(Self {
|
||||
schema_id,
|
||||
revision,
|
||||
digest,
|
||||
verbs: table,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn identity(&self) -> GrammarIdentity {
|
||||
GrammarIdentity {
|
||||
schema_id: self.schema_id.clone(),
|
||||
revision: self.revision,
|
||||
digest: self.digest.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every verb, in kind order.
|
||||
pub(crate) fn verbs(&self) -> impl Iterator<Item = (&str, &GrammarVerb)> {
|
||||
self.verbs.iter().map(|(kind, verb)| (kind.as_str(), verb))
|
||||
}
|
||||
|
||||
pub(crate) fn verb(&self, kind: &str) -> Option<&GrammarVerb> {
|
||||
self.verbs.get(kind)
|
||||
}
|
||||
|
||||
/// The one grammar check. The resolver turns a refusal into a mismatch and
|
||||
/// `admit_resolved`, the only writer of the affordance catalog, into an
|
||||
/// invariant error, so no path adds an entry without passing it.
|
||||
pub(crate) fn refusal(
|
||||
&self,
|
||||
kind: &AffordanceKindName,
|
||||
roles: &[RoleSpec],
|
||||
) -> Option<GrammarRefusal> {
|
||||
let Some(verb) = self.verb(&kind.0) else {
|
||||
return Some(GrammarRefusal::KindNotInGrammar);
|
||||
};
|
||||
if !verb.admits_roles(roles) {
|
||||
return Some(GrammarRefusal::RolesDisagree);
|
||||
}
|
||||
if *kind == kernel_speak_entry().kind && verb.render_path != RenderPath::Conversation {
|
||||
return Some(GrammarRefusal::SpeakNotConversation);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
745
crates/ghostlight/src/grammar/tests.rs
Normal file
745
crates/ghostlight/src/grammar/tests.rs
Normal file
|
|
@ -0,0 +1,745 @@
|
|||
use super::*;
|
||||
use crate::patch::{
|
||||
AffordanceDeclaration, AffordanceKindName, AudienceSpec, Declaration, DraftHandle, EntityKind,
|
||||
Mismatch, OutcomeBand, Precondition, Role, RoleSpec,
|
||||
};
|
||||
use crate::tests::{auth_principal, command, creation, owner};
|
||||
use crate::{
|
||||
CallerId, CommandBody, CommandId, CreateWorld, KernelError, SubjectKind, SubmitReceipt,
|
||||
WorldId, WorldKernel, WorldPatch,
|
||||
};
|
||||
|
||||
fn verb(path: RenderPath, roles: &[(&str, GrammarReferentKind)]) -> GrammarVerb {
|
||||
GrammarVerb {
|
||||
render_path: path,
|
||||
roles: roles
|
||||
.iter()
|
||||
.map(|(role, referent)| GrammarRole {
|
||||
role: (*role).into(),
|
||||
referent: *referent,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// What the fixture world's genesis needs: the kernel speak entry and the
|
||||
/// fixture's own `convene`. Anything else a test wants is added to it.
|
||||
fn grammar_with(extra: &[(&str, GrammarVerb)]) -> GrammarBinding {
|
||||
let mut verbs = vec![
|
||||
("speak".to_owned(), verb(RenderPath::Conversation, &[])),
|
||||
("convene".to_owned(), verb(RenderPath::Conversation, &[])),
|
||||
];
|
||||
verbs.extend(
|
||||
extra
|
||||
.iter()
|
||||
.map(|(kind, v)| ((*kind).to_owned(), v.clone())),
|
||||
);
|
||||
GrammarBinding::new("aetheria.grammar", 3, verbs).unwrap()
|
||||
}
|
||||
|
||||
fn bound(grammar: Option<GrammarBinding>, title: &str) -> CreateWorld {
|
||||
let mut world = creation(CommandId::new(), title);
|
||||
world.grammar = grammar;
|
||||
world
|
||||
}
|
||||
|
||||
fn declare(handle: &str, kind: &str, roles: &[(&str, RefKind)]) -> Declaration {
|
||||
Declaration::Affordance(AffordanceDeclaration {
|
||||
handle: DraftHandle::new(handle),
|
||||
kind: AffordanceKindName(kind.into()),
|
||||
roles: roles
|
||||
.iter()
|
||||
.map(|(role, kind)| RoleSpec {
|
||||
role: Role((*role).into()),
|
||||
kind: *kind,
|
||||
})
|
||||
.collect(),
|
||||
// Speech with one audience is the smallest entry the resolver does
|
||||
// not refuse as inert; the grammar check does not read it.
|
||||
preconditions: vec![Precondition::CanBroadcast {
|
||||
via: AudienceSpec::Colocated,
|
||||
}],
|
||||
effect_slots: Vec::new(),
|
||||
outcome_bands: vec![OutcomeBand {
|
||||
weight: 1,
|
||||
effects: Vec::new(),
|
||||
}],
|
||||
carries_speech: true,
|
||||
})
|
||||
}
|
||||
|
||||
fn patch_of(declarations: Vec<Declaration>) -> WorldPatch {
|
||||
WorldPatch {
|
||||
declarations,
|
||||
operations: Vec::new(),
|
||||
evidence: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn rejected(result: Result<impl Sized, KernelError>) -> Vec<Mismatch> {
|
||||
match result {
|
||||
Err(KernelError::PatchRejected(set)) => set,
|
||||
Err(other) => panic!("expected a patch refusal, got {other:?}"),
|
||||
Ok(_) => panic!("expected a patch refusal, the command was admitted"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Each call gets its own store path: kernels are created, not reopened.
|
||||
fn create(world: CreateWorld) -> (tempfile::TempDir, Result<WorldKernel, KernelError>) {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let result = WorldKernel::create(
|
||||
directory.path().join("world.cc"),
|
||||
world,
|
||||
&auth_principal(owner()),
|
||||
)
|
||||
.map(|(kernel, _)| kernel);
|
||||
(directory, result)
|
||||
}
|
||||
|
||||
fn admit(kernel: &mut WorldKernel, patch: WorldPatch) -> Result<SubmitReceipt, KernelError> {
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
kernel.submit(
|
||||
command(
|
||||
&snapshot,
|
||||
CommandId::new(),
|
||||
CallerId::Principal(owner()),
|
||||
CommandBody::AdmitPatch {
|
||||
answers: None,
|
||||
patch,
|
||||
},
|
||||
),
|
||||
&auth_principal(owner()),
|
||||
)
|
||||
}
|
||||
|
||||
fn person() -> RefKind {
|
||||
RefKind::Subject(Some(SubjectKind::Person))
|
||||
}
|
||||
|
||||
fn place() -> RefKind {
|
||||
RefKind::Entity(EntityKind::Place)
|
||||
}
|
||||
|
||||
fn not_in_grammar(handle: &str) -> Vec<Mismatch> {
|
||||
vec![Mismatch::AffordanceKindNotInGrammar {
|
||||
handle: DraftHandle::new(handle),
|
||||
}]
|
||||
}
|
||||
|
||||
fn disagrees(handle: &str) -> Vec<Mismatch> {
|
||||
vec![Mismatch::AffordanceRolesDisagreeWithGrammar {
|
||||
handle: DraftHandle::new(handle),
|
||||
}]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grammar_world_refuses_an_affordance_kind_outside_the_grammar() {
|
||||
// Genesis: a declared kind the binding lacks is refused.
|
||||
let mut world = bound(Some(grammar_with(&[])), "Outside");
|
||||
world.patch.declarations.push(declare("wave", "wave", &[]));
|
||||
assert_eq!(rejected(create(world).1), not_in_grammar("wave"));
|
||||
|
||||
// A later AdmitPatch: the same kind is refused, and a verb the grammar
|
||||
// does carry is admitted by the same path.
|
||||
let extra = [("hail", verb(RenderPath::Conversation, &[]))];
|
||||
let (_dir, kernel) = create(bound(Some(grammar_with(&extra)), "Outside"));
|
||||
let mut kernel = kernel.unwrap();
|
||||
assert_eq!(
|
||||
rejected(admit(
|
||||
&mut kernel,
|
||||
patch_of(vec![declare("wave", "wave", &[])])
|
||||
)),
|
||||
not_in_grammar("wave")
|
||||
);
|
||||
let receipt = admit(&mut kernel, patch_of(vec![declare("hail", "hail", &[])])).unwrap();
|
||||
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grammar_world_refuses_a_role_signature_the_grammar_does_not_carry() {
|
||||
let extra = [
|
||||
(
|
||||
"meet",
|
||||
verb(
|
||||
RenderPath::ShipAction,
|
||||
&[
|
||||
("who", GrammarReferentKind::Person),
|
||||
("where_", GrammarReferentKind::Place),
|
||||
],
|
||||
),
|
||||
),
|
||||
("hail", verb(RenderPath::Conversation, &[])),
|
||||
];
|
||||
let (_dir, kernel) = create(bound(Some(grammar_with(&extra)), "Signatures"));
|
||||
let mut kernel = kernel.unwrap();
|
||||
for (label, roles) in [
|
||||
("renamed", vec![("whom", person()), ("where_", place())]),
|
||||
("rekinded", vec![("who", person()), ("where_", person())]),
|
||||
("swapped", vec![("where_", place()), ("who", person())]),
|
||||
("short", vec![("who", person())]),
|
||||
(
|
||||
"long",
|
||||
vec![("who", person()), ("where_", place()), ("why", person())],
|
||||
),
|
||||
] {
|
||||
let set = rejected(admit(
|
||||
&mut kernel,
|
||||
patch_of(vec![declare(label, "meet", &roles)]),
|
||||
));
|
||||
assert_eq!(set, disagrees(label), "{label}");
|
||||
}
|
||||
// A role on a verb the grammar declares with none.
|
||||
let set = rejected(admit(
|
||||
&mut kernel,
|
||||
patch_of(vec![declare("hail", "hail", &[("who", person())])]),
|
||||
));
|
||||
assert_eq!(set, disagrees("hail"));
|
||||
// The exact signature is admitted.
|
||||
let receipt = admit(
|
||||
&mut kernel,
|
||||
patch_of(vec![declare(
|
||||
"meet",
|
||||
"meet",
|
||||
&[("who", person()), ("where_", place())],
|
||||
)]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_grammar_referent_kind_admits_exactly_its_own_kernel_kind() {
|
||||
let kinds = [
|
||||
(GrammarReferentKind::Person, person()),
|
||||
(
|
||||
GrammarReferentKind::Faction,
|
||||
RefKind::Subject(Some(SubjectKind::Institution)),
|
||||
),
|
||||
(
|
||||
GrammarReferentKind::Population,
|
||||
RefKind::Subject(Some(SubjectKind::Population)),
|
||||
),
|
||||
(GrammarReferentKind::Place, place()),
|
||||
(
|
||||
GrammarReferentKind::Cargo,
|
||||
RefKind::Entity(EntityKind::Resource),
|
||||
),
|
||||
];
|
||||
for (referent, own) in &kinds {
|
||||
let extra = [("act", verb(RenderPath::ShipAction, &[("with", *referent)]))];
|
||||
for (_, offered) in &kinds {
|
||||
let (_dir, kernel) = create(bound(Some(grammar_with(&extra)), "Referents"));
|
||||
let mut kernel = kernel.unwrap();
|
||||
let result = admit(
|
||||
&mut kernel,
|
||||
patch_of(vec![declare("act", "act", &[("with", *offered)])]),
|
||||
);
|
||||
if offered == own {
|
||||
assert!(
|
||||
matches!(result, Ok(SubmitReceipt::Applied(_))),
|
||||
"{referent:?} refused its own kind"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
rejected(result),
|
||||
disagrees("act"),
|
||||
"{referent:?} admitted {offered:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grammar_world_without_speak_refuses_the_kernel_speak_grant() {
|
||||
let no_speak = GrammarBinding::new(
|
||||
"aetheria.grammar",
|
||||
1,
|
||||
[("convene".to_owned(), verb(RenderPath::Conversation, &[]))],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
rejected(create(bound(Some(no_speak), "Mute")).1),
|
||||
not_in_grammar(crate::patch::KERNEL_SPEAK_HANDLE)
|
||||
);
|
||||
|
||||
// The kernel entry is held to the signature as well as the kind.
|
||||
let loud = GrammarBinding::new(
|
||||
"aetheria.grammar",
|
||||
1,
|
||||
[
|
||||
(
|
||||
"speak".to_owned(),
|
||||
verb(
|
||||
RenderPath::Conversation,
|
||||
&[("to", GrammarReferentKind::Person)],
|
||||
),
|
||||
),
|
||||
("convene".to_owned(), verb(RenderPath::Conversation, &[])),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
rejected(create(bound(Some(loud), "Loud")).1),
|
||||
disagrees(crate::patch::KERNEL_SPEAK_HANDLE)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbound_world_keeps_the_open_catalog() {
|
||||
let mut world = bound(None, "Open");
|
||||
world
|
||||
.patch
|
||||
.declarations
|
||||
.push(declare("wave", "wave", &[("at", person())]));
|
||||
let (_dir, kernel) = create(world);
|
||||
let mut kernel = kernel.unwrap();
|
||||
let receipt = admit(
|
||||
&mut kernel,
|
||||
patch_of(vec![declare("beacon", "beacon", &[])]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
assert!(snapshot.grammar.is_none());
|
||||
assert!(snapshot.affordances.len() >= 4);
|
||||
assert!(snapshot.affordances.iter().all(|a| a.render_path.is_none()));
|
||||
|
||||
// The state row of a world with no grammar carries no grammar key, so its
|
||||
// bytes and digests are those of a world written before grammars existed.
|
||||
let encoded = rmp_serde::to_vec_named(&kernel.state).unwrap();
|
||||
let keys: std::collections::BTreeMap<String, serde::de::IgnoredAny> =
|
||||
rmp_serde::from_slice(&encoded).unwrap();
|
||||
assert!(!keys.contains_key("grammar"));
|
||||
assert!(keys.contains_key("brief"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bound_world_reports_its_grammar_and_derives_render_paths_from_it() {
|
||||
let extra = [("dock", verb(RenderPath::ShipAction, &[]))];
|
||||
let grammar = grammar_with(&extra);
|
||||
let identity = grammar.identity();
|
||||
let (_dir, kernel) = create(bound(Some(grammar), "Reports"));
|
||||
let mut kernel = kernel.unwrap();
|
||||
admit(&mut kernel, patch_of(vec![declare("dock", "dock", &[])])).unwrap();
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
assert_eq!(snapshot.grammar, Some(identity));
|
||||
let path_of = |kind: &str| {
|
||||
snapshot
|
||||
.affordances
|
||||
.iter()
|
||||
.find(|a| a.entry.kind.0 == kind)
|
||||
.unwrap_or_else(|| panic!("{kind} is in the catalog"))
|
||||
.render_path
|
||||
};
|
||||
assert_eq!(path_of("dock"), Some(RenderPath::ShipAction));
|
||||
assert_eq!(path_of("convene"), Some(RenderPath::Conversation));
|
||||
assert_eq!(path_of("speak"), Some(RenderPath::Conversation));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reopened_grammar_world_still_holds_its_grammar() {
|
||||
let grammar = grammar_with(&[]);
|
||||
let identity = grammar.identity();
|
||||
let (directory, kernel) = create(bound(Some(grammar), "Reopened"));
|
||||
let kernel = kernel.unwrap();
|
||||
let world_id = kernel.snapshot().unwrap().world_id;
|
||||
drop(kernel);
|
||||
let mut reopened = WorldKernel::open(directory.path().join("world.cc"), world_id).unwrap();
|
||||
assert_eq!(reopened.snapshot().unwrap().grammar, Some(identity));
|
||||
assert_eq!(
|
||||
rejected(admit(
|
||||
&mut reopened,
|
||||
patch_of(vec![declare("wave", "wave", &[])])
|
||||
)),
|
||||
not_in_grammar("wave")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bound_grammar_is_part_of_the_world_state_digest() {
|
||||
let (_dir, kernel) = create(bound(Some(grammar_with(&[])), "Digested"));
|
||||
let kernel = kernel.unwrap();
|
||||
let bound_digest = crate::state_digest(&kernel.state).unwrap();
|
||||
let mut state = kernel.state.clone();
|
||||
state.grammar = None;
|
||||
let open_digest = crate::state_digest(&state).unwrap();
|
||||
state.grammar = Some(grammar_with(&[("dock", verb(RenderPath::ShipAction, &[]))]));
|
||||
let other_digest = crate::state_digest(&state).unwrap();
|
||||
assert_ne!(bound_digest, open_digest);
|
||||
assert_ne!(bound_digest, other_digest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grammar_digest_is_derived_and_order_free() {
|
||||
let forward = [
|
||||
("alpha".to_owned(), verb(RenderPath::ShipAction, &[])),
|
||||
(
|
||||
"beta".to_owned(),
|
||||
verb(
|
||||
RenderPath::Conversation,
|
||||
&[("to", GrammarReferentKind::Person)],
|
||||
),
|
||||
),
|
||||
("gamma".to_owned(), verb(RenderPath::Conversation, &[])),
|
||||
];
|
||||
let mut reversed = forward.clone();
|
||||
reversed.reverse();
|
||||
let a = GrammarBinding::new("aetheria.grammar", 1, forward.clone()).unwrap();
|
||||
let b = GrammarBinding::new("aetheria.grammar", 1, reversed).unwrap();
|
||||
assert_eq!(a.digest, b.digest);
|
||||
assert_eq!(a, b);
|
||||
|
||||
// Content-derived: each part of a verb moves the digest.
|
||||
let changed = |edit: &dyn Fn(&mut Vec<(String, GrammarVerb)>)| {
|
||||
let mut verbs = forward.to_vec();
|
||||
edit(&mut verbs);
|
||||
GrammarBinding::new("aetheria.grammar", 1, verbs)
|
||||
.unwrap()
|
||||
.digest
|
||||
};
|
||||
assert_ne!(
|
||||
a.digest,
|
||||
changed(&|v| v[0].1.render_path = RenderPath::Conversation)
|
||||
);
|
||||
assert_ne!(a.digest, changed(&|v| v[1].1.roles[0].role = "from".into()));
|
||||
assert_ne!(
|
||||
a.digest,
|
||||
changed(&|v| v[1].1.roles[0].referent = GrammarReferentKind::Faction)
|
||||
);
|
||||
assert_ne!(a.digest, changed(&|v| v[2].0 = "delta".into()));
|
||||
assert_ne!(
|
||||
a.digest,
|
||||
changed(&|v| {
|
||||
v.pop();
|
||||
})
|
||||
);
|
||||
// Role order is part of a verb.
|
||||
let ordered = |roles: &[(&str, GrammarReferentKind)]| {
|
||||
GrammarBinding::new(
|
||||
"g",
|
||||
1,
|
||||
[("meet".to_owned(), verb(RenderPath::ShipAction, roles))],
|
||||
)
|
||||
.unwrap()
|
||||
.digest
|
||||
};
|
||||
assert_ne!(
|
||||
ordered(&[
|
||||
("a", GrammarReferentKind::Person),
|
||||
("b", GrammarReferentKind::Place)
|
||||
]),
|
||||
ordered(&[
|
||||
("b", GrammarReferentKind::Place),
|
||||
("a", GrammarReferentKind::Person)
|
||||
])
|
||||
);
|
||||
// The identity reads the same fields back.
|
||||
let identity = a.identity();
|
||||
assert_eq!(identity.schema_id, "aetheria.grammar");
|
||||
assert_eq!(identity.revision, 1);
|
||||
assert_eq!(identity.digest, a.digest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tampered_grammar_digest_fails_to_deserialise() {
|
||||
let binding = grammar_with(&[]);
|
||||
let value = serde_json::to_value(&binding).unwrap();
|
||||
// Untouched: the round trip holds.
|
||||
let back: GrammarBinding = serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(back, binding);
|
||||
|
||||
// A digest the verbs do not have.
|
||||
let mut forged = value.clone();
|
||||
forged["digest"] = "sha256:00".into();
|
||||
assert!(serde_json::from_value::<GrammarBinding>(forged.clone()).is_err());
|
||||
|
||||
// A verb edited under the digest it already had.
|
||||
let mut edited = value;
|
||||
edited["verbs"]["convene"]["render_path"] = "ship_action".into();
|
||||
assert!(serde_json::from_value::<GrammarBinding>(edited).is_err());
|
||||
|
||||
// The named encoding a state row uses refuses it the same way.
|
||||
let bytes = rmp_serde::to_vec_named(&forged).unwrap();
|
||||
assert!(rmp_serde::from_slice::<GrammarBinding>(&bytes).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grammar_names_each_verb_once_and_has_a_schema() {
|
||||
assert_eq!(
|
||||
GrammarBinding::new("", 1, Vec::new()).unwrap_err(),
|
||||
GrammarError::EmptySchemaId
|
||||
);
|
||||
assert_eq!(
|
||||
GrammarBinding::new(
|
||||
"g",
|
||||
1,
|
||||
[
|
||||
("hail".to_owned(), verb(RenderPath::Conversation, &[])),
|
||||
("hail".to_owned(), verb(RenderPath::ShipAction, &[])),
|
||||
]
|
||||
)
|
||||
.unwrap_err(),
|
||||
GrammarError::DuplicateVerb
|
||||
);
|
||||
}
|
||||
|
||||
/// The fixture world was written by the code at base 367e2eb, which has no
|
||||
/// grammar field anywhere. Reopening it must reproduce the digests that code
|
||||
/// recorded.
|
||||
#[test]
|
||||
fn existing_world_reopens_with_unchanged_digests() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("world.cc");
|
||||
std::fs::write(&path, include_bytes!("../../tests/fixtures/base_world.cc")).unwrap();
|
||||
let world_id: WorldId =
|
||||
serde_json::from_str(include_str!("../../tests/fixtures/base_world.id").trim()).unwrap();
|
||||
let kernel = WorldKernel::open(&path, world_id).expect("a base-written world reopens");
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
let recorded = include_str!("../../tests/fixtures/base_world.digests");
|
||||
let mut lines = recorded.lines();
|
||||
assert_eq!(snapshot.revision.to_string(), lines.next().unwrap());
|
||||
assert_eq!(snapshot.state_digest, lines.next().unwrap());
|
||||
assert_eq!(
|
||||
snapshot.last_commit_digest.as_deref(),
|
||||
Some(lines.next().unwrap())
|
||||
);
|
||||
assert!(snapshot.grammar.is_none());
|
||||
// Its state still derives the recorded digest from its bytes.
|
||||
assert_eq!(
|
||||
crate::state_digest(&kernel.state).unwrap(),
|
||||
snapshot.state_digest
|
||||
);
|
||||
}
|
||||
|
||||
fn submit_as(
|
||||
kernel: &mut WorldKernel,
|
||||
caller: CallerId,
|
||||
answers: Option<crate::PatchAnswer>,
|
||||
patch: WorldPatch,
|
||||
) -> Result<SubmitReceipt, KernelError> {
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
kernel.submit(
|
||||
command(
|
||||
&snapshot,
|
||||
CommandId::new(),
|
||||
caller.clone(),
|
||||
CommandBody::AdmitPatch { answers, patch },
|
||||
),
|
||||
&crate::AuthenticatedCaller::fixture(caller),
|
||||
)
|
||||
}
|
||||
|
||||
/// A bound Draft world whose revision has moved past genesis: one owner patch
|
||||
/// that adds a dead-end place the elaborator can answer.
|
||||
fn advanced_grammar_world() -> (tempfile::TempDir, WorldKernel, crate::EntityId) {
|
||||
let extra = [("hail", verb(RenderPath::Conversation, &[]))];
|
||||
let (dir, kernel) = create(bound(Some(grammar_with(&extra)), "Advanced"));
|
||||
let mut kernel = kernel.unwrap();
|
||||
let commons = *kernel.state.entities.keys().next().unwrap();
|
||||
admit(
|
||||
&mut kernel,
|
||||
patch_of(vec![
|
||||
Declaration::Entity(crate::patch::EntityDeclaration {
|
||||
handle: DraftHandle::new("dead-end"),
|
||||
label: "The Unwalked Road".into(),
|
||||
kind: EntityKind::Place,
|
||||
container: None,
|
||||
}),
|
||||
Declaration::Route(crate::patch::RouteDeclaration {
|
||||
handle: DraftHandle::new("gate"),
|
||||
label: "The Field Gate".into(),
|
||||
from: crate::patch::Ref::Existing(commons),
|
||||
to: crate::patch::Ref::Draft(DraftHandle::new("dead-end")),
|
||||
access: crate::patch::AccessKind::Public,
|
||||
cost: crate::patch::Cost(1),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
let dead_end = *kernel
|
||||
.state
|
||||
.entities
|
||||
.iter()
|
||||
.find(|(_, record)| record.label == "The Unwalked Road")
|
||||
.map(|(id, _)| id)
|
||||
.unwrap();
|
||||
(dir, kernel, dead_end)
|
||||
}
|
||||
|
||||
type Door = (&'static str, CallerId, Option<crate::PatchAnswer>);
|
||||
|
||||
/// Each door refuses an outside kind and a wrong role signature, typed, and the
|
||||
/// refusal leaves the revision where it was. The owner and the consumer declare
|
||||
/// unanswered only in Draft; Play and the elaborator act in Active.
|
||||
fn refuse_at_each_door(kernel: &mut WorldKernel, doors: Vec<Door>) {
|
||||
for (door, caller, answers) in doors {
|
||||
let revision = kernel.state.revision;
|
||||
let kind = submit_as(
|
||||
kernel,
|
||||
caller.clone(),
|
||||
answers.clone(),
|
||||
patch_of(vec![declare("wave", "wave", &[])]),
|
||||
);
|
||||
assert_eq!(rejected(kind), not_in_grammar("wave"), "{door}");
|
||||
let roles = submit_as(
|
||||
kernel,
|
||||
caller,
|
||||
answers,
|
||||
patch_of(vec![declare("hail", "hail", &[("who", person())])]),
|
||||
);
|
||||
assert_eq!(rejected(roles), disagrees("hail"), "{door}");
|
||||
assert_eq!(
|
||||
kernel.state.revision, revision,
|
||||
"{door} refusal moved the world"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_admission_door_refuses_outside_the_grammar_after_the_revision_has_moved() {
|
||||
let (_dir, mut kernel, dead_end) = advanced_grammar_world();
|
||||
assert_eq!(kernel.state.revision, 1);
|
||||
let draft_doors = vec![
|
||||
("owner", CallerId::Principal(owner()), None),
|
||||
(
|
||||
"consumer",
|
||||
CallerId::System(crate::SystemCapability::Consumer {
|
||||
consumer: crate::ConsumerId::of_name("grammar-door"),
|
||||
}),
|
||||
None,
|
||||
),
|
||||
];
|
||||
refuse_at_each_door(&mut kernel, draft_doors);
|
||||
crate::tests::activate(&mut kernel);
|
||||
assert!(
|
||||
kernel.state.revision > 1,
|
||||
"the world has moved past genesis"
|
||||
);
|
||||
let answer = crate::derive_boundaries(&kernel.state)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|boundary| {
|
||||
matches!(boundary, crate::CausalBoundary::UnelaboratedDestination { place, .. }
|
||||
if *place == dead_end)
|
||||
})
|
||||
.expect("the dead end is an unelaborated destination");
|
||||
let active_doors = vec![
|
||||
(
|
||||
"play",
|
||||
CallerId::System(crate::SystemCapability::Play),
|
||||
None,
|
||||
),
|
||||
(
|
||||
"elaborator",
|
||||
CallerId::System(crate::SystemCapability::Elaborator {
|
||||
jurisdiction: crate::JurisdictionKey::PlaceSubtree(dead_end),
|
||||
}),
|
||||
Some(crate::PatchAnswer::Boundary(answer)),
|
||||
),
|
||||
];
|
||||
refuse_at_each_door(&mut kernel, active_doors);
|
||||
// The same path admits what the grammar carries, so the refusals above are
|
||||
// the grammar's and not a closed door.
|
||||
let receipt = submit_as(
|
||||
&mut kernel,
|
||||
CallerId::System(crate::SystemCapability::Play),
|
||||
None,
|
||||
patch_of(vec![declare("hail", "hail", &[])]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grammar_that_binds_speak_as_anything_but_conversation_is_refused_at_bind() {
|
||||
let ship = |roles: &[(&str, GrammarReferentKind)]| {
|
||||
GrammarBinding::new(
|
||||
"aetheria.grammar",
|
||||
1,
|
||||
[
|
||||
("speak".to_owned(), verb(RenderPath::ShipAction, roles)),
|
||||
("convene".to_owned(), verb(RenderPath::Conversation, &[])),
|
||||
],
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(
|
||||
rejected(create(bound(Some(ship(&[])), "Filmed speech")).1),
|
||||
vec![Mismatch::SpeakRenderPathDisagreesWithGrammar {
|
||||
handle: DraftHandle::new(crate::patch::KERNEL_SPEAK_HANDLE),
|
||||
}]
|
||||
);
|
||||
// A ShipAction speak with a role fails on the signature first and is still
|
||||
// refused: no variant of speak but zero roles, Conversation, is admitted.
|
||||
assert_eq!(
|
||||
rejected(
|
||||
create(bound(
|
||||
Some(ship(&[("to", GrammarReferentKind::Person)])),
|
||||
"Loud"
|
||||
))
|
||||
.1
|
||||
),
|
||||
disagrees(crate::patch::KERNEL_SPEAK_HANDLE)
|
||||
);
|
||||
// The control: Conversation speak binds.
|
||||
assert!(create(bound(Some(grammar_with(&[])), "Spoken")).1.is_ok());
|
||||
}
|
||||
|
||||
/// `apply_effect` re-decides an effect it is handed. An effect resolved against
|
||||
/// a world with no grammar must not install an entry the bound grammar lacks:
|
||||
/// the catalog's only writer holds the grammar check itself.
|
||||
#[test]
|
||||
fn an_effect_resolved_elsewhere_cannot_install_an_entry_outside_the_grammar() {
|
||||
let extra = [
|
||||
(
|
||||
"meet",
|
||||
verb(
|
||||
RenderPath::ShipAction,
|
||||
&[("who", GrammarReferentKind::Person)],
|
||||
),
|
||||
),
|
||||
("hail", verb(RenderPath::Conversation, &[])),
|
||||
];
|
||||
let (_dir, kernel) = create(bound(Some(grammar_with(&extra)), "Forged"));
|
||||
let kernel = kernel.unwrap();
|
||||
let mut twin = kernel.state.clone();
|
||||
twin.grammar = None;
|
||||
let resolve = |kind: &str, roles: &[(&str, RefKind)]| {
|
||||
crate::patch::resolve_patch(
|
||||
&twin,
|
||||
CommandId::new(),
|
||||
&patch_of(vec![declare("probe", kind, roles)]),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("the unbound twin admits any canonical kind")
|
||||
};
|
||||
for (label, resolved, admitted) in [
|
||||
("outside kind", resolve("wave", &[]), false),
|
||||
("wrong roles", resolve("meet", &[("whom", person())]), false),
|
||||
("carried verb", resolve("hail", &[]), true),
|
||||
] {
|
||||
let mut state = kernel.state.clone();
|
||||
let catalog = state.affordance_catalog.len();
|
||||
let result = crate::apply_effect(
|
||||
&mut state,
|
||||
CommandId::new(),
|
||||
&CallerId::Principal(owner()),
|
||||
&crate::WorldEffect::PatchAdmitted {
|
||||
answers: None,
|
||||
resolved,
|
||||
},
|
||||
);
|
||||
if admitted {
|
||||
result.unwrap_or_else(|error| panic!("{label}: {error:?}"));
|
||||
assert_eq!(state.affordance_catalog.len(), catalog + 1, "{label}");
|
||||
} else {
|
||||
assert!(
|
||||
matches!(result, Err(KernelError::Invariant(_))),
|
||||
"{label}: {result:?}"
|
||||
);
|
||||
assert_eq!(state.affordance_catalog.len(), catalog, "{label}");
|
||||
}
|
||||
}
|
||||
}
|
||||
752
crates/ghostlight/src/import_key_tests.rs
Normal file
752
crates/ghostlight/src/import_key_tests.rs
Normal file
|
|
@ -0,0 +1,752 @@
|
|||
//! One record, one subject: admission keeps the import-key mapping injective at
|
||||
//! every door and at every revision, and a world nobody imported into is
|
||||
//! byte-for-byte what it was before keys existed.
|
||||
|
||||
use crate::patch::{
|
||||
AccessKind, ComponentOp, Cost, Declaration, DraftHandle, EntityDeclaration, EntityKind,
|
||||
ImportKey, Mismatch, Ref, RouteDeclaration,
|
||||
};
|
||||
use crate::tests::{activate, auth_principal, command, creation, owner};
|
||||
use crate::{
|
||||
CallerId, CommandBody, CommandId, ConsumerId, CreateWorld, EntityId, JurisdictionKey,
|
||||
KernelError, NewController, PatchAnswer, SubjectDeclaration, SubjectKind, SubmitReceipt,
|
||||
SystemCapability, WorldKernel, WorldPatch,
|
||||
};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn key(schema: &str, record: &str) -> ImportKey {
|
||||
ImportKey::new(schema, record)
|
||||
}
|
||||
|
||||
fn alpha() -> ImportKey {
|
||||
key("aetheria.ship", "Alpha")
|
||||
}
|
||||
|
||||
/// The fixture genesis with its witness imported from `alpha()`.
|
||||
fn keyed_creation(title: &str) -> CreateWorld {
|
||||
let mut world = creation(CommandId::new(), title);
|
||||
for declaration in &mut world.patch.declarations {
|
||||
if let Declaration::Subject(subject) = declaration
|
||||
&& subject.handle == DraftHandle::new("persona")
|
||||
{
|
||||
subject.import_key = Some(alpha());
|
||||
}
|
||||
}
|
||||
world
|
||||
}
|
||||
|
||||
fn create(world: CreateWorld) -> (tempfile::TempDir, Result<WorldKernel, KernelError>) {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let result = WorldKernel::create(
|
||||
directory.path().join("world.cc"),
|
||||
world,
|
||||
&auth_principal(owner()),
|
||||
)
|
||||
.map(|(kernel, _)| kernel);
|
||||
(directory, result)
|
||||
}
|
||||
|
||||
fn keyed_world() -> (tempfile::TempDir, WorldKernel) {
|
||||
let (dir, kernel) = create(keyed_creation("Keyed"));
|
||||
(dir, kernel.unwrap())
|
||||
}
|
||||
|
||||
fn rejected(result: Result<impl Sized, KernelError>) -> Vec<Mismatch> {
|
||||
match result {
|
||||
Err(KernelError::PatchRejected(set)) => set,
|
||||
Err(other) => panic!("expected a patch refusal, got {other:?}"),
|
||||
Ok(_) => panic!("expected a patch refusal, the command was admitted"),
|
||||
}
|
||||
}
|
||||
|
||||
fn patch_of(declarations: Vec<Declaration>) -> WorldPatch {
|
||||
WorldPatch {
|
||||
declarations,
|
||||
operations: Vec::new(),
|
||||
evidence: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn submit_as(
|
||||
kernel: &mut WorldKernel,
|
||||
caller: CallerId,
|
||||
answers: Option<PatchAnswer>,
|
||||
patch: WorldPatch,
|
||||
) -> Result<SubmitReceipt, KernelError> {
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
kernel.submit(
|
||||
command(
|
||||
&snapshot,
|
||||
CommandId::new(),
|
||||
caller.clone(),
|
||||
CommandBody::AdmitPatch { answers, patch },
|
||||
),
|
||||
&crate::AuthenticatedCaller::fixture(caller),
|
||||
)
|
||||
}
|
||||
|
||||
fn commons(kernel: &WorldKernel) -> EntityId {
|
||||
*kernel.state.entities.keys().next().unwrap()
|
||||
}
|
||||
|
||||
/// A mirror subject: the one shape every door may declare, since it needs no
|
||||
/// catalog grant.
|
||||
fn mirror(handle: &str, import_key: Option<ImportKey>, place: EntityId) -> Declaration {
|
||||
Declaration::Subject(SubjectDeclaration {
|
||||
handle: DraftHandle::new(handle),
|
||||
label: format!("The {handle}"),
|
||||
kind: SubjectKind::Person,
|
||||
controller: NewController::External {
|
||||
consumer: ConsumerId::of_name("import-key"),
|
||||
},
|
||||
affordances: BTreeSet::new(),
|
||||
position: Some(Ref::Existing(place)),
|
||||
import_key,
|
||||
})
|
||||
}
|
||||
|
||||
fn held(handle: &str) -> Mismatch {
|
||||
Mismatch::ImportKeyHeld {
|
||||
handle: DraftHandle::new(handle),
|
||||
}
|
||||
}
|
||||
|
||||
fn duplicate(handle: &str) -> Mismatch {
|
||||
Mismatch::DuplicateImportKey {
|
||||
handle: DraftHandle::new(handle),
|
||||
}
|
||||
}
|
||||
|
||||
fn malformed(handle: &str) -> Mismatch {
|
||||
Mismatch::MalformedImportKey {
|
||||
handle: DraftHandle::new(handle),
|
||||
}
|
||||
}
|
||||
|
||||
fn keys_in(kernel: &WorldKernel) -> Vec<ImportKey> {
|
||||
kernel
|
||||
.snapshot()
|
||||
.unwrap()
|
||||
.subjects
|
||||
.into_iter()
|
||||
.filter_map(|subject| subject.import_key)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_patch_cannot_declare_two_subjects_with_one_import_key() {
|
||||
// At genesis.
|
||||
let mut world = creation(CommandId::new(), "Twins");
|
||||
for declaration in &mut world.patch.declarations {
|
||||
if let Declaration::Subject(subject) = declaration
|
||||
&& (subject.handle == DraftHandle::new("persona")
|
||||
|| subject.handle == DraftHandle::new("operator"))
|
||||
{
|
||||
subject.import_key = Some(alpha());
|
||||
}
|
||||
}
|
||||
let (_dir, result) = create(world);
|
||||
assert_eq!(rejected(result), vec![duplicate("operator")]);
|
||||
|
||||
// In a later patch, with the key unheld.
|
||||
let (_dir, mut kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
let revision = kernel.state.revision;
|
||||
let beta = Some(key("aetheria.ship", "Beta"));
|
||||
let error = submit_as(
|
||||
&mut kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(vec![
|
||||
mirror("first", beta.clone(), place),
|
||||
mirror("second", beta, place),
|
||||
]),
|
||||
);
|
||||
assert_eq!(rejected(error), vec![duplicate("second")]);
|
||||
assert_eq!(kernel.state.revision, revision, "a refusal moved the world");
|
||||
}
|
||||
|
||||
/// The fixture world past genesis but still in Draft: the owner has added an
|
||||
/// unwalked place the elaborator can later answer.
|
||||
fn advanced_keyed_world() -> (tempfile::TempDir, WorldKernel, EntityId) {
|
||||
let (dir, mut kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
submit_as(
|
||||
&mut kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(vec![
|
||||
Declaration::Entity(EntityDeclaration {
|
||||
handle: DraftHandle::new("dead-end"),
|
||||
label: "The Unwalked Road".into(),
|
||||
kind: EntityKind::Place,
|
||||
container: None,
|
||||
}),
|
||||
Declaration::Route(RouteDeclaration {
|
||||
handle: DraftHandle::new("gate"),
|
||||
label: "The Field Gate".into(),
|
||||
from: Ref::Existing(place),
|
||||
to: Ref::Draft(DraftHandle::new("dead-end")),
|
||||
access: AccessKind::Public,
|
||||
cost: Cost(1),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
let dead_end = *kernel
|
||||
.state
|
||||
.entities
|
||||
.iter()
|
||||
.find(|(_, record)| record.label == "The Unwalked Road")
|
||||
.map(|(id, _)| id)
|
||||
.unwrap();
|
||||
(dir, kernel, dead_end)
|
||||
}
|
||||
|
||||
/// Each door names the held key, typed, and the refusal leaves the revision
|
||||
/// where it was. The owner and the consumer declare in Draft only; Play and the
|
||||
/// elaborator act in Active.
|
||||
fn refuse_held_key_at_each_door(
|
||||
kernel: &mut WorldKernel,
|
||||
place: EntityId,
|
||||
doors: Vec<(&str, CallerId, Option<PatchAnswer>)>,
|
||||
) {
|
||||
for (door, caller, answers) in doors {
|
||||
let revision = kernel.state.revision;
|
||||
let result = submit_as(
|
||||
kernel,
|
||||
caller,
|
||||
answers,
|
||||
patch_of(vec![mirror("copy", Some(alpha()), place)]),
|
||||
);
|
||||
assert_eq!(rejected(result), vec![held("copy")], "{door}");
|
||||
assert_eq!(
|
||||
kernel.state.revision, revision,
|
||||
"{door} refusal moved the world"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn consumer() -> CallerId {
|
||||
CallerId::System(SystemCapability::Consumer {
|
||||
consumer: ConsumerId::of_name("import-key"),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_held_import_key_is_refused_at_every_door_after_the_revision_has_moved() {
|
||||
let (_dir, mut kernel, dead_end) = advanced_keyed_world();
|
||||
assert!(kernel.state.revision > 0, "the world has moved past genesis");
|
||||
refuse_held_key_at_each_door(
|
||||
&mut kernel,
|
||||
dead_end,
|
||||
vec![
|
||||
("owner", CallerId::Principal(owner()), None),
|
||||
("consumer", consumer(), None),
|
||||
],
|
||||
);
|
||||
activate(&mut kernel);
|
||||
let answer = crate::derive_boundaries(&kernel.state)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|boundary| {
|
||||
matches!(boundary, crate::CausalBoundary::UnelaboratedDestination { place, .. }
|
||||
if *place == dead_end)
|
||||
})
|
||||
.expect("the dead end is an unelaborated destination");
|
||||
refuse_held_key_at_each_door(
|
||||
&mut kernel,
|
||||
dead_end,
|
||||
vec![
|
||||
("play", CallerId::System(SystemCapability::Play), None),
|
||||
(
|
||||
"elaborator",
|
||||
CallerId::System(SystemCapability::Elaborator {
|
||||
jurisdiction: JurisdictionKey::PlaceSubtree(dead_end),
|
||||
}),
|
||||
Some(PatchAnswer::Boundary(answer.clone())),
|
||||
),
|
||||
],
|
||||
);
|
||||
// The same doors admit an unheld key, so the refusals are the key's; and
|
||||
// what a door admitted is held against every later door.
|
||||
let fresh = key("aetheria.ship", "Gamma");
|
||||
let receipt = submit_as(
|
||||
&mut kernel,
|
||||
CallerId::System(SystemCapability::Elaborator {
|
||||
jurisdiction: JurisdictionKey::PlaceSubtree(dead_end),
|
||||
}),
|
||||
Some(PatchAnswer::Boundary(answer)),
|
||||
patch_of(vec![mirror("fresh", Some(fresh.clone()), dead_end)]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
|
||||
let again = submit_as(
|
||||
&mut kernel,
|
||||
CallerId::System(SystemCapability::Play),
|
||||
None,
|
||||
patch_of(vec![mirror("fresh-again", Some(fresh), dead_end)]),
|
||||
);
|
||||
assert_eq!(rejected(again), vec![held("fresh-again")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_held_import_key_is_refused_in_a_later_patch() {
|
||||
let (_dir, mut kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
let revision = kernel.state.revision;
|
||||
let error = submit_as(
|
||||
&mut kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(vec![mirror("again", Some(alpha()), place)]),
|
||||
);
|
||||
assert_eq!(rejected(error), vec![held("again")]);
|
||||
assert_eq!(kernel.state.revision, revision);
|
||||
assert_eq!(keys_in(&kernel), vec![alpha()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_retired_subjects_import_key_is_never_reissued() {
|
||||
let (_dir, mut kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
let witness = kernel
|
||||
.snapshot()
|
||||
.unwrap()
|
||||
.subjects
|
||||
.into_iter()
|
||||
.find(|subject| subject.import_key.is_some())
|
||||
.unwrap()
|
||||
.id;
|
||||
submit_as(
|
||||
&mut kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
WorldPatch {
|
||||
declarations: Vec::new(),
|
||||
operations: vec![ComponentOp::Retire {
|
||||
subject: Ref::Existing(witness),
|
||||
}],
|
||||
evidence: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let retired = kernel
|
||||
.snapshot()
|
||||
.unwrap()
|
||||
.subjects
|
||||
.into_iter()
|
||||
.find(|subject| subject.id == witness)
|
||||
.unwrap();
|
||||
assert!(retired.retired, "the witness is retired");
|
||||
assert_eq!(
|
||||
retired.import_key,
|
||||
Some(alpha()),
|
||||
"a retired subject keeps its key"
|
||||
);
|
||||
let error = submit_as(
|
||||
&mut kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(vec![mirror("reborn", Some(alpha()), place)]),
|
||||
);
|
||||
assert_eq!(rejected(error), vec![held("reborn")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_that_differ_in_any_way_are_distinct_records() {
|
||||
let (_dir, mut kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
// Same length, case, prefix, the same concatenation split elsewhere, and
|
||||
// the parts swapped: none is the held key.
|
||||
let forgeries = [
|
||||
key("aetheria.ship", "Alphb"),
|
||||
key("aetheria.ship", "alpha"),
|
||||
key("aetheria.ship", "Alph"),
|
||||
key("aetheria.ship", "Alpha2"),
|
||||
key("aetheria.shipA", "lpha"),
|
||||
key("aetheria.sh", "ipAlpha"),
|
||||
key("Aetheria.ship", "Alpha"),
|
||||
key("Alpha", "aetheria.ship"),
|
||||
key("aetheria.ship2", "Alpha"),
|
||||
];
|
||||
let declarations = forgeries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, forged)| mirror(&format!("forged-{index}"), Some(forged.clone()), place))
|
||||
.collect();
|
||||
submit_as(
|
||||
&mut kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(declarations),
|
||||
)
|
||||
.expect("nine distinct keys beside the held one are admitted together");
|
||||
let held_now: BTreeSet<ImportKey> = keys_in(&kernel).into_iter().collect();
|
||||
assert_eq!(held_now.len(), forgeries.len() + 1);
|
||||
// And each is then held exactly: repeating any one is refused.
|
||||
for (index, forged) in forgeries.iter().enumerate() {
|
||||
let again = submit_as(
|
||||
&mut kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(vec![mirror("repeat", Some(forged.clone()), place)]),
|
||||
);
|
||||
assert_eq!(rejected(again), vec![held("repeat")], "forgery {index}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_import_key_is_refused_without_echoing_it() {
|
||||
let (_dir, mut kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
let long = "x".repeat(201);
|
||||
let wide = "é".repeat(101);
|
||||
let cases = [
|
||||
key("", "Delta"),
|
||||
key("aetheria.ship", ""),
|
||||
key("aetheria ship", "Delta"),
|
||||
key("aetheria.ship", "Del\tta"),
|
||||
key("aetheria.ship", "Del\u{a0}ta"),
|
||||
key("aetheria.ship", " Delta"),
|
||||
key("aetheria.ship", "Delta\n"),
|
||||
key(&long, "Delta"),
|
||||
key("aetheria.ship", &long),
|
||||
key("aetheria.ship", &wide),
|
||||
key(&wide, "Delta"),
|
||||
];
|
||||
for (index, bad) in cases.iter().enumerate() {
|
||||
let set = rejected(submit_as(
|
||||
&mut kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(vec![mirror("bad", Some(bad.clone()), place)]),
|
||||
));
|
||||
assert_eq!(set, vec![malformed("bad")], "case {index}");
|
||||
let spelled = format!("{set:?}");
|
||||
assert!(
|
||||
!spelled.contains("Delta") && !spelled.contains("aetheria"),
|
||||
"case {index} echoed the key"
|
||||
);
|
||||
}
|
||||
// The limit is 200 bytes per part, counted in bytes.
|
||||
let edge = "é".repeat(100);
|
||||
let at_limit = [
|
||||
key(&"x".repeat(200), &edge),
|
||||
key("aetheria.ship", &"y".repeat(200)),
|
||||
];
|
||||
let declarations = at_limit
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, limit)| mirror(&format!("edge-{index}"), Some(limit.clone()), place))
|
||||
.collect();
|
||||
submit_as(
|
||||
&mut kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(declarations),
|
||||
)
|
||||
.expect("keys of exactly 200 bytes per part are admitted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_subjects_map_refuses_a_second_subject_for_one_key_even_when_resolved_elsewhere() {
|
||||
let (_dir, kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
let resolve = |handle: &str| {
|
||||
crate::patch::resolve_patch(
|
||||
&kernel.state,
|
||||
CommandId::new(),
|
||||
&patch_of(vec![mirror(
|
||||
handle,
|
||||
Some(key("aetheria.ship", "Omega")),
|
||||
place,
|
||||
)]),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("the key is unheld at the revision both were resolved against")
|
||||
};
|
||||
let (first, second) = (resolve("first"), resolve("second"));
|
||||
let mut state = kernel.state.clone();
|
||||
let before = state.subjects.len();
|
||||
let effect = |resolved| crate::WorldEffect::PatchAdmitted {
|
||||
answers: None,
|
||||
resolved,
|
||||
};
|
||||
crate::apply_effect(
|
||||
&mut state,
|
||||
CommandId::new(),
|
||||
&CallerId::Principal(owner()),
|
||||
&effect(first),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(state.subjects.len(), before + 1);
|
||||
let result = crate::apply_effect(
|
||||
&mut state,
|
||||
CommandId::new(),
|
||||
&CallerId::Principal(owner()),
|
||||
&effect(second),
|
||||
);
|
||||
assert!(
|
||||
matches!(result, Err(KernelError::Invariant(_))),
|
||||
"{result:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
state.subjects.len(),
|
||||
before + 1,
|
||||
"the second subject was installed"
|
||||
);
|
||||
}
|
||||
|
||||
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
|
||||
haystack.windows(needle.len()).any(|window| window == needle)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyless_world_digests_unchanged() {
|
||||
// A subject nobody imported serialises without the field at all, so every
|
||||
// digest written before keys existed is the digest of the same bytes.
|
||||
let (_dir, kernel) = create(creation(CommandId::new(), "Keyless"));
|
||||
let kernel = kernel.unwrap();
|
||||
let state = rmp_serde::to_vec_named(&kernel.state).unwrap();
|
||||
assert!(
|
||||
!contains(&state, b"import_key"),
|
||||
"a keyless world grew a field"
|
||||
);
|
||||
// A keyed world carries it, round-trips, and digests differently.
|
||||
let (_dir, keyed) = keyed_world();
|
||||
let keyed_state = rmp_serde::to_vec_named(&keyed.state).unwrap();
|
||||
assert!(contains(&keyed_state, b"import_key"));
|
||||
let back: crate::WorldState = rmp_serde::from_slice(&keyed_state).unwrap();
|
||||
assert_eq!(
|
||||
crate::state_digest(&back).unwrap(),
|
||||
crate::state_digest(&keyed.state).unwrap()
|
||||
);
|
||||
assert_ne!(
|
||||
crate::state_digest(&keyed.state).unwrap(),
|
||||
crate::state_digest(&kernel.state).unwrap()
|
||||
);
|
||||
// And a pre-key state deserialises with every key absent.
|
||||
let stripped: crate::WorldState = rmp_serde::from_slice(&state).unwrap();
|
||||
assert!(stripped.subjects.values().all(|s| s.import_key.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_keyed_world_reopens_with_its_keys_and_digests() {
|
||||
let (dir, kernel) = keyed_world();
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
let world_id = snapshot.world_id;
|
||||
drop(kernel);
|
||||
let reopened = WorldKernel::open(dir.path().join("world.cc"), world_id).unwrap();
|
||||
let again = reopened.snapshot().unwrap();
|
||||
assert_eq!(again.state_digest, snapshot.state_digest);
|
||||
let keys = keys_in(&reopened);
|
||||
assert_eq!(keys, vec![alpha()]);
|
||||
// A reader of the snapshot gets both parts back, separately.
|
||||
assert_eq!(keys[0].schema(), "aetheria.ship");
|
||||
assert_eq!(keys[0].record(), "Alpha");
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn a_record_key_is_one_canonical_spelling() {
|
||||
let (_dir, mut kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
let nfc = "Caf\u{e9}";
|
||||
let nfd = "Cafe\u{301}";
|
||||
let submit = |kernel: &mut WorldKernel, handle: &str, record: &str| {
|
||||
submit_as(
|
||||
kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(vec![mirror(
|
||||
handle,
|
||||
Some(key("aetheria.ship", record)),
|
||||
place,
|
||||
)]),
|
||||
)
|
||||
};
|
||||
submit(&mut kernel, "nfc", nfc).expect("the NFC spelling is admitted");
|
||||
let refused = [
|
||||
nfd,
|
||||
"Alpha\u{200b}",
|
||||
"Alpha\u{0}",
|
||||
"\u{feff}Alpha",
|
||||
"Alpha\u{202e}",
|
||||
"Alpha\u{7}",
|
||||
"Del ta",
|
||||
"Del\tta",
|
||||
"Del\u{a0}ta",
|
||||
"Del\u{2003}ta",
|
||||
"Del\nta",
|
||||
// The violation sits deep in a long, real-shaped path, past any prefix.
|
||||
"Worldbuilding/Pre-Elysium/Factions/Powers/Minor/Cafe\u{301}",
|
||||
"Worldbuilding/Pre-Elysium/Factions/Powers/Minor/Ewan\u{200b}Hart",
|
||||
];
|
||||
for (index, record) in refused.iter().enumerate() {
|
||||
let set = rejected(submit(&mut kernel, "bad", record));
|
||||
assert_eq!(set, vec![malformed("bad")], "spelling {index}");
|
||||
assert!(
|
||||
!format!("{set:?}").contains("Alpha"),
|
||||
"spelling {index} echoed the key"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
keys_in(&kernel).len(),
|
||||
2,
|
||||
"only the genesis key and the NFC key are held"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lore_page_path_with_spaces_is_a_record_key() {
|
||||
let (_dir, mut kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
let record = "Worldbuilding/Pre-Elysium/Factions/Powers/Minor/Ewan Hart";
|
||||
let declare = |kernel: &mut WorldKernel, handle: &str| {
|
||||
submit_as(
|
||||
kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(vec![mirror(
|
||||
handle,
|
||||
Some(key("aetheria.lore", record)),
|
||||
place,
|
||||
)]),
|
||||
)
|
||||
};
|
||||
declare(&mut kernel, "ewan").expect("a path with interior spaces is admitted");
|
||||
assert_eq!(rejected(declare(&mut kernel, "again")), vec![held("again")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loaded_state_with_a_bad_import_key_is_refused() {
|
||||
let (_dir, mut kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
submit_as(
|
||||
&mut kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(vec![mirror(
|
||||
"beta",
|
||||
Some(key("aetheria.ship", "Beta")),
|
||||
place,
|
||||
)]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(crate::journal::verify_state_shape(&kernel.state).is_ok());
|
||||
let corrupt = |edit: &dyn Fn(&mut ImportKey)| {
|
||||
let mut state = kernel.state.clone();
|
||||
for subject in state.subjects.values_mut() {
|
||||
if let Some(held) = subject.import_key.as_mut() {
|
||||
edit(held);
|
||||
}
|
||||
}
|
||||
crate::journal::verify_state_shape(&state)
|
||||
};
|
||||
let shared = corrupt(&|held| *held = alpha());
|
||||
assert!(
|
||||
matches!(&shared, Err(crate::journal::JournalError::Corrupt(why)) if why.contains("import key")),
|
||||
"two subjects sharing one key: {shared:?}"
|
||||
);
|
||||
assert!(
|
||||
corrupt(&|held| *held = key("aetheria.ship", " Beta ")).is_err(),
|
||||
"a key with edge spaces must not load"
|
||||
);
|
||||
assert!(
|
||||
corrupt(&|held| *held = key("", "")).is_err(),
|
||||
"an empty key must not load"
|
||||
);
|
||||
assert!(
|
||||
corrupt(&|held| *held = key("aetheria.ship", "Be\u{200b}ta")).is_err(),
|
||||
"a key with a format character must not load"
|
||||
);
|
||||
}
|
||||
|
||||
/// The admission backstop, reached with an effect resolution never produced: a
|
||||
/// resolved patch whose two subjects carry one key. The control is the same
|
||||
/// effect with distinct keys, admitted by the same function.
|
||||
#[test]
|
||||
fn admitting_a_resolved_effect_refuses_two_subjects_with_one_key() {
|
||||
let (_dir, kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
let resolve = || {
|
||||
crate::patch::resolve_patch(
|
||||
&kernel.state,
|
||||
CommandId::new(),
|
||||
&patch_of(vec![
|
||||
mirror("first", Some(key("aetheria.ship", "Beta")), place),
|
||||
mirror("second", Some(key("aetheria.ship", "Gamma")), place),
|
||||
]),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("two distinct keys resolve")
|
||||
};
|
||||
let resolves_to = crate::resolution_revision(&kernel.state).unwrap();
|
||||
let mut control = kernel.state.clone();
|
||||
crate::admit_resolved(&mut control, &resolve(), resolves_to)
|
||||
.expect("distinct keys are admitted");
|
||||
|
||||
let mut twinned = resolve();
|
||||
assert_eq!(twinned.subjects.len(), 2);
|
||||
twinned.subjects[1].subject.import_key = twinned.subjects[0].subject.import_key.clone();
|
||||
let mut state = kernel.state.clone();
|
||||
let error = crate::admit_resolved(&mut state, &twinned, resolves_to)
|
||||
.expect_err("one key on two resolved subjects is refused");
|
||||
assert!(
|
||||
matches!(&error, KernelError::Invariant(why) if why.contains("import key")),
|
||||
"{error:?}"
|
||||
);
|
||||
assert!(!format!("{error:?}").contains("Beta"), "the refusal echoed the key");
|
||||
}
|
||||
|
||||
/// A decomposable character at every position of a real-shaped key: the NFD
|
||||
/// spelling is refused wherever it sits, the NFC spelling is admitted, so the
|
||||
/// rule cannot be a prefix, suffix or first-character check.
|
||||
#[test]
|
||||
fn a_decomposable_character_is_refused_in_nfd_at_every_position() {
|
||||
let (_dir, mut kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
let template: Vec<char> = "Worldbuilding/Pre-Elysium/Factions/Powers/Minor/Ewan Hart"
|
||||
.chars()
|
||||
.collect();
|
||||
let submit = |kernel: &mut WorldKernel, handle: &str, record: String| {
|
||||
submit_as(
|
||||
kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(vec![mirror(
|
||||
handle,
|
||||
Some(key("aetheria.lore", &record)),
|
||||
place,
|
||||
)]),
|
||||
)
|
||||
};
|
||||
let spelled = |position: usize, inserted: &str| {
|
||||
let mut record: String = template[..position].iter().collect();
|
||||
record.push_str(inserted);
|
||||
record.extend(&template[position..]);
|
||||
record
|
||||
};
|
||||
for position in 0..=template.len() {
|
||||
let nfd = spelled(position, "e\u{301}");
|
||||
assert!(
|
||||
!unicode_normalization::is_nfc(&nfd),
|
||||
"position {position} is not NFD"
|
||||
);
|
||||
let set = rejected(submit(&mut kernel, "bad", nfd));
|
||||
assert_eq!(set, vec![malformed("bad")], "NFD at position {position}");
|
||||
}
|
||||
for position in 0..=template.len() {
|
||||
let nfc = spelled(position, "\u{e9}");
|
||||
submit(&mut kernel, &format!("good{position}"), nfc)
|
||||
.unwrap_or_else(|_| panic!("NFC at position {position} was refused"));
|
||||
}
|
||||
assert_eq!(
|
||||
keys_in(&kernel).len(),
|
||||
1 + template.len() + 1,
|
||||
"the genesis key and one NFC key per position are held, no NFD key"
|
||||
);
|
||||
}
|
||||
|
|
@ -181,6 +181,11 @@ impl WorldJournal {
|
|||
self.commits.get(&command_id)
|
||||
}
|
||||
|
||||
/// Every commit the journal holds, in no particular order.
|
||||
pub(super) fn commits(&self) -> impl Iterator<Item = &WorldCommit> {
|
||||
self.commits.values()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn commit_count(&self) -> usize {
|
||||
self.commits.len()
|
||||
|
|
@ -826,6 +831,17 @@ pub(super) fn verify_state_shape(state: &WorldState) -> Result<(), JournalError>
|
|||
"one subject holds two overlapping jurisdictions of one kind".into(),
|
||||
));
|
||||
}
|
||||
let mut declared_keys = BTreeSet::new();
|
||||
let no_held = BTreeSet::new();
|
||||
for subject in state.subjects.values() {
|
||||
if let Some(key) = &subject.import_key
|
||||
&& super::patch::import_key_fault(key, &no_held, &mut declared_keys).is_some()
|
||||
{
|
||||
return Err(JournalError::Corrupt(
|
||||
"subject import key is malformed or held by two subjects".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut controller_ids = BTreeSet::new();
|
||||
for (subject_id, subject) in &state.subjects {
|
||||
if !super::patch::is_canonical_text(&subject.label) {
|
||||
|
|
@ -1107,8 +1123,10 @@ mod tests {
|
|||
},
|
||||
affordances: kernel_speak_grant(),
|
||||
position: None,
|
||||
import_key: None,
|
||||
}),
|
||||
scale_intent: WorldScaleIntentRef::default(),
|
||||
grammar: None,
|
||||
};
|
||||
let (mut kernel, _) = WorldKernel::create(&path, creation, &authenticated).unwrap();
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
|
|
@ -1190,8 +1208,10 @@ mod tests {
|
|||
},
|
||||
affordances: kernel_speak_grant(),
|
||||
position: None,
|
||||
import_key: None,
|
||||
}),
|
||||
scale_intent: WorldScaleIntentRef::default(),
|
||||
grammar: None,
|
||||
};
|
||||
let (mut kernel, _) = WorldKernel::create(&path, creation, &authenticated).unwrap();
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
|
|
@ -1220,6 +1240,7 @@ mod tests {
|
|||
controller: NewController::OperationalAgent,
|
||||
affordances: BTreeSet::from([speak_entry(&kernel)]),
|
||||
position: Some(Ref::Draft(DraftHandle::new("rhythm-road"))),
|
||||
import_key: None,
|
||||
}),
|
||||
],
|
||||
operations: Vec::new(),
|
||||
|
|
@ -1755,8 +1776,10 @@ mod tests {
|
|||
controller: NewController::Human { principal: owner },
|
||||
affordances: kernel_speak_grant(),
|
||||
position: None,
|
||||
import_key: None,
|
||||
}),
|
||||
scale_intent: WorldScaleIntentRef::default(),
|
||||
grammar: None,
|
||||
};
|
||||
let (kernel, _) = WorldKernel::create(&path, creation, &authenticated).unwrap();
|
||||
let mut forged_head = kernel.state.clone();
|
||||
|
|
|
|||
|
|
@ -56,11 +56,15 @@
|
|||
//! ```
|
||||
|
||||
mod action;
|
||||
mod aetheria;
|
||||
mod clock;
|
||||
mod consumer;
|
||||
mod controllers;
|
||||
mod cover;
|
||||
mod elaboration;
|
||||
mod grammar;
|
||||
#[cfg(test)]
|
||||
mod import_key_tests;
|
||||
mod journal;
|
||||
mod lens;
|
||||
mod local_inference;
|
||||
|
|
@ -70,6 +74,7 @@ mod sdk_inference;
|
|||
mod table;
|
||||
mod tool_schema;
|
||||
mod vault;
|
||||
mod verse;
|
||||
|
||||
pub(crate) use action::ActionMismatch;
|
||||
pub(crate) use clock::Motion;
|
||||
|
|
@ -80,23 +85,33 @@ pub use consumer::{
|
|||
};
|
||||
pub use controllers::{
|
||||
CONTROLLER_WORK_SCHEMA, CellRun, ConnectorBinding, ControllerError, ControllerModels,
|
||||
ControllerOpenError,
|
||||
ControllerNeed, ControllerPendingReason, ControllerRunner, ControllerWork,
|
||||
ControllerWorkCustody, ControllerWorkLookup, ControllerWorkStore, ControllerWorkStoreError,
|
||||
ControllerWorkWrite,
|
||||
GroupedCheckpoint, InferenceEvent, InferenceFault, InferenceFaultDisposition, InferenceOutput,
|
||||
GroupedCheckpoint, InferenceEvent, InferenceFault, InferenceFaultClass, InferenceFaultDisposition, InferenceOutput,
|
||||
InferencePort,
|
||||
InferencePurpose, InferenceRequest, NarrativeCheckpoint, NarrativeRun, OperationalCheckpoint,
|
||||
OperationalRun, PersonaLane, PreparedInference, SubmissionDisposition, ToolResultOracle,
|
||||
OperationalRun, PersonaLane, PreparedInference, SubmissionDisposition, TokenUsage, ToolResultOracle,
|
||||
TracingInferencePort, open_controller_work, open_inference,
|
||||
};
|
||||
pub(crate) use cover::{CellId, Constituent, Resolution};
|
||||
pub use aetheria::{
|
||||
AetheriaCatalog, AetheriaFaction, CatalogReadError, VerbRule, read_aetheria_catalog,
|
||||
};
|
||||
pub use cover::{AgencyGraph, Cell, Cover, CoverBudget, CoverBudgetError, TickIndex, derive_cover};
|
||||
pub use elaboration::{
|
||||
ElaborationCheckpoint, ElaborationRunner, ElaboratorSession, EvidenceError, EvidenceQuery,
|
||||
EvidenceReceipt, EvidenceSource, SeedCheckpoint, SeedOutcome, SeedRunner, select_row,
|
||||
};
|
||||
pub use grammar::{
|
||||
GrammarBinding, GrammarError, GrammarIdentity, GrammarReferentKind, GrammarRole, GrammarVerb,
|
||||
RenderPath,
|
||||
};
|
||||
pub use lens::{Lens, LensWeights};
|
||||
pub use local_inference::{DEFAULT_LOCAL_MODEL_PREFIX, LocalBinding};
|
||||
pub use local_inference::{DEFAULT_HOSTED_MODEL_PREFIX, DEFAULT_LOCAL_MODEL_PREFIX, HostedBinding, LocalBinding};
|
||||
#[cfg(feature = "test-support")]
|
||||
pub use local_inference::open_local_port_with_timeout;
|
||||
pub(crate) use mailbox::ElaborationPort;
|
||||
pub use mailbox::{ConsumerPort, ControllerPort, MailboxError, PlayPort, SeedPort, WorldMailbox};
|
||||
pub use patch::{
|
||||
|
|
@ -111,6 +126,7 @@ pub(crate) use patch::{
|
|||
PersonaMaterial, Position, PressureMagnitude, PressureSource, Quantity, Reach,
|
||||
SubjectDeclaration, WorldScaleIntent, WorldScaleIntentRef,
|
||||
};
|
||||
pub use patch::ImportKey;
|
||||
#[cfg(test)]
|
||||
use patch::{
|
||||
AffordanceDeclaration, AudienceRef, AudienceSpec, ChannelDeclaration, ComponentOp,
|
||||
|
|
@ -128,6 +144,13 @@ pub use table::{
|
|||
id_text, id_text_matches, table_view,
|
||||
};
|
||||
pub use vault::{VaultError, VaultEvidenceSource};
|
||||
pub use verse::{
|
||||
CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, CreateVerseWorldIntent, ProviderCallRecord,
|
||||
RUN_RECORD_SCHEMA, RecordedCoverBudget, RecordingInferencePort, Refusal, RefusalReason, RunClaim,
|
||||
ReplayInferencePort, RunField, RunId, RunOutcome, RunStart, RunStatus, TickCheckpoint,
|
||||
TickReport, UnattributedCall, VerseGenesisError, VerseRunConfig, VerseRunError, VerseRunRecord, VerseRunStore,
|
||||
call_run_id, run_manual,
|
||||
};
|
||||
use patch::{EdgeRecord, EntityRecord, LedgerDelta, ResolvedOp, ResolvedPatch};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
|
@ -423,6 +446,10 @@ struct CreateWorld {
|
|||
/// The world's flavor as genesis writes it. `SetLensWeights` may replace
|
||||
/// it later; this command carries what the world began with.
|
||||
lens_weights: LensWeights,
|
||||
/// The grammar the world is bound to for its whole life, or none for an
|
||||
/// open-catalog world. Authored here and nowhere else: genesis installs it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
grammar: Option<GrammarBinding>,
|
||||
}
|
||||
|
||||
/// Unattributed creation intent. World ingress derives ownership, controller
|
||||
|
|
@ -707,6 +734,10 @@ pub enum CommandBody {
|
|||
struct SubjectState {
|
||||
label: String,
|
||||
kind: SubjectKind,
|
||||
/// Written only when a subject declaration is resolved; absent from a
|
||||
/// subject nobody imported, so its digests are unchanged.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
import_key: Option<ImportKey>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
|
|
@ -826,6 +857,11 @@ struct WorldState {
|
|||
/// Authored at creation and never mutated; the one prose the world says
|
||||
/// about itself, projected as guidance to every lane.
|
||||
brief: String,
|
||||
/// Bound by genesis and never written again; absent from a world with no
|
||||
/// grammar, so such a world's state and digests are what they were before
|
||||
/// grammars existed.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
grammar: Option<GrammarBinding>,
|
||||
draft_approvals: BTreeSet<PrincipalId>,
|
||||
subjects: BTreeMap<SubjectId, SubjectState>,
|
||||
entities: BTreeMap<EntityId, EntityRecord>,
|
||||
|
|
@ -991,6 +1027,8 @@ pub struct SubjectSnapshot {
|
|||
pub id: SubjectId,
|
||||
pub label: String,
|
||||
pub kind: SubjectKind,
|
||||
/// The Aetheria record this subject was imported from, if it was.
|
||||
pub import_key: Option<ImportKey>,
|
||||
/// Absent for an externally controlled mirror, which has no controller and
|
||||
/// no turn. Every reader fails closed on `None`; none gets a default.
|
||||
pub(crate) controller_id: Option<ControllerId>,
|
||||
|
|
@ -1133,6 +1171,9 @@ pub(crate) struct ForumSnapshot {
|
|||
pub struct AffordanceSnapshot {
|
||||
pub id: AffordanceId,
|
||||
pub entry: Affordance,
|
||||
/// Derived from the world's grammar by this entry's kind; none when the
|
||||
/// world has no grammar.
|
||||
pub render_path: Option<RenderPath>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
|
@ -1195,6 +1236,8 @@ pub struct WorldSnapshot {
|
|||
pub owner: PrincipalId,
|
||||
pub title: String,
|
||||
pub brief: String,
|
||||
/// The bound grammar's identity; none for a world with an open catalog.
|
||||
pub grammar: Option<GrammarIdentity>,
|
||||
pub draft_approvals: BTreeSet<PrincipalId>,
|
||||
pub required_approvers: BTreeSet<PrincipalId>,
|
||||
pub subjects: Vec<SubjectSnapshot>,
|
||||
|
|
@ -1236,6 +1279,21 @@ pub struct OperatorEvent {
|
|||
pub speaker_label: String,
|
||||
pub speech: Option<Statement>,
|
||||
pub display: Option<Statement>,
|
||||
/// The affordance kind the act exercised.
|
||||
pub verb: String,
|
||||
/// How the world's grammar renders that verb; none in a world with no
|
||||
/// grammar.
|
||||
pub render_path: Option<RenderPath>,
|
||||
/// What the act bound each of the verb's roles to, in the invocation's
|
||||
/// order.
|
||||
pub roles: Vec<BoundRole>,
|
||||
}
|
||||
|
||||
/// One role an act bound, with the label of what it was bound to.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct BoundRole {
|
||||
pub role: String,
|
||||
pub target: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
|
@ -1423,7 +1481,12 @@ fn prepare_creation(
|
|||
// derived ID, so it is minted before resolution rather than by it.
|
||||
let world_id = WorldId::issue();
|
||||
let resolved = patch::resolve_patch(
|
||||
&WorldState::empty(world_id, input.owner.clone(), title.clone()),
|
||||
&WorldState::empty(
|
||||
world_id,
|
||||
input.owner.clone(),
|
||||
title.clone(),
|
||||
input.grammar.clone(),
|
||||
),
|
||||
input.id,
|
||||
&input.patch,
|
||||
Some(&input.scale_intent),
|
||||
|
|
@ -1521,7 +1584,43 @@ impl WorldKernel {
|
|||
/// no consumer may read in full would be a loaded gun with a comment on it.
|
||||
fn operator_log(&self) -> Result<Vec<OperatorEvent>, KernelError> {
|
||||
self.journal.ensure_healthy()?;
|
||||
operator_log(&self.state)
|
||||
// An event records what the world did; the invocation, which names the
|
||||
// bound roles, lives on the commit that produced it.
|
||||
let invocations: BTreeMap<EventId, &DecisionInvocation> = self
|
||||
.journal
|
||||
.commits()
|
||||
.filter_map(|commit| match &commit.effect {
|
||||
WorldEffect::DecisionExercised {
|
||||
invocation, event, ..
|
||||
} => Some((event.id, invocation)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
operator_log(&self.state, &invocations)
|
||||
}
|
||||
|
||||
/// The tick driver's opportunity list, in the same order a snapshot
|
||||
/// carries it. Separate from `snapshot` for the reason `agency_graph` is:
|
||||
/// only the tick driver reads it.
|
||||
fn opportunities(&self) -> Result<Vec<DecisionOpportunity>, KernelError> {
|
||||
self.journal.ensure_healthy()?;
|
||||
Ok(order_opportunities(
|
||||
&self.state,
|
||||
derive_opportunities(&self.state)?,
|
||||
))
|
||||
}
|
||||
|
||||
/// How many clock advances the world has committed: the next tick's
|
||||
/// ordinal, held by the world and read from its journal. Separate from
|
||||
/// `snapshot` for the reason `opportunities` is: only the tick driver
|
||||
/// reads it.
|
||||
fn clock_advances(&self) -> Result<u64, KernelError> {
|
||||
self.journal.ensure_healthy()?;
|
||||
Ok(self
|
||||
.journal
|
||||
.commits()
|
||||
.filter(|commit| matches!(commit.effect, WorldEffect::TimeAdvanced { .. }))
|
||||
.count() as u64)
|
||||
}
|
||||
|
||||
/// The scheduler's adjacency projection. Separate from `snapshot` for the
|
||||
|
|
@ -1894,7 +1993,12 @@ impl WorldState {
|
|||
/// The world before any structure. Genesis resolves its own patch against
|
||||
/// this value, so the genesis lane and `AdmitPatch` share one resolver
|
||||
/// signature instead of hand-passing empty partitions.
|
||||
fn empty(world_id: WorldId, owner: PrincipalId, title: String) -> Self {
|
||||
fn empty(
|
||||
world_id: WorldId,
|
||||
owner: PrincipalId,
|
||||
title: String,
|
||||
grammar: Option<GrammarBinding>,
|
||||
) -> Self {
|
||||
Self {
|
||||
schema: STATE_SCHEMA.into(),
|
||||
world_id,
|
||||
|
|
@ -1903,6 +2007,7 @@ impl WorldState {
|
|||
owner,
|
||||
title,
|
||||
brief: String::new(),
|
||||
grammar,
|
||||
draft_approvals: BTreeSet::new(),
|
||||
subjects: BTreeMap::new(),
|
||||
entities: BTreeMap::new(),
|
||||
|
|
@ -1960,7 +2065,7 @@ impl WorldState {
|
|||
// The same re-derive-and-compare that `apply_committed_command` runs for
|
||||
// every other command. Deterministic allocation is what lets one
|
||||
// equality replace a field-by-field binding zip.
|
||||
let mut state = Self::empty(world_id, owner.clone(), title.clone());
|
||||
let mut state = Self::empty(world_id, owner.clone(), title.clone(), command.grammar.clone());
|
||||
state.brief = brief.clone();
|
||||
let expected = patch::resolve_patch(
|
||||
&state,
|
||||
|
|
@ -2069,6 +2174,18 @@ fn admit_resolved(
|
|||
.filter_map(ControllerAssignment::id)
|
||||
.collect();
|
||||
for entry in &resolved.affordances {
|
||||
// The catalog's only writer holds the same grammar check the resolver
|
||||
// runs, so an effect resolved elsewhere cannot install an entry the
|
||||
// bound grammar refuses.
|
||||
if state.grammar.as_ref().is_some_and(|grammar| {
|
||||
grammar
|
||||
.refusal(&entry.affordance.kind, &entry.affordance.roles)
|
||||
.is_some()
|
||||
}) {
|
||||
return Err(KernelError::Invariant(
|
||||
"admitted affordance is outside the bound grammar".into(),
|
||||
));
|
||||
}
|
||||
if state
|
||||
.affordance_catalog
|
||||
.insert(entry.affordance_id, entry.affordance.clone())
|
||||
|
|
@ -2079,6 +2196,21 @@ fn admit_resolved(
|
|||
));
|
||||
}
|
||||
}
|
||||
// The subjects map's only writer holds the same injectivity the resolver
|
||||
// checks, decided by the same function.
|
||||
{
|
||||
let held = patch::held_import_keys(state.subjects.values());
|
||||
let mut declared = BTreeSet::new();
|
||||
for subject in &resolved.subjects {
|
||||
if let Some(key) = &subject.subject.import_key
|
||||
&& patch::import_key_fault(key, &held, &mut declared).is_some()
|
||||
{
|
||||
return Err(KernelError::Invariant(
|
||||
"admitted subject's import key is malformed, held or repeated".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
for subject in &resolved.subjects {
|
||||
let scope = DecisionScope {
|
||||
subject_id: subject.subject_id,
|
||||
|
|
@ -3663,7 +3795,10 @@ fn scope_components(state: &WorldState, subject_id: SubjectId) -> ScopeComponent
|
|||
/// The human operator's story feed, derived from the causal record. The events
|
||||
/// log is not a perception surface: `knowledge` is the sole answer to what a
|
||||
/// subject may act on and what its projection may contain.
|
||||
fn operator_log(state: &WorldState) -> Result<Vec<OperatorEvent>, KernelError> {
|
||||
fn operator_log(
|
||||
state: &WorldState,
|
||||
invocations: &BTreeMap<EventId, &DecisionInvocation>,
|
||||
) -> Result<Vec<OperatorEvent>, KernelError> {
|
||||
state
|
||||
.events
|
||||
.iter()
|
||||
|
|
@ -3672,6 +3807,30 @@ fn operator_log(state: &WorldState) -> Result<Vec<OperatorEvent>, KernelError> {
|
|||
.subjects
|
||||
.get(&event.scope.subject_id)
|
||||
.ok_or_else(|| KernelError::Invariant("event names no live subject".into()))?;
|
||||
let invocation = invocations.get(&event.id).ok_or_else(|| {
|
||||
KernelError::Invariant("event has no committed invocation".into())
|
||||
})?;
|
||||
let entry = state
|
||||
.affordance_catalog
|
||||
.get(&event.affordance)
|
||||
.ok_or_else(|| KernelError::Invariant("event names no catalog entry".into()))?;
|
||||
let roles = invocation
|
||||
.bindings
|
||||
.iter()
|
||||
.map(|binding| {
|
||||
let label = match binding.target {
|
||||
Target::Subject(id) => state.subjects.get(&id).map(|s| s.label.clone()),
|
||||
Target::Entity(id) => state.entities.get(&id).map(|e| e.label.clone()),
|
||||
Target::Edge(id) => state.edges.contains_key(&id).then(|| "edge".to_owned()),
|
||||
};
|
||||
label
|
||||
.map(|target| BoundRole {
|
||||
role: binding.role.0.clone(),
|
||||
target,
|
||||
})
|
||||
.ok_or_else(|| KernelError::Invariant("event binds no live referent".into()))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(OperatorEvent {
|
||||
revision: event.revision,
|
||||
speaker: event.scope.subject_id,
|
||||
|
|
@ -3684,6 +3843,13 @@ fn operator_log(state: &WorldState) -> Result<Vec<OperatorEvent>, KernelError> {
|
|||
.display
|
||||
.and_then(|fact| state.facts.get(&fact))
|
||||
.map(|record| record.statement.clone()),
|
||||
verb: entry.kind.0.clone(),
|
||||
render_path: state
|
||||
.grammar
|
||||
.as_ref()
|
||||
.and_then(|grammar| grammar.verb(&entry.kind.0))
|
||||
.map(|verb| verb.render_path),
|
||||
roles,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
|
|
@ -3962,6 +4128,7 @@ fn snapshot(state: &WorldState) -> Result<WorldSnapshot, KernelError> {
|
|||
id: *subject_id,
|
||||
label: subject.label.clone(),
|
||||
kind: subject.kind,
|
||||
import_key: subject.import_key.clone(),
|
||||
controller_id: controller.id(),
|
||||
controller_mode: controller.mode(),
|
||||
human_controller: controller.human_principal().cloned(),
|
||||
|
|
@ -4077,6 +4244,7 @@ fn snapshot(state: &WorldState) -> Result<WorldSnapshot, KernelError> {
|
|||
owner: state.owner.clone(),
|
||||
title: state.title.clone(),
|
||||
brief: state.brief.clone(),
|
||||
grammar: state.grammar.as_ref().map(GrammarBinding::identity),
|
||||
draft_approvals: state.draft_approvals.clone(),
|
||||
required_approvers: required_approvers(state),
|
||||
subjects,
|
||||
|
|
@ -4086,6 +4254,11 @@ fn snapshot(state: &WorldState) -> Result<WorldSnapshot, KernelError> {
|
|||
.map(|(affordance_id, entry)| AffordanceSnapshot {
|
||||
id: *affordance_id,
|
||||
entry: entry.clone(),
|
||||
render_path: state
|
||||
.grammar
|
||||
.as_ref()
|
||||
.and_then(|grammar| grammar.verb(&entry.kind.0))
|
||||
.map(|verb| verb.render_path),
|
||||
})
|
||||
.collect(),
|
||||
places,
|
||||
|
|
@ -5436,6 +5609,7 @@ mod tests {
|
|||
// The fixture world is one room. Co-located speech needs a place to
|
||||
// fill, so every fixture subject stands in the commons.
|
||||
position: Some(Ref::Draft(DraftHandle::new(COMMONS))),
|
||||
import_key: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -5538,11 +5712,13 @@ mod tests {
|
|||
.chain(std::iter::once(Ref::Draft(DraftHandle::new("convene"))))
|
||||
.collect(),
|
||||
position: Some(Ref::Draft(DraftHandle::new(COMMONS))),
|
||||
import_key: None,
|
||||
}),
|
||||
],
|
||||
},
|
||||
scale_intent: WorldScaleIntentRef::default(),
|
||||
lens_weights: stock_weights(),
|
||||
grammar: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -5687,6 +5863,7 @@ mod tests {
|
|||
controller: NewController::OperationalAgent,
|
||||
affordances: BTreeSet::from([speak]),
|
||||
position: Some(Ref::Draft(DraftHandle::new("yard"))),
|
||||
import_key: None,
|
||||
}),
|
||||
],
|
||||
// A route that should start closed is declared and closed in one
|
||||
|
|
@ -5792,6 +5969,7 @@ mod tests {
|
|||
}))
|
||||
.collect(),
|
||||
position: Some(Ref::Existing(place)),
|
||||
import_key: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -6312,6 +6490,7 @@ mod tests {
|
|||
controller: NewController::OperationalAgent,
|
||||
affordances: civic_grants(handles, &speak),
|
||||
position: Some(place),
|
||||
import_key: None,
|
||||
})
|
||||
};
|
||||
let declarations = civic_affordances()
|
||||
|
|
@ -6368,6 +6547,7 @@ mod tests {
|
|||
&speak,
|
||||
),
|
||||
position: Some(Ref::Draft(DraftHandle::new("hall"))),
|
||||
import_key: None,
|
||||
}),
|
||||
Declaration::Subject(SubjectDeclaration {
|
||||
handle: DraftHandle::new("reeve"),
|
||||
|
|
@ -6379,6 +6559,7 @@ mod tests {
|
|||
&speak,
|
||||
),
|
||||
position: Some(Ref::Draft(DraftHandle::new("chamber"))),
|
||||
import_key: None,
|
||||
}),
|
||||
person(
|
||||
"farmer",
|
||||
|
|
@ -6402,6 +6583,7 @@ mod tests {
|
|||
controller: NewController::NarrativePersona,
|
||||
affordances: civic_grants(&["levy", "petition"], &speak),
|
||||
position: None,
|
||||
import_key: None,
|
||||
}),
|
||||
])
|
||||
.collect();
|
||||
|
|
@ -6565,6 +6747,7 @@ mod tests {
|
|||
Ref::Draft(DraftHandle::new("recant")),
|
||||
]),
|
||||
position: place.map(|place| Ref::Draft(DraftHandle::new(place))),
|
||||
import_key: None,
|
||||
})
|
||||
};
|
||||
submit_owner(
|
||||
|
|
@ -7833,6 +8016,7 @@ mod tests {
|
|||
controller: NewController::OperationalAgent,
|
||||
affordances: BTreeSet::from([speak.clone()]),
|
||||
position: Some(Ref::Existing(topology.road)),
|
||||
import_key: None,
|
||||
}),
|
||||
],
|
||||
operations: Vec::new(),
|
||||
|
|
@ -8851,6 +9035,7 @@ mod custody_tests {
|
|||
controller: NewController::OperationalAgent,
|
||||
affordances: BTreeSet::from([speak.clone()]),
|
||||
position: Some(Ref::Existing(topology.yard)),
|
||||
import_key: None,
|
||||
}),
|
||||
],
|
||||
operations: vec![ComponentOp::Admit {
|
||||
|
|
@ -9510,6 +9695,7 @@ mod custody_tests {
|
|||
controller: NewController::OperationalAgent,
|
||||
affordances: BTreeSet::from([speak.clone()]),
|
||||
position: Some(Ref::Existing(topology.yard)),
|
||||
import_key: None,
|
||||
}),
|
||||
],
|
||||
operations: vec![admit(u64::MAX), admit(1)],
|
||||
|
|
@ -11436,7 +11622,7 @@ mod soul_knowledge_tests {
|
|||
// Nobody holds it.
|
||||
assert!(kernel.state.knowledge.is_empty());
|
||||
// The operator reads it anyway, with the speaker resolved by label.
|
||||
let log = operator_log(&kernel.state).unwrap();
|
||||
let log = kernel.operator_log().unwrap();
|
||||
assert_eq!(
|
||||
log,
|
||||
vec![OperatorEvent {
|
||||
|
|
@ -11445,6 +11631,9 @@ mod soul_knowledge_tests {
|
|||
speaker_label: "The Yard Bystander".into(),
|
||||
speech: Some(Statement::new(text).unwrap()),
|
||||
display: None,
|
||||
verb: "speak".into(),
|
||||
render_path: None,
|
||||
roles: Vec::new(),
|
||||
}]
|
||||
);
|
||||
// And no subject snapshot carries it.
|
||||
|
|
@ -11502,6 +11691,7 @@ mod witness_tests {
|
|||
controller: NewController::NarrativePersona,
|
||||
affordances: BTreeSet::from([speak.clone(), Ref::Draft(DraftHandle::new("beacon"))]),
|
||||
position: Some(Ref::Draft(DraftHandle::new(place))),
|
||||
import_key: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -12615,6 +12805,7 @@ mod witness_tests {
|
|||
// Every non-mirror subject must hold one.
|
||||
affordances: BTreeSet::from([Ref::Existing(beacon)]),
|
||||
position: position.map(Ref::Existing),
|
||||
import_key: None,
|
||||
}),
|
||||
],
|
||||
operations: vec![witness(
|
||||
|
|
@ -12979,6 +13170,7 @@ mod clock_tests {
|
|||
Ref::Draft(DraftHandle::new("threaten")),
|
||||
]),
|
||||
position: Some(Ref::Draft(DraftHandle::new(place))),
|
||||
import_key: None,
|
||||
})
|
||||
};
|
||||
let subject = |handle: &str| Ref::Draft(DraftHandle::new(handle));
|
||||
|
|
@ -15160,7 +15352,7 @@ mod clock_tests {
|
|||
}));
|
||||
let title = normalize_title(&input.title).unwrap();
|
||||
let resolved = patch::resolve_patch(
|
||||
&WorldState::empty(world_id, input.owner.clone(), title.clone()),
|
||||
&WorldState::empty(world_id, input.owner.clone(), title.clone(), None),
|
||||
input.id,
|
||||
&input.patch,
|
||||
Some(&input.scale_intent),
|
||||
|
|
@ -15189,7 +15381,7 @@ mod clock_tests {
|
|||
});
|
||||
let title = normalize_title(&input.title).unwrap();
|
||||
let resolved = patch::resolve_patch(
|
||||
&WorldState::empty(world_id, input.owner.clone(), title.clone()),
|
||||
&WorldState::empty(world_id, input.owner.clone(), title.clone(), None),
|
||||
input.id,
|
||||
&input.patch,
|
||||
Some(&input.scale_intent),
|
||||
|
|
@ -17032,6 +17224,7 @@ mod clock_tests {
|
|||
controller: NewController::NarrativePersona,
|
||||
affordances: BTreeSet::from([speak]),
|
||||
position: Some(Ref::Existing(clockwork.dead_end)),
|
||||
import_key: None,
|
||||
}),
|
||||
],
|
||||
operations: Vec::new(),
|
||||
|
|
@ -17098,6 +17291,7 @@ mod clock_tests {
|
|||
controller: NewController::NarrativePersona,
|
||||
affordances: BTreeSet::from([speak]),
|
||||
position: Some(Ref::Existing(clockwork.dead_end)),
|
||||
import_key: None,
|
||||
}),
|
||||
],
|
||||
operations: Vec::new(),
|
||||
|
|
@ -17175,6 +17369,7 @@ mod clock_tests {
|
|||
},
|
||||
affordances: BTreeSet::new(),
|
||||
position: Some(Ref::Existing(commons)),
|
||||
import_key: None,
|
||||
})],
|
||||
operations: Vec::new(),
|
||||
evidence: Vec::new(),
|
||||
|
|
@ -17268,6 +17463,7 @@ mod clock_tests {
|
|||
controller: NewController::NarrativePersona,
|
||||
affordances: BTreeSet::from([speak]),
|
||||
position: Some(Ref::Existing(clockwork.dead_end)),
|
||||
import_key: None,
|
||||
}),
|
||||
],
|
||||
operations: vec![ComponentOp::Mint {
|
||||
|
|
@ -17510,6 +17706,7 @@ mod clock_tests {
|
|||
// the boundary its own answer claims: an occupied
|
||||
// place is no longer unelaborated.
|
||||
position: Some(Ref::Existing(clockwork.dead_end)),
|
||||
import_key: None,
|
||||
})],
|
||||
operations: Vec::new(),
|
||||
evidence: Vec::new(),
|
||||
|
|
@ -17688,6 +17885,7 @@ mod clock_tests {
|
|||
controller: NewController::NarrativePersona,
|
||||
affordances: BTreeSet::from([speak]),
|
||||
position: Some(Ref::Existing(clockwork.dead_end)),
|
||||
import_key: None,
|
||||
})],
|
||||
operations: vec![ComponentOp::Retire {
|
||||
subject: Ref::Draft(DraftHandle::new("stray")),
|
||||
|
|
@ -17797,6 +17995,7 @@ mod clock_tests {
|
|||
controller: NewController::NarrativePersona,
|
||||
affordances: BTreeSet::from([speak]),
|
||||
position: Some(Ref::Existing(commons)),
|
||||
import_key: None,
|
||||
}),
|
||||
],
|
||||
operations: vec![ComponentOp::GrantAffordance {
|
||||
|
|
@ -18011,6 +18210,7 @@ mod clock_tests {
|
|||
controller: NewController::NarrativePersona,
|
||||
affordances: BTreeSet::from([speak]),
|
||||
position: Some(Ref::Existing(clockwork.dead_end)),
|
||||
import_key: None,
|
||||
})],
|
||||
operations: vec![ComponentOp::GrantAffordance {
|
||||
subject: Ref::Draft(DraftHandle::new("stray")),
|
||||
|
|
@ -18429,6 +18629,7 @@ mod clock_tests {
|
|||
Ref::Existing(clockwork.threaten),
|
||||
]),
|
||||
position: Some(Ref::Existing(clockwork.dead_end)),
|
||||
import_key: None,
|
||||
})],
|
||||
operations: vec![ComponentOp::RevokeAffordance {
|
||||
subject: Ref::Draft(DraftHandle::new("stray")),
|
||||
|
|
@ -18877,6 +19078,7 @@ mod clock_tests {
|
|||
controller: NewController::NarrativePersona,
|
||||
affordances: BTreeSet::from([speak.clone()]),
|
||||
position: None,
|
||||
import_key: None,
|
||||
}),
|
||||
Declaration::Channel(ChannelDeclaration {
|
||||
handle: DraftHandle::new("wire"),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -3,9 +3,10 @@ use super::{
|
|||
ConsumerId, CreateWorld, CreateWorldIntent, CreationReceipt, DecisionInvocation,
|
||||
DecisionOpportunity, Declaration, DraftHandle, EntityDeclaration, EntityKind, JurisdictionKey,
|
||||
KernelError, NewController, OperatorEvent, PatchAnswer, PrincipalCommandIntent, PrincipalId,
|
||||
Ref, SubjectDeclaration, SubjectKind, SubmitReceipt, SystemCapability, TickMinutes, WorldId,
|
||||
EntityId, Ref, SubjectDeclaration, SubjectKind, SubmitReceipt, SystemCapability, TickMinutes, WorldId,
|
||||
WorldKernel, WorldPatch, WorldPhase, WorldScaleIntentRef, WorldSnapshot, journal,
|
||||
patch::kernel_speak_grant, prepare_creation,
|
||||
verse::{CreateVerseWorldIntent, VerseGenesisError},
|
||||
};
|
||||
use crate::VerifiedPrincipalEvidence;
|
||||
use chrono::Utc;
|
||||
|
|
@ -18,6 +19,21 @@ use tokio::task::JoinHandle;
|
|||
/// other. A world that wants more declares them through the patch lane.
|
||||
const GENESIS_PLACE: &str = "commons";
|
||||
|
||||
/// The commons every genesis declares, the one room its first subjects share.
|
||||
pub(crate) fn commons_declaration() -> Declaration {
|
||||
Declaration::Entity(EntityDeclaration {
|
||||
handle: DraftHandle::new(GENESIS_PLACE),
|
||||
label: "The Commons".into(),
|
||||
kind: EntityKind::Place,
|
||||
container: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Where a genesis subject stands.
|
||||
pub(crate) fn commons_ref() -> Ref<EntityId> {
|
||||
Ref::Draft(DraftHandle::new(GENESIS_PLACE))
|
||||
}
|
||||
|
||||
const REQUEST_CAPACITY: usize = 32;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
|
@ -66,6 +82,16 @@ enum Request {
|
|||
AgencyGraph {
|
||||
reply: oneshot::Sender<Result<AgencyGraph, KernelError>>,
|
||||
},
|
||||
/// The tick driver's opportunity list. Separate from `Snapshot` so the
|
||||
/// list a cover is derived from has one door, held by the driver.
|
||||
Opportunities {
|
||||
reply: oneshot::Sender<Result<Vec<DecisionOpportunity>, KernelError>>,
|
||||
},
|
||||
/// The tick ordinal's one door: how many clock advances the world has
|
||||
/// committed. Held by the tick driver, as `Opportunities` is.
|
||||
ClockAdvances {
|
||||
reply: oneshot::Sender<Result<u64, KernelError>>,
|
||||
},
|
||||
ControllerReceipt {
|
||||
command_id: CommandId,
|
||||
opportunity: super::DecisionOpportunity,
|
||||
|
|
@ -120,7 +146,8 @@ impl WorldMailbox {
|
|||
kind,
|
||||
controller,
|
||||
affordances: kernel_speak_grant(),
|
||||
position: Some(Ref::Draft(DraftHandle::new(GENESIS_PLACE))),
|
||||
position: Some(commons_ref()),
|
||||
import_key: None,
|
||||
})
|
||||
};
|
||||
// Roots stand beside the commons, not inside it: a root contained by
|
||||
|
|
@ -129,12 +156,7 @@ impl WorldMailbox {
|
|||
// jurisdiction's population target. The owner subject lands in
|
||||
// `Uncovered`, visible and reducing nothing.
|
||||
let mut declarations = vec![
|
||||
Declaration::Entity(EntityDeclaration {
|
||||
handle: DraftHandle::new(GENESIS_PLACE),
|
||||
label: "The Commons".into(),
|
||||
kind: EntityKind::Place,
|
||||
container: None,
|
||||
}),
|
||||
commons_declaration(),
|
||||
declare(
|
||||
"first-person",
|
||||
input.human_subject_label,
|
||||
|
|
@ -189,6 +211,7 @@ impl WorldMailbox {
|
|||
},
|
||||
scale_intent,
|
||||
lens_weights: input.lens_weights,
|
||||
grammar: None,
|
||||
},
|
||||
AuthenticatedCaller::verified_principal(principal_id),
|
||||
)
|
||||
|
|
@ -217,6 +240,20 @@ impl WorldMailbox {
|
|||
.map_err(mailbox_outcome)
|
||||
}
|
||||
|
||||
/// Creates the canonical Aetheria verse world through the same
|
||||
/// authenticated creation `create` uses.
|
||||
pub async fn create_verse(
|
||||
&self,
|
||||
intent: CreateVerseWorldIntent,
|
||||
principal: &VerifiedPrincipalEvidence,
|
||||
) -> Result<CreationReceipt, VerseGenesisError> {
|
||||
let principal_id = PrincipalId::new(principal.account_subject_hash());
|
||||
let input = intent.lower(principal_id.clone())?;
|
||||
Ok(self
|
||||
.create_authenticated(input, AuthenticatedCaller::verified_principal(principal_id))
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// A wall-clock read at the boundary a request crosses into the mailbox is
|
||||
/// ingress, not reduce: the reducer stays deterministic over its journal,
|
||||
/// and this is the one place a caller's own claim of continued authority
|
||||
|
|
@ -459,6 +496,37 @@ impl WorldMailbox {
|
|||
.map_err(MailboxError::Kernel)
|
||||
}
|
||||
|
||||
/// The tick driver's opportunity list, in snapshot order. It is on
|
||||
/// `WorldMailbox` and on no controller port, as `agency_graph` is.
|
||||
pub async fn opportunities(&self) -> Result<Vec<DecisionOpportunity>, MailboxError> {
|
||||
let (reply, response) = oneshot::channel();
|
||||
self.sender
|
||||
.send(Request::Opportunities { reply })
|
||||
.await
|
||||
.map_err(|_| MailboxError::Unavailable)?;
|
||||
|
||||
response
|
||||
.await
|
||||
.map_err(|_| MailboxError::Unavailable)?
|
||||
.map_err(MailboxError::Kernel)
|
||||
}
|
||||
|
||||
/// The tick driver's ordinal: how many clock advances the world has
|
||||
/// committed. It is on `WorldMailbox` and on no controller port, as
|
||||
/// `opportunities` is.
|
||||
pub async fn clock_advances(&self) -> Result<u64, MailboxError> {
|
||||
let (reply, response) = oneshot::channel();
|
||||
self.sender
|
||||
.send(Request::ClockAdvances { reply })
|
||||
.await
|
||||
.map_err(|_| MailboxError::Unavailable)?;
|
||||
|
||||
response
|
||||
.await
|
||||
.map_err(|_| MailboxError::Unavailable)?
|
||||
.map_err(MailboxError::Kernel)
|
||||
}
|
||||
|
||||
pub async fn operator_log(&self) -> Result<Vec<OperatorEvent>, MailboxError> {
|
||||
let (reply, response) = oneshot::channel();
|
||||
self.sender
|
||||
|
|
@ -928,6 +996,32 @@ async fn run_owner(mut owned: OwnedWorld, mut receiver: mpsc::Receiver<Request>)
|
|||
return;
|
||||
}
|
||||
}
|
||||
Request::Opportunities { reply } => {
|
||||
let result = match &owned {
|
||||
OwnedWorld::Empty(_) => Err(KernelError::WorldNotCreated),
|
||||
OwnedWorld::Live(kernel) => kernel.opportunities(),
|
||||
};
|
||||
let owner_must_stop = result
|
||||
.as_ref()
|
||||
.is_err_and(KernelError::requires_owner_restart);
|
||||
let _ = reply.send(result);
|
||||
if owner_must_stop {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Request::ClockAdvances { reply } => {
|
||||
let result = match &owned {
|
||||
OwnedWorld::Empty(_) => Err(KernelError::WorldNotCreated),
|
||||
OwnedWorld::Live(kernel) => kernel.clock_advances(),
|
||||
};
|
||||
let owner_must_stop = result
|
||||
.as_ref()
|
||||
.is_err_and(KernelError::requires_owner_restart);
|
||||
let _ = reply.send(result);
|
||||
if owner_must_stop {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Request::ControllerReceipt {
|
||||
command_id,
|
||||
opportunity,
|
||||
|
|
@ -988,6 +1082,7 @@ mod tests {
|
|||
fn creation(id: CommandId, title: &str) -> CreateWorld {
|
||||
CreateWorld {
|
||||
lens_weights: crate::tests::stock_weights(),
|
||||
grammar: None,
|
||||
id,
|
||||
owner: PrincipalId::new("owner"),
|
||||
title: title.into(),
|
||||
|
|
@ -1009,6 +1104,7 @@ mod tests {
|
|||
},
|
||||
affordances: kernel_speak_grant(),
|
||||
position: Some(Ref::Draft(DraftHandle::new(GENESIS_PLACE))),
|
||||
import_key: None,
|
||||
}),
|
||||
],
|
||||
operations: Vec::new(),
|
||||
|
|
@ -1396,6 +1492,7 @@ mod tests {
|
|||
controller,
|
||||
affordances: kernel_speak_grant(),
|
||||
position: Some(Ref::Draft(DraftHandle::new(format!("room-{handle}")))),
|
||||
import_key: None,
|
||||
})
|
||||
};
|
||||
let room = |handle: &str| {
|
||||
|
|
@ -1425,6 +1522,7 @@ mod tests {
|
|||
evidence: Vec::new(),
|
||||
},
|
||||
scale_intent: WorldScaleIntentRef::default(),
|
||||
grammar: None,
|
||||
},
|
||||
&authenticated,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
|
||||
use super::clock::{FictionalMinutes, TickMinutes};
|
||||
use super::tool_schema;
|
||||
use super::grammar::GrammarRefusal;
|
||||
use super::{
|
||||
AffordanceId, CommandId, ControllerAssignment, ControllerId, EdgeId, EntityId, LensWeights,
|
||||
NewController, SubjectId, SubjectKind, SubjectState, WorldId,
|
||||
|
|
@ -22,6 +23,7 @@ use serde::{Deserialize, Serialize};
|
|||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{BTreeMap, BTreeSet, btree_map::Entry};
|
||||
use unicode_properties::{GeneralCategory, UnicodeGeneralCategory};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
|
|
@ -944,6 +946,102 @@ impl EvidenceRef {
|
|||
}
|
||||
}
|
||||
|
||||
/// The Aetheria record a subject was imported from: the CultCache schema id and
|
||||
/// the record key, compared exactly as two separate texts so no pair of
|
||||
/// distinct records can spell one key. Admission keeps the mapping injective;
|
||||
/// a key is never matched by prefix, case or normalisation.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ImportKey {
|
||||
schema: String,
|
||||
record: String,
|
||||
}
|
||||
|
||||
/// The longest either part of an import key may be, in bytes.
|
||||
const MAX_IMPORT_KEY_PART_BYTES: usize = 200;
|
||||
|
||||
impl ImportKey {
|
||||
pub fn new(schema: impl Into<String>, record: impl Into<String>) -> Self {
|
||||
Self {
|
||||
schema: schema.into(),
|
||||
record: record.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn schema(&self) -> &str {
|
||||
&self.schema
|
||||
}
|
||||
|
||||
pub fn record(&self) -> &str {
|
||||
&self.record
|
||||
}
|
||||
|
||||
/// The one predicate for an import key, called by the resolver and by the
|
||||
/// load-time state gate alike. The record part is canonical free text: not
|
||||
/// empty, at most 200 bytes, no leading, trailing or doubled space, already NFC
|
||||
/// (refused, never normalised, so the kernel derives no second spelling),
|
||||
/// no control or format character, and no whitespace but U+0020. The schema
|
||||
/// part is a schema id: the same text with no space at all.
|
||||
pub(super) fn is_canonical(&self) -> bool {
|
||||
is_canonical_key_text(&self.record)
|
||||
&& is_canonical_key_text(&self.schema)
|
||||
&& !self.schema.contains(' ')
|
||||
}
|
||||
}
|
||||
|
||||
fn is_canonical_key_text(part: &str) -> bool {
|
||||
!part.is_empty()
|
||||
&& part.len() <= MAX_IMPORT_KEY_PART_BYTES
|
||||
&& !part.starts_with(' ')
|
||||
&& !part.ends_with(' ')
|
||||
&& !part.contains(" ")
|
||||
&& unicode_normalization::is_nfc(part)
|
||||
&& part.chars().all(|c| {
|
||||
!c.is_control()
|
||||
&& c.general_category() != GeneralCategory::Format
|
||||
&& (c == ' ' || !c.is_whitespace())
|
||||
})
|
||||
}
|
||||
|
||||
/// Why an import key cannot join a world: the resolver names these by draft
|
||||
/// handle, the state gates by invariant text.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum ImportKeyFault {
|
||||
Malformed,
|
||||
Held,
|
||||
Duplicate,
|
||||
}
|
||||
|
||||
/// The keys the subjects hold, retired ones included, so a key is never
|
||||
/// reissued. Derived on demand; no stored index.
|
||||
pub(super) fn held_import_keys<'a>(
|
||||
subjects: impl IntoIterator<Item = &'a super::SubjectState>,
|
||||
) -> BTreeSet<&'a ImportKey> {
|
||||
subjects
|
||||
.into_iter()
|
||||
.filter_map(|subject| subject.import_key.as_ref())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The one decision on a key: malformed, held by a subject already in the
|
||||
/// world, or already claimed in `declared` (which it joins). Every door that
|
||||
/// admits or loads subjects calls this, so none can decide otherwise.
|
||||
pub(super) fn import_key_fault<'a>(
|
||||
key: &'a ImportKey,
|
||||
held: &BTreeSet<&ImportKey>,
|
||||
declared: &mut BTreeSet<&'a ImportKey>,
|
||||
) -> Option<ImportKeyFault> {
|
||||
if !key.is_canonical() {
|
||||
Some(ImportKeyFault::Malformed)
|
||||
} else if held.contains(key) {
|
||||
Some(ImportKeyFault::Held)
|
||||
} else if !declared.insert(key) {
|
||||
Some(ImportKeyFault::Duplicate)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// `position` is the subject's presence: one place it stands in. A subject
|
||||
/// declared without one is unplaced until a later pass gives placement an owner.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
|
|
@ -958,6 +1056,11 @@ pub(crate) struct SubjectDeclaration {
|
|||
/// atomically.
|
||||
pub(crate) affordances: BTreeSet<Ref<AffordanceId>>,
|
||||
pub(crate) position: Option<Ref<EntityId>>,
|
||||
/// The record this subject was imported from, if any. Absent from a
|
||||
/// subject nobody imported, so such a world's state and digests are what
|
||||
/// they were before import keys existed.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) import_key: Option<ImportKey>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
|
|
@ -1350,6 +1453,33 @@ pub enum Mismatch {
|
|||
EmptyLabel {
|
||||
handle: DraftHandle,
|
||||
},
|
||||
/// The world is bound to a grammar and this affordance's kind is not one of
|
||||
/// its verbs.
|
||||
AffordanceKindNotInGrammar {
|
||||
handle: DraftHandle,
|
||||
},
|
||||
/// The kind is a verb of the bound grammar, but the declared roles are not
|
||||
/// its signature (name, referent kind or order).
|
||||
AffordanceRolesDisagreeWithGrammar {
|
||||
handle: DraftHandle,
|
||||
},
|
||||
/// The grammar binds the kernel's speak verb as anything but a Conversation.
|
||||
SpeakRenderPathDisagreesWithGrammar {
|
||||
handle: DraftHandle,
|
||||
},
|
||||
/// An import key is empty, has whitespace, or is longer than 200 bytes in
|
||||
/// either part.
|
||||
MalformedImportKey {
|
||||
handle: DraftHandle,
|
||||
},
|
||||
/// Two subjects in one patch declare the same import key.
|
||||
DuplicateImportKey {
|
||||
handle: DraftHandle,
|
||||
},
|
||||
/// A subject already in the world, retired or not, holds this import key.
|
||||
ImportKeyHeld {
|
||||
handle: DraftHandle,
|
||||
},
|
||||
UnresolvedDraft {
|
||||
site: Site,
|
||||
referent: DraftHandle,
|
||||
|
|
@ -3061,6 +3191,28 @@ fn candidate_set(
|
|||
}
|
||||
}
|
||||
|
||||
/// A bound grammar is the whole catalog: a kind it lacks, or a role signature
|
||||
/// it does not carry, is refused. A world with no grammar is not checked.
|
||||
fn check_against_grammar(
|
||||
state: &super::WorldState,
|
||||
handle: &DraftHandle,
|
||||
kind: &AffordanceKindName,
|
||||
roles: &[RoleSpec],
|
||||
mismatches: &mut Vec<Mismatch>,
|
||||
) {
|
||||
let Some(grammar) = &state.grammar else {
|
||||
return;
|
||||
};
|
||||
let handle = handle.clone();
|
||||
mismatches.extend(grammar.refusal(kind, roles).map(|refusal| match refusal {
|
||||
GrammarRefusal::KindNotInGrammar => Mismatch::AffordanceKindNotInGrammar { handle },
|
||||
GrammarRefusal::RolesDisagree => Mismatch::AffordanceRolesDisagreeWithGrammar { handle },
|
||||
GrammarRefusal::SpeakNotConversation => {
|
||||
Mismatch::SpeakRenderPathDisagreesWithGrammar { handle }
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// The one resolution owner for every admission lane: declarations, references,
|
||||
/// topology admission, and operation preconditions. Every check runs against the
|
||||
/// complete candidate graph — what the world already holds plus what this patch
|
||||
|
|
@ -3084,6 +3236,11 @@ pub(super) fn resolve_patch(
|
|||
if admits_human {
|
||||
index.insert(speak_handle.clone(), RefKind::Affordance);
|
||||
}
|
||||
if admits_human {
|
||||
// The kernel-built entry is held to the grammar like any declaration.
|
||||
let speak = kernel_speak_entry();
|
||||
check_against_grammar(state, &speak_handle, &speak.kind, &speak.roles, &mut mismatches);
|
||||
}
|
||||
let mut kind_names: BTreeSet<AffordanceKindName> = state
|
||||
.affordance_catalog
|
||||
.values()
|
||||
|
|
@ -3093,6 +3250,8 @@ pub(super) fn resolve_patch(
|
|||
kind_names.insert(kernel_speak_entry().kind);
|
||||
}
|
||||
|
||||
let held_keys = held_import_keys(state.subjects.values());
|
||||
let mut declared_keys: BTreeSet<&ImportKey> = BTreeSet::new();
|
||||
for (position, declaration) in patch.declarations.iter().enumerate() {
|
||||
let (handle, label, kind) = match declaration {
|
||||
Declaration::Subject(subject) => (
|
||||
|
|
@ -3132,6 +3291,21 @@ pub(super) fn resolve_patch(
|
|||
}),
|
||||
}
|
||||
}
|
||||
if let Declaration::Subject(subject) = declaration
|
||||
&& let Some(key) = &subject.import_key
|
||||
{
|
||||
let handle = subject.handle.clone();
|
||||
match import_key_fault(key, &held_keys, &mut declared_keys) {
|
||||
Some(ImportKeyFault::Malformed) => {
|
||||
mismatches.push(Mismatch::MalformedImportKey { handle });
|
||||
}
|
||||
Some(ImportKeyFault::Held) => mismatches.push(Mismatch::ImportKeyHeld { handle }),
|
||||
Some(ImportKeyFault::Duplicate) => {
|
||||
mismatches.push(Mismatch::DuplicateImportKey { handle });
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
if let Declaration::Affordance(affordance) = declaration {
|
||||
validate_affordance(
|
||||
&affordance.handle,
|
||||
|
|
@ -3143,6 +3317,13 @@ pub(super) fn resolve_patch(
|
|||
affordance.carries_speech,
|
||||
&mut mismatches,
|
||||
);
|
||||
check_against_grammar(
|
||||
state,
|
||||
&affordance.handle,
|
||||
&affordance.kind,
|
||||
&affordance.roles,
|
||||
&mut mismatches,
|
||||
);
|
||||
if !kind_names.insert(affordance.kind.clone()) {
|
||||
mismatches.push(Mismatch::DuplicateAffordanceKind {
|
||||
handle: affordance.handle.clone(),
|
||||
|
|
@ -5229,6 +5410,7 @@ pub(super) fn resolve_patch(
|
|||
subject: SubjectState {
|
||||
label: input.label.clone(),
|
||||
kind: input.kind,
|
||||
import_key: input.import_key.clone(),
|
||||
},
|
||||
controller,
|
||||
affordances: granted,
|
||||
|
|
@ -7734,7 +7916,8 @@ mod tests {
|
|||
};
|
||||
|
||||
/// Owner: `WorldPatch::sets_persona_of_existing_subject`. Consumer:
|
||||
/// `ghostlight-dungeon`'s play agent (PA.f61), which refuses a
|
||||
/// `ghostlight-dungeon`'s play agent (PA.f61, parked at
|
||||
/// `parked/ghostlight-eve-surface`), which refuses a
|
||||
/// `set_persona_material` run whose patch answers true here, since
|
||||
/// `WorldPatch`'s own fields are `pub(crate)` and Dungeon may not parse
|
||||
/// its JSON to decide the question itself.
|
||||
|
|
@ -7853,6 +8036,7 @@ mod tests {
|
|||
controller: NewController::OperationalAgent,
|
||||
affordances: BTreeSet::from([speak_entry(kernel)]),
|
||||
position,
|
||||
import_key: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -8120,6 +8304,7 @@ mod tests {
|
|||
controller: NewController::NarrativePersona,
|
||||
affordances: BTreeSet::from([Ref::Draft(DraftHandle::new(handle))]),
|
||||
position: None,
|
||||
import_key: None,
|
||||
})
|
||||
};
|
||||
|
||||
|
|
@ -8420,6 +8605,7 @@ mod tests {
|
|||
},
|
||||
affordances: BTreeSet::from([speak_entry(&kernel)]),
|
||||
position: None,
|
||||
import_key: None,
|
||||
}),
|
||||
]);
|
||||
let error = kernel
|
||||
|
|
@ -8472,6 +8658,7 @@ mod tests {
|
|||
controller: NewController::NarrativePersona,
|
||||
affordances: BTreeSet::from([speak_entry(&kernel)]),
|
||||
position: None,
|
||||
import_key: None,
|
||||
}),
|
||||
]);
|
||||
let receipt = submit_owner(&mut kernel, &before, admit(repaired));
|
||||
|
|
@ -9027,6 +9214,7 @@ mod tests {
|
|||
controller: NewController::NarrativePersona,
|
||||
affordances: BTreeSet::from([speak_entry(&kernel)]),
|
||||
position: None,
|
||||
import_key: None,
|
||||
})]);
|
||||
submit_owner(&mut kernel, &before, admit(patch.clone()));
|
||||
let admitted = *kernel
|
||||
|
|
@ -9303,6 +9491,7 @@ mod tests {
|
|||
controller: NewController::NarrativePersona,
|
||||
affordances: BTreeSet::from([speak]),
|
||||
position: None,
|
||||
import_key: None,
|
||||
})])),
|
||||
);
|
||||
let active = activate(&mut kernel);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
//! its own, computes no tool result, and holds no credential.
|
||||
|
||||
use super::controllers::{
|
||||
InferenceEvent, InferenceFault, InferenceOutput, InferencePort, InferenceRequest,
|
||||
InferenceEvent, InferenceFault, InferenceOutput, InferencePort, InferenceRequest, TokenUsage,
|
||||
PreparedInference, REQUEST_EXPIRY, RESPONSE_TIMEOUT, ToolResultOracle, call_id_is_valid,
|
||||
tool_name_is_valid, unix_ms,
|
||||
};
|
||||
|
|
@ -121,6 +121,22 @@ pub(super) struct SdkModelUsage {
|
|||
pub(super) cache_creation_input_tokens: u64,
|
||||
}
|
||||
|
||||
/// The reply's token usage summed over the models the run used. The whole
|
||||
/// prompt is the fresh input plus both cache figures (Anthropic reports them
|
||||
/// apart); the cached part is what was read from cache. A run that reported
|
||||
/// no usage at all is `None`, never a zero.
|
||||
fn sdk_usage(usage: &[SdkModelUsage]) -> Option<TokenUsage> {
|
||||
if usage.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let sum = |field: fn(&SdkModelUsage) -> u64| usage.iter().map(field).sum::<u64>();
|
||||
Some(TokenUsage {
|
||||
prompt: sum(|u| u.input_tokens) + sum(|u| u.cache_read_input_tokens) + sum(|u| u.cache_creation_input_tokens),
|
||||
completion: sum(|u| u.output_tokens),
|
||||
cached_prompt: Some(sum(|u| u.cache_read_input_tokens)),
|
||||
})
|
||||
}
|
||||
|
||||
/// The failures the sidecar is allowed to name. It reports a reason; this
|
||||
/// module assigns the disposition, so TypeScript can never quarantine the
|
||||
/// world's cognition by naming an integrity violation directly.
|
||||
|
|
@ -146,8 +162,27 @@ pub(super) enum SidecarFaultReason {
|
|||
}
|
||||
|
||||
impl SidecarFaultReason {
|
||||
fn into_fault(self, detail: String) -> InferenceFault {
|
||||
let detail = format!("SDK sidecar reported {self:?}: {detail}");
|
||||
/// The sidecar's own `detail` text is never carried: the reason names the
|
||||
/// failure and the fault's text is fixed per reason.
|
||||
fn into_fault(self) -> InferenceFault {
|
||||
let detail = match self {
|
||||
Self::RateLimited => "SDK sidecar reported rate limited",
|
||||
Self::Overloaded => "SDK sidecar reported overloaded",
|
||||
Self::ServerError => "SDK sidecar reported a server error",
|
||||
Self::ApiTimeout => "SDK sidecar reported an API timeout",
|
||||
Self::AuthenticationFailed => "SDK sidecar reported authentication failed",
|
||||
Self::OrgNotAllowed => "SDK sidecar reported organization not allowed",
|
||||
Self::BillingError => "SDK sidecar reported a billing error",
|
||||
Self::InvalidRequest => "SDK sidecar reported an invalid request",
|
||||
Self::ModelNotFound => "SDK sidecar reported model not found",
|
||||
Self::MaxOutputTokens => "SDK sidecar reported max output tokens",
|
||||
Self::MaxBudgetUsd => "SDK sidecar reported max budget",
|
||||
Self::ExecutionError => "SDK sidecar reported an execution error",
|
||||
Self::Unknown => "SDK sidecar reported an unknown failure",
|
||||
Self::ProtocolViolation => "SDK sidecar reported a protocol violation",
|
||||
Self::ToolRegistrationFailed => "SDK sidecar reported tool registration failed",
|
||||
Self::TurnCapRefused => "SDK sidecar reported the turn cap refused",
|
||||
};
|
||||
match self {
|
||||
Self::RateLimited | Self::Overloaded | Self::ServerError | Self::ApiTimeout => {
|
||||
InferenceFault::retryable(detail)
|
||||
|
|
@ -173,10 +208,10 @@ pub(super) enum SidecarLinkError {
|
|||
/// The pipe reached EOF or the child exited. Never a partial output.
|
||||
Closed,
|
||||
/// A length prefix over the cap, a truncated body, or an undecodable one.
|
||||
Codec(String),
|
||||
Codec,
|
||||
/// The child could not be started. The entry file existed at open, so this
|
||||
/// is an environment fault an operator must fix.
|
||||
Spawn(String),
|
||||
Spawn,
|
||||
}
|
||||
|
||||
/// The child-process seam, so the protocol driver is testable without Node, a
|
||||
|
|
@ -203,26 +238,21 @@ pub(super) async fn read_frame<R: AsyncRead + Unpin>(
|
|||
.map_err(|_| SidecarLinkError::Closed)?;
|
||||
let length = u32::from_be_bytes(prefix) as usize;
|
||||
if length == 0 || length > MAX_SIDECAR_FRAME_BYTES {
|
||||
return Err(SidecarLinkError::Codec(format!(
|
||||
"sidecar frame length {length} is outside 1..={MAX_SIDECAR_FRAME_BYTES}"
|
||||
)));
|
||||
return Err(SidecarLinkError::Codec);
|
||||
}
|
||||
let mut body = vec![0_u8; length];
|
||||
reader
|
||||
.read_exact(&mut body)
|
||||
.await
|
||||
.map_err(|error| SidecarLinkError::Codec(error.to_string()))?;
|
||||
rmp_serde::from_slice(&body).map_err(|error| SidecarLinkError::Codec(error.to_string()))
|
||||
.map_err(|_| SidecarLinkError::Codec)?;
|
||||
rmp_serde::from_slice(&body).map_err(|_| SidecarLinkError::Codec)
|
||||
}
|
||||
|
||||
fn encode_frame(frame: &SidecarFrame) -> Result<Vec<u8>, SidecarLinkError> {
|
||||
let body = rmp_serde::to_vec_named(frame)
|
||||
.map_err(|error| SidecarLinkError::Codec(error.to_string()))?;
|
||||
.map_err(|_| SidecarLinkError::Codec)?;
|
||||
if body.len() > MAX_SIDECAR_FRAME_BYTES {
|
||||
return Err(SidecarLinkError::Codec(format!(
|
||||
"sidecar frame of {} bytes exceeds {MAX_SIDECAR_FRAME_BYTES}",
|
||||
body.len()
|
||||
)));
|
||||
return Err(SidecarLinkError::Codec);
|
||||
}
|
||||
let mut framed = Vec::with_capacity(body.len() + 4);
|
||||
framed.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
|
|
@ -260,15 +290,15 @@ impl ChildProcessLink {
|
|||
.stderr(Stdio::inherit())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.map_err(|error| SidecarLinkError::Spawn(error.to_string()))?;
|
||||
.map_err(|_| SidecarLinkError::Spawn)?;
|
||||
let stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| SidecarLinkError::Spawn("sidecar stdin was not piped".into()))?;
|
||||
.ok_or(SidecarLinkError::Spawn)?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| SidecarLinkError::Spawn("sidecar stdout was not piped".into()))?;
|
||||
.ok_or(SidecarLinkError::Spawn)?;
|
||||
Ok(SidecarChild {
|
||||
child,
|
||||
stdin,
|
||||
|
|
@ -466,9 +496,9 @@ impl SdkInferencePort {
|
|||
return Err(link_fault(error));
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
Err(_) => {
|
||||
let _ = self.link.restart().await;
|
||||
return Err(InferenceFault::new(error.to_string()));
|
||||
return Err(InferenceFault::new("the tool-result owner refused a tool call"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -482,10 +512,10 @@ impl SdkInferencePort {
|
|||
SidecarFrame::Fault {
|
||||
query_id: seen,
|
||||
reason,
|
||||
detail,
|
||||
..
|
||||
} if seen == query_id => {
|
||||
let _ = self.link.restart().await;
|
||||
return Err(reason.into_fault(detail));
|
||||
return Err(reason.into_fault());
|
||||
}
|
||||
_ => {
|
||||
let _ = self.link.restart().await;
|
||||
|
|
@ -501,13 +531,11 @@ impl SdkInferencePort {
|
|||
fn link_fault(error: SidecarLinkError) -> InferenceFault {
|
||||
match error {
|
||||
SidecarLinkError::Closed => {
|
||||
InferenceFault::new("the SDK sidecar exited during a query".to_string())
|
||||
InferenceFault::new("the SDK sidecar exited during a query")
|
||||
}
|
||||
SidecarLinkError::Spawn(detail) => {
|
||||
InferenceFault::new(format!("the SDK sidecar could not start: {detail}"))
|
||||
}
|
||||
SidecarLinkError::Codec(detail) => {
|
||||
InferenceFault::integrity_violation(format!("SDK sidecar framing: {detail}"))
|
||||
SidecarLinkError::Spawn => InferenceFault::new("the SDK sidecar could not start"),
|
||||
SidecarLinkError::Codec => {
|
||||
InferenceFault::integrity_violation("the SDK sidecar's framing was invalid")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -526,9 +554,9 @@ pub(super) fn lower_query(
|
|||
if let Some(effort) = request.reasoning_effort.as_deref()
|
||||
&& !SDK_EFFORT_LEVELS.contains(&effort)
|
||||
{
|
||||
return Err(InferenceFault::integrity_violation(format!(
|
||||
"reasoning effort `{effort}` has no SDK counterpart"
|
||||
)));
|
||||
return Err(InferenceFault::integrity_violation(
|
||||
"the requested reasoning effort has no SDK counterpart",
|
||||
));
|
||||
}
|
||||
let mut items = request.input.iter();
|
||||
let Some(CodexInputItem::UserText { text: prompt }) = items.next() else {
|
||||
|
|
@ -641,10 +669,11 @@ fn assemble_output(
|
|||
total_cost_usd_estimate: material.total_cost_usd_estimate,
|
||||
};
|
||||
let receipt_bytes = rmp_serde::to_vec_named(&receipt)
|
||||
.map_err(|error| InferenceFault::new(error.to_string()))?;
|
||||
Ok(InferenceOutput {
|
||||
events: lowered,
|
||||
receipt_digest: format!("sha256:{:x}", Sha256::digest(&receipt_bytes)),
|
||||
.map_err(|_| InferenceFault::new("the SDK receipt could not be encoded"))?;
|
||||
let output = InferenceOutput::new(lowered, format!("sha256:{:x}", Sha256::digest(&receipt_bytes)));
|
||||
Ok(match sdk_usage(&receipt.usage) {
|
||||
Some(usage) => output.with_usage(usage),
|
||||
None => output,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -701,10 +730,10 @@ pub(super) struct RoutedInferencePort {
|
|||
connector: Option<Arc<dyn InferencePort>>,
|
||||
sdk: Option<Arc<dyn InferencePort>>,
|
||||
sdk_model_prefix: String,
|
||||
/// The local port's own claimed prefix, carried beside it rather than in a
|
||||
/// third bare `String` field, so `route` can compare its length against
|
||||
/// the SDK's claim without a third parallel field to keep in sync.
|
||||
local: Option<(String, Arc<dyn InferencePort>)>,
|
||||
/// The OpenAI-compatible ports (the loopback one and the hosted one), each
|
||||
/// with its own claimed prefix carried beside it, so `route` can compare
|
||||
/// lengths without a parallel field per port to keep in sync.
|
||||
claims: Vec<(String, Arc<dyn InferencePort>)>,
|
||||
}
|
||||
|
||||
impl RoutedInferencePort {
|
||||
|
|
@ -712,13 +741,13 @@ impl RoutedInferencePort {
|
|||
connector: Option<Arc<dyn InferencePort>>,
|
||||
sdk: Option<Arc<dyn InferencePort>>,
|
||||
sdk_model_prefix: impl Into<String>,
|
||||
local: Option<(String, Arc<dyn InferencePort>)>,
|
||||
claims: Vec<(String, Arc<dyn InferencePort>)>,
|
||||
) -> Self {
|
||||
Self {
|
||||
connector,
|
||||
sdk,
|
||||
sdk_model_prefix: sdk_model_prefix.into(),
|
||||
local,
|
||||
claims,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -735,9 +764,10 @@ impl RoutedInferencePort {
|
|||
// the connector, which is what keeps a typo'd model prefix loud
|
||||
// instead of silently reaching the wrong backend.
|
||||
let local_claim = self
|
||||
.local
|
||||
.as_ref()
|
||||
.claims
|
||||
.iter()
|
||||
.filter(|(prefix, _)| model.starts_with(prefix.as_str()))
|
||||
.max_by_key(|(prefix, _)| prefix.len())
|
||||
.map(|(prefix, port)| (prefix.len(), port));
|
||||
let sdk_claim = model
|
||||
.starts_with(&self.sdk_model_prefix)
|
||||
|
|
@ -763,9 +793,9 @@ impl InferencePort for RoutedInferencePort {
|
|||
let model = request.provider_model().to_owned();
|
||||
match self.route(&model) {
|
||||
Some(port) => port.prepare(request),
|
||||
None => Err(InferenceFault::integrity_violation(format!(
|
||||
"no configured inference backend claims the model `{model}`"
|
||||
))),
|
||||
None => Err(InferenceFault::integrity_violation(
|
||||
"no configured inference backend claims the requested model",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -773,9 +803,9 @@ impl InferencePort for RoutedInferencePort {
|
|||
let model = request.invocation.request.model.clone();
|
||||
match self.route(&model) {
|
||||
Some(port) => port.infer(request).await,
|
||||
None => Err(InferenceFault::integrity_violation(format!(
|
||||
"no configured inference backend claims the model `{model}`"
|
||||
))),
|
||||
None => Err(InferenceFault::integrity_violation(
|
||||
"no configured inference backend claims the requested model",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1190,6 +1220,41 @@ mod tests {
|
|||
assert_eq!(output.events.len(), 2);
|
||||
}
|
||||
|
||||
/// The SDK lane reports the run's tokens summed over its models: the whole
|
||||
/// prompt is fresh input plus both cache figures, the cached part is the
|
||||
/// cache read, and a run that reported nothing is `None`, not a zero.
|
||||
#[tokio::test]
|
||||
async fn the_sdk_receipts_token_counts_reach_inference_output() {
|
||||
let port = SdkInferencePort::new(ScriptedLink::new(Vec::new()), TEST_RUNTIME);
|
||||
let prepared = prose_prepared(&port, "Say something true.");
|
||||
let events = vec![SidecarEvent::Text { text: "ok".into() }];
|
||||
let mut two_models = material();
|
||||
two_models.usage = vec![
|
||||
SdkModelUsage {
|
||||
model: TEST_MODEL.into(),
|
||||
input_tokens: 11,
|
||||
output_tokens: 5,
|
||||
cache_read_input_tokens: 3,
|
||||
cache_creation_input_tokens: 2,
|
||||
},
|
||||
SdkModelUsage {
|
||||
model: "claude-haiku-5".into(),
|
||||
input_tokens: 100,
|
||||
output_tokens: 40,
|
||||
cache_read_input_tokens: 7,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
];
|
||||
let output = assemble_output(&prepared, events.clone(), two_models, &[]).unwrap();
|
||||
assert_eq!(
|
||||
output.usage(),
|
||||
Some(TokenUsage { prompt: 123, completion: 45, cached_prompt: Some(10) })
|
||||
);
|
||||
let mut none = material();
|
||||
none.usage = Vec::new();
|
||||
assert_eq!(assemble_output(&prepared, events, none, &[]).unwrap().usage(), None);
|
||||
}
|
||||
|
||||
/// Spec test 11. The digest is a pure function of the receipt, so it is
|
||||
/// compared directly rather than through two whole queries.
|
||||
#[tokio::test]
|
||||
|
|
@ -1262,6 +1327,7 @@ mod tests {
|
|||
let granted = vec![AffordanceSnapshot {
|
||||
id: AffordanceId::issue(),
|
||||
entry: kernel_speak_entry(),
|
||||
render_path: None,
|
||||
}];
|
||||
let speak = granted[0].entry.kind.0.clone();
|
||||
let link = ScriptedLink::new(vec![
|
||||
|
|
@ -1323,7 +1389,7 @@ mod tests {
|
|||
Some(Arc::clone(&connector)),
|
||||
Some(Arc::clone(&sdk)),
|
||||
DEFAULT_SDK_MODEL_PREFIX,
|
||||
None,
|
||||
Vec::new(),
|
||||
);
|
||||
assert!(Arc::ptr_eq(routed.route("claude-opus-5").unwrap(), &sdk));
|
||||
assert!(Arc::ptr_eq(
|
||||
|
|
@ -1334,7 +1400,7 @@ mod tests {
|
|||
Some(Arc::clone(&connector)),
|
||||
Some(Arc::clone(&sdk)),
|
||||
"gpt-",
|
||||
None,
|
||||
Vec::new(),
|
||||
);
|
||||
assert!(Arc::ptr_eq(moved.route("gpt-5.6-terra").unwrap(), &sdk));
|
||||
assert!(Arc::ptr_eq(
|
||||
|
|
@ -1373,11 +1439,11 @@ mod tests {
|
|||
};
|
||||
|
||||
assert!(matches!(
|
||||
open_inference(None, Some(sdk()), None, &models("gpt-5.6-terra").each()),
|
||||
open_inference(None, Some(sdk()), None, None, &models("gpt-5.6-terra").each()),
|
||||
Err(ControllerOpenError::UnroutableModel { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
open_inference(Some(connector()), None, None, &models("claude-opus-5").each()),
|
||||
open_inference(Some(connector()), None, None, None, &models("claude-opus-5").each()),
|
||||
Err(ControllerOpenError::UnroutableModel { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
|
|
@ -1389,12 +1455,13 @@ mod tests {
|
|||
model_prefix: DEFAULT_SDK_MODEL_PREFIX.into(),
|
||||
}),
|
||||
None,
|
||||
None,
|
||||
&models(TEST_MODEL).each(),
|
||||
),
|
||||
Err(ControllerOpenError::SdkSidecarMissing { .. })
|
||||
));
|
||||
assert!(
|
||||
open_inference(Some(connector()), Some(sdk()), None, &models(TEST_MODEL).each()).is_ok(),
|
||||
open_inference(Some(connector()), Some(sdk()), None, None, &models(TEST_MODEL).each()).is_ok(),
|
||||
"a configuration where every model routes did not open"
|
||||
);
|
||||
}
|
||||
|
|
@ -1482,7 +1549,7 @@ mod tests {
|
|||
fn soul_every_sidecar_reason_carries_its_named_disposition() {
|
||||
use SidecarFaultReason::*;
|
||||
for reason in [RateLimited, Overloaded, ServerError, ApiTimeout] {
|
||||
let fault = reason.into_fault("detail".into());
|
||||
let fault = reason.into_fault();
|
||||
assert!(
|
||||
!fault.requires_recovery() && !fault.integrity_was_violated(),
|
||||
"{reason:?} was not retryable"
|
||||
|
|
@ -1500,13 +1567,13 @@ mod tests {
|
|||
Unknown,
|
||||
] {
|
||||
assert!(
|
||||
reason.into_fault("detail".into()).requires_recovery(),
|
||||
reason.into_fault().requires_recovery(),
|
||||
"{reason:?} was not recovery-required"
|
||||
);
|
||||
}
|
||||
for reason in [ProtocolViolation, ToolRegistrationFailed, TurnCapRefused] {
|
||||
assert!(
|
||||
reason.into_fault("detail".into()).integrity_was_violated(),
|
||||
reason.into_fault().integrity_was_violated(),
|
||||
"{reason:?} did not violate integrity"
|
||||
);
|
||||
}
|
||||
|
|
@ -1684,6 +1751,88 @@ mod tests {
|
|||
assert!(fault.integrity_was_violated(), "{fault:?}");
|
||||
}
|
||||
|
||||
fn assert_names_no(fault: &InferenceFault, canary: &str) {
|
||||
let rendered = format!("{fault} | {fault:?}");
|
||||
assert!(!rendered.contains(canary), "{canary} leaked into {rendered}");
|
||||
}
|
||||
|
||||
/// The SDK port's share of the no-input-text rule: text the sidecar sent,
|
||||
/// a request's effort and a request's model
|
||||
/// name each arrive as a canary, and none reaches the fault. The fault
|
||||
/// type carries only fixed text, so this fails if a port ever routes a
|
||||
/// computed string into it (through `Box::leak`, say).
|
||||
#[tokio::test]
|
||||
async fn an_sdk_fault_carries_no_text_it_was_handed() {
|
||||
const CANARY: &str = "CANARY-sdk-91b6d0";
|
||||
|
||||
// The sidecar's own detail, for every reason it may name.
|
||||
for reason in [
|
||||
SidecarFaultReason::RateLimited,
|
||||
SidecarFaultReason::Overloaded,
|
||||
SidecarFaultReason::ServerError,
|
||||
SidecarFaultReason::ApiTimeout,
|
||||
SidecarFaultReason::AuthenticationFailed,
|
||||
SidecarFaultReason::OrgNotAllowed,
|
||||
SidecarFaultReason::BillingError,
|
||||
SidecarFaultReason::InvalidRequest,
|
||||
SidecarFaultReason::ModelNotFound,
|
||||
SidecarFaultReason::MaxOutputTokens,
|
||||
SidecarFaultReason::MaxBudgetUsd,
|
||||
SidecarFaultReason::ExecutionError,
|
||||
SidecarFaultReason::Unknown,
|
||||
SidecarFaultReason::ProtocolViolation,
|
||||
SidecarFaultReason::ToolRegistrationFailed,
|
||||
SidecarFaultReason::TurnCapRefused,
|
||||
] {
|
||||
let link = ScriptedLink::new(vec![SidecarFrame::Fault {
|
||||
query_id: 1,
|
||||
reason,
|
||||
detail: CANARY.into(),
|
||||
}]);
|
||||
let port = SdkInferencePort::new(link, TEST_RUNTIME);
|
||||
let prepared = prose_prepared(&port, "Say something true.");
|
||||
let fault = port.infer(prepared).await.expect_err("a fault frame produced an output");
|
||||
assert_names_no(&fault, CANARY);
|
||||
}
|
||||
|
||||
// An effort the SDK has no counterpart for.
|
||||
let port = SdkInferencePort::new(ScriptedLink::new(Vec::new()), TEST_RUNTIME);
|
||||
let mut prepared = prose_prepared(&port, "Say something true.");
|
||||
prepared.invocation.request.reasoning_effort = Some(CANARY.into());
|
||||
let fault = lower_query(1, &prepared, 1).expect_err("an unmapped effort lowered");
|
||||
assert_names_no(&fault, CANARY);
|
||||
|
||||
// A model no backend claims, prepared and inferred through the router.
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let entry = directory.path().join("main.js");
|
||||
std::fs::write(&entry, "// sidecar").unwrap();
|
||||
let routed = open_inference(
|
||||
None,
|
||||
Some(SdkBinding {
|
||||
sidecar_entry: entry,
|
||||
caller_runtime_id: TEST_RUNTIME.into(),
|
||||
model_prefix: DEFAULT_SDK_MODEL_PREFIX.into(),
|
||||
}),
|
||||
None,
|
||||
None,
|
||||
&models(TEST_MODEL).each(),
|
||||
)
|
||||
.expect("the SDK model routes");
|
||||
let request = tool_request(
|
||||
CommandId::new(),
|
||||
0,
|
||||
InferencePurpose::Persona,
|
||||
CANARY,
|
||||
"Respond only in natural prose.",
|
||||
vec![CodexInputItem::UserText { text: "Say something true.".into() }],
|
||||
Vec::<CodexToolDefinition>::new(),
|
||||
RequestShape { max_output_tokens: 1_200, parallel_tool_calls: false },
|
||||
)
|
||||
.expect("the request builds");
|
||||
let fault = routed.prepare(request).err().expect("an unroutable model prepared");
|
||||
assert_names_no(&fault, CANARY);
|
||||
}
|
||||
|
||||
/// Soul: the lowering's own gate table, every row.
|
||||
#[test]
|
||||
fn soul_lower_query_refuses_the_shapes_the_gate_table_names() {
|
||||
|
|
@ -1788,7 +1937,7 @@ mod tests {
|
|||
)) as Arc<dyn InferencePort>),
|
||||
None,
|
||||
DEFAULT_SDK_MODEL_PREFIX,
|
||||
None,
|
||||
Vec::new(),
|
||||
);
|
||||
assert!(connector_only.route(TEST_MODEL).is_none());
|
||||
let prepared =
|
||||
|
|
@ -1803,7 +1952,7 @@ mod tests {
|
|||
.expect_err("an unroutable model inferred"),
|
||||
] {
|
||||
assert!(fault.integrity_was_violated(), "{fault:?}");
|
||||
assert!(fault.to_string().contains(TEST_MODEL));
|
||||
assert!(fault.to_string().contains("no configured inference backend"), "{fault:?}");
|
||||
}
|
||||
|
||||
// An SDK-only deployment opens with no connector binding at all, so no
|
||||
|
|
@ -1820,6 +1969,7 @@ mod tests {
|
|||
model_prefix: DEFAULT_SDK_MODEL_PREFIX.into(),
|
||||
}),
|
||||
None,
|
||||
None,
|
||||
&models(TEST_MODEL).each(),
|
||||
)
|
||||
.is_ok()
|
||||
|
|
@ -1897,6 +2047,7 @@ mod tests {
|
|||
let granted = vec![AffordanceSnapshot {
|
||||
id: AffordanceId::issue(),
|
||||
entry: kernel_speak_entry(),
|
||||
render_path: None,
|
||||
}];
|
||||
let mut emitted: Vec<(String, String)> = patch_tools()
|
||||
.into_iter()
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
//! Projector may call it.
|
||||
|
||||
use super::controllers::{
|
||||
self, ControllerError, InferenceEvent, InferenceOutput, InferencePurpose, InferenceRequest,
|
||||
self, ControllerError, InferenceEvent, InferenceOutput, InferencePurpose, InferenceRequest, TokenUsage,
|
||||
RequestShape, tool_request,
|
||||
};
|
||||
use super::patch::{
|
||||
|
|
@ -1070,6 +1070,12 @@ impl InferenceOutput {
|
|||
pub fn receipt_digest(&self) -> &str {
|
||||
&self.receipt_digest
|
||||
}
|
||||
|
||||
/// The tokens the provider reported for this reply; `None` when it
|
||||
/// reported none.
|
||||
pub fn usage(&self) -> Option<TokenUsage> {
|
||||
self.usage
|
||||
}
|
||||
}
|
||||
|
||||
/// The canonical id as the patch vocabulary spells it: the bare UUID text,
|
||||
|
|
@ -1600,7 +1606,8 @@ mod tests {
|
|||
// PA.f75: `communicate` was removed from `PLAY_TOOLS`; Dungeon drops it
|
||||
// from its own list too. This is now the complete 24-tool list.
|
||||
// PA.f88: this is the library's own test fixture, kept in sync by hand.
|
||||
// Dungeon's `PLAY_TOOLS` in `ghostlight-dungeon/src/play.rs` is the
|
||||
// Dungeon's `PLAY_TOOLS` in `ghostlight-dungeon/src/play.rs` (parked at
|
||||
// `parked/ghostlight-eve-surface`) is the
|
||||
// single policy owner of what the play agent may actually call; this
|
||||
// copy pins nothing there and is not read by production code.
|
||||
const PLAY_TOOLS: &[&str] = &[
|
||||
|
|
@ -2489,6 +2496,7 @@ mod tests {
|
|||
outcome_bands: Vec::new(),
|
||||
carries_speech: false,
|
||||
},
|
||||
render_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
759
crates/ghostlight/src/verse/calls.rs
Normal file
759
crates/ghostlight/src/verse/calls.rs
Normal file
|
|
@ -0,0 +1,759 @@
|
|||
//! Provider call records, the run's spend, and replay.
|
||||
//!
|
||||
//! `RecordingInferencePort` is the one owner of a run's spend. Before it
|
||||
//! forwards a call it decides, from the request alone, whether the call could
|
||||
//! pass the run's token cap; after the reply it charges what the call really
|
||||
//! cost. Every reply is stored as a `ProviderCallRecord` before the output
|
||||
//! returns to the lane, so `ReplayInferencePort` can serve the same run again
|
||||
//! from the store with no provider behind it.
|
||||
//!
|
||||
//! Lanes and runners never count tokens. A request is held against the cap by
|
||||
//! its bound: the serialized provider request's bytes (a token is at least one
|
||||
//! byte, so bytes bound the prompt) plus the request's own output allowance.
|
||||
//!
|
||||
//! The run's spend is one persisted ledger row per run, owned by the store and
|
||||
//! written where it is spent. A call reserves its bound in that row before the
|
||||
//! provider is called; a reply settles the reservation to what it reported
|
||||
//! (prompt plus completion, cached prompt tokens being part of the prompt; none
|
||||
//! reported is charged the bound); a fault leaves the reservation charged. A
|
||||
//! restart reads the row, so no restart returns budget. A reply that reports
|
||||
//! more than the call's bound is charged in full and faults the call, so the
|
||||
//! run's spend never passes the cap unnoticed. One port at a time owns a run.
|
||||
|
||||
use crate::controllers::{
|
||||
InferenceEvent, InferenceFault, InferenceOutput, InferencePort, InferencePurpose,
|
||||
InferenceRequest, PreparedInference, REQUEST_EXPIRY, TokenUsage, ToolResultOracle, unix_ms,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use codex_connector::CodexProviderRequest;
|
||||
use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, OwnedRedbMessagePackBackingStore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet, VecDeque},
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use thiserror::Error;
|
||||
|
||||
const CALL_RECORD_ROW: &str = "verse_provider_call.v1";
|
||||
const RUN_LEDGER_ROW: &str = "verse_run_ledger.v1";
|
||||
/// The run ledger document's schema.
|
||||
const RUN_LEDGER_SCHEMA: &str = "ghostlight.verse_run_ledger.v1";
|
||||
/// The call record document's schema. A store holding any other row type or
|
||||
/// schema is refused at open: this store is not migrated from, or shared with,
|
||||
/// another document.
|
||||
pub const CALL_RECORD_SCHEMA: &str = "ghostlight.verse_provider_call.v1";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)]
|
||||
pub enum CallRecordError {
|
||||
#[error("a run id is 1 to 64 characters of letters, digits, '-' and '_'")]
|
||||
InvalidRunId,
|
||||
#[error("the call record store could not be read or written")]
|
||||
Store,
|
||||
#[error("the call record store holds a row that is not a canonical call record")]
|
||||
Corrupt,
|
||||
#[error("a call record's sequence is not the next in its run")]
|
||||
OutOfSequence,
|
||||
#[error("replay needs one run's records in dense sequence from 1")]
|
||||
NotReplayable,
|
||||
#[error("another recording port already owns this run")]
|
||||
RunInUse,
|
||||
#[error("the call could pass the run's token cap")]
|
||||
OverCap,
|
||||
#[error("the run was recorded with a different {0}")]
|
||||
RunMismatch(RunField),
|
||||
}
|
||||
|
||||
/// What a run is bound to when it begins, named by the field a mismatch refuses.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RunField {
|
||||
/// The token cap the run was opened with.
|
||||
Cap,
|
||||
/// The identity of the world the run started from.
|
||||
WorldId,
|
||||
/// The state digest of the world the run started from.
|
||||
StateDigest,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RunField {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::Cap => "token cap",
|
||||
Self::WorldId => "world id",
|
||||
Self::StateDigest => "world state digest",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The world a run began from: its identity and state digest. Replay is of a
|
||||
/// restored copy of this world, so it takes the same two values.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RunStart {
|
||||
world_id: String,
|
||||
state_digest: String,
|
||||
}
|
||||
|
||||
impl RunStart {
|
||||
pub fn of(world: &crate::WorldSnapshot) -> Self {
|
||||
Self {
|
||||
world_id: world.world_id.text(),
|
||||
state_digest: world.state_digest.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A run's identity as a key segment.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct RunId(String);
|
||||
|
||||
impl RunId {
|
||||
pub fn new(id: &str) -> Result<Self, CallRecordError> {
|
||||
let valid = !id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& id
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_');
|
||||
if valid {
|
||||
Ok(Self(id.to_owned()))
|
||||
} else {
|
||||
Err(CallRecordError::InvalidRunId)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// One provider call of one run: the request's content digest (never its text, so no
|
||||
/// prompt or credential is stored), the reply exactly as the lane received it,
|
||||
/// and what the call was charged. Keyed (run id, sequence).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProviderCallRecord {
|
||||
run_id: String,
|
||||
sequence: u64,
|
||||
purpose: InferencePurpose,
|
||||
model: String,
|
||||
content_sha256: String,
|
||||
events: Vec<InferenceEvent>,
|
||||
receipt_digest: String,
|
||||
usage: Option<TokenUsage>,
|
||||
charged: u64,
|
||||
}
|
||||
|
||||
impl ProviderCallRecord {
|
||||
pub fn run_id(&self) -> &str {
|
||||
&self.run_id
|
||||
}
|
||||
|
||||
pub fn sequence(&self) -> u64 {
|
||||
self.sequence
|
||||
}
|
||||
|
||||
pub fn purpose(&self) -> InferencePurpose {
|
||||
self.purpose
|
||||
}
|
||||
|
||||
pub fn model(&self) -> &str {
|
||||
&self.model
|
||||
}
|
||||
|
||||
/// Lowercase hex of the request's content digest (see `content_digest`).
|
||||
pub fn content_sha256(&self) -> &str {
|
||||
&self.content_sha256
|
||||
}
|
||||
|
||||
pub fn usage(&self) -> Option<TokenUsage> {
|
||||
self.usage
|
||||
}
|
||||
|
||||
/// Tokens this call was charged against the run's cap.
|
||||
pub fn charged(&self) -> u64 {
|
||||
self.charged
|
||||
}
|
||||
|
||||
/// The reply as the lane received it.
|
||||
pub fn output(&self) -> InferenceOutput {
|
||||
let output = InferenceOutput::new(self.events.clone(), self.receipt_digest.clone());
|
||||
match self.usage {
|
||||
Some(usage) => output.with_usage(usage),
|
||||
None => output,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn row_key(run: &str, sequence: u64) -> String {
|
||||
format!("{run}/{sequence:020}")
|
||||
}
|
||||
|
||||
fn hex(digest: &[u8]) -> String {
|
||||
digest.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
/// What a request asks of the provider, without the ids that name it. The
|
||||
/// request and conversation ids are derived from the command id, and a command
|
||||
/// id from the world it ran in, so keying on them would tie a recorded run to
|
||||
/// one world's random identity. Everything else the provider is sent
|
||||
/// (model, instructions, input, tools, shape) is in the digest.
|
||||
fn content_digest(request: &CodexProviderRequest) -> Result<String, InferenceFault> {
|
||||
// Exhaustive on purpose: a field added upstream must be placed in the key
|
||||
// or left out here by name before this compiles.
|
||||
let CodexProviderRequest {
|
||||
schema_id,
|
||||
request_id: _request_id, // names the call, not what it asks
|
||||
conversation_id: _conversation_id, // names the call, not what it asks
|
||||
model,
|
||||
instructions,
|
||||
input,
|
||||
reasoning_effort,
|
||||
reasoning_summary,
|
||||
service_tier,
|
||||
output_format_name,
|
||||
previous_response_id,
|
||||
tools,
|
||||
tool_choice,
|
||||
parallel_tool_calls,
|
||||
output_schema_json,
|
||||
max_output_tokens,
|
||||
prompt_cache_key,
|
||||
} = request;
|
||||
let bytes = rmp_serde::to_vec(&(
|
||||
schema_id,
|
||||
model,
|
||||
instructions,
|
||||
input,
|
||||
reasoning_effort,
|
||||
reasoning_summary,
|
||||
service_tier,
|
||||
output_format_name,
|
||||
previous_response_id,
|
||||
tools,
|
||||
tool_choice,
|
||||
parallel_tool_calls,
|
||||
output_schema_json,
|
||||
max_output_tokens,
|
||||
prompt_cache_key,
|
||||
))
|
||||
.map_err(|_| InferenceFault::new("a provider request could not be measured"))?;
|
||||
Ok(hex(&Sha256::digest(bytes)))
|
||||
}
|
||||
|
||||
/// A run's persisted ledger, the one owner of what the run began as: the cap
|
||||
/// it was opened under, the world it started from, and the tokens reserved or
|
||||
/// charged so far.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
struct RunLedger {
|
||||
run_id: String,
|
||||
cap: u64,
|
||||
start: RunStart,
|
||||
spent: u64,
|
||||
}
|
||||
|
||||
enum DecodedRow {
|
||||
Call(ProviderCallRecord),
|
||||
Ledger(RunLedger),
|
||||
}
|
||||
|
||||
pub(super) fn canonical<T: Serialize + for<'de> Deserialize<'de>>(
|
||||
row: &CultCacheEnvelope,
|
||||
) -> Result<T, CallRecordError> {
|
||||
let value: T = rmp_serde::from_slice(&row.payload).map_err(|_| CallRecordError::Corrupt)?;
|
||||
let again = rmp_serde::to_vec_named(&value).map_err(|_| CallRecordError::Corrupt)?;
|
||||
if again == row.payload {
|
||||
Ok(value)
|
||||
} else {
|
||||
Err(CallRecordError::Corrupt)
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_row(row: &CultCacheEnvelope) -> Result<DecodedRow, CallRecordError> {
|
||||
match (row.r#type.as_str(), row.schema_id.as_deref()) {
|
||||
(CALL_RECORD_ROW, Some(CALL_RECORD_SCHEMA)) => {
|
||||
let record: ProviderCallRecord = canonical(row)?;
|
||||
// A run id that is no whole key segment would key into another
|
||||
// run's records, and sequences count from 1.
|
||||
if RunId::new(&record.run_id).is_err()
|
||||
|| record.sequence == 0
|
||||
|| row.key != row_key(&record.run_id, record.sequence)
|
||||
{
|
||||
return Err(CallRecordError::Corrupt);
|
||||
}
|
||||
Ok(DecodedRow::Call(record))
|
||||
}
|
||||
(RUN_LEDGER_ROW, Some(RUN_LEDGER_SCHEMA)) => {
|
||||
let ledger: RunLedger = canonical(row)?;
|
||||
if RunId::new(&ledger.run_id).is_err() || row.key != ledger.run_id {
|
||||
return Err(CallRecordError::Corrupt);
|
||||
}
|
||||
Ok(DecodedRow::Ledger(ledger))
|
||||
}
|
||||
_ => Err(CallRecordError::Corrupt),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RunTally {
|
||||
last_sequence: u64,
|
||||
/// The persisted ledger, read at open and written where it is spent.
|
||||
ledger: Option<RunLedger>,
|
||||
}
|
||||
|
||||
struct StoreState {
|
||||
store: OwnedRedbMessagePackBackingStore,
|
||||
runs: BTreeMap<String, RunTally>,
|
||||
/// Runs with a recording port alive; one ledger has one owner.
|
||||
owned: BTreeSet<String>,
|
||||
}
|
||||
|
||||
/// The call records and spend ledgers of every run in one CultCache file.
|
||||
/// Records are appended and never changed; a run's sequence is dense from 1.
|
||||
pub struct CallRecordStore {
|
||||
state: Mutex<StoreState>,
|
||||
}
|
||||
|
||||
impl CallRecordStore {
|
||||
/// Opens the file, creating it empty when absent. Every row must be a
|
||||
/// canonical call record or run ledger of this schema with its key, a
|
||||
/// dense sequence, and a ledger that covers its records' charges; anything
|
||||
/// else, an earlier or later schema version included, is refused and
|
||||
/// nothing is migrated.
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self, CallRecordError> {
|
||||
let store = OwnedRedbMessagePackBackingStore::new(path.as_ref())
|
||||
.map_err(|_| CallRecordError::Store)?;
|
||||
store
|
||||
.validate_path_identity()
|
||||
.map_err(|_| CallRecordError::Store)?;
|
||||
let rows = store.pull_all().map_err(|_| CallRecordError::Store)?;
|
||||
let mut runs: BTreeMap<String, RunTally> = BTreeMap::new();
|
||||
let mut counts: BTreeMap<String, u64> = BTreeMap::new();
|
||||
let mut charged: BTreeMap<String, u64> = BTreeMap::new();
|
||||
for row in &rows {
|
||||
match decode_row(row)? {
|
||||
DecodedRow::Call(record) => {
|
||||
*counts.entry(record.run_id.clone()).or_default() += 1;
|
||||
let total = charged.entry(record.run_id.clone()).or_default();
|
||||
*total = total.saturating_add(record.charged);
|
||||
let tally = runs.entry(record.run_id).or_default();
|
||||
tally.last_sequence = tally.last_sequence.max(record.sequence);
|
||||
}
|
||||
DecodedRow::Ledger(ledger) => {
|
||||
let run = ledger.run_id.clone();
|
||||
runs.entry(run).or_default().ledger = Some(ledger);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sequences start at 1 and keys are unique, so a run is dense exactly
|
||||
// when its rows number its highest sequence. Every record was reserved
|
||||
// in a ledger before it was made, so a run with records has a ledger
|
||||
// that covers what they charged.
|
||||
for (run, tally) in &runs {
|
||||
let rows = counts.get(run).copied().unwrap_or(0);
|
||||
let recorded = charged.get(run).copied().unwrap_or(0);
|
||||
let covered = tally
|
||||
.ledger
|
||||
.as_ref()
|
||||
.is_some_and(|ledger| ledger.spent >= recorded);
|
||||
if rows != tally.last_sequence || !covered {
|
||||
return Err(CallRecordError::Corrupt);
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
state: Mutex::new(StoreState {
|
||||
store,
|
||||
runs,
|
||||
owned: BTreeSet::new(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
fn lock(&self) -> Result<std::sync::MutexGuard<'_, StoreState>, CallRecordError> {
|
||||
self.state.lock().map_err(|_| CallRecordError::Store)
|
||||
}
|
||||
|
||||
/// The sequence the next record of this run must carry.
|
||||
pub fn next_sequence(&self, run: &RunId) -> Result<u64, CallRecordError> {
|
||||
Ok(self
|
||||
.lock()?
|
||||
.runs
|
||||
.get(run.as_str())
|
||||
.map_or(0, |tally| tally.last_sequence)
|
||||
+ 1)
|
||||
}
|
||||
|
||||
/// Tokens this run has reserved or been charged: its persisted ledger.
|
||||
pub fn spent(&self, run: &RunId) -> Result<u64, CallRecordError> {
|
||||
Ok(self
|
||||
.lock()?
|
||||
.runs
|
||||
.get(run.as_str())
|
||||
.and_then(|tally| tally.ledger.as_ref())
|
||||
.map_or(0, |ledger| ledger.spent))
|
||||
}
|
||||
|
||||
/// Takes the run's ledger for one port, or refuses when another holds it.
|
||||
/// The first claim of a run writes its ledger: the cap it is opened under
|
||||
/// and the world it starts from. A later claim must name the same cap; the
|
||||
/// start is not compared, because a resumed world has moved on.
|
||||
fn claim(&self, run: &RunId, cap: u64, start: &RunStart) -> Result<(), CallRecordError> {
|
||||
let mut state = self.lock()?;
|
||||
if !state.owned.insert(run.as_str().to_owned()) {
|
||||
return Err(CallRecordError::RunInUse);
|
||||
}
|
||||
let begun = match state.runs.get(run.as_str()).and_then(|t| t.ledger.as_ref()) {
|
||||
Some(ledger) if ledger.cap == cap => Ok(()),
|
||||
Some(_) => Err(CallRecordError::RunMismatch(RunField::Cap)),
|
||||
None => Self::write_ledger(
|
||||
&mut state,
|
||||
RunLedger {
|
||||
run_id: run.as_str().to_owned(),
|
||||
cap,
|
||||
start: start.clone(),
|
||||
spent: 0,
|
||||
},
|
||||
),
|
||||
};
|
||||
if begun.is_err() {
|
||||
state.owned.remove(run.as_str());
|
||||
}
|
||||
begun
|
||||
}
|
||||
|
||||
/// The world a run began from, when it has begun.
|
||||
fn start_of(&self, run: &RunId) -> Result<Option<RunStart>, CallRecordError> {
|
||||
Ok(self
|
||||
.lock()?
|
||||
.runs
|
||||
.get(run.as_str())
|
||||
.and_then(|tally| tally.ledger.as_ref())
|
||||
.map(|ledger| ledger.start.clone()))
|
||||
}
|
||||
|
||||
fn disown(&self, run: &RunId) {
|
||||
if let Ok(mut state) = self.state.lock() {
|
||||
state.owned.remove(run.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists the ledger, then adopts it in memory. A write that fails
|
||||
/// leaves the previous ledger.
|
||||
fn write_ledger(state: &mut StoreState, ledger: RunLedger) -> Result<(), CallRecordError> {
|
||||
let row = CultCacheEnvelope {
|
||||
key: ledger.run_id.clone(),
|
||||
r#type: RUN_LEDGER_ROW.into(),
|
||||
payload: rmp_serde::to_vec_named(&ledger).map_err(|_| CallRecordError::Store)?,
|
||||
stored_at: Utc::now().to_rfc3339(),
|
||||
schema_id: Some(RUN_LEDGER_SCHEMA.into()),
|
||||
};
|
||||
state.store.push(&row).map_err(|_| CallRecordError::Store)?;
|
||||
let run = ledger.run_id.clone();
|
||||
state.runs.entry(run).or_default().ledger = Some(ledger);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persists `spent` as the run's spend; the run must have been claimed.
|
||||
fn write_spent(state: &mut StoreState, run: &str, spent: u64) -> Result<(), CallRecordError> {
|
||||
let mut ledger = state
|
||||
.runs
|
||||
.get(run)
|
||||
.and_then(|tally| tally.ledger.clone())
|
||||
.ok_or(CallRecordError::Store)?;
|
||||
ledger.spent = spent;
|
||||
Self::write_ledger(state, ledger)
|
||||
}
|
||||
|
||||
fn held(state: &StoreState, run: &RunId) -> u64 {
|
||||
state
|
||||
.runs
|
||||
.get(run.as_str())
|
||||
.and_then(|tally| tally.ledger.as_ref())
|
||||
.map_or(0, |ledger| ledger.spent)
|
||||
}
|
||||
|
||||
/// Charges `bound` to the run's persisted ledger, or refuses with
|
||||
/// `OverCap` when the ledger plus the bound would pass `cap`. The check and
|
||||
/// the write hold one lock, so calls on the wire hold their bounds.
|
||||
fn reserve(&self, run: &RunId, bound: u64, cap: u64) -> Result<(), CallRecordError> {
|
||||
let mut state = self.lock()?;
|
||||
let after = Self::held(&state, run).saturating_add(bound);
|
||||
if after > cap {
|
||||
return Err(CallRecordError::OverCap);
|
||||
}
|
||||
Self::write_spent(&mut state, run.as_str(), after)
|
||||
}
|
||||
|
||||
/// Replaces a reservation of `bound` with what the call was charged. With
|
||||
/// a record, the record is stored first, so a crash between the two writes
|
||||
/// leaves the larger reservation charged.
|
||||
fn settle(
|
||||
&self,
|
||||
run: &RunId,
|
||||
bound: u64,
|
||||
charged: u64,
|
||||
record: Option<&ProviderCallRecord>,
|
||||
) -> Result<(), CallRecordError> {
|
||||
let mut state = self.lock()?;
|
||||
if let Some(record) = record {
|
||||
Self::push_record(&mut state, record)?;
|
||||
}
|
||||
let settled = Self::held(&state, run)
|
||||
.saturating_sub(bound)
|
||||
.saturating_add(charged);
|
||||
Self::write_spent(&mut state, run.as_str(), settled)
|
||||
}
|
||||
|
||||
fn append(&self, record: &ProviderCallRecord) -> Result<(), CallRecordError> {
|
||||
let mut state = self.lock()?;
|
||||
Self::push_record(&mut state, record)
|
||||
}
|
||||
|
||||
fn push_record(
|
||||
state: &mut StoreState,
|
||||
record: &ProviderCallRecord,
|
||||
) -> Result<(), CallRecordError> {
|
||||
let last = state
|
||||
.runs
|
||||
.get(&record.run_id)
|
||||
.map_or(0, |tally| tally.last_sequence);
|
||||
if record.sequence != last + 1 {
|
||||
return Err(CallRecordError::OutOfSequence);
|
||||
}
|
||||
let row = CultCacheEnvelope {
|
||||
key: row_key(&record.run_id, record.sequence),
|
||||
r#type: CALL_RECORD_ROW.into(),
|
||||
payload: rmp_serde::to_vec_named(record).map_err(|_| CallRecordError::Store)?,
|
||||
stored_at: Utc::now().to_rfc3339(),
|
||||
schema_id: Some(CALL_RECORD_SCHEMA.into()),
|
||||
};
|
||||
state.store.push(&row).map_err(|_| CallRecordError::Store)?;
|
||||
state
|
||||
.runs
|
||||
.entry(record.run_id.clone())
|
||||
.or_default()
|
||||
.last_sequence = record.sequence;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The run's records in sequence.
|
||||
pub fn records(&self, run: &RunId) -> Result<Vec<ProviderCallRecord>, CallRecordError> {
|
||||
let prefix = format!("{}/", run.as_str());
|
||||
let rows = self
|
||||
.lock()?
|
||||
.store
|
||||
.pull_all()
|
||||
.map_err(|_| CallRecordError::Store)?;
|
||||
let mut records = Vec::new();
|
||||
for row in rows.iter().filter(|row| row.key.starts_with(&prefix)) {
|
||||
if let DecodedRow::Call(record) = decode_row(row)? {
|
||||
records.push(record);
|
||||
}
|
||||
}
|
||||
records.sort_by_key(|record| record.sequence);
|
||||
Ok(records)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decorates a port for one run: reserves each call's bound in the run's
|
||||
/// ledger before the provider is called, settles it to the real cost, and
|
||||
/// stores every reply before returning it.
|
||||
pub struct RecordingInferencePort {
|
||||
inner: Arc<dyn InferencePort>,
|
||||
store: Arc<CallRecordStore>,
|
||||
run: RunId,
|
||||
cap: u64,
|
||||
}
|
||||
|
||||
impl RecordingInferencePort {
|
||||
/// Takes ownership of the run's ledger, which resumes from the store. The
|
||||
/// first open of a run records its cap and the world it starts from; a
|
||||
/// reopen with another cap is refused with `RunMismatch(Cap)`. A second
|
||||
/// port on a run whose first is alive is refused with `RunInUse`.
|
||||
pub fn open(
|
||||
inner: Arc<dyn InferencePort>,
|
||||
store: Arc<CallRecordStore>,
|
||||
run: RunId,
|
||||
cap_tokens: u64,
|
||||
start: &RunStart,
|
||||
) -> Result<Self, CallRecordError> {
|
||||
store.claim(&run, cap_tokens, start)?;
|
||||
Ok(Self {
|
||||
inner,
|
||||
store,
|
||||
run,
|
||||
cap: cap_tokens,
|
||||
})
|
||||
}
|
||||
|
||||
/// The run this port records.
|
||||
pub fn run_id(&self) -> &RunId {
|
||||
&self.run
|
||||
}
|
||||
|
||||
/// The token cap the run was opened under.
|
||||
pub fn cap(&self) -> u64 {
|
||||
self.cap
|
||||
}
|
||||
|
||||
/// Tokens reserved or charged to the run so far.
|
||||
pub fn spent(&self) -> u64 {
|
||||
self.store.spent(&self.run).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
/// The most this request can cost: its serialized bytes plus its output
|
||||
/// allowance. A request with no output allowance has no bound, so it
|
||||
/// cannot be held under a cap.
|
||||
fn bound(request: &PreparedInference) -> Result<u64, InferenceFault> {
|
||||
let allowance = request
|
||||
.invocation
|
||||
.request
|
||||
.max_output_tokens
|
||||
.ok_or_else(InferenceFault::budget_exhausted)?;
|
||||
let payload = serde_json::to_vec(&request.invocation.request)
|
||||
.map_err(|_| InferenceFault::new("a provider request could not be measured"))?;
|
||||
Ok((payload.len() as u64).saturating_add(u64::from(allowance)))
|
||||
}
|
||||
|
||||
fn settle(
|
||||
&self,
|
||||
bound: u64,
|
||||
identity: (InferencePurpose, String, String),
|
||||
result: Result<InferenceOutput, InferenceFault>,
|
||||
) -> Result<InferenceOutput, InferenceFault> {
|
||||
// A fault leaves its reservation charged: the request may have run.
|
||||
let output = result?;
|
||||
let charged = match output.usage {
|
||||
Some(usage) => usage.prompt.saturating_add(usage.completion),
|
||||
None => bound,
|
||||
};
|
||||
if charged > bound {
|
||||
self.store
|
||||
.settle(&self.run, bound, charged, None)
|
||||
.map_err(unrecorded)?;
|
||||
return Err(InferenceFault::budget_exhausted());
|
||||
}
|
||||
let (purpose, model, content_sha256) = identity;
|
||||
let record = ProviderCallRecord {
|
||||
run_id: self.run.as_str().to_owned(),
|
||||
sequence: self.store.next_sequence(&self.run).map_err(unrecorded)?,
|
||||
purpose,
|
||||
model,
|
||||
content_sha256,
|
||||
events: output.events.clone(),
|
||||
receipt_digest: output.receipt_digest.clone(),
|
||||
usage: output.usage,
|
||||
charged,
|
||||
};
|
||||
self.store
|
||||
.settle(&self.run, bound, charged, Some(&record))
|
||||
.map_err(unrecorded)?;
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RecordingInferencePort {
|
||||
fn drop(&mut self) {
|
||||
self.store.disown(&self.run);
|
||||
}
|
||||
}
|
||||
|
||||
fn unrecorded(_: CallRecordError) -> InferenceFault {
|
||||
InferenceFault::integrity_violation("a provider call could not be recorded")
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl InferencePort for RecordingInferencePort {
|
||||
fn prepare(&self, request: InferenceRequest) -> Result<PreparedInference, InferenceFault> {
|
||||
self.inner.prepare(request)
|
||||
}
|
||||
|
||||
async fn infer(&self, request: PreparedInference) -> Result<InferenceOutput, InferenceFault> {
|
||||
let bound = Self::bound(&request)?;
|
||||
let identity = (
|
||||
request.purpose,
|
||||
request.invocation.request.model.clone(),
|
||||
content_digest(&request.invocation.request)?,
|
||||
);
|
||||
self.store
|
||||
.reserve(&self.run, bound, self.cap)
|
||||
.map_err(|error| match error {
|
||||
CallRecordError::OverCap => InferenceFault::budget_exhausted(),
|
||||
_ => unrecorded(error),
|
||||
})?;
|
||||
let result = self.inner.infer(request).await;
|
||||
self.settle(bound, identity, result)
|
||||
}
|
||||
|
||||
fn lend_tool_results(&self, prepared: &PreparedInference, oracle: Box<dyn ToolResultOracle>) {
|
||||
self.inner.lend_tool_results(prepared, oracle);
|
||||
}
|
||||
}
|
||||
|
||||
/// Serves a run's stored replies, in sequence, to the same requests, matched
|
||||
/// by what they ask of the provider and not by the ids that name them. It holds
|
||||
/// no provider and makes no call: a request that is not the next recorded one
|
||||
/// is a fault.
|
||||
pub struct ReplayInferencePort {
|
||||
caller_runtime_id: String,
|
||||
pending: Mutex<VecDeque<ProviderCallRecord>>,
|
||||
}
|
||||
|
||||
impl ReplayInferencePort {
|
||||
/// Replays `run` from the store against `world`, a restored copy of the
|
||||
/// world the run started from. Any other world is refused here, before a
|
||||
/// call, with `RunMismatch` naming the field; a run the store never began
|
||||
/// is `NotReplayable`.
|
||||
pub fn new(
|
||||
caller_runtime_id: impl Into<String>,
|
||||
store: &CallRecordStore,
|
||||
run: &RunId,
|
||||
world: &RunStart,
|
||||
) -> Result<Self, CallRecordError> {
|
||||
let began = store.start_of(run)?.ok_or(CallRecordError::NotReplayable)?;
|
||||
if began.world_id != world.world_id {
|
||||
return Err(CallRecordError::RunMismatch(RunField::WorldId));
|
||||
}
|
||||
if began.state_digest != world.state_digest {
|
||||
return Err(CallRecordError::RunMismatch(RunField::StateDigest));
|
||||
}
|
||||
Ok(Self {
|
||||
caller_runtime_id: caller_runtime_id.into(),
|
||||
pending: Mutex::new(store.records(run)?.into()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl InferencePort for ReplayInferencePort {
|
||||
fn prepare(&self, request: InferenceRequest) -> Result<PreparedInference, InferenceFault> {
|
||||
PreparedInference::prepare(
|
||||
&self.caller_runtime_id,
|
||||
unix_ms()?.saturating_add(REQUEST_EXPIRY.as_millis() as u64),
|
||||
request,
|
||||
)
|
||||
}
|
||||
|
||||
async fn infer(&self, request: PreparedInference) -> Result<InferenceOutput, InferenceFault> {
|
||||
let mut pending = self
|
||||
.pending
|
||||
.lock()
|
||||
.map_err(|_| InferenceFault::integrity_violation("the replay queue was poisoned"))?;
|
||||
let digest = content_digest(&request.invocation.request)?;
|
||||
let Some(next) = pending.front() else {
|
||||
return Err(InferenceFault::integrity_violation(
|
||||
"replay has no recorded call left",
|
||||
));
|
||||
};
|
||||
if next.content_sha256 != digest {
|
||||
return Err(InferenceFault::integrity_violation(
|
||||
"the request is not the next recorded call",
|
||||
));
|
||||
}
|
||||
let output = next.output();
|
||||
pending.pop_front();
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
1230
crates/ghostlight/src/verse/calls/tests.rs
Normal file
1230
crates/ghostlight/src/verse/calls/tests.rs
Normal file
File diff suppressed because it is too large
Load diff
143
crates/ghostlight/src/verse/genesis.rs
Normal file
143
crates/ghostlight/src/verse/genesis.rs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
//! The canonical Aetheria verse world's genesis.
|
||||
//!
|
||||
//! One intent lowers to one `CreateWorld`: a commons, every grammar verb
|
||||
//! declared as an affordance, and each Aetheria faction as an institution
|
||||
//! imported by its record key. The world is bound to the catalog's grammar, so
|
||||
//! the kernel refuses any affordance the grammar does not carry.
|
||||
//!
|
||||
//! Phase A verbs record the act and change no component: an outcome band with
|
||||
//! no effects, and the act carried as the utterance that makes an entry
|
||||
//! admissible. Ghostlight owns each verb's lowering to kernel effects (ruling
|
||||
//! `verb-effects-ghostlight`), and those arrive with phase B.
|
||||
|
||||
use crate::aetheria::AetheriaCatalog;
|
||||
use crate::mailbox::{MailboxError, commons_declaration, commons_ref};
|
||||
use crate::patch::{
|
||||
AffordanceDeclaration, AffordanceKindName, AudienceSpec, Declaration, DraftHandle, EvidenceRef,
|
||||
FactDeclaration, FactStandingRef, OutcomeBand, Precondition, Ref, Statement, SubjectDeclaration,
|
||||
kernel_speak_entry, kernel_speak_grant,
|
||||
};
|
||||
use crate::{
|
||||
CommandId, CreateWorld, LensWeights, NewController, PrincipalId, SubjectKind,
|
||||
WorldPatch, WorldScaleIntentRef,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const COMMIT_LEN: usize = 40;
|
||||
|
||||
/// What a caller states to create the canonical verse world. Everything else a
|
||||
/// world needs (the commons, the subjects, the affordances, their grants and
|
||||
/// the provenance of what is known) is derived from the catalog.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CreateVerseWorldIntent {
|
||||
pub id: CommandId,
|
||||
pub title: String,
|
||||
pub brief: String,
|
||||
pub catalog: AetheriaCatalog,
|
||||
/// The Aetheria commit the catalog was read at: 40 lower-case hex digits.
|
||||
/// It is the provenance of every fact the world holds about a faction.
|
||||
pub aetheria_commit: String,
|
||||
pub lens_weights: LensWeights,
|
||||
pub targets: BTreeMap<SubjectKind, u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum VerseGenesisError {
|
||||
#[error("aetheria_commit is not 40 lower-case hex digits")]
|
||||
NotACommit,
|
||||
#[error(transparent)]
|
||||
Mailbox(#[from] MailboxError),
|
||||
}
|
||||
|
||||
impl CreateVerseWorldIntent {
|
||||
/// The one lowering. The kernel supplies the `speak` entry at genesis and
|
||||
/// holds it to the grammar, so it is granted here and never declared.
|
||||
pub(crate) fn lower(self, owner: PrincipalId) -> Result<CreateWorld, VerseGenesisError> {
|
||||
let commit = &self.aetheria_commit;
|
||||
if commit.len() != COMMIT_LEN
|
||||
|| !commit
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
{
|
||||
return Err(VerseGenesisError::NotACommit);
|
||||
}
|
||||
|
||||
let speak = kernel_speak_entry().kind;
|
||||
let mut grants = kernel_speak_grant();
|
||||
let mut declarations = vec![commons_declaration()];
|
||||
for (kind, verb) in self.catalog.grammar.verbs() {
|
||||
if kind == speak.0 {
|
||||
continue;
|
||||
}
|
||||
let handle = DraftHandle::new(format!("verb-{kind}"));
|
||||
grants.insert(Ref::Draft(handle.clone()));
|
||||
declarations.push(Declaration::Affordance(AffordanceDeclaration {
|
||||
handle,
|
||||
kind: AffordanceKindName(kind.to_owned()),
|
||||
roles: verb.role_specs(),
|
||||
preconditions: vec![Precondition::CanBroadcast {
|
||||
via: AudienceSpec::Colocated,
|
||||
}],
|
||||
effect_slots: Vec::new(),
|
||||
outcome_bands: vec![OutcomeBand {
|
||||
weight: 1,
|
||||
effects: Vec::new(),
|
||||
}],
|
||||
// The kernel refuses an entry that can neither change a
|
||||
// component nor speak, and a speaking entry needs an audience,
|
||||
// so a phase A verb is its own utterance to the room: the act
|
||||
// is recorded as speech and nothing else changes.
|
||||
carries_speech: true,
|
||||
}));
|
||||
}
|
||||
|
||||
let mut evidence = Vec::new();
|
||||
for (position, faction) in self.catalog.factions.iter().enumerate() {
|
||||
declarations.push(Declaration::Subject(SubjectDeclaration {
|
||||
handle: DraftHandle::new(format!("faction-{position}")),
|
||||
label: faction.name.trim().to_owned(),
|
||||
kind: SubjectKind::Institution,
|
||||
controller: NewController::OperationalAgent,
|
||||
affordances: grants.clone(),
|
||||
position: Some(commons_ref()),
|
||||
import_key: Some(faction.key.clone()),
|
||||
}));
|
||||
// A faction with no description has nothing to state.
|
||||
if let Some(statement) = Statement::new(faction.description.trim()) {
|
||||
let receipt = EvidenceRef::new(format!(
|
||||
"aetheria:{commit}:{}/{}",
|
||||
faction.key.schema(),
|
||||
faction.key.record()
|
||||
));
|
||||
evidence.push(receipt.clone());
|
||||
declarations.push(Declaration::Fact(FactDeclaration {
|
||||
handle: DraftHandle::new(format!("fact-{position}")),
|
||||
label: faction.name.trim().to_owned(),
|
||||
statement,
|
||||
standing: FactStandingRef::Canonical { evidence: receipt },
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(CreateWorld {
|
||||
id: self.id,
|
||||
owner,
|
||||
title: self.title,
|
||||
brief: self.brief,
|
||||
patch: WorldPatch {
|
||||
declarations,
|
||||
operations: Vec::new(),
|
||||
evidence,
|
||||
},
|
||||
scale_intent: WorldScaleIntentRef {
|
||||
targets: self.targets,
|
||||
jurisdictions: BTreeMap::new(),
|
||||
},
|
||||
lens_weights: self.lens_weights,
|
||||
grammar: Some(self.catalog.grammar),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
476
crates/ghostlight/src/verse/genesis/tests.rs
Normal file
476
crates/ghostlight/src/verse/genesis/tests.rs
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
use super::*;
|
||||
use crate::aetheria::read_aetheria_catalog;
|
||||
use crate::patch::{FactStanding, Mismatch};
|
||||
use crate::tests::{auth_principal, owner, stock_weights};
|
||||
use crate::{
|
||||
CommandBody, ControllerMode, GrammarBinding, GrammarReferentKind, GrammarRole, GrammarVerb,
|
||||
KernelError, PrincipalCommandIntent, RenderPath, VerifiedPrincipalEvidence, WorldKernel,
|
||||
WorldMailbox, WorldPhase,
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// Aetheria master when the fixture catalog was written.
|
||||
const COMMIT: &str = "92b82e9aa5bb422c568c4bfc85a8f641426a41fc";
|
||||
const OTHER_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567";
|
||||
|
||||
const FIXTURE: &str = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/fixtures/aetheria-verse-catalog.cc"
|
||||
);
|
||||
|
||||
fn catalog() -> AetheriaCatalog {
|
||||
read_aetheria_catalog(FIXTURE).unwrap()
|
||||
}
|
||||
|
||||
fn intent_of(catalog: AetheriaCatalog, commit: &str) -> CreateVerseWorldIntent {
|
||||
CreateVerseWorldIntent {
|
||||
id: CommandId::new(),
|
||||
title: "The Verse".into(),
|
||||
brief: "Aetheria.".into(),
|
||||
catalog,
|
||||
aetheria_commit: commit.into(),
|
||||
lens_weights: stock_weights(),
|
||||
targets: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A world created through the kernel from the lowering, for what the
|
||||
/// snapshot hides: the facts' evidence.
|
||||
fn kernel_of(intent: CreateVerseWorldIntent) -> (tempfile::TempDir, Result<WorldKernel, KernelError>) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let world = intent.lower(owner()).unwrap();
|
||||
let result = WorldKernel::create(dir.path().join("world.cc"), world, &auth_principal(owner()))
|
||||
.map(|(kernel, _)| kernel);
|
||||
(dir, result)
|
||||
}
|
||||
|
||||
struct Verse {
|
||||
_dir: tempfile::TempDir,
|
||||
_task: tokio::task::JoinHandle<()>,
|
||||
mailbox: WorldMailbox,
|
||||
principal: VerifiedPrincipalEvidence,
|
||||
receipt: crate::CreationReceipt,
|
||||
}
|
||||
|
||||
async fn verse(intent: CreateVerseWorldIntent) -> Verse {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mailbox, task) = WorldMailbox::open(dir.path().join("world.cc")).unwrap();
|
||||
let principal =
|
||||
VerifiedPrincipalEvidence::fixture("verse-owner", Utc::now() + Duration::hours(1));
|
||||
let receipt = mailbox.create_verse(intent, &principal).await.unwrap();
|
||||
Verse {
|
||||
_dir: dir,
|
||||
_task: task,
|
||||
mailbox,
|
||||
principal,
|
||||
receipt,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verse_genesis_from_the_aetheria_fixture() {
|
||||
let catalog = catalog();
|
||||
let verse = verse(intent_of(catalog.clone(), COMMIT)).await;
|
||||
let snapshot = verse.mailbox.snapshot().await.unwrap();
|
||||
|
||||
// One institution per faction, keyed by its record, named by its Name.
|
||||
let mut factions: Vec<_> = snapshot
|
||||
.subjects
|
||||
.iter()
|
||||
.map(|subject| {
|
||||
(
|
||||
subject.import_key.clone().unwrap(),
|
||||
subject.label.clone(),
|
||||
subject.kind,
|
||||
subject.controller_mode,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
factions.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let expected: Vec<_> = catalog
|
||||
.factions
|
||||
.iter()
|
||||
.map(|faction| {
|
||||
(
|
||||
faction.key.clone(),
|
||||
faction.name.clone(),
|
||||
SubjectKind::Institution,
|
||||
Some(ControllerMode::OperationalAgent),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(factions.len(), 2);
|
||||
assert_eq!(factions, expected);
|
||||
|
||||
// One fact per faction, carrying its description.
|
||||
let statements: BTreeSet<_> = snapshot
|
||||
.facts
|
||||
.iter()
|
||||
.map(|fact| fact.statement.as_str().to_owned())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
statements,
|
||||
catalog
|
||||
.factions
|
||||
.iter()
|
||||
.map(|faction| faction.description.clone())
|
||||
.collect::<BTreeSet<_>>()
|
||||
);
|
||||
|
||||
// The world is bound to the catalog's grammar.
|
||||
assert_eq!(snapshot.grammar, Some(catalog.grammar.identity()));
|
||||
assert_eq!(verse.receipt.world_id, snapshot.world_id);
|
||||
|
||||
// Every faction stands in the one commons.
|
||||
let commons: Vec<_> = snapshot
|
||||
.places
|
||||
.iter()
|
||||
.filter(|place| place.label == "The Commons")
|
||||
.collect();
|
||||
assert_eq!(commons.len(), 1);
|
||||
assert!(
|
||||
snapshot
|
||||
.subjects
|
||||
.iter()
|
||||
.all(|subject| subject.position == Some(commons[0].id))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_fact_cites_its_faction_record_and_the_commit_it_was_read_at() {
|
||||
for commit in [COMMIT, OTHER_COMMIT] {
|
||||
let (_dir, kernel) = kernel_of(intent_of(catalog(), commit));
|
||||
let kernel = kernel.unwrap();
|
||||
let evidence: BTreeSet<String> = kernel
|
||||
.state
|
||||
.facts
|
||||
.values()
|
||||
.map(|fact| match &fact.standing {
|
||||
FactStanding::Canonical { evidence } => evidence.text().to_owned(),
|
||||
other => panic!("a verse fact is canonical, not {other:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
evidence,
|
||||
catalog()
|
||||
.factions
|
||||
.iter()
|
||||
.map(|faction| format!(
|
||||
"aetheria:{commit}:aetheria.faction/{}",
|
||||
faction.key.record()
|
||||
))
|
||||
.collect::<BTreeSet<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_commit_is_forty_lower_case_hex_digits() {
|
||||
for bad in [
|
||||
"",
|
||||
&COMMIT[..39],
|
||||
&format!("{COMMIT}0"),
|
||||
&COMMIT.to_uppercase(),
|
||||
&format!("{}g", &COMMIT[..39]),
|
||||
] {
|
||||
assert!(
|
||||
matches!(
|
||||
intent_of(catalog(), bad).lower(owner()),
|
||||
Err(VerseGenesisError::NotACommit)
|
||||
),
|
||||
"{bad:?}"
|
||||
);
|
||||
}
|
||||
assert!(intent_of(catalog(), COMMIT).lower(owner()).is_ok());
|
||||
assert!(!VerseGenesisError::NotACommit.to_string().contains(COMMIT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verse_genesis_refuses_a_duplicate_faction_record() {
|
||||
let mut twice = catalog();
|
||||
let first = twice.factions[0].clone();
|
||||
twice.factions.push(first);
|
||||
// The lowering does not decide: it declares all three.
|
||||
let world = intent_of(twice.clone(), COMMIT).lower(owner()).unwrap();
|
||||
let subjects = world
|
||||
.patch
|
||||
.declarations
|
||||
.iter()
|
||||
.filter(|declaration| matches!(declaration, Declaration::Subject(_)))
|
||||
.count();
|
||||
assert_eq!(subjects, 3);
|
||||
|
||||
let (_dir, kernel) = kernel_of(intent_of(twice, COMMIT));
|
||||
let Err(KernelError::PatchRejected(set)) = kernel else {
|
||||
panic!("a second faction under one record key was admitted");
|
||||
};
|
||||
assert!(
|
||||
set.iter()
|
||||
.any(|mismatch| matches!(mismatch, Mismatch::DuplicateImportKey { .. })),
|
||||
"{set:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_granted_affordance_is_a_grammar_verb() {
|
||||
let catalog = catalog();
|
||||
let verse = verse(intent_of(catalog.clone(), COMMIT)).await;
|
||||
let snapshot = verse.mailbox.snapshot().await.unwrap();
|
||||
|
||||
let mut kinds: Vec<_> = snapshot
|
||||
.affordances
|
||||
.iter()
|
||||
.map(|affordance| affordance.entry.kind.0.clone())
|
||||
.collect();
|
||||
kinds.sort();
|
||||
// Each verb once: the kernel's speak is the grammar's speak.
|
||||
assert_eq!(
|
||||
kinds,
|
||||
catalog
|
||||
.grammar
|
||||
.verbs()
|
||||
.map(|(kind, _)| kind.to_owned())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert!(kinds.contains(&"speak".to_owned()));
|
||||
|
||||
// Each has its render path from the grammar, and phase A changes nothing.
|
||||
for affordance in &snapshot.affordances {
|
||||
let verb = catalog.grammar.verb(&affordance.entry.kind.0).unwrap();
|
||||
assert_eq!(affordance.render_path, Some(verb.render_path));
|
||||
assert!(affordance.entry.effect_slots.is_empty());
|
||||
assert_eq!(affordance.entry.outcome_bands.len(), 1);
|
||||
assert!(affordance.entry.outcome_bands[0].effects.is_empty());
|
||||
}
|
||||
|
||||
// Every faction is granted every verb.
|
||||
let all: BTreeSet<_> = snapshot.affordances.iter().map(|a| a.id).collect();
|
||||
for subject in &snapshot.subjects {
|
||||
assert_eq!(subject.affordances, all);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn factions_get_opportunities() {
|
||||
let verse = verse(intent_of(catalog(), COMMIT)).await;
|
||||
for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] {
|
||||
let revision = verse.mailbox.snapshot().await.unwrap().revision;
|
||||
verse
|
||||
.mailbox
|
||||
.submit_principal(
|
||||
PrincipalCommandIntent {
|
||||
id: CommandId::new(),
|
||||
world_id: verse.receipt.world_id,
|
||||
expected_revision: revision,
|
||||
body,
|
||||
},
|
||||
&verse.principal,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let snapshot = verse.mailbox.snapshot().await.unwrap();
|
||||
assert_eq!(snapshot.phase, WorldPhase::Active);
|
||||
assert_eq!(snapshot.opportunities.len(), catalog().factions.len());
|
||||
let scopes: BTreeSet<_> = snapshot.opportunities.iter().map(|o| o.scope).collect();
|
||||
assert_eq!(scopes.len(), snapshot.opportunities.len());
|
||||
for opportunity in &snapshot.opportunities {
|
||||
assert_eq!(opportunity.controller_mode, ControllerMode::OperationalAgent);
|
||||
assert_eq!(opportunity.affordance_ids.len(), snapshot.affordances.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_faction_without_a_description_holds_no_fact() {
|
||||
let mut bare = catalog();
|
||||
bare.factions[0].description = " ".into();
|
||||
bare.factions[1].description = " Second. ".into();
|
||||
let (_dir, kernel) = kernel_of(intent_of(bare, COMMIT));
|
||||
let facts: Vec<_> = kernel
|
||||
.unwrap()
|
||||
.state
|
||||
.facts
|
||||
.values()
|
||||
.map(|fact| fact.statement.as_str().to_owned())
|
||||
.collect();
|
||||
assert_eq!(facts, vec!["Second.".to_owned()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grammar_the_kernel_speak_cannot_live_in_refuses_genesis() {
|
||||
let mut mute = catalog();
|
||||
mute.grammar = GrammarBinding::new(
|
||||
"aetheria.verse_grammar",
|
||||
1,
|
||||
[(
|
||||
"haul".to_owned(),
|
||||
GrammarVerb {
|
||||
render_path: RenderPath::ShipAction,
|
||||
roles: vec![GrammarRole {
|
||||
role: "cargo".into(),
|
||||
referent: GrammarReferentKind::Cargo,
|
||||
}],
|
||||
},
|
||||
)],
|
||||
)
|
||||
.unwrap();
|
||||
let (_dir, kernel) = kernel_of(intent_of(mute, COMMIT));
|
||||
let Err(KernelError::PatchRejected(set)) = kernel else {
|
||||
panic!("a world whose grammar has no speak was admitted");
|
||||
};
|
||||
assert!(
|
||||
set.iter()
|
||||
.any(|mismatch| matches!(mismatch, Mismatch::AffordanceKindNotInGrammar { .. })),
|
||||
"{set:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_dungeon_ingress_still_creates_an_unbound_world() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mailbox, _task) = WorldMailbox::open(dir.path().join("world.cc")).unwrap();
|
||||
let principal =
|
||||
VerifiedPrincipalEvidence::fixture("dungeon-owner", Utc::now() + Duration::hours(1));
|
||||
mailbox
|
||||
.create(
|
||||
crate::CreateWorldIntent {
|
||||
id: CommandId::new(),
|
||||
title: "Dungeon".into(),
|
||||
brief: String::new(),
|
||||
human_subject_label: "Player".into(),
|
||||
narrative_persona_label: None,
|
||||
operational_agent_label: None,
|
||||
targets: BTreeMap::new(),
|
||||
jurisdictions: Vec::new(),
|
||||
lens_weights: stock_weights(),
|
||||
},
|
||||
&principal,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let snapshot = mailbox.snapshot().await.unwrap();
|
||||
assert_eq!(snapshot.grammar, None);
|
||||
assert!(
|
||||
snapshot
|
||||
.places
|
||||
.iter()
|
||||
.any(|place| place.label == "The Commons")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_faction_name_is_trimmed_into_its_institution_label() {
|
||||
let mut padded = catalog();
|
||||
let expected = padded.factions[0].name.trim().to_owned();
|
||||
padded.factions[0].name = format!(" {expected}\t ");
|
||||
let key = padded.factions[0].key.clone();
|
||||
let verse = verse(intent_of(padded, COMMIT)).await;
|
||||
let snapshot = verse.mailbox.snapshot().await.unwrap();
|
||||
let subject = snapshot
|
||||
.subjects
|
||||
.iter()
|
||||
.find(|subject| subject.import_key.as_ref() == Some(&key))
|
||||
.expect("the padded faction was created");
|
||||
assert_eq!(subject.label, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn genesis_twice_with_one_intent_is_one_creation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mailbox, _task) = WorldMailbox::open(dir.path().join("world.cc")).unwrap();
|
||||
let principal =
|
||||
VerifiedPrincipalEvidence::fixture("verse-owner", Utc::now() + Duration::hours(1));
|
||||
let intent = intent_of(catalog(), COMMIT);
|
||||
let first = mailbox.create_verse(intent.clone(), &principal).await.unwrap();
|
||||
let before = mailbox.snapshot().await.unwrap();
|
||||
let again = mailbox.create_verse(intent, &principal).await.unwrap();
|
||||
let after = mailbox.snapshot().await.unwrap();
|
||||
assert_eq!(again, first);
|
||||
assert_eq!(after.revision, before.revision);
|
||||
assert_eq!(after.state_digest, before.state_digest);
|
||||
assert_eq!(after.subjects.len(), before.subjects.len());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn genesis_under_another_command_id_finds_the_world_occupied() {
|
||||
let verse = verse(intent_of(catalog(), COMMIT)).await;
|
||||
let before = verse.mailbox.snapshot().await.unwrap();
|
||||
let refused = verse
|
||||
.mailbox
|
||||
.create_verse(intent_of(catalog(), COMMIT), &verse.principal)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(
|
||||
refused,
|
||||
Err(VerseGenesisError::Mailbox(MailboxError::Kernel(
|
||||
KernelError::CreationTargetOccupied
|
||||
)))
|
||||
),
|
||||
"{refused:?}"
|
||||
);
|
||||
let after = verse.mailbox.snapshot().await.unwrap();
|
||||
assert_eq!(after.revision, before.revision);
|
||||
assert_eq!(after.state_digest, before.state_digest);
|
||||
}
|
||||
|
||||
/// Pins the standing phase A behaviour (ruling `ship-acts-speech-shaped-phase-a`):
|
||||
/// the lowering admits a ship verb only as an utterance,
|
||||
/// and committing it asserts a claim fact the room hears.
|
||||
#[test]
|
||||
fn phase_a_ship_verbs_are_spoken_acts_in_the_room() {
|
||||
use crate::tests::{command, opportunity_for, spoken, submit_owner};
|
||||
use crate::{
|
||||
AuthenticatedCaller, CallerId, DecisionInvocation, Role, RoleBinding, SubmitReceipt,
|
||||
Target,
|
||||
};
|
||||
|
||||
let (_scratch, kernel) = kernel_of(intent_of(catalog(), COMMIT));
|
||||
let mut kernel = kernel.unwrap();
|
||||
for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] {
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
submit_owner(&mut kernel, &snapshot, body);
|
||||
}
|
||||
let active = kernel.snapshot().unwrap();
|
||||
let attack = active
|
||||
.affordances
|
||||
.iter()
|
||||
.find(|a| a.entry.kind.0 == "attack")
|
||||
.unwrap()
|
||||
.id;
|
||||
let [first, second] = [&active.subjects[0], &active.subjects[1]];
|
||||
let opportunity = opportunity_for(&active, first.id);
|
||||
let caller = CallerId::Controller(opportunity.controller_id);
|
||||
let invocation = |speech: Option<&str>| DecisionInvocation {
|
||||
affordance: attack,
|
||||
bindings: vec![RoleBinding {
|
||||
role: Role("target".into()),
|
||||
target: Target::Subject(second.id),
|
||||
}],
|
||||
proposed: Vec::new(),
|
||||
speech: speech.map(|text| crate::patch::Statement::new(text).unwrap()),
|
||||
display: None,
|
||||
};
|
||||
let try_it = |kernel: &mut WorldKernel, speech: Option<&str>| {
|
||||
kernel.submit(
|
||||
command(
|
||||
&active,
|
||||
CommandId::new(),
|
||||
caller.clone(),
|
||||
CommandBody::ExerciseDecision {
|
||||
opportunity: opportunity.clone(),
|
||||
invocation: invocation(speech),
|
||||
},
|
||||
),
|
||||
&AuthenticatedCaller::fixture(caller.clone()),
|
||||
)
|
||||
};
|
||||
|
||||
// Without an utterance the act is refused: the verb has no other way in.
|
||||
assert!(try_it(&mut kernel, None).is_err());
|
||||
// With one, the act commits and its words are a claim fact.
|
||||
let receipt = try_it(&mut kernel, Some("We strike.")).expect("the attack commits");
|
||||
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
|
||||
let event = kernel.state.events.last().unwrap();
|
||||
assert_eq!(spoken(&kernel.state, event), Some("We strike."));
|
||||
}
|
||||
19
crates/ghostlight/src/verse/mod.rs
Normal file
19
crates/ghostlight/src/verse/mod.rs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
//! The Aetheria verse run's own organs. The verse binary sits above this
|
||||
//! module; here sit the parts of a run that belong to the library: the record
|
||||
//! of every provider call and the spend it implies, the genesis of the
|
||||
//! canonical verse world, and the manual run that advances it with its record.
|
||||
|
||||
mod calls;
|
||||
mod genesis;
|
||||
mod run;
|
||||
|
||||
pub use genesis::{CreateVerseWorldIntent, VerseGenesisError};
|
||||
pub use calls::{
|
||||
CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, ProviderCallRecord,
|
||||
RecordingInferencePort, ReplayInferencePort, RunField, RunId, RunStart,
|
||||
};
|
||||
pub use run::{
|
||||
RUN_RECORD_SCHEMA, RecordedCoverBudget, Refusal, RefusalReason, RunClaim, RunOutcome, RunStatus,
|
||||
TickCheckpoint, TickReport, UnattributedCall, VerseRunConfig, VerseRunError, VerseRunRecord, VerseRunStore,
|
||||
call_run_id, run_manual,
|
||||
};
|
||||
1165
crates/ghostlight/src/verse/run.rs
Normal file
1165
crates/ghostlight/src/verse/run.rs
Normal file
File diff suppressed because it is too large
Load diff
2783
crates/ghostlight/src/verse/run/tests.rs
Normal file
2783
crates/ghostlight/src/verse/run/tests.rs
Normal file
File diff suppressed because it is too large
Load diff
BIN
crates/ghostlight/tests/fixtures/aetheria-verse-catalog.cc
vendored
Normal file
BIN
crates/ghostlight/tests/fixtures/aetheria-verse-catalog.cc
vendored
Normal file
Binary file not shown.
16
crates/ghostlight/tests/fixtures/aetheria-verse-catalog.provenance.txt
vendored
Normal file
16
crates/ghostlight/tests/fixtures/aetheria-verse-catalog.provenance.txt
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
aetheria-verse-catalog.cc was written by Aetheria's own types, not by hand and not by Ghostlight.
|
||||
|
||||
Writer: tests/Aetheria.Shared.Tests/VerseGrammarTests.cs, WriteFixture (xunit test
|
||||
Fixture_writer_is_deterministic_and_emits_what_the_grammar_reader_needs),
|
||||
which authors VerseGrammar, three VerseVerb and two Faction records through
|
||||
Aetheria's CultCache types and leaves the file at AETHERIA_VERSE_FIXTURE_PATH.
|
||||
Aetheria: GameCult/Aetheria origin/master 92b82e9aa5bb422c568c4bfc85a8f641426a41fc
|
||||
(contains aetheria-release cut verse-grammar, merged at 35af2930).
|
||||
Command: AETHERIA_VERSE_FIXTURE_PATH=/tmp/aetheria-verse-catalog.cc
|
||||
dotnet test tests/Aetheria.Shared.Tests
|
||||
-p:CultLibRoot=<CultLib 45c2f40 clone> -p:CultMathRoot=<CultLib 252e226c clone>
|
||||
--filter "FullyQualifiedName~Fixture_writer_is_deterministic"
|
||||
run on Yggdrasil through the verify stopgap (dotnet/sdk:10.0), 2026-10-10.
|
||||
Content: six records; two writes of the writer agree on content, though not bytes
|
||||
(CultCache stamps storedAt from the wall clock). Regenerate and diff on content,
|
||||
never hand-edit.
|
||||
BIN
crates/ghostlight/tests/fixtures/base_world.cc
vendored
Normal file
BIN
crates/ghostlight/tests/fixtures/base_world.cc
vendored
Normal file
Binary file not shown.
3
crates/ghostlight/tests/fixtures/base_world.digests
vendored
Normal file
3
crates/ghostlight/tests/fixtures/base_world.digests
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
3
|
||||
sha256:b706f57cb417603366a1f47a5dd099fbf68bfecc5b2e8d61d281630791d1d76d
|
||||
sha256:2039956a26c3d5b073d101e5898ac5a60a9eb63a3b93f447b2d8f78ef4caf979
|
||||
1
crates/ghostlight/tests/fixtures/base_world.id
vendored
Normal file
1
crates/ghostlight/tests/fixtures/base_world.id
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
"a534b32a-4d00-429c-bf6c-ce1d55385380"
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
schema = "gamecult.idunn.target_declaration.v1"
|
||||
target = "ghostlight"
|
||||
source_stamp_environment = "GHOSTLIGHT_BUILD_COMMIT"
|
||||
required_gitlinks = ["vendor/eve"]
|
||||
|
||||
[[steps]]
|
||||
id = "test-persona-boundary"
|
||||
phase = "test"
|
||||
runner = "rust-build"
|
||||
required_environment = ["CARGO_HOME", "HOME"]
|
||||
argv = ["cargo", "test", "--locked", "-p", "ghostlight-persona-projection"]
|
||||
|
||||
[[steps]]
|
||||
id = "test-world-library"
|
||||
phase = "test"
|
||||
runner = "rust-build"
|
||||
required_environment = ["CARGO_HOME", "HOME"]
|
||||
argv = ["cargo", "test", "--locked", "-p", "ghostlight"]
|
||||
|
||||
[[steps]]
|
||||
id = "test-dungeon"
|
||||
phase = "test"
|
||||
runner = "rust-build"
|
||||
required_environment = ["CARGO_HOME", "HOME"]
|
||||
argv = ["cargo", "test", "--locked", "-p", "ghostlight-dungeon", "--bin", "ghostlight-dungeon"]
|
||||
|
||||
[[steps]]
|
||||
id = "build-daemon"
|
||||
phase = "build"
|
||||
runner = "rust-build"
|
||||
required_environment = ["CARGO_HOME", "HOME"]
|
||||
argv = ["cargo", "build", "--locked", "--release", "-p", "ghostlight-dungeon", "--bin", "ghostlight-dungeon"]
|
||||
|
||||
[[steps]]
|
||||
id = "install-web-dependencies"
|
||||
phase = "prepare"
|
||||
runner = "web-build"
|
||||
required_environment = ["HOME", "NPM_CONFIG_CACHE"]
|
||||
argv = ["npm", "--prefix", "web", "ci"]
|
||||
|
||||
[[steps]]
|
||||
id = "test-web"
|
||||
phase = "test"
|
||||
runner = "web-build"
|
||||
required_environment = ["HOME", "NPM_CONFIG_CACHE"]
|
||||
argv = ["npm", "--prefix", "web", "test"]
|
||||
|
||||
[[steps]]
|
||||
id = "build-web"
|
||||
phase = "build"
|
||||
runner = "web-build"
|
||||
required_environment = ["HOME", "NPM_CONFIG_CACHE"]
|
||||
argv = ["npm", "--prefix", "web", "run", "build"]
|
||||
|
||||
[[steps]]
|
||||
id = "accept-real-persona-membrane"
|
||||
phase = "acceptance"
|
||||
runner = "rust-acceptance"
|
||||
argv = [
|
||||
"cargo",
|
||||
"test",
|
||||
"--locked",
|
||||
"-p",
|
||||
"ghostlight",
|
||||
"--lib",
|
||||
"controllers::tests::real_local_model_cognition_modes_commit_speech",
|
||||
"--",
|
||||
"--ignored",
|
||||
"--exact",
|
||||
]
|
||||
required_environment = [
|
||||
"CARGO_HOME",
|
||||
"GHOSTLIGHT_ACCEPTANCE_PERSONA_PROSE_LOG",
|
||||
"GHOSTLIGHT_ACCEPTANCE_RUNTIME_ID",
|
||||
"GHOSTLIGHT_CONTROLLER_ELABORATOR_MODEL",
|
||||
"GHOSTLIGHT_CONTROLLER_INTERPRETER_MODEL",
|
||||
"GHOSTLIGHT_CONTROLLER_OPERATIONAL_MODEL",
|
||||
"GHOSTLIGHT_CONTROLLER_PERSONA_MODEL",
|
||||
"GHOSTLIGHT_CONTROLLER_PROJECTOR_MODEL",
|
||||
"HOME",
|
||||
"GHOSTLIGHT_LOCAL_ENDPOINT",
|
||||
]
|
||||
|
||||
[[artifacts]]
|
||||
id = "ghostlight-dungeon"
|
||||
source_kind = "runner-output"
|
||||
runner = "rust-build"
|
||||
source = "target/release/ghostlight-dungeon"
|
||||
destination = "ghostlight-dungeon"
|
||||
executable = true
|
||||
|
||||
[[artifacts]]
|
||||
id = "web"
|
||||
source_kind = "runner-output"
|
||||
runner = "web-build"
|
||||
source = "web/dist"
|
||||
destination = "web"
|
||||
|
||||
[service]
|
||||
executable_artifact = "ghostlight-dungeon"
|
||||
required_adjacent_artifacts = ["web"]
|
||||
arguments = [
|
||||
{ kind = "literal", value = "--state-root" },
|
||||
{ kind = "binding", name = "state_root" },
|
||||
]
|
||||
transport = "http"
|
||||
route_required = true
|
||||
required_environment = [
|
||||
"GAMECULT_IDUNN_CANDIDATE_BIND",
|
||||
"GAMECULT_IDUNN_PROCESS_WRITE_LEASE",
|
||||
"GAMECULT_IDUNN_RUNTIME_BUNDLE",
|
||||
"GHOSTLIGHT_CONTROLLER_ELABORATOR_MODEL",
|
||||
"GHOSTLIGHT_CONTROLLER_INTERPRETER_MODEL",
|
||||
"GHOSTLIGHT_CONTROLLER_OPERATIONAL_MODEL",
|
||||
"GHOSTLIGHT_CONTROLLER_PERSONA_MODEL",
|
||||
"GHOSTLIGHT_CONTROLLER_PROJECTOR_MODEL",
|
||||
"GHOSTLIGHT_DISCORD_GUILD_ID",
|
||||
"GHOSTLIGHT_DISCORD_ROLE_ID",
|
||||
"GHOSTLIGHT_HEIMDALL_APP_SECRET_FILE",
|
||||
"GHOSTLIGHT_HEIMDALL_BASE_URL",
|
||||
"GHOSTLIGHT_LOCAL_ENDPOINT",
|
||||
"GHOSTLIGHT_LOCATED_SERVICE",
|
||||
"GHOSTLIGHT_ODIN_RUDP",
|
||||
"GHOSTLIGHT_PLAY_MODEL",
|
||||
"GHOSTLIGHT_PUBLIC_APP_URL",
|
||||
"GHOSTLIGHT_SERVICE_ID",
|
||||
"GHOSTLIGHT_SESSION_WRAPPING_KEY_FILE",
|
||||
]
|
||||
optional_environment = ["RUST_LOG"]
|
||||
|
||||
[service.health]
|
||||
contract = "ghostlight.cultnet-rudp-service-health"
|
||||
|
||||
[state]
|
||||
schema_generation = "world-v3"
|
||||
|
||||
[[state.slots]]
|
||||
id = "world"
|
||||
relative_path = "world.cc"
|
||||
kind = "cultcache-file"
|
||||
schema = "ghostlight.world_state.consumer.v6"
|
||||
writer = "process-bound-single-writer"
|
||||
recovery = "preserve"
|
||||
startup = "create-or-open-after-write-lease"
|
||||
|
||||
[[state.slots]]
|
||||
id = "app-sessions"
|
||||
relative_path = "service/app-sessions-v2.cc"
|
||||
kind = "cultcache-file"
|
||||
schema = "ghostlight.app_session_store.v2"
|
||||
writer = "process-bound-single-writer"
|
||||
recovery = "preserve"
|
||||
startup = "create-or-open-after-write-lease"
|
||||
|
||||
[[state.slots]]
|
||||
id = "controller-work"
|
||||
relative_path = "service/controller-work.cc"
|
||||
kind = "cultcache-file"
|
||||
schema = "ghostlight.controller_work.v17"
|
||||
writer = "process-bound-single-writer"
|
||||
recovery = "preserve"
|
||||
startup = "create-or-open-after-write-lease"
|
||||
|
||||
[[state.slots]]
|
||||
id = "mesh-projection"
|
||||
relative_path = "service/mesh-v2.cc"
|
||||
kind = "cultcache-file"
|
||||
schema = "gamecult.eve.surface.v1"
|
||||
writer = "process-bound-single-writer"
|
||||
recovery = "rebuildable"
|
||||
startup = "create-or-open-after-write-lease"
|
||||
|
||||
[[provides]]
|
||||
capability = "ghostlight.world-service"
|
||||
schema = "ghostlight.world_state.consumer.v6"
|
||||
compatibility = "consumer-v6"
|
||||
|
||||
[[provides]]
|
||||
capability = "gamecult.eve.surface-provider"
|
||||
schema = "gamecult.eve.surface.v1"
|
||||
compatibility = "v1"
|
||||
|
||||
[[dependencies]]
|
||||
kind = "shared-infrastructure"
|
||||
capability = "odin.verse-rendezvous"
|
||||
schema = "odin.verse-topology.v1"
|
||||
compatibility = "v1"
|
||||
startup = "before-promotion"
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
# Call Of The Void Brainstorm
|
||||
|
||||
Source: https://docs.google.com/document/d/1YWn5RWsciBxuaQ4l5TN2lAEz4okaQes-aiyx1E9NAxA/edit?tab=t.0
|
||||
|
||||
This is a local plaintext import of the scattered Call of the Void brainstorming doc. Treat it as raw source material, not canon. The current compact design note remains E:\Projects\AetheriaLore\Aetheria\Game Design\Call of the Void.md.
|
||||
|
||||
## Current Canon Correction
|
||||
|
||||
This brainstorm predates a major Aetheria timeline migration.
|
||||
|
||||
The old version assumes a colony fleet or exploratory force was shunted into
|
||||
Elysium and slowly discovers that nobody is going home. That is no longer the
|
||||
live premise.
|
||||
|
||||
Current canon, summarized in
|
||||
`E:\Projects\AetheriaLore\Aetheria\Worldbuilding\Pre-Elysium\From Sol to Elysium.md`,
|
||||
is that the FTL trigger sequestered the whole Solar system, with its fleets,
|
||||
institutions, corporate blocs, uploaded minds, labor systems, histories, and
|
||||
unresolved social machinery. Elysium is not a remote elsewhere that a fleet
|
||||
cannot leave. It is the sealed domain into which Sol itself was displaced.
|
||||
|
||||
So the useful dramatic pressure is not "can the expedition return home?"
|
||||
because home came with them. The better pressure is:
|
||||
|
||||
- there is no clean outside to return to
|
||||
- the systems people hoped to escape are trapped in Elysium too
|
||||
- the old order can no longer rely on the old universe remaining stable around
|
||||
it
|
||||
- every faction is trying to define what survival, legitimacy, personhood, and
|
||||
ownership mean now that Sol's whole inheritance has been exported into the
|
||||
sealed domain
|
||||
- Cat's story should turn on the realization that starting over is not escape
|
||||
when the machinery you fled arrived in the same disaster
|
||||
|
||||
The raw notes below remain useful for cases, characters, tones, and comic
|
||||
cruelty. Treat their old "return home" twist as obsolete pressure, not active
|
||||
continuity.
|
||||
|
||||
---
|
||||
|
||||
Potential ideas here, feel free to discuss/deplore/detour in comments.
|
||||
|
||||
|
||||
Protagonist: Catastrophe “Cat” Marrigan, an out of work private investigator, driving a particularly failure-prone space taxi to make ends meet
|
||||
|
||||
|
||||
Overarching plot: Episodic PI cases/random space taxi fares which lead, somewhat circuitously, towards the discovery/revelation that the “exploratory force” had better get used to being “locals”, because the universe is not offering return tickets, and all that follows from that.
|
||||
|
||||
|
||||
What’s Cat’s role in this? And are we going to have alternate endings based on choices made? Ideas, in no particular order:
|
||||
* She's the original founder/inspiration for the frontier renegades who reject corporate rule and live low-tech and tiny to avoid being crushed.
|
||||
* The more noir-esque ending, where the disillusioned protagonist sees the way things are going and kills themselves/goes out in a blaze of explosive glory in a futile attempt to change the course of society.
|
||||
* The out-for-what-you-can-get ending, where she allies with an up and coming power to become a behind the scenes force in future aetheria.
|
||||
* The really unsatisfying but realistic ending, where the corps take over and life just goes on.
|
||||
* I sort of like the idea of heading towards a “you can’t win against the capitalist machine, but it’s worth fighting anyway” theme. Thoughts?
|
||||
|
||||
|
||||
case/fares/conversations that exemplify the different ways people react to the point of no return, so to speak, ie: denial, determination to find a way to get back, culty doomsdays types, the “we’re all doomed so let’s party” people, the ones who see great opportunity without old earth holding them back, the people who are determined to do things better this time around (in the ‘we royally screwed up earth, let’s not do it again” sense), etc.
|
||||
|
||||
|
||||
Accidental AI ship whose buggy programming makes them the worst autopilot ever orrrrr…the inciting incident is that in one of her cases/fares gone wrong Cat gets mind-melded with said slightly buggy AI, and that’s how she finds out all those juicy government secrets. Some of them may be inaccurate (see: bad code/failsafes/sabotage), but there’s no way of knowing which without going digging. Plus AI is a one of a kind experimental prototype, which sets all kinds of interesting people on their tail. Also could give us some fun things to do with ship control and actual gameplay quests.
|
||||
Over the course of the story, one way to show people’s reactions to events could be to have some dialogued characters talking about what they plan to do when they get back home, then use it as a major stress point once the truth is revealed.
|
||||
|
||||
|
||||
Maybe Cat works for a small, kind of terrible PI company, in which case we could get some of our quests from a dispatcher/boss/whatever. I think it’d be a little funny if in true noir fashion, boss was a hard-drinking dude in a trenchcoat, only he’s a really giggly drunk/can’t hold his liquor.
|
||||
First quest for PI company: find where the boss left a random object after drinking himself to the floor. Find out he left a whole-ass ship somewhere in his drunken stupor.
|
||||
Another PI quest: investigate the disappearing foodstuffs of a concerned homeowner and find out that a whole nother person was living in the crawlspace above their home and stealing their food. Decide how to deal with Mr Crawlspace.
|
||||
PI quest: Investigate “ghost” sightings in an old part of a station that’s been shut down. Find out a whole town’s worth of people are living there out of the station’s control slowly siphoning off resources so as not to be noticed. Get some slightly culty vibes from them.
|
||||
A short quest from an old lady who just wants to have someone over for tea. Turns out if you tolerate her long enough she’ll come in handy during a critical time. Could be she’s either rich or well-connected.
|
||||
A botanist looking for his daughter. Turns out instead of some sorta protomolecule conspiracy, she’s just wandered off and is being looked after by a kind lady who’s found her.
|
||||
Get a quest from a mysterious person who is only contacting Cat online, find out eventually the quest giver is some sort of AI who’s either seeking the sweet release of deletion, is looking to find another AI, or is trying to steal some sort of drone so it can go around freely.
|
||||
Possibly a character who we only see as a remote-controlled drone who helps out at times but we never see the person behind the drone because they use the drone as their public avatar or something. Could be someone who might not otherwise get the chance to go out and about in public because of reasons. This does give them a limited sphere of helpfulness since the drone has to be close enough to the controller to work effectively. Could have Cat mistake the drone for the character, believing it’s some sort of self-sufficient AI driven drone and be sad when it “dies” only to meet it again once it’s re-built and realise there’s a person behind it. Bit like the “pay no attention to the man behind the curtain” bit, could call the drone “Oz”.
|
||||
|
||||
|
||||
Cat is tasked with investigating a mysterious and aggressive band of pirates. Upon finding them it is revealed that they are actually a community of rogue AIs who have no option but to loot technology transports in order to keep themselves alive.
|
||||
|
||||
|
||||
Cat stumbles into a friendship with a shady character, earning the attention of the even shadier Space CIA, which recruits her as a potential double agent with instructions to infiltrate the organization.
|
||||
|
||||
|
||||
Cat is sent to investigate a series of mysterious disappearances of ships sent to mine a gas giant’s belt. Locals consider the place haunted. Turns out it is, by a sentient particle system which considers itself the embodied spirit of the planet.
|
||||
|
||||
|
||||
DLC Area: Cat falls afoul of some sort of anomaly which sends her into a parallel universe with an alternative history. Somehow despite a very different timeline and political situation, everyone she meets is a bizarro mirror of the people she knows from her dimension, and somehow the exact same cast of characters are present despite having very different roles (listen, we can’t afford any extra actors so we’re just gonna use the cast we already have)
|
||||
|
||||
|
||||
Finch is destructively scanning recently deceased human brains and then performing extensive virtual surgery on emulated instances of them in order to create and sell packaged “AI” servitors for specific tasks to compete on the market against AGI products from competitors. The Crew manages to rescue one of the program’s victims while still mostly human, before the process is complete, who [joins the party / is installed on Cat’s ship].
|
||||
|
||||
|
||||
Character: Reginald Moneybags III, inheritor of a massive family fortune, is looking to become a patron of the arts in order to impress his old-money friends. Perhaps they’re the one giving the player their ship?
|
||||
|
||||
|
||||
Might could explore some of Cat’s motivations for taking the ship to Elysium in the first place. Was it by choice? Who has she left behind, if anyone? It would put her in a more conflicted position regarding her role as a double-agent.
|
||||
* Seems natural that she would be escaping a maze of debt and obligation, only to end up terrifyingly close to falling into the same trap again. I kinda like this idea of completely dumping her whole life and trying to start over somewhere completely different.
|
||||
* She was caught committing some felony/is railroaded/scapegoated for same/investigated someone way too powerful and was given a choice between prison and joining the colonists.
|
||||
* Or combine both and bring back the idea of debtor’s prisons/workhouses and have the ship to Elysium be offered as a way out
|
||||
* So, was she indentured when she arrived only to recently gain the freedom to make her own way, or has she been doing what she’s currently doing since arrival?
|
||||
|
||||
|
||||
The faceless man: a guy? maybe. Wearing a spacesuit or some other obscuring disguise that pops up a little too often for random chance. Is he a red herring? Or is he 10 red herrings in a spacesuit pretending to be a guy? If he has any dialogue let it just be *splashing noises*.
|
||||
|
||||
|
||||
Cat is sent to investigate an urban legend on one other stations--the simply and slightly erroneously historically named “Ripper Lee” who lives in the air vents and steals children. It actually turns out that the missing kids are just exploring the vents as children will and getting macheted by fans or getting stuck or getting pulled into the inertial compensator. [an act II continuation of this quest sees Cat investigating her suspicions that the kids didn’t necessarily go into the dangerous areas of their own impetus, but were lured there. The third act reveals an alien intelligence--though an animal one--a being of energy or something stranger who finds the particular pitch of childrens’ laughter unbearable. Maybe it destructively interferes with its phase, who knows]
|
||||
|
||||
|
||||
The one perpetual drunk who always needs a lift to a different space bar. Cat can go on
|
||||
a bender with him as a quest, and then have to figure out exactly what the hell happened and why there’s a crash test dummy in the front seat of her taxi.
|
||||
|
||||
|
||||
The person who jumps into Cat’s cab and yells “follow that ship!”. A high octane space chase follows, at the end of which it is revealed they’ve been following the wrong ship, and the Minister of such and such is *very* displeased that he missed his Very Important Meeting for what turns out to be not even an actual assassination attempt.
|
||||
|
||||
|
||||
A carjacking with several different options for resolution
|
||||
|
||||
|
||||
Something strange is going on in the mining operation on PXR 6937, and Cat is sent to find out what: suggestions: the drilling released a substance from the rock that causes hallucinations and paranoia in human brains. Someone is deliberately sabotaging the operation as an environmental protest/act of general rebellion/the mining operation killed someone they cared about and now everyone must feel the same misery
|
||||
|
||||
|
||||
Cat is forced to navigate the pointless bureaucracy of space offices to uncover an embezzlement scheme that conceals something much bigger. What is it? Fuck if I know.
|
||||
* A furniture selling operation that appears to cover some financial wizardry but turns out to be a human trafficking operation where the names of the items are actually those of missing children OW MY HEART
|
||||
* A fiendish plan to utilize the combined computing power of thousands of office servers to...mine future bitcoin? Solve something the megas don’t want solved?
|
||||
|
||||
|
||||
Cat’s taxi breaks down somewhere off the beaten path, so to speak, and she finds herself drawn into an ongoing and very brutal feud between two families.
|
||||
|
||||
|
||||
Have a random cab passenger speak in Nemsk and never explain it.
|
||||
|
||||
|
||||
An entire district of a certain station orbiting a strange ocean planet begin to complain of seeing people who could logically never be there. Cat is asked to do a cursory look into it and remains sceptical until one day she starts seeing a person shaped figure always walking just barely out of her line of sight. We could either go full Lem, or subvert this reference by having the reveal be that the air or water are contaminated with a hallucinogenic compound.
|
||||
|
||||
|
||||
A Captain Nemo like figure who flies his rather unusual ship on the far edges of the system. His purpose is unclear, and his identity more so.
|
||||
|
||||
|
||||
Silica storage ruptured, spilling into common area. Now we’re doing the obligatory beach episode! Sand got into the gears of the docking bay so now everyone has to sit back for an afternoon and do anime tropes while it's getting repaired.
|
||||
|
||||
|
||||
What organization is Cat infiltrating?
|
||||
* People who are trying to go home, even if it costs them the ship or other people’s lives
|
||||
* Believe the Megas are withholding research on the return trip, trapping everyone in Elysium for profit
|
||||
* Name ideas
|
||||
* Home (nice and simple; is it an acronym: H.O.M.E.?)
|
||||
* A nickname could be the “homies” lmao love it “wassup homie?”
|
||||
* Terra
|
||||
* The Terra-rists or Terrists (like a hick would say) Get out no.
|
||||
* Could be a media nickname (and everyone is really tired of explaining that “no, no, we’re the Alliance for Returning Swiftly Earthward, really” really? Butt jokes? I have never pretended to be above them. Neither have I.
|
||||
* The Earth-Bound
|
||||
* The Regressionists
|
||||
* The Returners (sounds like a ya novel title) true
|
||||
* The Retrograde Alliance
|
||||
* Earthers
|
||||
* The Genesis Project (Project Genesis? Sounds better.) Genesis Faction?
|
||||
* Alliance for Returning Swiftly Earthward
|
||||
* Gaia-ists possibly too many vowels
|
||||
* The Followers of Gaia? Get that good greek mythology I kind of like this one. The Gaia Cult lmao
|
||||
* Cult of Gaiains, but also yeah, the GaiaCult
|
||||
* Hades (if we’re going Greek mythology, normal people go to Hades while demigods go to Elysium) Erebus
|
||||
* Has the bonus “evil” connotation
|
||||
* Hadesians Perhaps? Like people from there...Hadesites?
|
||||
* Fits into the sentence “The CIA needs you to infiltrate Hades”
|
||||
* Though if we’re going for the greek theme, Odysseans might be more apropos with the whole ‘returning home’ I like this for greek references
|
||||
|
||||
|
||||
|
||||
|
||||
How do we handle double agent shenanigans?
|
||||
* Cat grows to sympathise with their goals and philosophy, ends up rejecting the CIA
|
||||
* Cat comes clean with her new friends, who begin reciting the typical cliche, only to have someone start to read a page that semi-describes events later in the story, like it's a script.
|
||||
|
|
@ -1,386 +0,0 @@
|
|||
# Aetheria Agent Population Model
|
||||
|
||||
This note explains how a socially dense Aetheria region can feel inhabited
|
||||
without simulating every implied person as a fully persistent agent.
|
||||
|
||||
The short answer is that population and agency are not the same thing.
|
||||
|
||||
A corridor can imply hundreds of thousands or millions of people while only a
|
||||
few dozen individuals need to exist as full long-lived social nodes.
|
||||
|
||||
## Design Goal
|
||||
|
||||
We want a region that feels:
|
||||
|
||||
- crowded
|
||||
- economically alive
|
||||
- politically pressured
|
||||
- socially stratified
|
||||
- full of rumor, labor, traffic, and institutional drag
|
||||
|
||||
We do **not** want:
|
||||
|
||||
- ten thousand expensive little chatbots idling in a warehouse
|
||||
- fake scale achieved by silent cardboard extras
|
||||
- a world where only named characters seem to exist
|
||||
|
||||
So the population model needs to separate:
|
||||
|
||||
- who exists as a persistent social mind
|
||||
- who exists as a grouped social force
|
||||
- who exists only as local scene color
|
||||
- how offscreen population pressure becomes visible
|
||||
|
||||
## Four Population Layers
|
||||
|
||||
The MVP should use four layers.
|
||||
|
||||
### 1. Persistent Agents
|
||||
|
||||
These are the full agents.
|
||||
|
||||
They have:
|
||||
|
||||
- identity
|
||||
- memory
|
||||
- relationships
|
||||
- goals
|
||||
- obligations
|
||||
- changing stance toward institutions and people
|
||||
|
||||
These are the characters who accumulate history and can plausibly remember what
|
||||
happened last week, who lied to them, who saved them, and which institution
|
||||
just humiliated them.
|
||||
|
||||
Examples in an Aetheria content-generation slice:
|
||||
|
||||
- a faction liaison
|
||||
- a ship officer
|
||||
- a labor organizer
|
||||
- a corporate handler
|
||||
- a sanctuary coordinator
|
||||
- a black-market broker
|
||||
- a cutout vendor
|
||||
- a technical specialist with inconvenient loyalties
|
||||
|
||||
These are not the population. They are the pressure points where
|
||||
population-scale systems become legible.
|
||||
|
||||
### 2. Cohorts
|
||||
|
||||
Cohorts are grouped social bodies that matter but do not need full individual
|
||||
cognition.
|
||||
|
||||
They should represent classes of people whose collective condition can change
|
||||
over time.
|
||||
|
||||
Examples:
|
||||
|
||||
- dock labor shifts
|
||||
- convoy deck crews
|
||||
- insurer back-office clerks
|
||||
- sanctuary residents
|
||||
- clinic queues
|
||||
- pirate route crews
|
||||
- private security contractors
|
||||
- debt pilgrims
|
||||
- repair-bay technicians
|
||||
- refugee transit waves
|
||||
|
||||
Each cohort should have state such as:
|
||||
|
||||
- size
|
||||
- location
|
||||
- stress
|
||||
- trust in institutions
|
||||
- rumor exposure
|
||||
- faction sympathy
|
||||
- fatigue
|
||||
- scarcity pressure
|
||||
- protest potential
|
||||
- desertion / mutiny risk
|
||||
|
||||
This is how the world gets to have labor, crowds, queues, shortages, and
|
||||
unrest without pretending every forklift operator needs a hundred memories and
|
||||
a private speech cadence.
|
||||
|
||||
### 3. Ephemeral Scene Actors
|
||||
|
||||
These are temporary people instantiated only when a scene needs bodies,
|
||||
witnesses, clerks, mechanics, medics, bystanders, or small bits of friction.
|
||||
|
||||
They should have:
|
||||
|
||||
- a role
|
||||
- a scene goal
|
||||
- local awareness of current events
|
||||
- a little voice
|
||||
- minimal short-term memory
|
||||
|
||||
They should **not** be treated as deep persistent minds by default.
|
||||
|
||||
Examples:
|
||||
|
||||
- a berth marshal waving traffic through a panic checkpoint
|
||||
- a frightened passenger arguing with a clerk
|
||||
- a dockhand spreading a fresh rumor
|
||||
- a medic triaging one more body than the shift can really handle
|
||||
- a sanctimonious insurer junior who disappears once the scene ends
|
||||
|
||||
If an ephemeral keeps mattering, the system can promote them into a persistent
|
||||
agent later.
|
||||
|
||||
### 4. Ambient Population Pressure
|
||||
|
||||
This is the layer that makes the world feel bigger than the visible cast.
|
||||
|
||||
It is not represented by people at all. It is represented by signals.
|
||||
|
||||
Examples:
|
||||
|
||||
- berth queues growing or shrinking
|
||||
- cargo backlogs
|
||||
- corridor delay maps
|
||||
- sanctuary occupancy
|
||||
- clinic wait times
|
||||
- food prices
|
||||
- insurance repricing
|
||||
- labor slowdown notices
|
||||
- protest bulletins
|
||||
- casualty lists
|
||||
- rescue requests
|
||||
- media chatter
|
||||
- tribunal rulings
|
||||
- route closures
|
||||
|
||||
This is where the implied millions live in practice.
|
||||
|
||||
## How The Layers Interact
|
||||
|
||||
The model should feel like this:
|
||||
|
||||
- ambient pressure changes cohort state
|
||||
- cohort state changes the incentives around persistent agents
|
||||
- persistent agents make decisions that alter ambient pressure
|
||||
- ephemeral actors express the local scene consequences
|
||||
|
||||
That loop lets a single adjudicator decision or pirate rescue cascade outward
|
||||
into visible population-level effects without simulating every single person
|
||||
affected.
|
||||
|
||||
## Why A Few Dozen Persistent Agents Is Enough
|
||||
|
||||
Because the simulation is not trying to create a census. It is trying to create
|
||||
a socially legible machine.
|
||||
|
||||
In a dense Aetheria slice, one persistent agent can sit at the hinge point for
|
||||
a great many people:
|
||||
|
||||
- one dispatcher affects dozens of ships and hundreds of crew
|
||||
- one sanctuary coordinator gates a network under pressure from thousands of vulnerable lives
|
||||
- one corporate official can reshape access, risk, and reputation
|
||||
- one faction delegate can legitimize or delegitimize a compromise
|
||||
- one broker can redirect shadow traffic across many dependent cells
|
||||
|
||||
The persistent cast is therefore best understood as:
|
||||
|
||||
- decision-makers
|
||||
- bottlenecks
|
||||
- brokers
|
||||
- organizers
|
||||
- interpreters of institutional rules
|
||||
- people with unusual leverage over larger flows
|
||||
|
||||
They are the knots in the rope, not every fiber.
|
||||
|
||||
## Cohort Model
|
||||
|
||||
Cohorts should be tractable and opinionated.
|
||||
|
||||
Each cohort record should include:
|
||||
|
||||
- `cohortId`
|
||||
- `type`
|
||||
- `sizeBand`
|
||||
- `region`
|
||||
- `factionAffinity`
|
||||
- `currentPressures`
|
||||
- `resourceDependency`
|
||||
- `confidence`
|
||||
- `grievance`
|
||||
- `discipline`
|
||||
- `mobilizationPotential`
|
||||
- `recentIncidents`
|
||||
|
||||
Useful cohort types for Aetheria story and content generation:
|
||||
|
||||
- dock labor
|
||||
- ship crews
|
||||
- sanctuary residents
|
||||
- corporate workforce
|
||||
- shadow-market crews
|
||||
- repair-yard workers
|
||||
- shadow clinic clients
|
||||
- private security detachments
|
||||
- local merchant bodies
|
||||
- transient passenger pools
|
||||
|
||||
Cohorts do not need rich dialogue. They need believable collective reactions.
|
||||
|
||||
## Ephemeral Actor Rules
|
||||
|
||||
Ephemeral actors should be generated from:
|
||||
|
||||
- scene location
|
||||
- cohort state
|
||||
- current world pressures
|
||||
- factional tone
|
||||
- time of day / shift
|
||||
|
||||
That means the same dock concourse can produce different incidental people
|
||||
depending on whether the corridor is calm, panicking, starving, over-inspected,
|
||||
or politically inflamed.
|
||||
|
||||
Ephemerals should support:
|
||||
|
||||
- local exposition
|
||||
- scene texture
|
||||
- small moral friction
|
||||
- witness testimony
|
||||
- short disputes
|
||||
- visible consequences
|
||||
|
||||
They should expire cheaply unless the world keeps pulling them back into
|
||||
relevance.
|
||||
|
||||
## Promotion Rules
|
||||
|
||||
Some ephemerals should be promoted into persistent agents when:
|
||||
|
||||
- a player or major agent forms an ongoing relationship with them
|
||||
- they become central to repeated incidents
|
||||
- they accumulate leverage or notoriety
|
||||
- they are the surviving witness to something politically explosive
|
||||
- they become the face of a cohort-level shift
|
||||
|
||||
Promotion is how the world grows specific people organically instead of
|
||||
requiring the whole cast to be authored up front.
|
||||
|
||||
## Population Signals That Make A Place Feel Busy
|
||||
|
||||
If the corridor is meant to feel inhabited, the system should constantly
|
||||
surface signs of offscreen life.
|
||||
|
||||
That means scenes, dashboards, and local UI should be fed with things like:
|
||||
|
||||
- arrivals and departures
|
||||
- route advisories
|
||||
- late manifests
|
||||
- dockside rumor digests
|
||||
- occupancy warnings
|
||||
- strike whispers
|
||||
- inspection delays
|
||||
- tribunal alerts
|
||||
- debt-claim notices
|
||||
- casualty and rescue numbers
|
||||
- commodity shocks
|
||||
- public feeds and scandal bursts
|
||||
|
||||
The player does not need to meet everyone. They need to feel surrounded by
|
||||
consequences.
|
||||
|
||||
## Content-Generation Population Recipe
|
||||
|
||||
For the first authoring slice, a sensible starting mix is:
|
||||
|
||||
- **20-40 persistent agents**
|
||||
- **10-25 cohorts**
|
||||
- **0-20 ephemerals per active scene**, generated on demand
|
||||
|
||||
Persistent clusters should be chosen from the story context, but the useful
|
||||
categories are:
|
||||
|
||||
- primary faction representatives
|
||||
- ship or station operators
|
||||
- labor and logistics figures
|
||||
- care, sanctuary, or rights workers
|
||||
- criminal or deniable-economy brokers
|
||||
- corporate cutouts
|
||||
- technical specialists
|
||||
- witnesses with leverage
|
||||
|
||||
Cohorts:
|
||||
|
||||
- dock labor
|
||||
- crews
|
||||
- sanctuary or civilian overflow
|
||||
- clinic queues
|
||||
- administrative or inspection staff
|
||||
- security contractors
|
||||
- shadow-market cells
|
||||
- repair techs
|
||||
- cargo handlers
|
||||
- passengers / transients
|
||||
|
||||
That is enough to make generated scenes feel socially populated without turning
|
||||
the authoring runtime into a screaming furnace.
|
||||
|
||||
## Simulation Tier Mapping
|
||||
|
||||
The population layers should map onto the simulation tiers cleanly.
|
||||
|
||||
### Tier 0
|
||||
|
||||
- dormant persistent agents
|
||||
- slowly changing cohorts
|
||||
- coarse ambient indicators
|
||||
|
||||
### Tier 1
|
||||
|
||||
- active persistent agents making strategic moves
|
||||
- cohorts shifting under pressure
|
||||
- route, labor, and sanctuary state updating
|
||||
|
||||
### Tier 2
|
||||
|
||||
- a handful of persistent agents in scene
|
||||
- relevant ephemerals generated for the local moment
|
||||
- cohort state informing mood, risk, and crowd behavior
|
||||
|
||||
### Tier 3
|
||||
|
||||
- full interactive dialogue for the most relevant persistent agents
|
||||
- ephemerals only as needed for texture or interruption
|
||||
- world pressure visible through feeds, queues, alerts, and consequences
|
||||
|
||||
This keeps expensive cognition focused where it matters.
|
||||
|
||||
## What Not To Do
|
||||
|
||||
Do not:
|
||||
|
||||
- simulate every resident as a full agent
|
||||
- let cohorts become inert statistical wallpaper
|
||||
- use ephemerals as consequence-free decoration
|
||||
- make named agents behave as if the rest of the world does not exist
|
||||
- make ambient scale purely visual with no mechanical pressure
|
||||
|
||||
That path leads to a world that looks crowded and feels dead.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
The model is working if:
|
||||
|
||||
- the player can feel institutional pressure without being told about it in lore prose
|
||||
- small decisions by key agents visibly affect larger populations
|
||||
- cohorts can become restless, relieved, fearful, or politically active
|
||||
- scenes feel crowded when they should
|
||||
- the same corridor can feel different under calm, panic, shortage, scandal, or repression
|
||||
- the named cast feels embedded in a larger society rather than posed in a diorama
|
||||
|
||||
The real test is simple:
|
||||
|
||||
If the player leaves the room, do they still believe thousands of people are
|
||||
having a bad day because of what just happened?
|
||||
|
||||
If yes, the world is inhabited.
|
||||
|
|
@ -1,206 +0,0 @@
|
|||
# Aetheria Content Generation Targets
|
||||
|
||||
This is the first concrete target note for applying Ghostlight to Aetheria.
|
||||
|
||||
The build target is not a standalone corridor simulation or local crisis game
|
||||
slice. That older shape was fertile story terrain, but it is not the current
|
||||
product direction. Do not let it shape the runtime unless the user explicitly
|
||||
revives it.
|
||||
|
||||
Ghostlight should first serve two Aetheria uses:
|
||||
|
||||
1. content generation inside the **Call of the Void** slice described in the
|
||||
`AetheriaLore` repository
|
||||
2. dialogue scaffolding and procedural drama for narrated stories across the
|
||||
Aetheria timeline
|
||||
|
||||
The goal is a story and content-generation organ: a machine that can hold
|
||||
characters, factions, pressures, memories, masks, and conflict arcs in explicit
|
||||
state, then project that state into usable scenes, dialogue beats, outlines,
|
||||
and dramatic complications.
|
||||
|
||||
That organ must also hold tone. Aetheria should not be flattened into one dry
|
||||
technical crisis register. The setting can carry Adams/Pratchett-style
|
||||
wit-with-stakes, domestic comedy, noir suspicion, horror, wonder, dour poetry,
|
||||
romance, workplace absurdity, and procedural systems prose, often inside the
|
||||
same broad canon. The common rule is not "be grim." The common rule is that
|
||||
tone must reveal character, setting, and consequence.
|
||||
|
||||
## Why This Helps Dialogue
|
||||
|
||||
Ghostlight should not ask one LLM prompt to "write good character drama" from
|
||||
vibes. That path produces clever paste: fluent, energetic, and usually a little
|
||||
dead behind the eyes.
|
||||
|
||||
The useful split is:
|
||||
|
||||
- Ghostlight builds the drama, narrative pressure, scene setup, and state.
|
||||
- The dialogue renderer gives each character a constrained local context.
|
||||
- The character speaks from who they are, what they have endured, what they
|
||||
want, what they are feeling, what they are hiding, and how they perceive the
|
||||
person in front of them.
|
||||
|
||||
This does not mean every line should sound like emergency procedure. Characters
|
||||
also speak from routine, fatigue, affection, boredom, private jokes, little
|
||||
status games, cultural rituals, and ordinary work. Those are not filler; they
|
||||
are what make later pressure legible.
|
||||
|
||||
That means dialogue prompts should be character-local rather than omniscient.
|
||||
They should include only the state that character plausibly has access to:
|
||||
|
||||
- identity and role
|
||||
- current mood and pressure
|
||||
- relevant memories
|
||||
- active goals
|
||||
- relationship stance toward the listener
|
||||
- perceived stakes
|
||||
- cultural or factional scripts
|
||||
- mask, concealment, or presentation strategy
|
||||
|
||||
This lets characters breathe and act inside the scene instead of serving as
|
||||
mouthpieces for a single plot-generating agent. The agent can shape the drama.
|
||||
The character projection should preserve subjectivity.
|
||||
|
||||
## Primary Use: Call Of The Void Content
|
||||
|
||||
Call of the Void should be treated as the first consumer context for Ghostlight,
|
||||
not as an excuse to build a full social MMO simulation immediately.
|
||||
|
||||
The local source of truth is:
|
||||
|
||||
- `E:\Projects\AetheriaLore\Aetheria\Game Design\Call of the Void.md`
|
||||
- `docs/aetheria/call-of-the-void-brainstorm.md` for raw imported brainstorm
|
||||
material
|
||||
|
||||
The slice is a story-first Aetheria scope cut about Catastrophe "Cat" Marrigan:
|
||||
an out-of-work private investigator making ends meet with a failure-prone space
|
||||
taxi inside the aftermath of Sol's sequestration into Elysium.
|
||||
|
||||
The older brainstorm frames the core twist as a colony fleet or exploratory
|
||||
force discovering that nobody is going home. That premise is obsolete. Current
|
||||
Aetheria canon says the FTL trigger displaced the whole Solar system into the
|
||||
sealed spacetime domain later called Elysium. Home did not stay behind. Home,
|
||||
with every corporate bloc, labor regime, upload abuse, political failure, and
|
||||
unresolved compromise, came through too.
|
||||
|
||||
So the active Call of the Void pressure should be refactored from "there is no
|
||||
return ticket" into something meaner and more useful: there is no outside, no
|
||||
clean restart, and no frontier innocent of Sol's history. Cat can still try to
|
||||
outrun debt, coercion, legal pressure, or institutional rot, but Elysium proves
|
||||
that escape exported the machinery instead of dissolving it.
|
||||
|
||||
Its useful shape is authored density:
|
||||
|
||||
- episodic investigations
|
||||
- taxi fares as social x-rays
|
||||
- station districts
|
||||
- recurring characters
|
||||
- faction contacts
|
||||
- campaign consequence
|
||||
- the social aftermath of realizing Sol's old failures are now Elysium's local
|
||||
weather
|
||||
|
||||
Ghostlight should help generate and manage:
|
||||
|
||||
- character dossiers
|
||||
- faction stances
|
||||
- relationship tensions
|
||||
- tonal mode and style targets
|
||||
- ordinary-life texture before conflict
|
||||
- scene premises
|
||||
- dialogue scaffolds
|
||||
- procedural complications
|
||||
- continuity-aware reactions
|
||||
- memory-shaped follow-up scenes
|
||||
- local cultural and institutional pressure
|
||||
- case premises and clue chains
|
||||
- passenger encounters that reveal denial, opportunism, homesickness, fear, or reckless celebration
|
||||
- continuity pressure from the sealed-domain premise
|
||||
- cases that expose how old Sol institutions mutate when there is no larger
|
||||
outside to appeal to
|
||||
|
||||
The important thing is that generated content should emerge from structured
|
||||
state instead of a prompt asking for "dramatic dialogue" and hoping the machine
|
||||
does not start juggling knives in the pantry.
|
||||
|
||||
## Secondary Use: Narrated Timeline Stories
|
||||
|
||||
Ghostlight should also support story generation across Aetheria's broader
|
||||
timeline.
|
||||
|
||||
The existing Aetheria stories are tonal evidence. `When We Get Home` makes the
|
||||
setting legible through jokes, domestic longing, debt, delays, and the slow
|
||||
recognition that home has moved. `Rain` makes the setting legible through
|
||||
ritual, memory, sensory repetition, and a patient reversal from wonder to
|
||||
horror. Future fixtures should be able to choose similarly varied modes instead
|
||||
of filing every event into one stern technical drawer.
|
||||
|
||||
That means it should be able to produce:
|
||||
|
||||
- scene outlines
|
||||
- relationship escalation chains
|
||||
- factional pressure maps
|
||||
- alternate dialogue passes
|
||||
- conflict beats
|
||||
- character-specific reactions to historical events
|
||||
- continuity summaries for later story work
|
||||
|
||||
This does not require every story to run as a live simulation. Some outputs can
|
||||
be generated as authoring scaffolds: structured suggestions, scene cards,
|
||||
argument maps, motive webs, and dialogue skeletons for a human author to shape.
|
||||
|
||||
## What Ghostlight Should Build For This
|
||||
|
||||
The first useful organs are:
|
||||
|
||||
1. canonical agent state schema
|
||||
2. perceived-state overlay schema
|
||||
3. relationship and faction stance records
|
||||
4. event and scene records
|
||||
5. prompt projection renderer for content generation
|
||||
6. coordinator/story runtime driven by goals, values, pressure, memory,
|
||||
continuity, branch flags, and author constraints
|
||||
7. branch compiler that turns reviewed branch plans into Ink, sidecars,
|
||||
callbacks, consequence variables, and visual prompt handles
|
||||
8. IF artifact reviewer that catches decorative choices, fake folds, unused
|
||||
variables, and missing consequence callbacks
|
||||
9. dialogue scaffold renderer that preserves stance, mask, and relationship
|
||||
asymmetry
|
||||
10. character-local dialogue context packs that restrict each generated voice to
|
||||
what that character knows, feels, wants, fears, and perceives
|
||||
|
||||
The output should be inspectable. The author should be able to see why the
|
||||
machine thinks a character would deflect, confess, threaten, soften, bargain,
|
||||
lie, leave, spend a resource, conceal evidence, or make a choice that changes a
|
||||
later scene.
|
||||
|
||||
## What This Is Not
|
||||
|
||||
This is not yet:
|
||||
|
||||
- a standalone corridor crisis game
|
||||
- a persistent corridor simulator
|
||||
- a full MMO social runtime
|
||||
- a requirement to simulate every offscreen person
|
||||
- a replacement for authorial judgment
|
||||
|
||||
Ghostlight is an authoring and social-state engine first. The live game-shaped
|
||||
organs can come later if they earn the trouble.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
The first implementation is useful if it can:
|
||||
|
||||
- keep character state distinct from prompt prose
|
||||
- render dialogue from character-local context rather than global plot vibes
|
||||
- generate branching dialogue/action scaffolds that reflect relationship history
|
||||
- create procedural drama from incompatible goals instead of random hostility
|
||||
- preserve cultural and factional pressure without making clones
|
||||
- produce story beats a human author can actually use
|
||||
- make branch choices matter through later affordances, callbacks, visual
|
||||
modifiers, costs, risks, and endings
|
||||
- explain its choices in state terms rather than vibes in a rented coat
|
||||
|
||||
The real test is not whether the system can improvise a cool scene once. The
|
||||
real test is whether it can help generate the tenth scene without forgetting
|
||||
what the first nine did to everyone involved.
|
||||
|
|
@ -1,386 +0,0 @@
|
|||
# Aetheria Lore Grounding Architecture
|
||||
|
||||
Ghostlight needs three different Aetheria modes:
|
||||
|
||||
- grounded historical training
|
||||
- post-Rupture procedural future training
|
||||
- procedural Elysium generation
|
||||
|
||||
Do not blur them. That is how a useful mystery becomes a filing cabinet full of
|
||||
branch facts with the labels scraped off.
|
||||
|
||||
## Core Boundary
|
||||
|
||||
Planned Aetheria gameplay takes place in Elysium. What happens there is
|
||||
intentionally not fully fixed in the lore vault because discovering and shaping
|
||||
that history is part of the games themselves.
|
||||
|
||||
The Aetheria timeline begins when humanity is transplanted into Elysium. It can
|
||||
restart when humanity destroys itself again through negligence, greed,
|
||||
institutional capture, civilizational momentum, and the familiar psychological
|
||||
trash fire wearing a crown.
|
||||
|
||||
That means Elysium-era gameplay scenes are not the best first source for
|
||||
grounded Ghostlight training data. They are downstream procedural territory.
|
||||
|
||||
For grounded training data, Ghostlight should start from the authored historical
|
||||
archive: late Sol, Pre-Elysium institutions, factional flashpoints, personhood
|
||||
conflicts, labor regimes, political collapse, and the FTL trigger.
|
||||
|
||||
That does not mean Ghostlight should avoid Elysium futures. The trained models
|
||||
will need to handle post-Rupture concepts: Aether, pseudospace, temporal
|
||||
nonlinearity, spirits, altered substrates, necrotech, mutable bodies, and all
|
||||
the new ways power learns to monetize miracles while pretending it found ethics
|
||||
in a drawer.
|
||||
|
||||
Elysium future data belongs in a separate lane because its canon is conditional,
|
||||
not because it is fake. Sol history is a single artifact. Elysium history is a
|
||||
branching state-space: every possible future is canon inside the branch whose
|
||||
prior conditions produced it. A generated future must therefore carry branch
|
||||
lineage, dependencies, and local truth boundaries instead of being treated as
|
||||
scratch material waiting for permission to matter.
|
||||
|
||||
## Revised Architecture
|
||||
|
||||
Grounded historical training flow:
|
||||
|
||||
```text
|
||||
Aetheria authored historical lore
|
||||
-> lore grounding digest
|
||||
-> historical scene fixture
|
||||
-> agent-state fixture
|
||||
-> speaker-local input slice
|
||||
-> projection artifact
|
||||
-> prompt_text
|
||||
-> generated dialogue or drama
|
||||
-> reviewed training example
|
||||
```
|
||||
|
||||
Procedural Elysium flow:
|
||||
|
||||
```text
|
||||
grounded pre-Elysium priors
|
||||
-> procedural Elysium branch state
|
||||
-> Ghostlight agents and institutions
|
||||
-> projected dialogue and drama
|
||||
-> simulated branch history
|
||||
-> branch-local canonical state
|
||||
```
|
||||
|
||||
Post-Rupture future training flow:
|
||||
|
||||
```text
|
||||
authored Elysium concepts and constraints
|
||||
-> possible future premise
|
||||
-> procedural branch fixture
|
||||
-> coordinator plan and projected local contexts
|
||||
-> generated action, dialogue, and event consequences
|
||||
-> reviewed future-branch training artifact
|
||||
-> branch-local canonical history indexed by lineage
|
||||
```
|
||||
|
||||
The first flow teaches the machine what grounded social pressure looks like.
|
||||
The second and third flows teach it how those pressures mutate under Elysium's
|
||||
stranger conditions while keeping branch-local canon separate from the Sol
|
||||
artifact and from sibling Elysium branches.
|
||||
|
||||
## Source Priority
|
||||
|
||||
For projection training, prefer sources in this order:
|
||||
|
||||
1. Authored historical lore with explicit institutional, social, and political
|
||||
context.
|
||||
2. Grounded transition material around the FTL trigger and displacement.
|
||||
3. Historical fiction or scene fragments that remain canon-compatible.
|
||||
4. Authored post-Elysium concept documents that define altered conditions,
|
||||
technologies, species, metaphysics, or social pressures.
|
||||
5. Elysium-era procedural fixtures marked with explicit branch lineage and
|
||||
conditional truth boundaries.
|
||||
|
||||
Call of the Void and Cat/Oz can remain useful for testing projection mechanics,
|
||||
speaker-local boundaries, and prompt shape. They should not be treated as the
|
||||
primary linear-history training corpus because their facts belong to Elysium's
|
||||
conditional branch space.
|
||||
|
||||
## Historical Flashpoint Candidates
|
||||
|
||||
Good first fixtures should come from parts of Aetheria that are already
|
||||
pressure-dense and source-grounded:
|
||||
|
||||
- contract citizenship and debt regimes
|
||||
- labor-system enforcement and infrastructure dependency
|
||||
- upload abuse and contested personhood
|
||||
- uplift or nonhuman personhood conflicts
|
||||
- algorithmic governance and predictive policing
|
||||
- corporate blocs turning infrastructure into leverage
|
||||
- the FTL program as a political escape hatch from Sol's failure
|
||||
- the moment around the FTL trigger, where institutions and people discover
|
||||
that escape did not mean absolution
|
||||
|
||||
The best scenes are small, local, and socially loaded:
|
||||
|
||||
- a worker negotiating dangerous contract terms
|
||||
- an upload arguing over continuity, ownership, or service denial
|
||||
- a technician asked to sign off on an unsafe FTL dependency
|
||||
- a debtor deciding whether help is relief or capture
|
||||
- an institutional agent performing compassion while enforcing a machine
|
||||
- a family, crew, or workplace discovering that political collapse has become
|
||||
intimate
|
||||
|
||||
## Lore Grounding Digests
|
||||
|
||||
Before producing training projection examples from a historical flashpoint,
|
||||
create a lore grounding digest.
|
||||
|
||||
The digest format is defined in:
|
||||
|
||||
- `docs/architecture/lore-grounding-digest-format.md`
|
||||
- `schemas/lore-grounding-digest.schema.json`
|
||||
- `examples/lore-grounding/historical-flashpoint.template.json`
|
||||
|
||||
A digest should include:
|
||||
|
||||
- source files and relevant line or section notes
|
||||
- time period
|
||||
- location or institution
|
||||
- involved factions, classes, and roles
|
||||
- material constraints
|
||||
- local norms, taboos, and scripts
|
||||
- vocabulary or speech-register cues
|
||||
- what each speaker plausibly knows
|
||||
- what each speaker misreads
|
||||
- author-only context that must not enter prompt text
|
||||
- open questions needing lore elaboration
|
||||
|
||||
It should also include cultural and factional pressure fields:
|
||||
|
||||
- internal virtues
|
||||
- internal shames
|
||||
- prestige markers
|
||||
- taboo triggers
|
||||
- emotional display norms
|
||||
- trust defaults
|
||||
- speech register cues
|
||||
- outsider stereotypes
|
||||
- factional misread patterns
|
||||
- role obligations and failure modes
|
||||
|
||||
The digest is not a replacement for the lore vault. It is a compiled working
|
||||
slice for one fixture, so projection examples can cite grounded pressure
|
||||
without dragging the whole archive into every prompt.
|
||||
|
||||
## Fixture Types
|
||||
|
||||
Use explicit fixture status:
|
||||
|
||||
- `historical_grounded`
|
||||
- `transition_grounded`
|
||||
- `procedural_branch`
|
||||
- `future_branch`
|
||||
- `branch_lineage_canon`
|
||||
|
||||
`historical_grounded` and `transition_grounded` examples can become primary
|
||||
single-history training data.
|
||||
|
||||
`procedural_branch` and `future_branch` examples are canonical inside their own
|
||||
branch lineage. They are useful for testing, generation, responder training,
|
||||
coordinator training, and post-Rupture concept coverage, but must not be treated
|
||||
as facts that also apply to sibling branches or to the Sol artifact.
|
||||
|
||||
`branch_lineage_canon` examples are reviewed branch histories whose lineage,
|
||||
prior conditions, and local state have been made explicit enough to serve as
|
||||
stable training or gameplay continuity.
|
||||
|
||||
Use `future_branch` when the point of the fixture is to explore possible
|
||||
post-Rupture Elysium outcomes and weird-concept behavior. Use
|
||||
`procedural_branch` for ordinary gameplay-era branch fixtures whose main job is
|
||||
scene/runtime testing.
|
||||
|
||||
## Projection Rules
|
||||
|
||||
Grounded projection examples should prove:
|
||||
|
||||
- the prompt text is self-sufficient for the dialogue model
|
||||
- setting and institutional context are speaker-local
|
||||
- historical pressure appears as lived constraint, not exposition paste
|
||||
- the speaker does not know author-only future outcomes
|
||||
- the generated behavior follows from state, culture, memory, relationship, and
|
||||
material pressure
|
||||
|
||||
If the lore source is too vague for a convincing scene, stop and elaborate the
|
||||
lore slice first. Do not train the model to pave over missing social reality
|
||||
with confident fog.
|
||||
|
||||
## Post-Rupture Future Branches
|
||||
|
||||
Future-branch generation should be deliberate, not a slush pile of glittering
|
||||
possibilities with no handles.
|
||||
|
||||
Each possible future fixture should declare:
|
||||
|
||||
- which authored post-Elysium concepts it exercises
|
||||
- which facts are fixed source constraints
|
||||
- which facts are generated branch assumptions
|
||||
- which branch facts are local to this possible future
|
||||
- which prior conditions make this branch true
|
||||
- which facts belong to this branch lineage and not to sibling branches
|
||||
- which generated facts must not leak back into single-history Sol training
|
||||
- what new social pressure the weird concept creates
|
||||
- what old pre-Elysium pressure is returning in a new costume
|
||||
|
||||
## Attractors, Rails, And Injections
|
||||
|
||||
Elysium is a branching state-space, not an undifferentiated fog. Some events may
|
||||
happen in most branches because the pressures almost always produce them. Some
|
||||
events may be authored constraints because the game needs a stable enough world
|
||||
to design around. Some events may be injected by quests or scenario logic when
|
||||
an active branch reaches a compatible shape.
|
||||
|
||||
Use separate labels:
|
||||
|
||||
- `branch_attractor`
|
||||
- `fated_event`
|
||||
- `tech_order_constraint`
|
||||
- `quest_injection`
|
||||
- `branch_local_event`
|
||||
|
||||
`branch_attractor` means an event is highly likely under broad pressures but
|
||||
not absolutely forced. The founding of a faction born inside Elysium may start
|
||||
here if the social, economic, metaphysical, and institutional gradients make it
|
||||
show up again and again with local variation.
|
||||
|
||||
`fated_event` means the authorial hand is on the scale. The event should occur
|
||||
unless the branch has explicitly disabled it. Use this sparingly, because too
|
||||
much fate turns the quantum superposition back into a hallway with rude
|
||||
wallpaper.
|
||||
|
||||
`tech_order_constraint` means discovery order matters. Some technologies,
|
||||
metaphysical practices, industrial dependencies, or social adaptations should be
|
||||
unavailable until their prerequisites exist. This is not merely lore flavor; it
|
||||
is a runtime gate for what quests, economies, factions, and character actions
|
||||
can plausibly do.
|
||||
|
||||
`quest_injection` means authored content enters the active timeline at a branch
|
||||
point. A quest may require prerequisites, forbid certain prior states, create
|
||||
new local facts, or push an attractor toward one concrete manifestation.
|
||||
|
||||
`branch_local_event` means the event is canonical inside its lineage but has no
|
||||
special pull across other branches.
|
||||
|
||||
Future branch fixtures should record:
|
||||
|
||||
- event type from the labels above
|
||||
- probability or pull strength, for attractors
|
||||
- required branch prerequisites
|
||||
- excluded branch states
|
||||
- technology prerequisites
|
||||
- faction or institution prerequisites
|
||||
- quest ids or content hooks, if injected
|
||||
- local variation allowed
|
||||
- hard facts created
|
||||
- branch facts that remain conditional
|
||||
|
||||
Future branch fixtures should also emit technology and item manifest candidates
|
||||
when exploration changes the material economy:
|
||||
|
||||
- new item families
|
||||
- new item variants
|
||||
- component and subassembly breakdowns
|
||||
- assembly upgrade surfaces
|
||||
- technology prerequisites
|
||||
- faction or manufacturer access
|
||||
- supply bottlenecks
|
||||
- salvage, counterfeit, maintenance, and substitution paths
|
||||
- quest hooks created by scarcity or discovery
|
||||
|
||||
See `docs/architecture/technology-item-manifest-plan.md`.
|
||||
|
||||
Factions born in Elysium should be modeled through this lens. Adrasteia or Miss
|
||||
Terri's Sugarrific Snack Company can be represented as pressure-born attractors,
|
||||
fated institutions, quest-injected branch content, or some combination. The
|
||||
point is not to decide that once in prose and hope the game remembers. The point
|
||||
is to make the founding mechanism explicit enough for Ghostlight, the active
|
||||
timeline, and later game systems to agree on what kind of inevitability they are
|
||||
dealing with.
|
||||
|
||||
Good future branches should stress the model with conditions it cannot learn
|
||||
from late-Sol history alone:
|
||||
|
||||
- Aether as infrastructure, ecology, religion, weapon, medicine, or status good
|
||||
- pseudospace as transit, concealment, displacement, exile, or labor terrain
|
||||
- temporal nonlinearity as memory, debt, evidence, grief, insurance, or law
|
||||
- spirits as persons, tools, contaminants, witnesses, ancestors, or frauds
|
||||
- necrotech as care, extraction, continuity, punishment, or labor capture
|
||||
- mutable bodies and species boundaries as liberation, class marker, liability,
|
||||
taboo, or supply chain
|
||||
- post-Rupture institutions rebuilding old hierarchies with new miracles
|
||||
|
||||
These branches should feed:
|
||||
|
||||
- Aetheria responder training, so the model learns post-Rupture assumptions
|
||||
- coordinator training, so the story runtime can handle weird scene premises
|
||||
- appraiser and relationship training, so characters misread new phenomena in
|
||||
socially grounded ways
|
||||
- institution/faction/consumer training, so economic and political behavior can
|
||||
respond to miracles as markets, threats, privileges, and dependencies
|
||||
|
||||
Keep source provenance strict. A branch can invent a local future, but it should
|
||||
say what it invented and what branch conditions make it true. The machine may
|
||||
generate futures under supervision; it does not get to erase the branch index
|
||||
while everyone is looking at the shiny ghost.
|
||||
|
||||
## Operating Doctrine
|
||||
|
||||
Ghostlight will generate a lot of Aetheria dialogue, branching scenes, and
|
||||
training data. That means it will also be the first tool to stress-test many
|
||||
timeline details at room scale. Treat that as part of the job, not an
|
||||
exception.
|
||||
|
||||
Before writing an Aetheria scene or generating data from one, do a source pass:
|
||||
|
||||
1. Check AetheriaLore for the factions, social movements, institutions, species
|
||||
or body types, location, and time period present in the scene.
|
||||
2. Extract the material constraints that shape behavior: habitat form,
|
||||
infrastructure, law, money, route access, labor pressure, class position,
|
||||
surveillance, body affordances, communication channels, and local failure
|
||||
costs.
|
||||
3. Distill those details into the lore grounding digest, agent-state fixture,
|
||||
projected local context, or Ink sidecar before prompting a response model.
|
||||
4. Keep the prompt character-local. Source grounding tells Ghostlight what the
|
||||
room and culture make possible; it does not grant characters omniscience.
|
||||
5. If the lore does not support a needed concrete detail, do not fill the gap
|
||||
with confident furniture. Mark the gap, elaborate the AetheriaLore source,
|
||||
then regenerate or revise the Ghostlight artifact from the patched source.
|
||||
6. Commit the lore patch in AetheriaLore and cite the source path from
|
||||
Ghostlight so the training artifact has a clean provenance trail.
|
||||
|
||||
The output goal is not only clean training data. It is also playable, readable,
|
||||
fun interactive fiction that discovers where the worldbuilding is thin by
|
||||
leaning on it. If Ghostlight needs to know what a sanctuary intake board looks
|
||||
like to a cetacean Navigator, that is not trivia. That is worldbuilding under
|
||||
load.
|
||||
|
||||
## Lore Gap Policy
|
||||
|
||||
When a gap appears, classify it before writing:
|
||||
|
||||
- `blocking`: generation would produce wrong bodies, wrong institutions, wrong
|
||||
authority, or wrong material constraints without the missing detail.
|
||||
- `safe_to_stub`: the scene can proceed with a clearly marked provisional
|
||||
detail that does not affect canon-critical behavior.
|
||||
- `defer`: the detail is decorative and should not enter the prompt yet.
|
||||
|
||||
Blocking gaps should be patched in AetheriaLore first. Safe stubs must be
|
||||
tracked in the digest or sidecar and revisited before promotion. Deferred gaps
|
||||
should stay out of prompt text so they do not harden by accident.
|
||||
|
||||
## Training Boundary
|
||||
|
||||
Ground the machine in the past, then generate possible futures under
|
||||
supervision with branch labels sharp enough that infinity does not show up
|
||||
wearing one nametag.
|
||||
|
||||
Historical fixtures teach single-history social pressure. Future-branch
|
||||
fixtures teach conditional canon. Cat/Oz-style procedural fixtures teach
|
||||
runtime mechanics. Those lanes can compare against each other, but they should
|
||||
not collapse into one unlabeled corpus.
|
||||
|
|
@ -1,586 +0,0 @@
|
|||
# Aetheria Persistent Agent Architecture
|
||||
|
||||
This is the companion document to
|
||||
[Things To Steal](../research/things-to-steal.md).
|
||||
|
||||
For the first concrete target, see
|
||||
[Aetheria Content Generation Targets](./aetheria-content-generation-targets.md).
|
||||
|
||||
For the scaling model that explains how a crowded region can be simulated with
|
||||
a relatively small persistent cast, see
|
||||
[Aetheria Agent Population Model](./aetheria-agent-population-model.md).
|
||||
|
||||
For the deeper note on how culture, personality distributions, memory, and
|
||||
perceived state should project into prompts, see
|
||||
[Agent State Distributions And Prompt Projection](./agent-state-distributions-and-prompt-projection.md).
|
||||
|
||||
That document is the theft list. This one is the machine we are trying to build
|
||||
out of the stolen parts.
|
||||
|
||||
The long-horizon target is a persistent world full of in-universe agents who:
|
||||
|
||||
- remember what happened
|
||||
- want different things
|
||||
- collide with each other because their values and interests do not line up
|
||||
- interact with humans without collapsing into quest dispensers or therapy plushies
|
||||
|
||||
The important bit is that the world should not exist solely to flatter the
|
||||
player. Humans are participants, not the sun around which every synthetic
|
||||
little bastard must orbit.
|
||||
|
||||
## Design Goals
|
||||
|
||||
The near-term target is content generation and story scaffolding, not a
|
||||
standalone game slice. Ghostlight should first help Aetheria authors generate
|
||||
dialogue, scene pressure, procedural drama, and character reactions from
|
||||
explicit social state.
|
||||
|
||||
We want a world where:
|
||||
|
||||
- agents persist across sessions
|
||||
- relationships evolve over time
|
||||
- off-screen life continues
|
||||
- conflict emerges from incentives
|
||||
- the same character remains recognizably themselves under pressure
|
||||
- human intervention can redirect events without erasing everyone else's agency
|
||||
|
||||
We do **not** want:
|
||||
|
||||
- a thin layer of LLM chat stapled onto static NPC behavior
|
||||
- every character becoming a slightly differently dressed assistant
|
||||
- a world that only animates when a human looks at it
|
||||
- fake drama generated by random hostility instead of meaningful friction
|
||||
|
||||
## World Model
|
||||
|
||||
The world should be represented as a persistent simulation with five main state
|
||||
layers:
|
||||
|
||||
1. **Canonical world state**
|
||||
- locations
|
||||
- factions
|
||||
- resources
|
||||
- institutions
|
||||
- active conflicts
|
||||
- world-level events
|
||||
2. **Agent identity state**
|
||||
- role
|
||||
- background
|
||||
- loyalties
|
||||
- values
|
||||
- aversions
|
||||
- appetites
|
||||
- speech style
|
||||
3. **Agent dynamic state**
|
||||
- current location
|
||||
- current obligations
|
||||
- current mood / pressure / fatigue
|
||||
- current social exposure
|
||||
- short-horizon plans
|
||||
4. **Relationship state**
|
||||
- trust
|
||||
- respect
|
||||
- resentment
|
||||
- fascination
|
||||
- fear
|
||||
- obligation
|
||||
- debt
|
||||
5. **Memory state**
|
||||
- episodic incidents
|
||||
- scene summaries
|
||||
- semantic profile conclusions
|
||||
- relationship summaries
|
||||
- active inferred goals and concerns
|
||||
|
||||
## Agent Stack
|
||||
|
||||
Each agent should be built from explicit layers instead of one giant persona
|
||||
prompt.
|
||||
|
||||
### 1. Identity Layer
|
||||
|
||||
Relatively stable facts:
|
||||
|
||||
- who they are
|
||||
- what they believe
|
||||
- what they want to be seen as
|
||||
- what they cannot tolerate
|
||||
- what they are drawn toward
|
||||
|
||||
This should be treated as durable but revisable. Identity is not immutable, but
|
||||
neither should it drift because the model got bored.
|
||||
|
||||
### 2. Relationship Layer
|
||||
|
||||
Every important other should have a relationship record, including:
|
||||
|
||||
- another agent
|
||||
- a human player
|
||||
- a group or faction
|
||||
|
||||
The record should track:
|
||||
|
||||
- trust
|
||||
- respect
|
||||
- resentment
|
||||
- dependence
|
||||
- attraction or fascination where relevant
|
||||
- leverage
|
||||
- recent unresolved incidents
|
||||
|
||||
The point is not to create one friendship slider like a life sim from 2004. The
|
||||
point is to track the actual shape of a social bond.
|
||||
|
||||
### 3. Goal Layer
|
||||
|
||||
Agents need active goals, not just a biography.
|
||||
|
||||
Goals should be:
|
||||
|
||||
- private or public
|
||||
- immediate or long-range
|
||||
- strategic or emotional
|
||||
- revisable in response to new information
|
||||
|
||||
A goal object should include:
|
||||
|
||||
- description
|
||||
- priority
|
||||
- time horizon
|
||||
- emotional stake
|
||||
- blockers
|
||||
- dependencies
|
||||
- known rivals
|
||||
- likely next moves
|
||||
|
||||
### 4. Memory Layer
|
||||
|
||||
Memory should be split into:
|
||||
|
||||
- **working memory**
|
||||
- current scene
|
||||
- immediate context
|
||||
- active inferences
|
||||
- **episodic memory**
|
||||
- timestamped incidents
|
||||
- where, who, what, consequence
|
||||
- **scene memory**
|
||||
- distilled summaries of interactions
|
||||
- **semantic memory**
|
||||
- generalized beliefs about self, others, and world
|
||||
- **relationship memory**
|
||||
- remembered incidents and inferred patterns tied to a specific other
|
||||
|
||||
This is where the current interaction-memory work is directly useful.
|
||||
|
||||
### 5. Theory-of-Mind Layer
|
||||
|
||||
Each active interaction should have a lightweight scratchpad:
|
||||
|
||||
- what the other party likely wants
|
||||
- what they fear
|
||||
- what they think is happening
|
||||
- what they are trying to get from me
|
||||
- how confident we are about those guesses
|
||||
|
||||
This should be ephemeral and revisable. First impressions are useful, but
|
||||
worshipping them is how systems become smug and stupid.
|
||||
|
||||
## Simulation Tiers
|
||||
|
||||
A persistent world cannot run every agent at full dialogue fidelity all the
|
||||
time unless we want to immolate the hardware.
|
||||
|
||||
So the world should run on tiers.
|
||||
|
||||
### Tier 0: Dormant Persistence
|
||||
|
||||
For agents far away from any relevant action:
|
||||
|
||||
- maintain location, obligations, and long-horizon goals
|
||||
- advance the clock coarsely
|
||||
- update minimal state
|
||||
|
||||
### Tier 1: Strategic Tick
|
||||
|
||||
For agents in active regions but not in a live scene:
|
||||
|
||||
- update goals
|
||||
- choose broad actions
|
||||
- move, trade, coordinate, prepare, avoid, investigate
|
||||
- write compact event records
|
||||
|
||||
### Tier 2: Scene Simulation
|
||||
|
||||
For agents inside a socially or narratively relevant scene:
|
||||
|
||||
- model who is present
|
||||
- what each actor knows
|
||||
- what each actor wants
|
||||
- who is hiding what
|
||||
- who is under pressure
|
||||
- resolve short exchanges, negotiations, or conflicts
|
||||
|
||||
### Tier 3: Live Interactive Presence
|
||||
|
||||
For agents currently engaged with a human or central dramatic scene:
|
||||
|
||||
- full dialogue generation
|
||||
- richer perception and recall
|
||||
- fine-grained ToM updates
|
||||
- explicit action selection tied to the live interaction
|
||||
|
||||
This tiered model is how we get a world that feels alive without paying for a
|
||||
small national grid.
|
||||
|
||||
## Core Loop
|
||||
|
||||
At a high level, each active agent loop looks like:
|
||||
|
||||
1. Perceive
|
||||
- scene state
|
||||
- nearby actors
|
||||
- new events
|
||||
- human input
|
||||
2. Interpret
|
||||
- what matters
|
||||
- what changed
|
||||
- what this implies for goals and relationships
|
||||
3. Update internal state
|
||||
- working memory
|
||||
- ToM scratchpad
|
||||
- emotional pressure / stance
|
||||
4. Retrieve memory
|
||||
- relevant episodes
|
||||
- relationship incidents
|
||||
- semantic conclusions
|
||||
5. Evaluate goals
|
||||
- what is threatened
|
||||
- what is newly possible
|
||||
- what should take precedence now
|
||||
6. Choose action
|
||||
- speech
|
||||
- movement
|
||||
- trade
|
||||
- alliance
|
||||
- concealment
|
||||
- refusal
|
||||
- violence
|
||||
- delay
|
||||
7. Commit result
|
||||
- world state update
|
||||
- event log
|
||||
- memory writes
|
||||
8. Reflect later
|
||||
- consolidate incidents
|
||||
- revise relationship state
|
||||
- revise semantic beliefs
|
||||
- revise goals
|
||||
|
||||
## Relationship Engine
|
||||
|
||||
The relationship system should be treated as first-class infrastructure, not
|
||||
decorative metadata.
|
||||
|
||||
Each relationship record should have:
|
||||
|
||||
- scores or categorical state for trust, respect, resentment, obligation, fear, fascination
|
||||
- a short summary of the current relationship
|
||||
- the unresolved incidents driving that summary
|
||||
- known pressure points
|
||||
- current access level
|
||||
|
||||
Example consequences:
|
||||
|
||||
- low trust + high obligation = brittle alliance
|
||||
- low respect + high dependence = contemptuous reliance
|
||||
- high fascination + high fear = unstable orbit
|
||||
- high resentment + high access = sabotage risk
|
||||
|
||||
That is where drama comes from.
|
||||
|
||||
## Goal Engine
|
||||
|
||||
Goals should be generated and updated from:
|
||||
|
||||
- identity
|
||||
- current needs
|
||||
- memory
|
||||
- faction context
|
||||
- scene opportunities
|
||||
- rival actions
|
||||
|
||||
We should distinguish between:
|
||||
|
||||
- **maintenance goals**
|
||||
- survive
|
||||
- maintain station
|
||||
- avoid exposure
|
||||
- **strategic goals**
|
||||
- secure influence
|
||||
- gain a resource
|
||||
- undermine a rival
|
||||
- **emotional goals**
|
||||
- seek vindication
|
||||
- restore dignity
|
||||
- protect someone
|
||||
- punish betrayal
|
||||
|
||||
Emotional goals matter because otherwise every character becomes a boring little
|
||||
optimization engine with no pulse.
|
||||
|
||||
## Scene Engine
|
||||
|
||||
A scene should be an explicit object, not just "some chat happened."
|
||||
|
||||
A scene record should contain:
|
||||
|
||||
- location
|
||||
- participants
|
||||
- public stakes
|
||||
- hidden stakes
|
||||
- triggering event
|
||||
- current tension axes
|
||||
- known facts by actor
|
||||
- unknown facts by actor
|
||||
- scene outcome
|
||||
|
||||
Useful tension axes:
|
||||
|
||||
- status
|
||||
- safety
|
||||
- secrecy
|
||||
- leverage
|
||||
- affection
|
||||
- ideology
|
||||
- scarcity
|
||||
|
||||
The scene engine should decide:
|
||||
|
||||
- whether to escalate
|
||||
- whether to de-escalate
|
||||
- whether to fork into violence, bargain, confession, retreat, or deception
|
||||
|
||||
## Human Participation
|
||||
|
||||
Humans should be one force in the world, not the privileged audience every
|
||||
character was born to adore.
|
||||
|
||||
That means:
|
||||
|
||||
- agents should remember human actions
|
||||
- agents should form durable impressions of specific humans
|
||||
- agents should change how much they reveal, trust, or tolerate
|
||||
- agents should be willing to refuse or redirect
|
||||
- agents should sometimes try to use the human for their own ends
|
||||
|
||||
Humans should be able to:
|
||||
|
||||
- build alliances
|
||||
- ruin them
|
||||
- become symbols
|
||||
- become liabilities
|
||||
- become prey
|
||||
- become confidants
|
||||
|
||||
That is much more interesting than "click NPC, receive lore paragraph."
|
||||
|
||||
## Narrative Layer
|
||||
|
||||
We should not hard-script drama, but we should summarize it.
|
||||
|
||||
The narrative layer should:
|
||||
|
||||
- generate personal chronicles from actual events
|
||||
- generate regional or faction news from high-impact changes
|
||||
- generate rumor propagation when information is partial or distorted
|
||||
|
||||
The crucial rule:
|
||||
|
||||
- narrative is downstream of simulation
|
||||
- not a replacement for simulation
|
||||
|
||||
## How The Current Supporting Stack Maps To This
|
||||
|
||||
The nice part is that some useful scaffolding already exists elsewhere.
|
||||
|
||||
Current useful pieces:
|
||||
|
||||
- Postgres-backed persistent state
|
||||
- Qdrant-backed semantic retrieval
|
||||
- interaction memory with behavioral dimensions
|
||||
- source and lore indexing
|
||||
- provider-side tool use
|
||||
- owner-safe MCP patterns
|
||||
|
||||
What needs to be added:
|
||||
|
||||
- multi-agent world state store
|
||||
- relationship graph
|
||||
- explicit goal objects
|
||||
- scene scheduler
|
||||
- reflection/consolidation jobs for cast state
|
||||
- tiered simulation runtime
|
||||
- world-facing tools for observation and action
|
||||
|
||||
In other words, the existing conversational stack is already halfway to being a
|
||||
mind-and-memory service. What it lacks is the world model and the cast
|
||||
scheduler.
|
||||
|
||||
## Suggested Data Model
|
||||
|
||||
At minimum, the persistent store should grow tables or equivalents for:
|
||||
|
||||
- `world_regions`
|
||||
- `world_events`
|
||||
- `agents`
|
||||
- `agent_identity_snapshots`
|
||||
- `agent_dynamic_state`
|
||||
- `agent_goals`
|
||||
- `agent_relationships`
|
||||
- `agent_memories_episodic`
|
||||
- `agent_memories_scene`
|
||||
- `agent_memories_semantic`
|
||||
- `scene_instances`
|
||||
- `scene_participants`
|
||||
- `human_agent_interactions`
|
||||
- `rumors`
|
||||
|
||||
Qdrant or equivalent can remain the retrieval layer for:
|
||||
|
||||
- episodic recall
|
||||
- lore retrieval
|
||||
- source grounding
|
||||
- scene similarity
|
||||
|
||||
But the canonical social and world state should stay relational and
|
||||
inspectable.
|
||||
|
||||
## Suggested Runtime Services
|
||||
|
||||
The world runtime should likely split into:
|
||||
|
||||
- **world scheduler**
|
||||
- advances ticks
|
||||
- activates scenes
|
||||
- downgrades inactive agents
|
||||
- **agent executor**
|
||||
- runs an agent turn
|
||||
- retrieves memory
|
||||
- updates goals and relationships
|
||||
- **scene resolver**
|
||||
- handles active multi-party interactions
|
||||
- **reflection worker**
|
||||
- consolidates events into longer-term memory
|
||||
- **narrative worker**
|
||||
- writes chronicles and reports
|
||||
|
||||
That keeps the system legible instead of letting one monstrous worker do all
|
||||
the thinking and all the plumbing at once.
|
||||
|
||||
## Failure Modes To Watch For
|
||||
|
||||
### 1. Everyone Becomes The Same Person
|
||||
|
||||
The agent voices flatten and all conflict becomes cosmetic.
|
||||
|
||||
Countermeasure:
|
||||
|
||||
- strong identity layer
|
||||
- explicit value hierarchy
|
||||
- personality consistency evaluation
|
||||
|
||||
### 2. Memory Becomes Sludge
|
||||
|
||||
Everything is stored, nothing is distinguished, retrieval turns to soup.
|
||||
|
||||
Countermeasure:
|
||||
|
||||
- tiered memory
|
||||
- write-path filtering
|
||||
- periodic consolidation
|
||||
- clear forgetting policy
|
||||
|
||||
### 3. Drama Becomes Random Meanness
|
||||
|
||||
Characters feel chaotic rather than motivated.
|
||||
|
||||
Countermeasure:
|
||||
|
||||
- tie conflict to values, history, incentives, and pressure
|
||||
|
||||
### 4. Humans Break The World By Existing
|
||||
|
||||
Every character over-orients around the nearest player.
|
||||
|
||||
Countermeasure:
|
||||
|
||||
- keep off-screen goals live
|
||||
- preserve non-human social gravity
|
||||
- let humans matter without becoming the axis mundi
|
||||
|
||||
### 5. Sycophancy Wears A Costume
|
||||
|
||||
The bot sounds spicier but still validates whatever frame the user wants.
|
||||
|
||||
Countermeasure:
|
||||
|
||||
- explicit anti-sycophancy evaluation
|
||||
- persistent relationship memory
|
||||
- boundary-aware response guidance
|
||||
|
||||
## Build Order
|
||||
|
||||
### Phase 1: Authoring Cast Spine
|
||||
|
||||
- explicit agent identity dossiers
|
||||
- explicit goal objects
|
||||
- explicit relationship objects
|
||||
- episodic + semantic memory split
|
||||
|
||||
### Phase 2: Dialogue And Drama Scaffolding
|
||||
|
||||
- prompt projection for scene and dialogue drafts
|
||||
- relationship-aware dialogue skeletons
|
||||
- conflict beat generation from incompatible goals
|
||||
- author-visible rationale for generated moves
|
||||
|
||||
### Phase 3: Social Simulation
|
||||
|
||||
- scene objects
|
||||
- scene scheduler
|
||||
- relationship updates from scenes
|
||||
- goal revision from scenes
|
||||
|
||||
### Phase 4: Persistent World
|
||||
|
||||
- region state
|
||||
- off-screen strategic ticking
|
||||
- rumor/news propagation
|
||||
- faction and economy hooks
|
||||
|
||||
### Phase 5: Human Integration
|
||||
|
||||
- live player-facing interactions
|
||||
- persistent human reputation
|
||||
- access, trust, and consequence systems
|
||||
|
||||
### Phase 6: Rich Narrative Surface
|
||||
|
||||
- chronicles
|
||||
- regional reports
|
||||
- replay and spectator tooling
|
||||
|
||||
## The Real Ambition
|
||||
|
||||
The ambition here is not "better NPC dialogue."
|
||||
|
||||
It is:
|
||||
|
||||
- a world with inhabitants
|
||||
- inhabitants with memory
|
||||
- memory that changes what they want
|
||||
- wants that collide
|
||||
- collisions that produce story
|
||||
|
||||
That is the actual prize. Everything else is just wiring and smoke.
|
||||
|
|
@ -1,461 +0,0 @@
|
|||
# Agent State Distributions And Prompt Projection
|
||||
|
||||
This note describes how personality, culture, memory, and social context should
|
||||
be represented in structured state, and how that state should be projected into
|
||||
prompts for dialogue generation.
|
||||
|
||||
The central rule is simple:
|
||||
|
||||
- the prompt is not the source of truth
|
||||
- the prompt is a temporary projection of deeper state for one turn or one scene
|
||||
|
||||
If we try to store the whole person in prompt prose, the system turns back into
|
||||
theatrical mush. The real machine has to live in explicit state.
|
||||
|
||||
For the related notes on personality structure and dialogue classification, see:
|
||||
|
||||
- [Personality Model Roadmap](../research/personality-model-roadmap.md)
|
||||
- [Personality Signal Classifier](../research/personality-signal-classifier.md)
|
||||
|
||||
## Core Principle
|
||||
|
||||
We should model agents through:
|
||||
|
||||
1. canonical latent state
|
||||
2. perceived state for each agent
|
||||
3. prompt projection from perceived state into live generation
|
||||
|
||||
That gives us:
|
||||
|
||||
- stable world truth
|
||||
- subjective misunderstanding
|
||||
- efficient live prompting without pretending the prompt is the database
|
||||
|
||||
## State Families
|
||||
|
||||
Each agent should have explicit state families, not one giant personality blob.
|
||||
|
||||
### 1. Underlying Organization
|
||||
|
||||
These are deep structural variables:
|
||||
|
||||
- self-coherence
|
||||
- contingent worth
|
||||
- shame sensitivity
|
||||
- reciprocity capacity
|
||||
- mentalization quality
|
||||
- authenticity tolerance
|
||||
- mask rigidity
|
||||
- external-regulation dependence
|
||||
|
||||
These are slow-changing and often heavily shaped by early development and
|
||||
repeated relational history.
|
||||
|
||||
### 2. Stable Dispositions
|
||||
|
||||
These are relatively persistent tendencies:
|
||||
|
||||
- novelty-seeking
|
||||
- conformity
|
||||
- status hunger
|
||||
- risk tolerance
|
||||
- sociability
|
||||
- baseline threat sensitivity
|
||||
- aesthetic appetite
|
||||
- ideological rigidity
|
||||
|
||||
### 3. Behavioral Dimensions
|
||||
|
||||
These are first-class, not optional garnish. They belong in the rebuild from
|
||||
the start:
|
||||
|
||||
- interpersonal warmth
|
||||
- drive
|
||||
- grandiosity
|
||||
- validation-seeking
|
||||
- anxiety
|
||||
- control pressure
|
||||
- hostility
|
||||
- suspicion
|
||||
- rigidity
|
||||
- withdrawal
|
||||
- volatility
|
||||
- attachment-seeking
|
||||
- distance-seeking
|
||||
|
||||
These are the main handles for social behavior, institutional pressure, and
|
||||
consumer or factional drift.
|
||||
|
||||
### 4. Presentation Strategy
|
||||
|
||||
These describe how the self is performed:
|
||||
|
||||
- charm
|
||||
- compliance
|
||||
- superiority
|
||||
- detachment
|
||||
- seductiveness
|
||||
- competence theater
|
||||
- moral theater
|
||||
- strategic opacity
|
||||
- cultivated harmlessness
|
||||
- abrasive boundary
|
||||
- ironic distance
|
||||
|
||||
These are not just decorative style. In some agents they are survival
|
||||
machinery.
|
||||
|
||||
### 5. Voice Style
|
||||
|
||||
These describe how the performed self comes out in language:
|
||||
|
||||
- dryness
|
||||
- verbal warmth
|
||||
- formality
|
||||
- verbosity
|
||||
- pace
|
||||
- plainspoken directness
|
||||
- lexical precision
|
||||
- technical density
|
||||
- technical compression
|
||||
- figurative language
|
||||
- lyricism
|
||||
- narrative detail
|
||||
- emotional explicitness
|
||||
- pointedness
|
||||
- self disclosure
|
||||
- hedging
|
||||
- certainty marking
|
||||
- politeness
|
||||
- coded politeness
|
||||
- ritualized address
|
||||
- register switching
|
||||
- dialect marking
|
||||
- theatricality
|
||||
- humor
|
||||
- conversational dominance
|
||||
- listening responsiveness
|
||||
- question asking
|
||||
- profanity
|
||||
|
||||
### 6. Derived Verbal Responses
|
||||
|
||||
Defensive verbal moves are derived during projection. The canonical state stores
|
||||
the pressures and speech mechanics that make those moves likely.
|
||||
|
||||
Target emergent behaviors:
|
||||
|
||||
- sarcastic deflection can emerge from shame sensitivity, ironic distance,
|
||||
exposure pressure, suspicion, and a dry or pointed voice
|
||||
- sincerity evasion can emerge from mask rigidity, low authenticity tolerance,
|
||||
strategic opacity, and low emotional explicitness
|
||||
- emotional evasion can emerge from distance-seeking, withdrawal, acute shame,
|
||||
or control pressure with low self-disclosure
|
||||
- banter as boundary can emerge from humor, ironic distance, attachment
|
||||
pressure, and distance-seeking
|
||||
- verbal aggression can emerge from hostility, perceived status threat,
|
||||
grievance activation, and abrasive boundary
|
||||
|
||||
These are behaviors Ghostlight should be able to produce and test for without
|
||||
storing them as authored canonical traits.
|
||||
|
||||
### 7. Situational State
|
||||
|
||||
These are transient activations:
|
||||
|
||||
- exhaustion
|
||||
- scarcity pressure
|
||||
- humiliation
|
||||
- panic
|
||||
- triumph
|
||||
- grief
|
||||
- overstimulation
|
||||
- grievance activation
|
||||
- acute shame
|
||||
- perceived status threat
|
||||
|
||||
### 8. Relationship State
|
||||
|
||||
Per target person, faction, group, product domain, or institution:
|
||||
|
||||
- trust
|
||||
- resentment
|
||||
- dependence
|
||||
- fear
|
||||
- fascination
|
||||
- obligation
|
||||
- envy
|
||||
- moral disgust
|
||||
- perceived status gap
|
||||
- expectation of care
|
||||
- expectation of betrayal
|
||||
|
||||
### 9. Goals And Values
|
||||
|
||||
- active goals
|
||||
- protected values
|
||||
- emotional stakes
|
||||
- tradeoffs
|
||||
- unforgivables
|
||||
|
||||
## How To Represent Variables
|
||||
|
||||
Do not represent most of these as naked scalars if we want culture and history
|
||||
to shape them properly.
|
||||
|
||||
A practical representation is:
|
||||
|
||||
- `mean`
|
||||
- `plasticity`
|
||||
- `current_activation`
|
||||
|
||||
So instead of:
|
||||
|
||||
- `status_hunger = 0.72`
|
||||
|
||||
use something like:
|
||||
|
||||
- `status_hunger.mean = 0.72`
|
||||
- `status_hunger.plasticity = 0.22`
|
||||
- `status_hunger.current_activation = 0.89`
|
||||
|
||||
This gives us:
|
||||
|
||||
- stable baseline personality
|
||||
- different rates of change
|
||||
- scene-sensitive activation without rewriting the whole person every turn
|
||||
|
||||
Canonical authored state should not include epistemic uncertainty about itself.
|
||||
If a value is only an observer's read, store it as perceived state rather than
|
||||
canonical state. Perceived dimensions should not expose canonical `mean` or
|
||||
`plasticity`; they should track observed activation, attributed disposition,
|
||||
and confidence. This lets the model represent
|
||||
fundamental attribution bias: someone can behave a certain way under pressure
|
||||
and be perceived as simply being that way.
|
||||
|
||||
## Culture As A Prior Field
|
||||
|
||||
Culture should not create clones. Culture should shape distributions.
|
||||
|
||||
For each culture, faction, class stratum, or institution, define:
|
||||
|
||||
- prior means over variables
|
||||
- covariance structure
|
||||
- reward gradients
|
||||
- penalty gradients
|
||||
- preferred masks
|
||||
- taboo map
|
||||
- prestige map
|
||||
- default relationship posture to insiders, outsiders, rivals, inferiors, and superiors
|
||||
- favored narrative scripts
|
||||
|
||||
This means a culture is not "people here are high in trait X."
|
||||
|
||||
It is more like:
|
||||
|
||||
- this culture rewards controlled presentation
|
||||
- penalizes spontaneous vulnerability
|
||||
- elevates status anxiety
|
||||
- normalizes suspicion of low-status outsiders
|
||||
- attaches prestige to procedural fluency and emotional composure
|
||||
|
||||
That is how a Dominion or Lucent style environment should work: as a field that
|
||||
shapes what people become profitable or safe enough to be.
|
||||
|
||||
## Personal History As A Transform Layer
|
||||
|
||||
Culture is only the starting pressure.
|
||||
|
||||
Then the prior is transformed by:
|
||||
|
||||
- attachment history
|
||||
- class position
|
||||
- faction indoctrination
|
||||
- educational shaping
|
||||
- labor regime
|
||||
- remembered humiliations
|
||||
- remembered betrayals
|
||||
- remembered rescue, admiration, abandonment, and obligation
|
||||
|
||||
So agent state should be read as:
|
||||
|
||||
- cultural prior
|
||||
- transformed by developmental history
|
||||
- transformed by remembered episodes
|
||||
- currently activated by scene pressure
|
||||
|
||||
## Cohorts And Populations
|
||||
|
||||
The same representation can be lifted from individuals to populations.
|
||||
|
||||
A colony, district, labor bloc, or consumer cohort can have:
|
||||
|
||||
- shared priors
|
||||
- institutional conditioning
|
||||
- repeated collective incidents
|
||||
- relationship state toward factions, employers, media ecosystems, and product classes
|
||||
|
||||
This is much better than Big Five-style population sliders because it lets us
|
||||
model things like:
|
||||
|
||||
- prestige hunger
|
||||
- fear-state compliance
|
||||
- grievance accumulation
|
||||
- attachment to institutions
|
||||
- validation loops induced by media
|
||||
- suspicion induced by audit or betrayal
|
||||
|
||||
That is much closer to Aetheria than a broad trait scaffold alone.
|
||||
|
||||
## Canonical State Vs Perceived State
|
||||
|
||||
The world should not only track what is true. It should track what each agent
|
||||
thinks is true.
|
||||
|
||||
So we need:
|
||||
|
||||
- canonical world state
|
||||
- canonical agent state
|
||||
- per-agent perceived state
|
||||
|
||||
Perceived state includes:
|
||||
|
||||
- what this agent believes about others
|
||||
- what motives they attribute
|
||||
- what traits they think they are seeing
|
||||
- what mask they think someone is wearing
|
||||
- what threats or opportunities they think are present
|
||||
|
||||
This is where emotional intelligence, suspicion, attachment hunger, status
|
||||
anxiety, and cultural priors distort interpretation.
|
||||
|
||||
The acting agent should mostly be prompted from perceived state, not canonical
|
||||
truth. That is how misunderstanding becomes organic instead of hand-authored.
|
||||
|
||||
## Prompt Projection
|
||||
|
||||
Prompt rendering should be treated as projection, not storage.
|
||||
|
||||
For a live scene, build the prompt from:
|
||||
|
||||
### 1. Identity And Role
|
||||
|
||||
- who the agent is
|
||||
- what role they occupy
|
||||
- what they want to be seen as
|
||||
|
||||
### 2. Cultural Default Frame
|
||||
|
||||
- prestige rules
|
||||
- taboo zones
|
||||
- expected forms of control or intimacy
|
||||
- factional posture
|
||||
|
||||
### 3. Current Presentation Strategy
|
||||
|
||||
- how they are trying to appear right now
|
||||
- what they are concealing
|
||||
- what they must not look like
|
||||
|
||||
### 4. Current Situation
|
||||
|
||||
- scene pressure
|
||||
- public exposure
|
||||
- scarcity
|
||||
- humiliation risk
|
||||
- safety risk
|
||||
|
||||
### 5. Relationship Context
|
||||
|
||||
- what they think of the other party
|
||||
- what they need from them
|
||||
- what wounds or debts are active
|
||||
- what status relation they perceive
|
||||
|
||||
### 6. Relevant Memory
|
||||
|
||||
Not full history. Only:
|
||||
|
||||
- the few most relevant episodes
|
||||
- relationship summaries
|
||||
- current active fears, loyalties, or grievances
|
||||
- currently salient cultural scripts
|
||||
|
||||
### 7. Goals And Constraints
|
||||
|
||||
- what they are trying to achieve in this scene
|
||||
- what they cannot afford
|
||||
- what values they will not betray
|
||||
|
||||
### 8. Listener Model
|
||||
|
||||
If the scene involves another agent, include:
|
||||
|
||||
- what this agent thinks the other wants
|
||||
- what they fear the other is doing
|
||||
- where they may be misreading them
|
||||
|
||||
## What The Prompt Should Not Do
|
||||
|
||||
Do not make the prompt:
|
||||
|
||||
- the sole canonical store of identity
|
||||
- a full dump of all memories
|
||||
- a taxonomy recital
|
||||
- a fake omniscient summary that erases subjective misreading
|
||||
|
||||
The prompt should be compact, selective, and scene-bound. The state store
|
||||
carries the rest.
|
||||
|
||||
## Economic Consequences
|
||||
|
||||
This model gives us much more useful demand and cultural behavior than old
|
||||
trait-only systems.
|
||||
|
||||
For example:
|
||||
|
||||
- high status hunger plus validation-seeking plus fragile self-worth leads toward prestige goods, symbolic upgrades, and visible luxury
|
||||
- high threat sensitivity plus conformity plus shame reactivity leads toward safety goods, procedural systems, insurance, and respectable signaling
|
||||
- high drive plus novelty-seeking plus low institutional attachment leads toward frontier gear, gray-market experimentation, and risky migration
|
||||
- high mask rigidity plus high impression-management intensity leads toward corrective aesthetics, social camouflage, and reputation-maintenance consumption
|
||||
|
||||
That is how culture, labor, media, and institutions become market-shaping
|
||||
forces instead of mere flavor text.
|
||||
|
||||
## Recommended Implementation Path
|
||||
|
||||
1. Define the state schema with first-class support for:
|
||||
- underlying organization
|
||||
- stable dispositions
|
||||
- all core behavioral dimensions, including `volatility`, `attachment-seeking`, and `distance-seeking`
|
||||
- presentation strategies
|
||||
- voice styles
|
||||
- situational state
|
||||
- relationship state
|
||||
- goals and values
|
||||
2. Represent those variables as priors plus activation, not just fixed scalars.
|
||||
3. Define cultural and institutional prior templates.
|
||||
4. Add history transforms from remembered episodes.
|
||||
5. Add per-agent perceived state overlays.
|
||||
6. Build prompt renderers that project perceived state into live generation.
|
||||
7. Keep canonical state, perceived state, and prompt projection cleanly separated.
|
||||
|
||||
## Bottom Line
|
||||
|
||||
The right machine is not:
|
||||
|
||||
- a persona prompt with some adjectives taped to it
|
||||
- a canonical trait sheet with no cultural shaping
|
||||
- a direct line from "personality value" to dialogue
|
||||
|
||||
The right machine is:
|
||||
|
||||
- latent state distributions
|
||||
- shaped by culture
|
||||
- reshaped by history
|
||||
- distorted by perception
|
||||
- projected into prompts for one scene at a time
|
||||
|
||||
That gives us agents who can feel culturally situated, personally scarred,
|
||||
socially biased, and dynamically alive without making the prompt carry the
|
||||
whole civilization on its back.
|
||||
|
|
@ -1,273 +0,0 @@
|
|||
# Agent State Variable Glossary
|
||||
|
||||
This document explains the current v0 Ghostlight state variables. It is the
|
||||
human-facing companion to:
|
||||
|
||||
- `schemas/agent-state.schema.json`
|
||||
- `schemas/agent-state.required-fields.json`
|
||||
- `examples/agent-state.call-of-the-void.json`
|
||||
|
||||
The schema is not pretending this vocabulary is final. It is a first stable
|
||||
core: enough shared axes to compare characters, build prompt projection, and
|
||||
avoid turning every new scene into a bespoke little fog machine.
|
||||
|
||||
## Variable Components
|
||||
|
||||
Canonical scalar-like state variables use three numeric components.
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `mean` | The baseline tendency or long-run level for this variable. A high `mean` means this trait, strategy, pressure, or stance is normally strong for the agent. |
|
||||
| `plasticity` | How easily this variable can change. High plasticity means the value can move quickly under events, relationships, culture, or scene pressure. Low plasticity means it is entrenched. |
|
||||
| `current_activation` | How active the variable is right now, in this scene or current state. This can spike above or fall below the baseline without rewriting the agent. |
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"mean": 0.35,
|
||||
"plasticity": 0.2,
|
||||
"current_activation": 0.9
|
||||
}
|
||||
```
|
||||
|
||||
This means the agent is not generally high in the variable, the baseline is not
|
||||
very easy to change, but the variable is strongly activated right now.
|
||||
Translation: this is a situation, not a personality transplant.
|
||||
|
||||
Canonical character state does not include `certainty`. If a generated or
|
||||
imported profile is provisional, track that in workflow provenance, draft
|
||||
status, imported-source notes, or review status.
|
||||
|
||||
Perceived state uses a different shape because observers do not know canonical
|
||||
baseline or plasticity. They see behavior, then often treat that behavior as
|
||||
evidence of what the target is like.
|
||||
|
||||
```json
|
||||
{
|
||||
"observed_activation": 0.9,
|
||||
"attributed_disposition": 0.7,
|
||||
"confidence": 0.4
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `observed_activation` | How strongly the observer thinks the target expressed the dimension in the observed event or scene. |
|
||||
| `attributed_disposition` | How much the observer generalizes from the observed behavior into "this is what the target is like." |
|
||||
| `confidence` | How strongly the observer trusts this read. This is where evidence strength, emotional investment, prejudice, relationship stakes, prior memory, and social reinforcement show up. |
|
||||
|
||||
This models fundamental attribution bias directly. A character can see someone
|
||||
behave coldly during a crisis and conclude "they are cold," not "they were
|
||||
activated toward distance under exceptional pressure." Correcting that
|
||||
impression takes work because the observer's confidence is supported by memory,
|
||||
emotion, stakes, prior assumptions, and social reinforcement.
|
||||
|
||||
Perceived overlays may use structured belief metadata such as subject, claim
|
||||
type, evidence events, visibility, and emotional charge. That metadata is not
|
||||
canonical certainty. It describes why a specific observer trusts a specific
|
||||
read.
|
||||
|
||||
## Label Set Policy
|
||||
|
||||
The labels in `agent-state.required-fields.json` are the required v0 core,
|
||||
not per-character tags. They cover the axes Ghostlight currently treats as
|
||||
first-class.
|
||||
|
||||
The validator requires each canonical agent fixture to include the full core
|
||||
set so agents can be compared on the same axes. That means Cat's example does
|
||||
not contain only labels that apply strongly to Cat. It contains all required
|
||||
labels, with low values for weak or inactive axes and high values for strong or
|
||||
activated axes.
|
||||
|
||||
Variable maps may still grow extra labels where a character, culture, or story
|
||||
needs them. Do not promote a new label into the required core until it has
|
||||
proved broadly useful across multiple characters or scenes. Otherwise the
|
||||
schema becomes a junk drawer with a badge.
|
||||
|
||||
## Underlying Organization
|
||||
|
||||
| Label | Meaning |
|
||||
| --- | --- |
|
||||
| `self_coherence` | How integrated and stable the agent's sense of self is under pressure. |
|
||||
| `contingent_worth` | How much the agent's felt worth depends on approval, utility, status, purity, or external validation. |
|
||||
| `shame_sensitivity` | How painful exposure, correction, failure, or diminishment feels. |
|
||||
| `reciprocity_capacity` | Ability to recognize and sustain mutual obligations rather than purely extractive or avoidant relations. |
|
||||
| `mentalization_quality` | Ability to model other minds with nuance instead of flattening them into crude threat, need, or utility categories. |
|
||||
| `authenticity_tolerance` | Capacity to be known without immediately retreating into performance, control, or concealment. |
|
||||
| `mask_rigidity` | How necessary and inflexible the performed self is. High values mean the mask is load-bearing. |
|
||||
| `external_regulation_dependence` | How much emotional stability depends on other people, institutions, substances, scripts, or external structures. |
|
||||
|
||||
## Stable Dispositions
|
||||
|
||||
| Label | Meaning |
|
||||
| --- | --- |
|
||||
| `novelty_seeking` | Attraction to new experience, strange opportunities, and unproven paths. |
|
||||
| `conformity` | Preference for accepted norms, procedures, and group expectations. |
|
||||
| `status_hunger` | Desire for rank, recognition, prestige, or visible superiority. |
|
||||
| `risk_tolerance` | Willingness to accept danger, uncertainty, or loss for gain or freedom. |
|
||||
| `sociability` | Baseline draw toward company, conversation, networks, and social energy. |
|
||||
| `baseline_threat_sensitivity` | Default readiness to detect danger, exploitation, betrayal, or humiliation. |
|
||||
| `aesthetic_appetite` | Sensitivity to beauty, style, taste, symbolic form, or expressive environment. |
|
||||
| `ideological_rigidity` | Resistance to revising beliefs, myths, doctrines, or explanatory frames. |
|
||||
|
||||
## Behavioral Dimensions
|
||||
|
||||
| Label | Meaning |
|
||||
| --- | --- |
|
||||
| `interpersonal_warmth` | Tendency to express care, friendliness, affection, or social welcome. |
|
||||
| `drive` | Goal pressure, persistence, ambition, and forward force. |
|
||||
| `grandiosity` | Inflated self-importance, entitlement, exceptionalism, or self-mythologizing. |
|
||||
| `validation_seeking` | Need for reassurance, approval, recognition, or confirmation of worth. |
|
||||
| `anxiety` | Anticipatory fear, worry, nervous vigilance, or instability under uncertainty. |
|
||||
| `control_pressure` | Urge to manage people, situations, information, or outcomes tightly. |
|
||||
| `hostility` | Readiness toward anger, contempt, attack, punishment, or antagonism. |
|
||||
| `suspicion` | Expectation that others hide motives, threats, traps, or bad faith. |
|
||||
| `rigidity` | Difficulty adapting behavior, interpretation, or plans under new evidence. |
|
||||
| `withdrawal` | Tendency to disengage, retreat, shut down, or reduce availability. |
|
||||
| `volatility` | Speed and intensity of emotional swings or escalation. |
|
||||
| `attachment_seeking` | Pull toward closeness, reassurance, fusion, privileged access, or being chosen. |
|
||||
| `distance_seeking` | Push toward space, opacity, autonomy, and reduced emotional claim from others. |
|
||||
|
||||
## Presentation Strategy
|
||||
|
||||
Presentation strategy describes the self being performed. This is not identical
|
||||
to voice style. It is the social face, mask, posture, or survival presentation
|
||||
the agent is trying to maintain.
|
||||
|
||||
| Label | Meaning |
|
||||
| --- | --- |
|
||||
| `charm` | Performs warmth, ease, charisma, or pleasantness to influence or smooth contact. |
|
||||
| `compliance` | Performs agreement, submission, helpfulness, or nonthreatening cooperation. |
|
||||
| `superiority` | Performs being above others, more competent, more refined, or less vulnerable. |
|
||||
| `detachment` | Performs cool distance, emotional unavailability, or not-needing. |
|
||||
| `seductiveness` | Performs desirability, invitation, or intimate leverage. |
|
||||
| `competence_theater` | Performs capability and control, sometimes beyond what is actually secure. |
|
||||
| `moral_theater` | Performs righteousness, purity, sacrifice, or ethical authority. |
|
||||
| `strategic_opacity` | Controls what others can know; hides motives, pain, dependency, or plans. |
|
||||
| `cultivated_harmlessness` | Performs being safe, small, useful, cute, or beneath concern. |
|
||||
| `abrasive_boundary` | Performs difficulty, bite, or unpleasantness to keep others from pressing closer. |
|
||||
| `ironic_distance` | Performs irony, detachment, or joking distance to avoid naked sincerity. |
|
||||
|
||||
## Voice Style
|
||||
|
||||
Voice style describes how the performed self comes out in language. The v0
|
||||
required vector covers broad communication mechanics, not only the most obvious
|
||||
quirks in the current examples.
|
||||
|
||||
| Label | Meaning |
|
||||
| --- | --- |
|
||||
| `dryness` | Understated, deadpan, low-sentiment phrasing. |
|
||||
| `verbal_warmth` | Inviting, affectionate, reassuring, or socially soft phrasing. |
|
||||
| `formality` | Uses formal structure, titles, careful address, or institutionally correct phrasing. |
|
||||
| `verbosity` | Tends toward longer turns, elaboration, qualification, or verbal sprawl. |
|
||||
| `pace` | Moves quickly through turns, interruptions, replies, or topic shifts. |
|
||||
| `plainspoken_directness` | Speaks plainly, concretely, or bluntly rather than ornamenting or obscuring. |
|
||||
| `lexical_precision` | Chooses exact words, distinctions, definitions, or careful terms. |
|
||||
| `technical_density` | Packs speech with specialist terms, systems language, procedure, or analysis. |
|
||||
| `technical_compression` | Compresses technical meaning into terse expert shorthand. |
|
||||
| `figurative_language` | Uses metaphor, image, analogy, poetic compression, or symbolic framing. |
|
||||
| `lyricism` | Uses musical, poetic, sensuous, or rhythmically heightened language. |
|
||||
| `narrative_detail` | Explains through context, sequence, anecdote, or concrete scene detail. |
|
||||
| `emotional_explicitness` | Names feelings, needs, wounds, affection, fear, or attachment directly. |
|
||||
| `pointedness` | Cuts directly, sharply, or with barbed precision. |
|
||||
| `self_disclosure` | Volunteers personal history, inner state, motives, or private stakes. |
|
||||
| `hedging` | Softens claims with uncertainty markers, caveats, deference, or exits. |
|
||||
| `certainty_marking` | Signals confidence, finality, authority, or refusal to leave claims open. |
|
||||
| `politeness` | Uses courtesy, mitigation, face-saving language, or social smoothing. |
|
||||
| `coded_politeness` | Uses polite phrasing to imply criticism, threat, refusal, hierarchy, or hidden meaning. |
|
||||
| `ritualized_address` | Uses formulaic greetings, titles, honorifics, oaths, prayers, or ceremonial phrases. |
|
||||
| `register_switching` | Shifts between speech registers depending on audience, status, danger, or role. |
|
||||
| `dialect_marking` | Shows regional, class, occupational, subcultural, or community-specific speech markers. |
|
||||
| `theatricality` | Performs speech with heightened drama, staging, persona, flourish, or rhetorical display. |
|
||||
| `humor` | Uses jokes, wit, absurdity, teasing, or comic framing as a regular speech tool. |
|
||||
| `conversational_dominance` | Takes, holds, redirects, or controls conversational floor and agenda. |
|
||||
| `listening_responsiveness` | Reflects, tracks, validates, or adapts to what the other person just said. |
|
||||
| `question_asking` | Uses questions to probe, invite, corner, teach, test, or keep the other person talking. |
|
||||
| `profanity` | Uses taboo, vulgar, sacred, or deliberately coarse language. |
|
||||
|
||||
## Derived Verbal Responses
|
||||
|
||||
Defensive verbal moves are projection outcomes, not canonical labels. A renderer
|
||||
can derive sarcasm, sincerity evasion, emotional evasion, banter-as-boundary, or
|
||||
verbal aggression from the interaction between active state families.
|
||||
|
||||
Examples:
|
||||
|
||||
| Response | Common active-state pattern |
|
||||
| --- | --- |
|
||||
| Sarcastic deflection | High shame sensitivity, ironic distance, threat sensitivity, and exposure pressure through a dry or pointed voice. |
|
||||
| Sincerity evasion | Low authenticity tolerance, high mask rigidity, strategic opacity, and low emotional explicitness. |
|
||||
| Emotional evasion | High distance-seeking, withdrawal, acute shame, or control pressure with low self-disclosure. |
|
||||
| Banter as boundary | High ironic distance, humor, attachment pressure, and distance-seeking in the same scene. |
|
||||
| Verbal aggression | High hostility, perceived status threat, grievance activation, and abrasive boundary. |
|
||||
|
||||
Treat these as target emergent behaviors for projection tests. They are useful
|
||||
because the system should produce them under the right pressures, not because
|
||||
they deserve their own canonical storage slot.
|
||||
|
||||
## Situational State
|
||||
|
||||
| Label | Meaning |
|
||||
| --- | --- |
|
||||
| `exhaustion` | Current depletion, fatigue, overuse, or low reserve. |
|
||||
| `scarcity_pressure` | Pressure from money, supplies, access, time, space, safety, or opportunity being scarce. |
|
||||
| `humiliation` | Current felt diminishment, exposure, embarrassment, or status wound. |
|
||||
| `panic` | Acute fear, urgency, overwhelm, or threat response. |
|
||||
| `triumph` | Current victory, vindication, high confidence, or emotional lift. |
|
||||
| `grief` | Active loss, mourning, sorrow, or ache. |
|
||||
| `overstimulation` | Current sensory, social, cognitive, or emotional overload. |
|
||||
| `grievance_activation` | Active resentment, injustice memory, or retaliatory moral charge. |
|
||||
| `acute_shame` | Immediate shame flare, exposure pain, or self-disgust. |
|
||||
| `perceived_status_threat` | Current sense that rank, dignity, competence, or face is under threat. |
|
||||
|
||||
## Relationship Stance
|
||||
|
||||
Relationship stance is directional. A `source_id -> target_id` record describes
|
||||
how the source feels and predicts the target, not a shared relationship object.
|
||||
Reciprocal records can disagree.
|
||||
|
||||
| Label | Meaning |
|
||||
| --- | --- |
|
||||
| `trust` | Expectation that the target will not exploit vulnerability or act in bad faith. |
|
||||
| `respect` | Recognition of competence, integrity, status, or seriousness. |
|
||||
| `resentment` | Accumulated grievance, bitterness, or unpaid emotional debt. |
|
||||
| `dependence` | Need for the target's resources, care, access, protection, or recognition. |
|
||||
| `fear` | Expectation that the target can or will cause harm. |
|
||||
| `fascination` | Captivation, curiosity, attraction, fixation, or unresolved interest. |
|
||||
| `obligation` | Felt debt, duty, promise, or responsibility toward the target. |
|
||||
| `envy` | Pain around what the target has, is, receives, or represents. |
|
||||
| `moral_disgust` | Aversion rooted in perceived violation, corruption, cruelty, or wrongness. |
|
||||
| `perceived_status_gap` | Felt difference in rank, leverage, dignity, or social position. |
|
||||
| `expectation_of_care` | Belief that the target may protect, soothe, support, or choose the source. |
|
||||
| `expectation_of_betrayal` | Belief that the target may abandon, expose, exploit, or turn. |
|
||||
|
||||
## Cat Fixture Policy
|
||||
|
||||
Cat's example fixture currently includes all required core labels for Cat and
|
||||
Oz. It is not a sparse profile of only traits that apply to them.
|
||||
|
||||
That is deliberate for v0:
|
||||
|
||||
- complete vectors make comparison and validation easier
|
||||
- low `mean` values are meaningful
|
||||
- low `current_activation` values tell the renderer not to emphasize a variable
|
||||
|
||||
Later, Ghostlight may support sparse authoring overlays for convenience. The
|
||||
canonical fixture should remain explicit until we have enough tooling to avoid
|
||||
silent defaults becoming accidental canon.
|
||||
|
||||
## Event Interpretation
|
||||
|
||||
Events may carry three optional interpretation layers:
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `observed_exchange` | What was externally observable: who acted or spoke, what they seemed to do, and the visible cues. |
|
||||
| `private_interpretations` | How each participant appraised the event from their own partial state. |
|
||||
| `event_effects` | The proposed consequences: state deltas, new beliefs, or memory updates caused by the event. |
|
||||
|
||||
These fields are immediate scaffolding for projection. They do not yet define a
|
||||
complete transition engine. For now, they make important scene history legible
|
||||
enough that a prompt projector can explain why a later line bends toward trust,
|
||||
evasion, curiosity, fear, or repair.
|
||||
|
|
@ -1,244 +0,0 @@
|
|||
# Canonical Agent State Schema
|
||||
|
||||
Ghostlight's first runtime organ is a state contract, not a generator.
|
||||
|
||||
The schema lives in:
|
||||
|
||||
- `schemas/agent-state.schema.json`
|
||||
- `schemas/agent-state.required-fields.json`
|
||||
|
||||
The first example lives in:
|
||||
|
||||
- `examples/agent-state.call-of-the-void.json`
|
||||
|
||||
The label and numeric glossary lives in:
|
||||
|
||||
- `docs/architecture/agent-state-variable-glossary.md`
|
||||
|
||||
## Objective
|
||||
|
||||
The schema defines the minimum state Ghostlight needs before it can safely build
|
||||
prompt projection, dialogue scaffolding, classifier records, or procedural
|
||||
drama.
|
||||
|
||||
The core rule is unchanged:
|
||||
|
||||
- canonical state is what the world treats as true
|
||||
- perceived state is what a specific observer thinks is true
|
||||
- dialogue context packs are character-local projections for one speaker in one
|
||||
scene
|
||||
|
||||
If these layers collapse into each other, the machine turns back into one
|
||||
omniscient narrator with a pocket full of vibes. This is a known industrial
|
||||
hazard. We are wearing goggles now.
|
||||
|
||||
## State Families
|
||||
|
||||
Each agent has:
|
||||
|
||||
- `identity`
|
||||
- `canonical_state`
|
||||
- `goals`
|
||||
- `memories`
|
||||
- `perceived_state_overlays`
|
||||
|
||||
`canonical_state` is split into:
|
||||
|
||||
- `underlying_organization`
|
||||
- `stable_dispositions`
|
||||
- `behavioral_dimensions`
|
||||
- `presentation_strategy`
|
||||
- `voice_style`
|
||||
- `situational_state`
|
||||
- `values`
|
||||
|
||||
Each canonical scalar-like dimension is represented as:
|
||||
|
||||
```json
|
||||
{
|
||||
"mean": 0.5,
|
||||
"plasticity": 0.5,
|
||||
"current_activation": 0.5
|
||||
}
|
||||
```
|
||||
|
||||
This keeps baseline, change rate, and scene activation separate.
|
||||
A person can be dispositionally suspicious without being maximally suspicious
|
||||
in every scene. A person can be highly activated right now without rewriting
|
||||
their whole soul because someone coughed near a status wound.
|
||||
|
||||
Canonical variables do not include confidence or certainty. Perceived variables
|
||||
do not expose canonical `mean` or `plasticity`; they store observed activation,
|
||||
attributed disposition, and confidence.
|
||||
|
||||
For full meanings of canonical `mean`, `plasticity`, `current_activation`,
|
||||
perceived attribution fields, and every required v0 label, see
|
||||
`docs/architecture/agent-state-variable-glossary.md`.
|
||||
|
||||
## Required First-Class Dimensions
|
||||
|
||||
`schemas/agent-state.required-fields.json` names the required core variable
|
||||
families. The validator enforces those fields for canonical agent state and
|
||||
relationship stance.
|
||||
|
||||
The required core is not exhaustive. It is the shared comparison surface. A
|
||||
character fixture currently includes all required labels, even when many are low
|
||||
or inactive, so downstream tooling can compare agents without guessing whether a
|
||||
missing value means "not applicable," "forgotten," or "quietly eating the
|
||||
floorboards."
|
||||
|
||||
The behavioral dimensions include the known must-not-drop set:
|
||||
|
||||
- `volatility`
|
||||
- `attachment_seeking`
|
||||
- `distance_seeking`
|
||||
|
||||
Those are not garnish. They are load-bearing social handles.
|
||||
|
||||
## Presentation, Voice, And Response
|
||||
|
||||
`presentation_strategy` describes the self being performed.
|
||||
|
||||
That can include charm, detachment, competence theater, cultivated
|
||||
harmlessness, or deliberately performing someone who is abrasive or difficult.
|
||||
Sometimes that abrasive performance is not random meanness. It is a boundary,
|
||||
a threat display, a way to prevent intimacy, or a test to see who flinches.
|
||||
Human beings are regrettably efficient little porcupines.
|
||||
|
||||
`voice_style` describes how that performed self comes out in language.
|
||||
|
||||
The required voice handles are:
|
||||
|
||||
- `dryness`
|
||||
- `verbal_warmth`
|
||||
- `formality`
|
||||
- `verbosity`
|
||||
- `pace`
|
||||
- `plainspoken_directness`
|
||||
- `lexical_precision`
|
||||
- `technical_density`
|
||||
- `technical_compression`
|
||||
- `figurative_language`
|
||||
- `lyricism`
|
||||
- `narrative_detail`
|
||||
- `emotional_explicitness`
|
||||
- `pointedness`
|
||||
- `self_disclosure`
|
||||
- `hedging`
|
||||
- `certainty_marking`
|
||||
- `politeness`
|
||||
- `coded_politeness`
|
||||
- `ritualized_address`
|
||||
- `register_switching`
|
||||
- `dialect_marking`
|
||||
- `theatricality`
|
||||
- `humor`
|
||||
- `conversational_dominance`
|
||||
- `listening_responsiveness`
|
||||
- `question_asking`
|
||||
- `profanity`
|
||||
|
||||
Behavioral warmth and verbal warmth are intentionally separate:
|
||||
|
||||
- `behavioral_dimensions.interpersonal_warmth` is the tendency to offer care,
|
||||
welcome, or affiliation.
|
||||
- `voice_style.verbal_warmth` is the language surface that can sound soft,
|
||||
reassuring, affectionate, or socially inviting.
|
||||
|
||||
So Cat's prickliness is not only a dialogue prompt instruction. It is backed by
|
||||
canonical state:
|
||||
|
||||
- high `presentation_strategy.abrasive_boundary`
|
||||
- high `presentation_strategy.ironic_distance`
|
||||
- high `underlying_organization.shame_sensitivity`
|
||||
- high `underlying_organization.mask_rigidity`
|
||||
- high `behavioral_dimensions.distance_seeking`
|
||||
- high `behavioral_dimensions.suspicion`
|
||||
- high `situational_state.acute_shame` when exposed
|
||||
|
||||
The renderer derives defensive speech from those intersections plus voice
|
||||
style. A dry, pointed voice under shame and exposure pressure may produce
|
||||
sarcastic deflection; the schema does not store sarcasm-as-defense as its own
|
||||
canonical organ.
|
||||
|
||||
Sarcastic deflection, sincerity evasion, emotional evasion,
|
||||
banter-as-boundary, and verbal aggression remain target emergent behaviors for
|
||||
projection tests.
|
||||
|
||||
## Perceived State
|
||||
|
||||
Perceived overlays are observer-specific. They do not replace canonical state.
|
||||
|
||||
An overlay records:
|
||||
|
||||
- observer
|
||||
- target
|
||||
- perceived dimensions, including observed activation and attributed disposition
|
||||
- beliefs
|
||||
- distortions
|
||||
|
||||
This is where misunderstanding lives. Suspicious agents can overread threat.
|
||||
Attachment-hungry agents can overread interpersonal warmth. Avoidant agents can
|
||||
underread care. Fundamental attribution bias also lives here: an observer can
|
||||
mistake exceptional behavior for stable character. None of that requires
|
||||
corrupting the canonical state.
|
||||
|
||||
Relationship stance is directional. If Cat has a relationship record toward Oz,
|
||||
Oz needs a separate record toward Cat. Mutual presence does not imply symmetric
|
||||
trust, fear, obligation, dependence, or expectation of betrayal.
|
||||
|
||||
## Event Interpretation
|
||||
|
||||
Events can include optional interpretation scaffolding:
|
||||
|
||||
- `observed_exchange` for externally visible action or dialogue
|
||||
- `private_interpretations` for each participant's appraisal of what happened
|
||||
- `event_effects` for proposed state deltas, belief creation, and memory updates
|
||||
|
||||
This is not a full social transition engine yet. It is enough structure for the
|
||||
projection layer to know what an event changed without asking later prompts to
|
||||
infer everything from a one-line summary and a bad attitude.
|
||||
|
||||
## Dialogue Context Packs
|
||||
|
||||
Scenes contain `dialogue_context_packs`.
|
||||
|
||||
A pack is a character-local prompt payload for one speaker. It includes:
|
||||
|
||||
- speaker-local truth
|
||||
- speaker beliefs
|
||||
- active memories
|
||||
- active goals
|
||||
- presentation constraints
|
||||
|
||||
The point is not to make the dialogue generator smarter by shouting plot at it.
|
||||
The point is to keep each character inside their own partial reality.
|
||||
Packs are positive-context payloads: if the speaker should not know something,
|
||||
it is not included. Secrets the speaker does know but wants to withhold belong
|
||||
in goals, memories, beliefs, and presentation constraints.
|
||||
|
||||
Ghostlight can build the trap. The character projection decides how the person
|
||||
inside the trap breathes.
|
||||
|
||||
## Current Validation
|
||||
|
||||
The first validator is intentionally small and dependency-free:
|
||||
|
||||
- `tools/validate_agent_state.py`
|
||||
|
||||
It does not implement all of JSON Schema. It checks the invariants Ghostlight
|
||||
currently cares about:
|
||||
|
||||
- example files parse as JSON
|
||||
- schema versions match
|
||||
- agent IDs are unique
|
||||
- required canonical variable groups are present
|
||||
- required relationship stance fields are present
|
||||
- state variable components stay in `0..1`
|
||||
- goals, memories, relationships, scenes, and dialogue context packs reference
|
||||
existing IDs
|
||||
- dialogue packs carry speaker-local context without requiring negative
|
||||
author-only fences
|
||||
|
||||
This gives us a real seam before we build the prompt renderer. Not perfect.
|
||||
Useful. We are allowed to be both.
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
# Corpus Coverage Ledger
|
||||
|
||||
The corpus coverage ledger is the project-level index for accepted and planned
|
||||
Aetheria training fixtures.
|
||||
|
||||
Its job is not to store story content. Its job is to keep the corpus honest:
|
||||
which factions, movements, flashpoints, timeline lanes, tonal modes, collision
|
||||
axes, review states, and item/economy hooks are represented, and which are still
|
||||
missing.
|
||||
|
||||
## Live Files
|
||||
|
||||
- Ledger: `state/corpus-coverage.json`
|
||||
- Schema reference: `schemas/corpus-coverage.schema.json`
|
||||
- Validator/status tool: `tools/validate_corpus_coverage.py`
|
||||
- Command: `npm run coverage:status`
|
||||
|
||||
## Entry Contract
|
||||
|
||||
Each fixture entry records:
|
||||
|
||||
- `fixture_id`: stable fixture handle.
|
||||
- `status`: `planned`, `draft`, `accepted_as_draft`, `accepted`,
|
||||
`needs_revision`, `rejected`, or `superseded`.
|
||||
- `timeline_lane`: `historical_grounded`, `transition_grounded`, or
|
||||
`future_branch`.
|
||||
- `canon_status`: single-history, branch-local, promoted branch, template, or
|
||||
draft status.
|
||||
- `story_type`: founding-era, day-in-the-life, flashpoint, movement,
|
||||
future-branch, or trade-life story.
|
||||
- `primary_subjects` and `secondary_subjects`: factions, movements,
|
||||
institutions, species/body groups, or other represented subjects.
|
||||
- `collision_axes`: the pressures the fixture exercises, such as law, labor,
|
||||
care, class, logistics, species/body affordance, trade, memory, personhood,
|
||||
technology, legitimacy, or infrastructure.
|
||||
- `tonal_modes`: prose modes the fixture contributes to the corpus.
|
||||
- `training_targets`: organs or reference surfaces this fixture is relevant to.
|
||||
- `training_usability`: explicit per-stage declaration of whether the fixture
|
||||
is `training_ready`, `reference_only`, or `not_training_data`.
|
||||
- `artifacts`: paths to fixture files.
|
||||
- `review`: status of schema, narrative, lore, spatial, visual, and IF review.
|
||||
- `economy_hooks`: item, assembly, trade-good, supply-chain, or tech hooks.
|
||||
- `open_lore_gaps`: blocking or useful gaps discovered during generation.
|
||||
|
||||
## Acceptance Rule
|
||||
|
||||
An accepted or accepted-as-draft fixture must have the core artifact refs:
|
||||
|
||||
- lore digest
|
||||
- agent state
|
||||
- coordinator artifact
|
||||
- Ink file
|
||||
- training sidecar
|
||||
|
||||
It must also carry review fields for schema, narrative, lore, spatial, visual,
|
||||
and IF artifact review. A fixture may still be accepted-as-draft with review
|
||||
caveats, but the caveats should be visible instead of hidden under the nice rug.
|
||||
|
||||
Coverage status is not training readiness. A fixture that looks good as a story
|
||||
can still be unusable for a model stage if it lacks exact input/output receipts,
|
||||
raw outputs, reviewer decisions, hidden-context audits, or mutation records in
|
||||
the shape that stage requires. The ledger should be rude about this. If a
|
||||
fixture is only a reference for story shape or grounding, mark it
|
||||
`reference_only`; do not promote it by implication.
|
||||
|
||||
## Report Shape
|
||||
|
||||
`npm run coverage:status` validates the ledger and prints:
|
||||
|
||||
- total entries and accepted count
|
||||
- progress toward the 100-150 broad target
|
||||
- status, timeline lane, story type, collision-axis, tonal-mode, and subject
|
||||
counts
|
||||
- training-usability counts
|
||||
- major-power founding/day-in-life candidates when present
|
||||
- basic gap signals, such as missing future-branch coverage or no day-in-life
|
||||
stories yet
|
||||
|
||||
This is deliberately a coarse instrument. It should tell us where to point the
|
||||
next fixture without pretending to be a corpus scientist in a lab coat made of
|
||||
spreadsheet tabs.
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
# CultNet Shared Agent-State Contract
|
||||
|
||||
Ghostlight owns the first cross-runtime agent-state payload contract.
|
||||
|
||||
That contract is not a new ontology. It is the existing Ghostlight state shape
|
||||
moving over a wire without turning into bespoke JSON fog in every other repo.
|
||||
|
||||
## Canonical Payload
|
||||
|
||||
The first shared payload contract is:
|
||||
|
||||
- schema id:
|
||||
`https://github.com/GameCult/Ghostlight/schemas/agent-state.schema.json`
|
||||
- schema version:
|
||||
`ghostlight.agent_state.v0`
|
||||
- canonical file:
|
||||
`schemas/agent-state.schema.json`
|
||||
|
||||
This is the same state shape already used for Ghostlight fixtures and the same
|
||||
top-level shape Epiphany role dossiers adopted. The contract is:
|
||||
|
||||
- `world`
|
||||
- `agents`
|
||||
- `relationships`
|
||||
- `events`
|
||||
- `scenes`
|
||||
|
||||
If another runtime wants to carry socially persistent agent state without
|
||||
inventing its own private folk religion, this is the first house shape.
|
||||
|
||||
## Wire Envelope
|
||||
|
||||
CultNet should carry the Ghostlight payload as a typed document replication
|
||||
message, not as a one-off ad hoc blob.
|
||||
|
||||
Current expected document envelope:
|
||||
|
||||
- `documentType`: `ghostlight.agent-state`
|
||||
- `documentKey`: runtime-defined stable key
|
||||
- `payloadSchemaVersion`: `ghostlight.agent_state.v0`
|
||||
- `payload`: exact Ghostlight state document
|
||||
|
||||
The current TypeScript client library is:
|
||||
|
||||
- package: `cultnet-ts`, in `GameCult/CultLib` under `packages/cultnet-ts`
|
||||
|
||||
Its job is not merely to make pipes pretty. It owns:
|
||||
|
||||
- direct-pipe MessagePack framing
|
||||
- typed message contracts
|
||||
- authentication/session semantics compatible with CultLib's `GameCult.Networking`
|
||||
- typed replication into CultCache-compatible stores
|
||||
|
||||
## Authentication Story
|
||||
|
||||
CultNet inherits the useful security shape from CultLib:
|
||||
|
||||
- a shared connection key known to client and server/runtime peers
|
||||
- AES-GCM encrypted auth/session payloads derived from that key
|
||||
- a server-side session-signing secret
|
||||
- signed session tokens used for verify/reconnect flows
|
||||
|
||||
This matters because "shared agent state" is not only a serialization problem.
|
||||
It is also a trust boundary problem. We want one family resemblance across C#,
|
||||
TypeScript, Rust, and Python instead of four mutually unintelligible little
|
||||
shrines to local convenience.
|
||||
|
||||
## Contract Discipline
|
||||
|
||||
Ghostlight remains the source of truth for the payload shape.
|
||||
|
||||
That means:
|
||||
|
||||
- if `schemas/agent-state.schema.json` changes, CultNet mirrors must update in
|
||||
lockstep
|
||||
- consumers may add local document metadata around the payload, but should not
|
||||
silently mutate the payload contract itself
|
||||
- if a runtime needs a narrower projection or local working view, that is a
|
||||
derivative contract, not a revision of `ghostlight.agent_state.v0`
|
||||
|
||||
The rule is simple:
|
||||
|
||||
- prompt projection may differ
|
||||
- storage engines may differ
|
||||
- language bindings may differ
|
||||
- the shared payload contract does not drift just because another repo got
|
||||
lonely
|
||||
|
||||
## Immediate Consumers
|
||||
|
||||
The first intended consumers are:
|
||||
|
||||
- Ghostlight fixture/state tooling
|
||||
- EpiphanyAgent role dossiers and heartbeat-adjacent agent state
|
||||
- VoidBot companion-state and social memory machinery where the full Ghostlight
|
||||
payload is useful
|
||||
|
||||
More bindings can come later. They do not get to invent a prettier truth.
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
# Ghostlight Dungeon Multiplayer Intention
|
||||
|
||||
## Status
|
||||
|
||||
Bounded cooperative multiplayer is implemented in the current MVP: one to
|
||||
eight authenticated players create and seed a world (`world.create` v2,
|
||||
`world.seed`) and begin in one shared scene with sequential public actions,
|
||||
actor-filtered surfaces, pooled Persona-cell entitlements, and unanimous time,
|
||||
travel, and budget governance.
|
||||
The broader model in this document remains the direction for split parties,
|
||||
private play actions, simultaneous declarations, delegation, and PvP.
|
||||
|
||||
The existing simulation is already shaped around multiple bounded actors. The
|
||||
extension should expose that anatomy to multiple human controllers without
|
||||
creating a second world authority or a special multiplayer simulation loop.
|
||||
|
||||
## Objective
|
||||
|
||||
Let several human-controlled characters inhabit one persistent campaign while
|
||||
preserving the same spatial, epistemic, causal, and commit invariants as solo
|
||||
play. The implemented milestone keeps the party together. Split locations,
|
||||
late joining, and private in-play actions remain future governance work.
|
||||
|
||||
## Authority map
|
||||
|
||||
- **Owner:** one per-campaign `WorldKernel` continues to own canonical state,
|
||||
fictional time, revisions, rolls, events, and commits.
|
||||
- **Inputs:** authenticated commands attributed to one campaign member and one
|
||||
exact human-controlled actor; NPC and Gestalt proposals; scheduler commands;
|
||||
current campaign and resolution revisions.
|
||||
- **Outputs:** one atomic world transition plus actor-specific perceived events,
|
||||
narrative projections, news access, and command receipts.
|
||||
- **Derived state:** party rosters, shared transcript views, presence indicators,
|
||||
ready state, and client notifications are projections of campaign membership,
|
||||
occupancy, perception, and committed events.
|
||||
- **Forbidden writers:** browsers, sessions, party leaders, Personas,
|
||||
Interpreters, narrators, initiative, and realtime transports cannot commit or
|
||||
repair campaign state.
|
||||
- **Shared path:** solo commands, bounded multiplayer commands, unanimous waits
|
||||
and travel, NPC reactions, scheduler ticks, and reconnects use the same
|
||||
`WorldCommand → validate → atomic commit` primitive. Future simultaneous
|
||||
windows must join this path too.
|
||||
- **Cut line:** do not add a parallel room-state store, party prompt, shared
|
||||
narrator memory, or multiplayer-only mutation path.
|
||||
|
||||
## Intended model
|
||||
|
||||
```text
|
||||
Player A session ──> actor A command ─┐
|
||||
Player B session ──> actor B command ─┼─> campaign mailbox
|
||||
NPC / Gestalt proposals ──────────────┘ │
|
||||
v
|
||||
WorldKernel
|
||||
│
|
||||
one committed event + revision
|
||||
│ │
|
||||
A-perceived projection B-perceived projection
|
||||
│ │
|
||||
A's surface B's surface
|
||||
```
|
||||
|
||||
A human-controlled actor differs from a Persona-controlled actor only in the
|
||||
source of its candidate action. Human text still describes an attempt. It does
|
||||
not bypass capability, location, custody, knowledge, opposition, assessment,
|
||||
initiative, or commit validation.
|
||||
|
||||
## Invariants
|
||||
|
||||
- Every active canonical subject is represented exactly once in the resolution
|
||||
cover regardless of how many humans are connected.
|
||||
- One authenticated campaign member controls one or more explicitly assigned
|
||||
actors according to campaign policy. Control is never inferred from scene
|
||||
presence.
|
||||
- An absent or disconnected player's character is not puppeted. Delegation or
|
||||
Persona proxying requires an explicit, revocable campaign permission.
|
||||
- Perception and knowledge remain actor-local. A shared party transcript may
|
||||
contain only events all included viewers are permitted to perceive.
|
||||
- Private speech, hidden action, secret evidence, and character-local news are
|
||||
projected only to entitled sessions.
|
||||
- Players in different locations receive different narrative streams derived
|
||||
from the same committed world.
|
||||
- A player cannot make another player's action, reaction, injury, belief, or
|
||||
relationship true by describing it.
|
||||
- Simultaneously submitted incompatible attempts resolve from one snapshot and
|
||||
one deterministic initiative/conflict pass. Arrival order at HTTP or CultNet
|
||||
transport is not fictional initiative.
|
||||
- Each active member contributes the allowance returned by the entitlement
|
||||
port. The campaign pool is capped by the operator ceiling of 240. Human
|
||||
actors occupy mandatory cells without Persona inference; provider request
|
||||
concurrency remains independent of the chosen cover.
|
||||
- Reconnect and replay derive the player's view from committed receipts and
|
||||
perception rules. A client cache cannot repair or override the kernel.
|
||||
- One malformed, stale, or unauthorized participant proposal cannot partially
|
||||
mutate the shared world.
|
||||
|
||||
## Social policy still to design
|
||||
|
||||
The architecture makes shared worlds straightforward. A usable multiplayer
|
||||
game still needs explicit answers for:
|
||||
|
||||
- exclusive character control, temporary delegation, and permanent departure;
|
||||
- host powers over invitation, removal, reset, fork, export, and campaign policy;
|
||||
- asynchronous pacing and how much fictional time one active player may advance;
|
||||
- simultaneous declaration windows for conflict, stealth, and split scenes;
|
||||
- consent for player-versus-player persuasion, deception, theft, restraint,
|
||||
injury, extraordinary influence, and character death;
|
||||
- disclosure of hidden rolls, private messages, secrets, and retrospective logs;
|
||||
- moderation, blocking, reporting, and campaign-data deletion;
|
||||
- mixed privacy/provider lanes and whether one campaign may contain them;
|
||||
- campaign billing, seats, guests, and ownership transfer.
|
||||
|
||||
These are campaign-governance decisions, not browser affordances. They must be
|
||||
typed, persisted, inspectable, and enforced at command admission.
|
||||
|
||||
## Product sequence
|
||||
|
||||
1. Prove the solo loop and multiresolution setting agency. **Complete.**
|
||||
2. Add campaign membership and exact actor-control assignments. **Complete.**
|
||||
3. Support up to eight cooperative players in one scene with actor-specific
|
||||
output and unanimous group governance. **Complete in this milestone.**
|
||||
4. Support split locations, private communication, departure, and late joining.
|
||||
5. Add snapshot-bound simultaneous declaration windows.
|
||||
6. Admit adversarial actions only after consent and governance policy is typed
|
||||
and tested.
|
||||
|
||||
The first multiplayer acceptance target should be cooperative and small. The
|
||||
runtime should earn social complexity one invariant at a time.
|
||||
|
||||
## Acceptance direction
|
||||
|
||||
- Two players in one scene see one committed event and their own permitted
|
||||
interpretations.
|
||||
- A private fact learned by one player never appears in the other's projection.
|
||||
- Split players can act in different locations without duplicating geometry,
|
||||
actors, clocks, or institutions.
|
||||
- Concurrent incompatible attempts resolve once from the same revision.
|
||||
- A disconnected player remains unpuppeted while world clocks and remote agency
|
||||
continue under campaign policy.
|
||||
- Rejoining reconstructs the player's permitted continuity from exact receipts.
|
||||
- Gestalt merge, split, migration, and named-member rematerialisation remain
|
||||
correct while several human actors create simultaneous foreground pressure.
|
||||
|
|
@ -1,10 +1,14 @@
|
|||
# Ghostlight authority architecture
|
||||
|
||||
> Status: the Dungeon daemon described here is parked at tag `parked/ghostlight-eve-surface` (2026-10-22); it is not on main.
|
||||
|
||||
## Status
|
||||
|
||||
This document is the adopted rebuild target for the Ghostlight runtime. It is
|
||||
the authority map for implementation work. The pre-rebuild body remains
|
||||
described in `notes/ghostlight-current-system-map.md` only as teardown evidence.
|
||||
the authority map for implementation work. What it describes is built in
|
||||
`crates/ghostlight` except where it says otherwise here: bounded co-op and the
|
||||
`archived` phase with read/export-only archival are design intent, and nothing
|
||||
in `crates/` implements them (`WorldPhase` has `Draft` and `Active` only).
|
||||
|
||||
Ghostlight is a persistent, source-grounded narrative world. Its job is to
|
||||
preserve people, institutions, places, knowledge, material consequence, and
|
||||
|
|
@ -13,8 +17,8 @@ committee of models until one model permits another model's text to exist.
|
|||
|
||||
## Objective
|
||||
|
||||
Build one sparse, open-world causal machine that supports solo and bounded
|
||||
co-op play, autonomous subject decisions, source-grounded expansion, external
|
||||
Build one sparse, open-world causal machine that supports solo play (bounded
|
||||
co-op is design intent, unbuilt), autonomous subject decisions, source-grounded expansion, external
|
||||
world owners, and actor-private experience.
|
||||
|
||||
The world grows when committed pressure reaches a typed boundary: a new
|
||||
|
|
@ -61,11 +65,14 @@ multiple process attempts.
|
|||
|
||||
`WorldKernel` owns:
|
||||
|
||||
- lifecycle phase and revision;
|
||||
- membership, contract, boundaries, and approvals;
|
||||
- the typed world ontology;
|
||||
- committed events and fictional time;
|
||||
- fictional time, commitments, and pressures;
|
||||
- lifecycle phase and revision (`WorldState.phase`, `revision`);
|
||||
- the owner and the draft approvals (`owner`, `draft_approvals`); membership,
|
||||
a contract, and negotiated boundaries are design intent and no field of
|
||||
`WorldState` holds them;
|
||||
- the typed world ontology (the partitions listed under "Canonical world
|
||||
state");
|
||||
- committed events and fictional time (`events`, `now`);
|
||||
- commitments and pressures;
|
||||
- command idempotency and the commit digest chain.
|
||||
|
||||
There is no separate Session Zero owner, component-world owner, aggregate
|
||||
|
|
@ -75,11 +82,14 @@ campaign owner, elaboration committer, scheduler writer, or recovery writer.
|
|||
|
||||
The only mutation input is a `CommandEnvelope` containing:
|
||||
|
||||
- a stable command ID;
|
||||
- the exact world ID and expected revision;
|
||||
- an authenticated principal or internal system capability;
|
||||
- one closed command body;
|
||||
- exact evidence references where the command depends on source material.
|
||||
- a stable command ID (`id`);
|
||||
- the exact world ID and expected revision (`world_id`, `expected_revision`);
|
||||
- an authenticated principal or internal system capability (`caller`);
|
||||
- one closed command body (`body: CommandBody`).
|
||||
|
||||
`CommandEnvelope` (`crates/ghostlight/src/lib.rs`) has exactly these five
|
||||
fields. Evidence references travel inside `AdmitPatch`'s `WorldPatch`, not on
|
||||
the envelope.
|
||||
|
||||
The caller supplies identity evidence, not an authority verdict. For a patch
|
||||
command the kernel derives the caller's authority itself:
|
||||
|
|
@ -97,15 +107,17 @@ controller receives a privileged mutation path.
|
|||
|
||||
### Outputs
|
||||
|
||||
An accepted command produces one `WorldCommit` containing:
|
||||
An accepted command produces one `WorldCommit` (`crates/ghostlight/src/lib.rs`)
|
||||
whose fields are:
|
||||
|
||||
- previous and resulting revision;
|
||||
- previous and resulting state digest;
|
||||
- command ID and derived authority digest;
|
||||
- the exact admitted mutation batch;
|
||||
- committed events;
|
||||
- evidence references;
|
||||
- commit time and previous commit digest.
|
||||
- `schema` and `world_id`;
|
||||
- `command` (the committed command);
|
||||
- `previous_revision` and `resulting_revision`;
|
||||
- `previous_state_digest` and `resulting_state_digest`;
|
||||
- `previous_commit_digest` and its own `digest`;
|
||||
- `effect` (a `WorldEffect`: the admitted patch, decision, or time step, with
|
||||
its committed events);
|
||||
- `committed_at`.
|
||||
|
||||
The next `WorldState` and `WorldCommit` are persisted atomically. A rejection
|
||||
returns typed invariant failures and changes nothing.
|
||||
|
|
@ -151,28 +163,40 @@ command.
|
|||
`WorldState` is one aggregate with these owned partitions:
|
||||
|
||||
```text
|
||||
WorldState
|
||||
identity: world_id, revision, phase, state_digest, last_commit_digest
|
||||
governance: members, actor bindings, contract, boundaries, approvals
|
||||
WorldState (crates/ghostlight/src/lib.rs, the fields of the struct)
|
||||
identity: schema, world_id, revision, phase, state_digest, last_commit_digest
|
||||
authorship: owner, title, brief, draft_approvals
|
||||
ontology:
|
||||
places and directed routes
|
||||
subjects and typed components
|
||||
relations
|
||||
pressures and commitments
|
||||
facts, knowledge grants, and provenance
|
||||
external-owner grants
|
||||
time: fictional clock
|
||||
events: committed factual and speech events
|
||||
applied_commands: bounded idempotency ledger
|
||||
subjects, entities (place, resource, fact, channel), edges (routes)
|
||||
positions, holdings, dependencies
|
||||
facts, channels, knowledge
|
||||
authority, selection, redress
|
||||
commitments, pressures, last_opportunity_at
|
||||
persona_material
|
||||
controller_assignments, affordance_catalog, affordance_grants
|
||||
time: now
|
||||
intent: scale_intent, lens_weights
|
||||
events: events (decision events)
|
||||
```
|
||||
|
||||
There is no `members`, `contract`, or boundaries field and no
|
||||
`applied_commands` ledger in `WorldState`. Command idempotency is decided from
|
||||
the commit rows keyed by command id. `WorldCommit` rows, not `WorldState`,
|
||||
carry the digest chain.
|
||||
|
||||
The lifecycle is data inside the aggregate:
|
||||
|
||||
- `draft`: membership, negotiation, private/shared speech, character creation,
|
||||
evidence admission, and ontology construction are allowed by draft authority;
|
||||
- `active`: player and autonomous decisions, time, causal expansion, and
|
||||
contract amendments are allowed by active authority;
|
||||
- `archived`: only read/export operations are allowed.
|
||||
`WorldPhase` has two variants, `Draft` and `Active`:
|
||||
|
||||
- `Draft`: `ApproveDraft`, `ActivateWorld`, `AdmitPatch` (seed and ontology
|
||||
construction under draft authority, which answers nothing), and
|
||||
`SetLensWeights`;
|
||||
- `Active`: `ExerciseDecision`, `DeclineDecision`, `AdvanceTime`, `AdmitPatch`
|
||||
that answers a derived boundary or a jurisdiction deficit, and
|
||||
`SetLensWeights`.
|
||||
|
||||
An `archived` phase with read/export-only archival is design intent; no variant
|
||||
exists.
|
||||
|
||||
Activation changes `phase` in place after the current revision has the required
|
||||
member approvals. It does not copy a preview into another store or hand truth
|
||||
|
|
@ -233,19 +257,20 @@ custody, topology, or knowledge component.
|
|||
|
||||
### Relations
|
||||
|
||||
Relations have typed endpoints and kind-specific constraints. The vocabulary
|
||||
covers:
|
||||
`EdgeKind` (`crates/ghostlight/src/patch.rs`) has one variant, `Route`, so the
|
||||
kernel has no general relation record yet. What the kernel carries, each in
|
||||
its own `WorldState` partition with typed endpoints:
|
||||
|
||||
- containment and occupancy;
|
||||
- membership and lineage;
|
||||
- jurisdiction and representation;
|
||||
- custody and dependency;
|
||||
- authority and control;
|
||||
- supply and service;
|
||||
- alliance, opposition, and obligation;
|
||||
- knowledge and communication;
|
||||
- topology and access;
|
||||
- causal pressure and exposure.
|
||||
- containment (`EntityRecord.container`) and occupancy (`positions`);
|
||||
- topology and access (`Route` edges with `AccessKind`);
|
||||
- custody (`holdings`) and dependency (`dependencies`);
|
||||
- authority and representation (`authority`, `selection`, `redress`);
|
||||
- knowledge and communication (`knowledge`, `channels`);
|
||||
- obligation (`commitments`) and causal pressure (`pressures`);
|
||||
- control (`controller_assignments`).
|
||||
|
||||
Membership, lineage, supply and service, and alliance or opposition as
|
||||
relations are design intent and have no record shape.
|
||||
|
||||
Civic order is a typed subgraph of authority, selection or succession,
|
||||
resource access, representation, and redress relations. It is not a prose
|
||||
|
|
@ -288,15 +313,22 @@ authenticated intent or model tool call
|
|||
-> derive projections and next attention plan
|
||||
```
|
||||
|
||||
The closed command vocabulary includes lifecycle and membership changes,
|
||||
contract and boundary changes, speech, ontology construction, subject
|
||||
decisions, time advance, external snapshots, evidence admission, and archive.
|
||||
Every command lowers to the same mutation vocabulary. There is no
|
||||
The closed command vocabulary is `CommandBody` (`crates/ghostlight/src/lib.rs`),
|
||||
seven variants: `ApproveDraft`, `ActivateWorld`, `ExerciseDecision`,
|
||||
`DeclineDecision`, `AdmitPatch`, `AdvanceTime`, `SetLensWeights`. Speech is an
|
||||
`ExerciseDecision` effect, ontology construction and evidence admission are
|
||||
`AdmitPatch`, and world creation is a separate genesis (`CreateWorld`).
|
||||
Membership, contract and boundary changes, external snapshots, and archive are
|
||||
not commands. Every command lowers to the same mutation vocabulary. There is no
|
||||
`commit_elaboration`, separate component-batch ingress, or reload repair path.
|
||||
|
||||
## Inference boundary
|
||||
|
||||
Ghostlight supports two first-class decision interfaces. The ontology assigns
|
||||
`ControllerMode` (`crates/ghostlight/src/lib.rs`) has three variants, `Human`,
|
||||
`NarrativePersona`, and `OperationalAgent`; an externally controlled subject
|
||||
has no mode. Ghostlight supports two first-class model decision interfaces
|
||||
(`DecisionControllerMode` in `crates/ghostlight-persona-projection/src/lib.rs`:
|
||||
`NarrativePersona` and `OperationalAgent`). The ontology assigns
|
||||
each exact authority scope to one `DecisionController`; the scheduler does not
|
||||
choose a mode opportunistically. The scheduler does choose resolution: a
|
||||
`NarrativePersona` controller receives its prose membrane in a singleton cell
|
||||
|
|
@ -434,8 +466,9 @@ does not require a provider, model tier, stage name, output hash, or verifier
|
|||
receipt to authorize world state.
|
||||
|
||||
Every controller lane reaches one `InferencePort`, but that port now routes
|
||||
to either of two transports chosen by the lane's configured model name: the
|
||||
CodexConnector daemon peer, unchanged, or `SdkInferencePort`, a Node child
|
||||
to one of three transports chosen by the lane's configured model name: the
|
||||
CodexConnector daemon peer, unchanged; a local OpenAI-compatible loopback
|
||||
port (the `local/` prefix, Bonsai in production); or `SdkInferencePort`, a Node child
|
||||
process running the Claude Agent SDK against the Claude subscription while
|
||||
no Codex or Anthropic API budget exists. The SDK route is a named stopgap,
|
||||
not a peer design: its receipt attests only that this process's own child
|
||||
|
|
@ -521,11 +554,13 @@ author proposal; they do not become a hidden admission tribunal.
|
|||
|
||||
## Persistence and recovery
|
||||
|
||||
One world `.cc` store contains:
|
||||
One world `.cc` store (`crates/ghostlight/src/journal.rs`) contains:
|
||||
|
||||
- one current `world_state.v1` row;
|
||||
- immutable `world_commit.v1` rows forming a digest chain;
|
||||
- immutable exact evidence receipts referenced by commits.
|
||||
- one current `world_state.elaboration.v1` row, schema
|
||||
`ghostlight.world_state.consumer.v6`;
|
||||
- immutable `world_commit.elaboration.v1` rows, schema
|
||||
`ghostlight.world_commit.consumer.v6`, forming a digest chain. Evidence
|
||||
receipts ride inside the commits' patches; there is no separate evidence row.
|
||||
|
||||
Service authentication and model telemetry use separate service-owned stores.
|
||||
They cannot participate in world authority.
|
||||
|
|
@ -586,11 +621,16 @@ exists: the play agent's Cut 1 (`d69e9d4`) deleted the tick driver, cover and
|
|||
Active elaboration sweep it exercised, along with the ignored test itself, to
|
||||
clear the ground for the play agent's own owner. `notes/local-live-smoke.md`
|
||||
now documents only connector and Claude SDK sidecar bring-up, which the seed
|
||||
lane still uses. Exercising the road against a real controller again is the
|
||||
play agent's own gate (`ghostlight-play-agent.md`), not this contract.
|
||||
`world.create`'s current payload is `world_create.v4`: title, brief, targets,
|
||||
jurisdiction roots and lens weights are required, and Dungeon's create surface
|
||||
offers uniform weights as its editable default. `world.seed` runs one
|
||||
lane still uses. The real-model gate that exists today is the Idunn recipe's
|
||||
`accept-real-persona-membrane` step (`deployment/idunn/recipe.toml`), which
|
||||
runs `controllers::tests::real_local_model_cognition_modes_commit_speech`
|
||||
against a real model; exercising a whole play turn against one is not gated
|
||||
by this contract.
|
||||
`world.begin` is the one route that makes a world. Its payload is
|
||||
`world_begin.v1`: title, brief and subject_label, nothing else; the route
|
||||
decides everything else a world is born with (no targets, no roots, no persona
|
||||
or agent label, uniform lens weights) and `begin_plan` decides how far Begin
|
||||
goes. `world.seed` runs one
|
||||
`SeedRunner` session per invocation against a `VaultEvidenceSource`.
|
||||
|
||||
Focused tests must prove:
|
||||
|
|
|
|||
|
|
@ -1,333 +0,0 @@
|
|||
# Ghostlight Dungeon Session Zero and Bounded Co-op
|
||||
|
||||
## Status
|
||||
|
||||
Teardown evidence, not a live authority map. The `SessionZeroKernel`, the
|
||||
compiler, the preview and contract-review organs described below exist in no
|
||||
committed source. World creation is `world.create` on the sealed kernel; seed
|
||||
authoring is plan step 8, the seed producer: a Draft-phase authoring session
|
||||
that admits `AdmitPatch` seeds as the owner for approval before activation
|
||||
(`notes/ghostlight-implementation-plan.md`). Read this document only for the
|
||||
co-op intent and the rejected compiler shape.
|
||||
|
||||
## Owners
|
||||
|
||||
### SessionZeroKernel
|
||||
|
||||
`SessionZeroKernel` owns one persistent draft mailbox and its revisions. Its
|
||||
MessagePack CultCache store contains the roster, single-use invitations,
|
||||
shared/private channels, messages, contract, private character drafts,
|
||||
boundaries, decisions, approvals, DM Persona, evidence coverage, compilation
|
||||
preview, and publication receipt.
|
||||
|
||||
The store keeps two deliberately different model-receipt collections. The
|
||||
inference audit contains every committed invocation, including two calls whose
|
||||
semantic receipt hashes match but whose latency or provider-attempt telemetry
|
||||
differs. The active-preview proof set contains only the compiler transaction
|
||||
that produced the currently retained preview. Replacing or retiring a preview
|
||||
clears that proof set without erasing the audit. A campaign publication carries
|
||||
only the active proof set; historical DM turns and superseded compiler runs do
|
||||
not become evidence for a seed they did not produce.
|
||||
|
||||
Player messages are durable conversation, not campaign facts. Projector, DM
|
||||
Persona, Interpreter, retrieval, and compilation stages return proposals. Only
|
||||
the kernel may change the draft.
|
||||
|
||||
Private DM channels use character-component and channel epochs. Independent
|
||||
private turns can finish in parallel even if an unrelated private component
|
||||
commits first. Shared changes serialize through the shared epoch. No output is
|
||||
rebased.
|
||||
|
||||
### WorldKernel
|
||||
|
||||
`WorldKernel` receives nothing while Session Zero is drafting. Publication
|
||||
builds a new campaign store in a private staging directory. Campaign seed,
|
||||
membership, contract, governance, DM Persona, approved brief, approval digests,
|
||||
Vault evidence, and model receipts enter one empty CultCache batch. The
|
||||
directory becomes discoverable only after the batch succeeds.
|
||||
|
||||
Publication is idempotent by the approved seed digest. Repeating the same
|
||||
publication recovers the campaign. Reusing the campaign ID for a different
|
||||
digest is rejected.
|
||||
|
||||
## Privacy membrane
|
||||
|
||||
Shared DM inference receives the shared contract, aggregate boundary policy,
|
||||
public party cards, shared decisions, evidence coverage, and recent shared
|
||||
messages. It cannot receive raw boundaries, another player's private draft, or
|
||||
private messages.
|
||||
|
||||
Private DM inference receives the shared contract, aggregate policy, public
|
||||
party cards, that player's character draft, that player's attributed
|
||||
boundaries, visible decisions, and recent messages from that private channel.
|
||||
It cannot receive another player's private state.
|
||||
|
||||
Account hashes and invite-token hashes remain private persistence fields. They
|
||||
are absent from JSON Schemas, browser projections, CultMesh surfaces, and model
|
||||
contexts. Actor-specific campaign surfaces use random campaign member IDs and
|
||||
the exact assigned actor.
|
||||
|
||||
The structured Projector and Interpreter receive their exact stable JSON
|
||||
Schema before any dynamic context. OpenAI-compatible JSON-object modes
|
||||
constrain syntax but do not communicate the application schema by themselves.
|
||||
The DM Persona owns the natural utterance. The Interpreter's model-facing
|
||||
schema has no speech field: it emits only new typed proposals and reply
|
||||
affordances, after which the runtime binds the Persona output losslessly into
|
||||
the proposed kernel delta. It cannot rewrite, summarize, or truncate the
|
||||
Persona while extracting state. The kernel still treats the three stages as
|
||||
one atomic proposal; an invalid Interpreter commits neither speech nor state.
|
||||
|
||||
The Interpreter receives only the current contract, its channel and member
|
||||
scope, the entitled private character when applicable, unresolved material
|
||||
decisions, and the exact Persona response. Non-material opening and role cards
|
||||
remain renderer-visible suggestions but never enter Projector or Interpreter
|
||||
cognition unless the player explicitly selects or discusses one. Transcript windows, evidence coverage,
|
||||
boundaries, and public-party narrative already served their purpose in the
|
||||
Projector and are not replayed into extraction. It cannot restate existing
|
||||
draft fields or unresolved decisions as new proposals. Stable schema-first
|
||||
prefixes preserve provider cache reuse. Empty, malformed, schema-invalid, or
|
||||
stale outputs cannot change the typed draft. If both same-snapshot attempts
|
||||
fail, the kernel may append a fixed local DM notice to the conversation while
|
||||
leaving contract, characters, decisions, boundaries, approvals, and model
|
||||
receipts unchanged.
|
||||
|
||||
Every player-triggered Session Zero model task enters the same live-inference
|
||||
priority gate as campaign play. This includes opening and role suggestions, DM
|
||||
turns, counter replacements, and world compilation. Entering the gate cancels
|
||||
an in-flight strategic model wave before it can commit and prevents new
|
||||
background inference from launching until the interactive task leaves. A quiet
|
||||
campaign cannot consume provider concurrency while a player is waiting at the
|
||||
table.
|
||||
|
||||
One Interpreter turn has one owner per typed lane. A shared turn may directly
|
||||
edit the draft contract or offer a contract decision, never both; a private turn
|
||||
may directly edit the ordinary character draft or offer a character-patch
|
||||
decision, never both. An extraordinary-permission bargain is a separate consent
|
||||
lane, so it may accompany a direct mundane character patch without being
|
||||
silently granted. The model-facing schema requests a same-snapshot correction for a
|
||||
split-lane output, and the kernel repeats the invariant before mutation.
|
||||
|
||||
Decision metadata is not part of the general Interpreter's ontology. Its
|
||||
schema carries only the prompt, proposed resolution, and one or more typed
|
||||
semantic payloads. The Director assigns a fresh durable decision ID, exact
|
||||
private-channel owner (or shared ownership), material status, and clean
|
||||
lifecycle state. Extraordinary-permission proposals similarly contain terms
|
||||
only; Ghostlight assigns the permission ID and binds the exact entitled actor.
|
||||
Model output cannot reuse a decision ID, target another actor, replace an
|
||||
existing permission by guessing its ID, or claim evidence custody. Focused
|
||||
counter replacement preserves the already committed permission identity while
|
||||
allowing only its negotiated terms to change.
|
||||
|
||||
Generated opening and role suggestions are non-material decisions: the player
|
||||
may accept, counter, discuss, or ignore them without blocking a fully custom
|
||||
draft. They are UI affordances, not implicit DM memory. On daemon load, the registry demotes pre-cut payloadless questions and
|
||||
generated opening records whose old `material` flags would otherwise veto a
|
||||
custom draft. A payloadless question cannot own compilation readiness because
|
||||
it has no typed change to accept. This migration uses an exact CultCache
|
||||
replacement and does not advance the Session Zero revision or shared epoch.
|
||||
Material decisions preserve the distinction between discussion and consent.
|
||||
Every acceptable decision carries at least one non-empty typed contract,
|
||||
character, or extraordinary-permission payload. A question or a prose promise
|
||||
without an exact state change remains DM speech or a suggested reply; it cannot
|
||||
render an Accept control, advance the draft through acceptance, or claim that
|
||||
the promised materialization occurred. The Interpreter schema enforces the
|
||||
payload before correction/retry, and the kernel rejects any legacy payloadless
|
||||
decision that reaches acceptance.
|
||||
The actor-filtered Eve projection shows that exact payload beside the DM's
|
||||
explanation before exposing Accept. Its private character ledger projects every
|
||||
player-entitled draft field, including history, secrets, knowledge, equipment,
|
||||
relationships, goals, and the complete terms of extraordinary permissions.
|
||||
Prose explains a bargain; the visible typed payload is the state the player is
|
||||
actually consenting to.
|
||||
Character patches are reversible during negotiation. Every list supports exact
|
||||
removals before additions, and relationship keys support exact removal before a
|
||||
replacement is installed. This permits spelling, identity, and mistaken-ledger
|
||||
corrections without duplicate private subjects or transcript replay.
|
||||
Accept applies only the exact typed payload currently stored in the decision.
|
||||
Counter preserves that payload as an inert, visible audit and replacement
|
||||
basis, records the player's text in the decision's durable channel, marks the
|
||||
unresolved decision as awaiting a DM replacement, and invalidates the affected
|
||||
projection epoch. `pending_counter` removes the Accept surface and makes the
|
||||
retired payload uncommittable. The DM response
|
||||
must produce a fresh material decision against that same channel and component
|
||||
epoch; installing it and retiring the pending counter is one kernel commit.
|
||||
Until then the old decision has no Accept surface and forged acceptance fails.
|
||||
Inference failure, an irrelevant response, or a stale response leaves the
|
||||
counter unresolved and compilation blocked.
|
||||
Retry relaunches the membrane from that already persisted counter and unchanged
|
||||
Session Zero snapshot. It does not ask the player to retype the counter, append
|
||||
a duplicate message, or advance revision merely to try inference again; only a
|
||||
later kernel-validated replacement changes state.
|
||||
The countered decision ID is also part of the model snapshot binding and
|
||||
permitted turn context. A deterministic Ghostlight Projector renders that one
|
||||
pending decision, its retired typed payload, exact counter, and aggregate safety
|
||||
policy into the Persona's narrative stream. This exact projection needs no
|
||||
model call or model receipt; the focused turn records only Persona and
|
||||
Interpreter inference. The Interpreter separately receives the same bounded
|
||||
typed basis. When the retired proposal still has one exact typed lane, the
|
||||
Interpreter emits only that replacement payload: character patch, contract
|
||||
patch, or permission terms. Ghostlight binds the fresh decision ID, owner,
|
||||
materiality, evidence, prompt, and persisted counter locally. Permission IDs,
|
||||
actor bindings, and evidence receipts are preserved from the retired proposal
|
||||
and are absent from the model's output authority. The larger union Interpreter
|
||||
remains only for ordinary conversation and pre-cut payloadless migration.
|
||||
Conversation history, party
|
||||
cards, the contract, unrelated decisions, and unrelated private character state
|
||||
are not part of the focused retry. Pre-cut decisions whose typed payload was
|
||||
erased receive one bounded current-state basis for migration only. The kernel
|
||||
accepts exactly one same-lane replacement with stable extraordinary-permission
|
||||
identity; direct patches or unrelated decisions cannot retire the counter.
|
||||
|
||||
## Approval and publication
|
||||
|
||||
The host locks the roster before compilation. Material unresolved decisions
|
||||
block compilation. The kernel also refuses a conversation-only draft: the
|
||||
typed contract must identify premise, canon horizon, start, pressure, goal,
|
||||
tone, pacing, consequences, narrative focus, and DM style; every active
|
||||
character must have a public premise, capability, goal, and at least one
|
||||
obligation or vulnerability. Group drafts additionally require a party bond.
|
||||
The compiler receives an `approved_campaign_brief.v1`, not
|
||||
the transcript. Private character history and secrets are withheld from world
|
||||
generation; after the grounded seed validates, Ghostlight locally replaces the
|
||||
provisional compiler player with the exact approved actors at one shared start.
|
||||
Approved relationships cross that membrane by identity, not by free-form map
|
||||
key. A relationship naming another approved player resolves to that player's
|
||||
exact actor ID. An otherwise unresolved named person receives a stable,
|
||||
server-generated relationship-anchor actor ID; the compiler must materialize
|
||||
that exact actor and name in the seed, but it never receives the private
|
||||
relationship description and may not reveal the anchor in opening narration
|
||||
merely because the private ledger requires their existence. The owning player
|
||||
sees the compiled subject, placement, and relationship during private review.
|
||||
Their final approval therefore covers the compiler's placement without
|
||||
exposing it to the table. Actors may also hold directional relationships to an
|
||||
exact institution, Gestalt, or named Gestalt member. Such a relationship grants
|
||||
no knowledge, resource, membership, or collective authority.
|
||||
If compilation fails before a preview is installed, the roster remains locked
|
||||
and the typed brief remains authoritative. A host whose current brief still
|
||||
passes the readiness gate receives the Compile action again; retrying uses the
|
||||
new revision and never replays or accepts transcript prose.
|
||||
|
||||
Evidence gaps return the draft to conversation with explicit questions. A
|
||||
gap-bearing compiler result is retained as an exact non-canonical preview while
|
||||
the session remains in `drafting`. The host may move that exact digest into
|
||||
review, or the table may revise typed state and retire it before another
|
||||
compile. Moving it to review is not approval. Every active player must approve
|
||||
the current shared digest, their current private character digest, and the
|
||||
exact world-preview digest containing topology, cast, institutions,
|
||||
populations, clocks, evidence lanes, gaps, and branch-local assumptions.
|
||||
Publication persists the approved preview digest and exact accepted gap and
|
||||
assumption lists beside the campaign seed. Substituting any preview makes every
|
||||
prior approval stale. Relevant edits retire the preview and invalidate the
|
||||
affected approvals. The host has no override.
|
||||
|
||||
Pre-cut Session Zero stores used one receipt list for both meanings. Registry
|
||||
load migrates that list without advancing Session Zero revision or fictional
|
||||
time: all entries become audit history, while an extant world-seed preview
|
||||
receives the bounded final compiler transaction from its last
|
||||
`custom_retrieval_plan` through `agency_compile` as its exact proof set. Later
|
||||
conversation receipts and Contract Review previews are excluded. The migration
|
||||
is an atomic CultCache replacement and is idempotent on restart.
|
||||
|
||||
Accepted extraordinary bargains become typed permissions. Later assessment
|
||||
receives their exact scope, prerequisites, costs, limits, exposure, evidence,
|
||||
and effect ceiling.
|
||||
|
||||
## Bounded co-op
|
||||
|
||||
`campaign_membership.v1` binds authenticated account hashes to random member
|
||||
IDs and exact actor IDs. HTTP admission derives the acting actor from this
|
||||
record. Assessment confirmation includes that actor ID and cannot consume
|
||||
another actor's assessment.
|
||||
|
||||
The first co-op mode deliberately permits one shared scene and sequential
|
||||
public actions. It rejects player-versus-player state mutation, movement that
|
||||
would split the party, raw unilateral waits, and unilateral resolution-budget
|
||||
changes. Time, group travel, and pooled Persona-cell budget changes use
|
||||
revision-bound proposals; the final approval and campaign transition commit
|
||||
atomically. Group travel revalidates the shared origin and exact route, moves
|
||||
every assigned actor, and advances time in one world commit.
|
||||
|
||||
Every human-controlled actor has a non-simulatable agency profile. Live NPC
|
||||
appraisal, multiresolution cells, strategic outcomes, migration, scheduler
|
||||
ticks, and away-time work all exclude those actors from Persona control and
|
||||
direct mutation. Disconnection grants no proxy authority.
|
||||
|
||||
Each active member contributes their entitlement-provided cell allowance. Test
|
||||
accounts currently contribute eight. The campaign starts at the pooled total,
|
||||
capped at 240; physical provider concurrency remains independent.
|
||||
|
||||
## Realtime and interface
|
||||
|
||||
Ghostlight publishes an actor-filtered Eve surface for each Session Zero member
|
||||
and campaign member. Server-sent events contain revision notifications only.
|
||||
The browser refetches the authoritative Eve document after a notice; the
|
||||
transport owns no messages, drafts, ledgers, approvals, or world state.
|
||||
|
||||
The browser exposes keyboard-operable shared/private channel tabs, live status,
|
||||
public party cards, private ledgers and boundaries, unresolved decisions,
|
||||
roster readiness, and explicit compile/review/approve/publish actions. The Eve
|
||||
review tree visibly renders topology, non-private cast, institutions without
|
||||
private goals or resources, populations, clocks, evidence-use rationales,
|
||||
gaps, and branch assumptions. Relationship-anchor people remain absent from
|
||||
the shared cast and appear only in their owner's private relationship card.
|
||||
Privacy is labeled in text and never conveyed only by color.
|
||||
The host may discard any non-canonical preview through a typed Session Zero
|
||||
command and continue drafting without changing the contract, characters, or
|
||||
fictional time. A retained preview never traps the table into reviewing it.
|
||||
|
||||
Session Zero also remains the sole owner of every human-controlled character.
|
||||
The shared compiler may use player-approved public names and premises as world
|
||||
context, but those names are reserved: neither ordinary cast nor Gestalt member
|
||||
output may materialize them. Its singular player document is only a provisional
|
||||
starting-position marker. After compilation, Ghostlight removes that marker and
|
||||
installs the approved typed characters. A candidate that duplicates a reserved
|
||||
player identity is corrected against the same snapshot or rejected atomically.
|
||||
|
||||
An approved unresolved relationship person crosses world compilation as a
|
||||
server-owned identity anchor. The shared world compiler never receives that
|
||||
person's name, identity handle, or relationship. After the public topology is
|
||||
validated, a separate private stage receives the approved name and exact
|
||||
available locations and must synthesize exactly one ordinary actor candidate.
|
||||
It remains responsible for that person's location, capabilities, knowledge,
|
||||
equipment, obligations, and goals, but cannot emit IDs, relationships,
|
||||
narration, facts, gaps, assumptions, or public-world changes. The compiler then
|
||||
attaches the opaque canonical ID locally and the approved relationship is added
|
||||
only to its owner's actor. Omission, ambiguity, unknown placement, or ID
|
||||
collision rejects the whole candidate. Shared output cannot leak private input
|
||||
that its owning model never received.
|
||||
|
||||
## Inline grounded suggestions
|
||||
|
||||
Opening and role generation is no longer a browser-owned creation flow. On a
|
||||
new draft, the compiler retrieves three distinct Vault-grounded opening
|
||||
suggestions and installs them as shared typed decisions. Accepting one amends
|
||||
the draft for further discussion; it does not publish a world. Ghostlight then
|
||||
retrieves three grounded role suggestions and installs separate typed decisions
|
||||
in each player's private channel. A custom premise uses the same conversation,
|
||||
draft, approval, and compiler path.
|
||||
|
||||
## Contract Review
|
||||
|
||||
A launched campaign can enter a focused Contract Review backed by the same
|
||||
Session Zero kernel and privacy projections. The review is bound to the exact
|
||||
world revision at which it began. Any intervening world commit makes it stale.
|
||||
Every active member approves the revised shared digest and their own character
|
||||
digest before the host can publish.
|
||||
|
||||
Publication updates the existing campaign, contract, membership permissions,
|
||||
DM Persona, approved brief, governance epoch, actors' approved forward-looking
|
||||
ledger fields, event, and receipt in one CultCache batch. It cannot change
|
||||
membership custody, locations, topology, knowledge, memories, historical
|
||||
events, Vault, canon horizon, or established starting geometry. Tightened
|
||||
private boundaries publish immediately as an anonymous strictest campaign
|
||||
policy; relaxation waits for unanimous review publication. Narration, action
|
||||
assessment, resolution demand, cell Projectors, Interpreters, and semantic
|
||||
verification receive the approved contract and aggregate policy. Actor Personas
|
||||
still receive only lived narrative, not policy schemas or attributed safety
|
||||
records.
|
||||
|
||||
## Deferred governance
|
||||
|
||||
Split parties, private in-play actions, simultaneous declarations, PvP,
|
||||
delegation, late joining, permanent post-publication departure, group
|
||||
fork/reset/export, and voice remain unsupported.
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
# Ghostlight Eve-native interface
|
||||
|
||||
## Objective
|
||||
|
||||
Ghostlight exposes one actor-filtered logical surface, `ghostlight.play`. Eve
|
||||
owns its editable bindings, command invocation, receipts, plugin composition,
|
||||
and browser lowering. Ghostlight continues to own world creation
|
||||
(`world.create` v2), the Draft seed lane (`world.seed`), and world admission;
|
||||
Heimdall continues to own authentication and entitlement decisions.
|
||||
|
||||
```text
|
||||
Ghostlight projection
|
||||
-> EveBrowserProviderHost
|
||||
-> canonical Eve lowering
|
||||
-> gamecult.eve.command_invocation.v1
|
||||
-> Ghostlight ingress or Heimdall private command plane
|
||||
-> gamecult.eve.command_result.v1
|
||||
-> authoritative surface refresh
|
||||
```
|
||||
|
||||
## Authority map
|
||||
|
||||
- Owner: `SessionZeroKernel` owns draft changes, `WorldKernel` owns campaign
|
||||
changes, Heimdall owns OAuth attempts and claims, and Ghostlight's app-session
|
||||
owner binds a verified Heimdall subject to a local cookie.
|
||||
- Inputs: the provider projector reads only the caller's app session,
|
||||
membership, actor-filtered campaign or Draft world slice, and public
|
||||
Heimdall gate state. Command ingress reads one canonical Eve invocation and
|
||||
derives member and actor identity server-side.
|
||||
- Outputs: one `gamecult.eve.surface.v1` document and one persisted
|
||||
`gamecult.eve.command_receipt.v1` inside a command result. Invitation links
|
||||
may appear only in a transient projection.
|
||||
- Derived state: browser drafts, tab selection, focus, command status, the
|
||||
selected campaign card, and rendered HTML are projections or local
|
||||
interaction state. None grants access or mutates fiction.
|
||||
- Forbidden writers: the browser, Eve lowerer, Heimdall plugin, SSE stream,
|
||||
account preferences, and old auth store cannot choose an actor, establish
|
||||
membership, approve a Draft admission, or commit world state.
|
||||
- Shared paths: every UI operation resolves the authenticated account and
|
||||
exact membership, then calls the existing world mailbox.
|
||||
SSE carries invalidation only and the host refetches the same surface.
|
||||
- Cut line: the bespoke browser renderers and product-specific API routes are
|
||||
removed from the public router. Ghostlight's backend callback and local
|
||||
OAuth-attempt store stop participating. `service/auth.cc` is migration and
|
||||
rollback evidence only; `service/app-sessions.cc` owns new local sessions and
|
||||
account preferences.
|
||||
|
||||
## Editable values
|
||||
|
||||
Eve bindings are the input model. A component binds a typed value by stable
|
||||
binding name, document identity, field path, value kind, access mode,
|
||||
authority, and optional write command. Renderer-local composers use
|
||||
`local-draft`; direct edits name provider-owned state and an advertised write
|
||||
command. An operation captures one or more named binding values atomically.
|
||||
|
||||
The browser lowerer may use an HTML form for keyboard and accessibility
|
||||
behavior. HTML form structure never enters Eve state or command payload
|
||||
semantics. Accepted receipts may clear named drafts; rejection and stale
|
||||
conflict preserve them. An omitted clear-binding list means clear the surface's
|
||||
drafts; an explicit empty list means clear nothing. Operations that capture no
|
||||
editable bindings therefore cannot erase an unrelated composer, channel
|
||||
selection, boundary draft, or counterproposal. Provider-authored length limits
|
||||
lower onto editable controls before submission; Ghostlight still validates the
|
||||
same bound at ingress. Denied command receipts use an accessible alert region
|
||||
and survive authoritative surface refreshes alongside the preserved draft.
|
||||
|
||||
## Authentication membrane
|
||||
|
||||
Anonymous projection contains only `heimdall.access_gate`. Its begin and
|
||||
complete operations cross Heimdall's encrypted, loopback-only CultNet boundary.
|
||||
Ghostlight first reads the redacted `heimdall:command-boundary` record from
|
||||
Odin, validates its schema, runtime, loopback route, operations, and HMAC/AES
|
||||
contract, and then invokes that discovered route. Odin does not proxy the
|
||||
command and never receives claims or completion payloads. A discovery outage
|
||||
fails new authentication closed while already-valid local Ghostlight sessions
|
||||
continue until their verified expiry.
|
||||
The browser retains only the opaque attempt handle. Discord returns to
|
||||
Heimdall; Ghostlight redeems the completion, validates the access claim and
|
||||
`app_access`, and creates a local HttpOnly session.
|
||||
|
||||
Routine requests use local verified session state. The cookie hash, stable
|
||||
account-subject hash, Heimdall session/revision, capabilities, expiries, and
|
||||
wrapped refresh claim persist in `app-sessions.cc`. Campaign authorization is
|
||||
always derived from `campaign_membership.v1`; account preferences contain only
|
||||
the selected campaign. `campaign.entry` may clear only that preference so the
|
||||
same authenticated player can return to the campaign list or begin creating
|
||||
another world with `world.create`; it cannot leave, reset, fork, or mutate any
|
||||
campaign.
|
||||
Session Zero registry lookup treats only non-terminal negotiations as active:
|
||||
`published` and `archived` records remain durable history but cannot replace the
|
||||
campaign-entry surface after the preference is cleared.
|
||||
|
||||
Transient command-result surfaces preserve Eve's composite campaign interface
|
||||
version. A fresh or recompiled assessment may replace only the campaign-revision
|
||||
component while retaining the resolution and provider-configuration epochs.
|
||||
The roll control therefore invokes against the same version namespace as the
|
||||
authoritative `ghostlight.play` surface instead of collapsing it to a raw world
|
||||
revision.
|
||||
|
||||
## Public boundary
|
||||
|
||||
- `GET /api/eve/provider`
|
||||
- `GET /api/eve/surfaces/ghostlight.play`
|
||||
- `POST /api/eve/commands`
|
||||
- `GET /api/eve/events`
|
||||
|
||||
`/health` remains a probe of the service's typed CultMesh health. Static assets
|
||||
contain only the provider host, transport, Heimdall adapter, status mount, and
|
||||
SSE invalidation. Hermodr is not a runtime dependency.
|
||||
|
||||
## Live acceptance witness
|
||||
|
||||
The Yggdrasil release at Ghostlight commit
|
||||
`b515ca90c25573005a616244143803b37f2d06ec` and Eve commit
|
||||
`6766bee7c14a47144191475e2f35b0343b647b45` serves the anonymous access gate
|
||||
through the canonical browser lowerer with no console errors. A canonical
|
||||
`heimdall.auth.begin` invocation was accepted through Odin-discovered Heimdall
|
||||
and returned only the plugin-scoped navigation receipt with advertised Discord
|
||||
and Heimdall origins. The deployed unit contains
|
||||
`GHOSTLIGHT_ODIN_RUDP=10.77.0.1:17871` and no direct Heimdall private endpoint.
|
||||
|
|
@ -1,936 +0,0 @@
|
|||
# Ghostlight Multiresolution Agency Graph
|
||||
|
||||
Status: teardown evidence. Describes the pre-rebuild resolution cover,
|
||||
strategic waves, demand/deficit elaboration, and the wave-level outcome
|
||||
resolver, all on the deletion line. The current vocabulary is
|
||||
`docs/architecture/ghostlight-world-ontology.md`. It keeps the resumable
|
||||
per-jurisdiction elaborator session, compact mutation drafts, the complete
|
||||
deterministic mismatch set, the authored `WorldScaleIntent` and its
|
||||
deterministically derived deficit, the budgeted connected cover with singleton
|
||||
detail focus and grouped coarse cells, debt rotation, and deterministic clocks
|
||||
that advance regardless of attention. Detail first, then the ten-percent choke,
|
||||
is the operator's design and survives. It drops gestalt as identity:
|
||||
`GestaltPersonaState`, member deltas, arena knowledge union, fission as a scale
|
||||
loop, promotion and folding, and cover-completeness targets.
|
||||
|
||||
## Objective
|
||||
|
||||
Let a campaign contain the whole strategic setting without pretending every
|
||||
person, faction, institution, and population can receive a separate model turn.
|
||||
Ghostlight preserves canonical fine state and changes only the resolution at
|
||||
which that state is simulated during a wave.
|
||||
|
||||
This document is the authority map for the implemented multiresolution organ.
|
||||
The wider runtime map remains `ghostlight-dungeon-mvp.md`.
|
||||
|
||||
## Authority map
|
||||
|
||||
- **Owner:** `WorldKernel` owns canonical subjects, world revision, resolution
|
||||
controls, accepted fission, and atomic strategic-wave commits.
|
||||
- **Inputs:** canonical actors, institutions, gestalt leaves, typed agency
|
||||
profiles and relations, resolution pins, current pressure demand, committed
|
||||
clocks/events, and the previous accepted cover.
|
||||
- **Outputs:** a revision-bound `ResolutionCover`, its plan receipt, one
|
||||
appraisal per selected cell, exact constituent-attributed proposals, and one
|
||||
atomic strategic transition.
|
||||
- **Derived state:** simulation cells, merge losses, content-addressed cell IDs,
|
||||
detail focus, and the active cover. None owns a person, population, secret,
|
||||
relationship, possession, or fictional fact.
|
||||
- **Forbidden writers:** demand projection, graph construction, the partitioner,
|
||||
Projectors, Personas, Interpreters, scheduler, browser, and operator inspector
|
||||
cannot mutate canonical world state.
|
||||
- **Shared paths:** scheduled ticks and return catch-up both compile the same
|
||||
`AdvanceStrategicTick` command. Budget and pin changes use the campaign
|
||||
mailbox at safe boundaries. Provider parallelism uses a separate
|
||||
configuration epoch and does not repartition or advance fiction.
|
||||
- **Cut line:** the old flat strategic-plan proposer is gone. No active runtime
|
||||
path may tick a hand-picked list of factions outside the selected cover or
|
||||
union arena knowledge into a synthetic actor.
|
||||
|
||||
`ActorState`, `InstitutionState`, `GestaltPersonaState`, and durable gestalt
|
||||
member deltas remain canonical owners. `AgencyProfile` and `AgencyRelation` are
|
||||
typed partition inputs. `SimulationCell` is always disposable and rebuildable.
|
||||
|
||||
## Latent-world scale and elaboration demand
|
||||
|
||||
- **Owner:** consumer-authored `WorldScaleIntent` owns the desired active-cover
|
||||
ratio. `ResolutionPolicy` continues to own only the active-cell entitlement.
|
||||
A deterministic `WorldElaborationDemand` derivation owns the resulting target
|
||||
and deficit; it owns no fictional content.
|
||||
- **Inputs:** active-cell budget, target active-cover basis points, the current
|
||||
count of simulation-eligible canonical subjects, and positive complexity
|
||||
weights for admitted realm jurisdictions.
|
||||
- **Outputs:** target actionable-subject count, exact current deficit, a bounded
|
||||
round-effort budget, and deterministic per-realm target shares. At 240 cells
|
||||
and 1,000 basis points, the target is 2,400 subjects.
|
||||
- **Derived state:** demand, deficit, invocation schedule, and realm target
|
||||
shares are resumable planning artifacts. They are not subjects, facts,
|
||||
Personas, cells, or mutation permits.
|
||||
- **Forbidden writers:** the elaboration scheduler, titled workers, Gestalt
|
||||
compression, Nemesis, and provider concurrency cannot create or count a
|
||||
proposed subject as canonical. Only admitted population, institution, or
|
||||
actor operations committed through `WorldKernel` reduce the deficit.
|
||||
- **Shared paths:** initial compilation, destination expansion, resumed
|
||||
elaboration, and later consumer-requested scale changes use the same demand
|
||||
derivation and canonical actionability count. Each titled worker proposes at
|
||||
most one structural operation at a time; deterministic admission and kernel
|
||||
commit remain unchanged. The current scale loop raises complexity through
|
||||
grounded Gestalt fission or promotion of an individual into an active Actor
|
||||
leaf, never through unelaborated census or roster rows. Institution admission
|
||||
remains compiler-owned rather than being smuggled into this loop.
|
||||
- **Cut line:** fixed elaboration-pass count is not a world-scale owner. It may
|
||||
bound which jurisdictions are being worked in one run, but it cannot declare
|
||||
the latent world sufficiently populated. Provider concurrency controls only
|
||||
simultaneous calls and never changes the target or deficit.
|
||||
|
||||
Realm complexity weights distribute the target; they do not increase it. Any
|
||||
integer remainder is assigned deterministically so the realm shares sum exactly
|
||||
to the world target. A rejected or conflicting proposal leaves the deficit
|
||||
visible rather than being hidden behind an assumed model-call yield.
|
||||
|
||||
The actionable count includes only active simulation-eligible agency leaves.
|
||||
Dormant member records are useful latent identity, but do not pay down the
|
||||
complexity deficit until grounded elaboration promotes them. Resolution may
|
||||
still compress roughly 2,400 such leaves into at most 240 cells.
|
||||
|
||||
The mutation budget is not a cold-prompt budget. Each titled elaborator owns a
|
||||
resumable working session per realm jurisdiction containing its current frontier, unresolved leads,
|
||||
recent admitted mutations, exact rejection findings, and title mandate. The
|
||||
canonical world remains long-term memory. Bounded retrieval supplies older
|
||||
relevant state. After each round, every title that committed work receives one
|
||||
agentic compaction turn. It emits a typed checkpoint bound to the exact admitted
|
||||
commit ancestry; raw conversation is never authority, and the next turn appends
|
||||
from the checkpoint. This round boundary is the current bounded context policy.
|
||||
Scheduler frequency remains proportional to slider share across sequential
|
||||
turns.
|
||||
|
||||
The scale workbench uses mutation drafts rather than asking a model to restate a
|
||||
complete kernel command. A fission draft owns only new child identities,
|
||||
partition values, and exact member/resource assignments. The deterministic tool
|
||||
attaches campaign revision, parent, axis, approval fields, and inherited
|
||||
capabilities, knowledge, goals, and pressures before running the ordinary
|
||||
fission validator. An individuation draft owns only the new member delta; the
|
||||
tool attaches the exact parent version and location. Unchanged world state is
|
||||
therefore never paid for in model output and cannot drift while being copied.
|
||||
The shared fission validator reports the complete deterministic mismatch set in
|
||||
one observation, so an agent can repair the same compact draft without replaying
|
||||
inference or guessing which invariant failed first.
|
||||
The lane-specific action schema requires one entry for every exact frozen
|
||||
resource and dormant-member ID and rejects extra keys. The elaborator still
|
||||
chooses child custody; it cannot silently omit a scarce thing the kernel must
|
||||
conserve.
|
||||
|
||||
Round effort is proportional to unresolved pressure rather than presumed model
|
||||
yield: `ceil(active_cell_budget * deficit / target)`, zero when the target is
|
||||
satisfied. With 80 admitted leaves against the 2,400 target, the first round
|
||||
funds 232 one-operation turns. After admission the deterministic owner recounts
|
||||
active meaningful leaves and derives the next round afresh. A fission may add
|
||||
several meaningful child leaves; a rejected proposal may add none. Neither case
|
||||
changes the accounting rule or grants the scheduler fictional authority.
|
||||
|
||||
## Canonical population resolution
|
||||
|
||||
Known population detail is represented by non-overlapping active gestalt
|
||||
leaves. A material split uses approval-gated `FissionGestalt`:
|
||||
|
||||
1. the compiler retrieves exact Vault evidence and produces a fission preview;
|
||||
2. every requested enumerated facet gets a child and one `other/unknown`
|
||||
remainder is mandatory;
|
||||
3. children inherit the parent baseline and own only later deltas;
|
||||
4. every exact scarce resource is assigned to one child rather than copied as
|
||||
an inheritable trait;
|
||||
5. member deltas are assigned to one child without rewriting identity;
|
||||
6. the parent remains as inactive lineage rather than being destroyed;
|
||||
7. the kernel validates the preview, evidence, versions, assignments, and
|
||||
residual child before one atomic commit.
|
||||
|
||||
The commit is a canonical `WorldMutationBatch`: child admission establishes
|
||||
identity and occupancy, the lineage mutation inherits non-scarce baseline
|
||||
components, custody transfers partition resources, and membership transfers
|
||||
move exact named people. Agency profiles, inherited partition facets, and the
|
||||
discarded cover are resolution projections of that accepted transition; they
|
||||
cannot create a second result.
|
||||
|
||||
Reversible simulation aggregation never invokes fission and never deletes or
|
||||
reverses this lineage.
|
||||
|
||||
## Canonical individual migration
|
||||
|
||||
Resolution aggregation and population membership are separate authorities. A
|
||||
cell may temporarily contain many populations, but that never changes which
|
||||
population baseline a durable person is composed from. `WorldKernel` owns one
|
||||
explicit membership transition for that purpose.
|
||||
|
||||
- **Owner:** `WorldKernel` owns a durable member's current active-leaf
|
||||
`gestalt_id`. The member delta owns their identity, memories, relationships,
|
||||
possessions, injuries, obligations, and personal departures from the current
|
||||
baseline. `GestaltLineage` owns immutable fission ancestry.
|
||||
- **Inputs:** one exact member-attributed proposal, the source and destination
|
||||
active gestalt leaves, a typed migration relation, a reachable destination,
|
||||
and the campaign/resolution snapshot already bound to the strategic wave.
|
||||
- **Outputs:** the same member identity attached to the destination leaf, with
|
||||
capability and knowledge deltas rebased so their effective personal state is
|
||||
bit-for-bit unchanged; one attributed migration event is emitted.
|
||||
- **Derived state:** simulation cells, arena membership, member prompt
|
||||
selection, and rematerialized `ActorState` projections do not own population
|
||||
membership.
|
||||
- **Forbidden writers:** a source gestalt, destination gestalt, arena, demand
|
||||
model, Projector, Persona prose, or migration relation cannot move a person.
|
||||
Only an action attributed to that exact durable member may propose the
|
||||
transition, and the kernel validates it.
|
||||
- **Shared paths:** source and destination may be leaves at any depth under
|
||||
unrelated lineage trees. The same rebase primitive handles crisis camps,
|
||||
neighborhoods, departments, corporations, species populations, and later
|
||||
refinements.
|
||||
- **Cut line:** changing `GestaltMemberDelta.gestalt_id` directly is forbidden.
|
||||
It would reinterpret the old delta against a different baseline and silently
|
||||
alter the person's knowledge, capability, or default goals.
|
||||
|
||||
A bounded set of salient dematerialized member exceptions may be projected
|
||||
inside the cell containing their current gestalt. They remain separately
|
||||
attributed people, not shared gestalt state. Selection favors unresolved
|
||||
personal conditions, obligations, goals, and relationships to the player and
|
||||
is capped so a large population does not dump its roster into the prompt. A
|
||||
member may migrate only along an explicit active `migration` relation to a
|
||||
reachable destination leaf. At any active leaf, the same member may instead
|
||||
emit a `member_activity` from their exact location toward their current
|
||||
population, an explicitly related subject, or another selected named person or
|
||||
active subject at that exact location. Only the capped salient member
|
||||
exceptions exposed to the cell may appear as named targets in its prompt;
|
||||
canonical validation still checks the target's identity and current location.
|
||||
Migration and ordinary activity share one per-member strategic action slot.
|
||||
This lets a refugee who mattered during
|
||||
a crisis choose to travel, offer work after settling, and later rematerialize
|
||||
as the same person without forcing every refugee to consume an active Persona
|
||||
cell or giving their agency to the destination Gestalt.
|
||||
|
||||
The membrane distinguishes a chosen journey from preparation for one. When a
|
||||
member commits to board, depart, travel, or join a supplied destination within
|
||||
the strategic horizon, the Interpreter must emit `member_migration`; approaching
|
||||
the queue is not allowed to erase that commitment into `member_activity`.
|
||||
Preparing applies only while departure remains unchosen. The semantic verifier
|
||||
checks both directions so neither consideration becomes travel nor chosen
|
||||
travel disappears into local activity.
|
||||
|
||||
Presence projection is location- and lineage-bounded. The planner sees only
|
||||
active gestalt leaves at the player's location, dormant members whose exact
|
||||
current location is there, and materialized members that might be eligible to
|
||||
fold. Inactive ancestors and remote dormant rosters do not enter the prompt.
|
||||
The kernel independently rejects individuation from an inactive leaf,
|
||||
promotion outside the member's exact location, and automatic reconciliation
|
||||
outside the player's active location. A direct internal command may preserve a
|
||||
named offscreen actor, but model-driven presence reconciliation cannot teleport
|
||||
one into view. Existing member deltas are preferred when their relationship,
|
||||
memory, obligation, or goal makes an ordinary encounter a meaningful callback.
|
||||
The player actor's reciprocal relationship to each nearby dormant member is
|
||||
projected beside that member's exact delta. Presence planning is scene casting,
|
||||
not roster cleanup: an earned local callback may surface without the player
|
||||
asking for the person, while an incompatible event or absence of durable
|
||||
callback evidence may still produce no promotion. The model only proposes the
|
||||
cast; `WorldKernel` validates exact member, gestalt, location, and versions
|
||||
before materialization. Live harnesses persist the private presence preflight
|
||||
even when their acceptance assertion fails.
|
||||
|
||||
The presence-planner schema enumerates promotion IDs only from the supplied
|
||||
nearby dormant roster, demotion IDs only from supplied materialized actors, and
|
||||
gestalt and location IDs only from the exact scene candidates. A structurally
|
||||
valid plan that mismatches member, gestalt, location, or version receives one
|
||||
same-snapshot semantic correction. When that correction succeeds, both the
|
||||
rejected and accepted stage receipts remain durable; the rejected receipt is
|
||||
marked `semantic_invalid` with the bounded local reason.
|
||||
|
||||
## Canonical population migration
|
||||
|
||||
Population fission and population movement are separate decisions. Fission
|
||||
answers which non-overlapping cohort exists. Migration answers where one active
|
||||
cohort goes. Simulation aggregation answers how much inference attention that
|
||||
cohort receives after arrival. None of those operations is a canonical merge.
|
||||
|
||||
- **Owner:** `WorldKernel` exclusively owns an active gestalt leaf's
|
||||
`home_location_id` and the matching agency-profile location set.
|
||||
- **Inputs:** one exact gestalt-attributed proposal, an active source leaf, an
|
||||
explicit migration relation to an active destination population leaf, a
|
||||
reachable destination, and the unchanged world and resolution revisions.
|
||||
- **Outputs:** the source leaf and its agency profile move atomically, their
|
||||
versions advance, and one attributed population-migration event records the
|
||||
source and destination locations and populations.
|
||||
- **Derived state:** the resolution cover may later place the migrant cohort
|
||||
and destination population in one cohesive or arena cell. That cover does
|
||||
not change either population's identity, knowledge, authority, or lineage.
|
||||
- **Forbidden writers:** an arena, destination population, Projector, Persona,
|
||||
Interpreter, scheduler, or relation cannot move a population. A population
|
||||
move never carries named member deltas by implication; each named person's
|
||||
exact location and membership remain unchanged until that person acts.
|
||||
- **Shared paths:** every active leaf uses the same transition, whether it is a
|
||||
root population or a child many levels below it. Approval-gated fission may
|
||||
first create destination, stayer, and `other/unknown` cohorts; each resulting
|
||||
leaf can then migrate independently.
|
||||
- **Cut line:** collective departure is neither `prepare` nor
|
||||
`member_migration`. The former loses the chosen journey; the latter gives a
|
||||
population authority over a person. Canonical population merging remains
|
||||
absent: arrival changes geography, while derived covers provide cheap local
|
||||
aggregation.
|
||||
|
||||
## Demand projection
|
||||
|
||||
A flash-model structured stage converts the committed event or strategic
|
||||
horizon into weights for geography, ideology, authority, economy role,
|
||||
species/body, and information. Focal subjects must be chosen from supplied IDs.
|
||||
Local semantic validation permits one same-snapshot correction. Failure falls
|
||||
back to the last accepted demand; the initial fallback favors geography and
|
||||
authority.
|
||||
|
||||
Demand is relevance evidence. It cannot create profiles, pins, cells, or facts.
|
||||
|
||||
## Agency graph
|
||||
|
||||
Vertices are active canonical actors, institutions, and gestalt leaves. Edges
|
||||
carry containment, command, membership, alliance, rivalry, trade, migration,
|
||||
communication, coercion, and shared-location meaning. Subjects sharing a
|
||||
location receive derived adjacency. Disconnected components receive a
|
||||
resolution-only Steiner chain so the graph remains coarsenable; those bridge
|
||||
edges assert no lore relation, common authority, proximity, or shared
|
||||
knowledge.
|
||||
|
||||
The compiler must profile every non-player actor, institution, and gestalt
|
||||
exactly once across all six axes. It compiles a bounded local play region and a
|
||||
global skeleton of major powers, coarse regions, institutions, information
|
||||
channels, and strategic pressures. Runtime migration derives profiles for old
|
||||
flat campaigns without changing their world revision, actors, institutions,
|
||||
gestalts, or fictional time.
|
||||
|
||||
## Budgeted connected cover
|
||||
|
||||
The campaign selects an active Persona-cell budget from 1 through 240, default
|
||||
8, bounded by its pooled entitlement. Provider parallelism is a separate
|
||||
control capped at 32 concurrent cell pipelines.
|
||||
The scale fixture covers 1,000 canonical subjects with 200 unique cells, then
|
||||
dispatches every cell membrane in one wave while a seven-permit semaphore
|
||||
bounds physical provider work. Wave width and provider pressure are therefore
|
||||
separate authorities; all terminals still join, sort, validate, and commit as
|
||||
one atomic resolution wave.
|
||||
The partitioner:
|
||||
|
||||
1. contracts valid `keep_together` pins;
|
||||
2. forces focal, directly engaged, leased, initiative, targeted, and
|
||||
`minimum_individual_detail` subjects into singleton cells;
|
||||
3. respects `keep_separate` pins and reports mandatory effective-budget
|
||||
overage rather than hiding a foreground subject;
|
||||
4. starts at fine resolution and repeatedly merges the lowest-loss legal
|
||||
graph-adjacent pair with deterministic tie-breaking;
|
||||
5. performs one connected boundary-refinement pass and accepts a move only
|
||||
when compression cost improves by at least five percent;
|
||||
6. preserves a previous cover during its lease, or until a replacement improves
|
||||
the weighted objective by at least ten percent, unless a forced split or
|
||||
budget change applies;
|
||||
7. validates complete unique coverage, connectedness, cell identity, mode, and
|
||||
epoch before the cover can enter a wave.
|
||||
|
||||
Compression cost is merge loss multiplied by the number of collapsed
|
||||
trajectories. This prevents the objective from favoring one overloaded arena
|
||||
merely because a singleton has zero local loss.
|
||||
|
||||
The normalized merge loss is:
|
||||
|
||||
```text
|
||||
0.25 facet divergence
|
||||
+ 0.20 hidden causal-boundary mass
|
||||
+ 0.15 information-scope divergence
|
||||
+ 0.15 spatial divergence
|
||||
+ 0.10 clock/obligation divergence
|
||||
+ 0.10 salience burial
|
||||
+ 0.05 partition churn
|
||||
```
|
||||
|
||||
Facet divergence is demand-weighted Jaccard distance. Spatial divergence uses
|
||||
known locations, direct travel time, and the current strategic horizon.
|
||||
Candidate scoring uses a deterministic mergeable representative sample for
|
||||
bounded local work; exact authority, hostile relation, coverage, connectivity,
|
||||
pins, and knowledge validation remain unsampled.
|
||||
|
||||
Union-by-size owns constituent-set merges. This is not merely an optimization:
|
||||
it keeps the 1,000-subject path from degenerating into quadratic copying while
|
||||
leaving stable merge ordering unchanged. The release property test completes
|
||||
the 1,000-subject, budget-8 partition in approximately 50 ms on the development
|
||||
host, under the 100 ms contract. Setting `GHOSTLIGHT_PARTITION_TRACE` enables a
|
||||
development-only phase timing probe.
|
||||
|
||||
## Cell modes
|
||||
|
||||
- **Cohesive:** all constituents share one actual collective authority, contain
|
||||
no active hostile edge, and remain within information and behavioral
|
||||
divergence thresholds. The cell may speak and act plurally.
|
||||
- **Arena:** constituents remain distinct or opposed. The cell receives an
|
||||
attributed polyphonic situation and may simulate interaction, but it has no
|
||||
actor ID and cannot speak, know, decide, or act as a collective.
|
||||
|
||||
Cross-faction aggregation is therefore an arena. Arena interpreters may emit at
|
||||
most `min(4, 1 + ceil(log2(constituent_count)))` actions, each attributed to an
|
||||
exact constituent. The kernel validates that subject's knowledge, information
|
||||
channel, resources, authority, relationships, location, and reachable
|
||||
destinations. Secrets are never unioned.
|
||||
|
||||
## Strategic outcome authority
|
||||
|
||||
A valid attributed attempt is not yet a world consequence. The Interpreter
|
||||
owns the narrow claim that a constituent chose an admissible attempt; it does
|
||||
not decide whether the attempt worked, what opposition did, or which durable
|
||||
state changed. Treating the attempt event as a sufficient result made the
|
||||
world busy in prose while its resources, relationships, knowledge, and
|
||||
pressures mostly stood still.
|
||||
|
||||
One wave-level strategic outcome resolver owns that missing decision. It runs
|
||||
after priority and incompatibility selection but before the command enters the
|
||||
kernel:
|
||||
|
||||
1. Ghostlight content-addresses every selected `gestalt_activity`,
|
||||
`actor_activity`, and `member_activity` proposal. The digest binds exact
|
||||
source, intent, intended effect, targets, locations, state references,
|
||||
channels, and typed activity.
|
||||
2. A local context compiler supplies only those selected attempts and their
|
||||
legal consequence handles: source capabilities and holdings, exact target
|
||||
state, active incident relations, current pressures, eligible named-member
|
||||
deltas, and source-discoverable canonical facts.
|
||||
3. One cheap structured model call resolves the batch. It returns exactly one
|
||||
success, mixed, or failure result for every digest and chooses one bounded
|
||||
typed effect, including an explicit `no_material_change` when the attempt
|
||||
cannot honestly alter canonical state.
|
||||
4. Local validation recomputes every permission from the unchanged campaign
|
||||
snapshot. It rejects invented owners, resources, relations, facts, pressure
|
||||
resolutions, player mutation, missing outcomes, duplicate outcomes, and
|
||||
conflicting effect targets. One same-snapshot correction is permitted.
|
||||
5. `WorldKernel` requires the outcome-stage receipt bound to the complete
|
||||
ordered digest set, then applies the plan and all outcomes to one private
|
||||
campaign copy. A late invalid effect aborts the wave; no clock, activity,
|
||||
outcome, detail debt, or world state reaches the committed campaign.
|
||||
|
||||
The public `strategic_activity_outcome.v1` contract is a typed sum. Its MVP
|
||||
effects are deliberately small:
|
||||
|
||||
- create, spend, or give away a resource owned by the acting population or
|
||||
named member;
|
||||
- add or resolve pressure on the acting population or an exact targeted
|
||||
population;
|
||||
- shift one exact active agency relation incident to the source and a supplied
|
||||
target by a bounded amount;
|
||||
- add one memory, obligation, or relationship description to an exact named
|
||||
member who owns that perspective;
|
||||
- learn one exact canonical fact that was discoverable at the attempt location
|
||||
or already known by an exact communication target;
|
||||
- or record an explicit reason why no durable material change occurred.
|
||||
|
||||
The provider boundary uses a flat JSON proposal rather than asking the model to
|
||||
emit the public tagged sum directly. The private flat shape avoids fragile
|
||||
`oneOf` generation; a local interpreter is the only component allowed to turn
|
||||
it into the typed public effect. JSON remains a model-provider boundary. The
|
||||
accepted outcome, its binding, and its receipt are persisted through the
|
||||
campaign's CultCache path.
|
||||
|
||||
Outcome resolution does not grant an arena collective authority. Every result
|
||||
remains bound to the exact constituent proposal digest. A rival can suffer a
|
||||
pressure or relation consequence, but cannot be made to speak, spend a
|
||||
resource, reveal knowledge, or acquire a private relationship as though the
|
||||
arena had chosen for it. Effects on the player are forbidden; accessible news
|
||||
may later reveal a committed consequence, but the absent player is never
|
||||
puppeted or directly harmed.
|
||||
|
||||
The resolver is batched once per strategic wave rather than once per cell or
|
||||
attempt. This keeps the stable rules and schema in one cacheable prompt prefix,
|
||||
lets it compare simultaneous opposition, and spends completion tokens only on
|
||||
the handful of attempts that survived selection. Provider concurrency still
|
||||
controls the preceding Persona-cell wave and does not alter outcome authority.
|
||||
|
||||
## Fairness and quiet-world agency
|
||||
|
||||
Every active profile carries persistent non-fictional `detail_debt`. Aggregate
|
||||
representation increases debt; singleton or explicit detail focus clears it.
|
||||
Each strategic wave reserves focus for the highest-debt subject. At budget 1,
|
||||
the root arena retains the global situation while explicitly appraising that
|
||||
subject. Deterministic clocks and obligations advance regardless of focus, and
|
||||
every cell must emit explicit action or inaction.
|
||||
|
||||
With N subjects, deterministic debt rotation gives every subject direct
|
||||
resolution attention within at most N strategic waves, absent a mandatory
|
||||
foreground override.
|
||||
|
||||
## Persona-cell wave
|
||||
|
||||
Each selected cell runs one Ghostlight-owned
|
||||
Projector → Persona → Interpreter membrane:
|
||||
|
||||
- cohesive Projectors expose genuinely shared lived state plus attributed
|
||||
exceptions;
|
||||
- arena Projectors expose attributed constituent slices and no synthetic actor;
|
||||
- Personas receive only the private lived narrative stream;
|
||||
- Interpreters emit a typed appraisal and exact constituent effects;
|
||||
- malformed or semantically invalid output gets one same-snapshot correction.
|
||||
|
||||
An appraisal has two attributed lanes: `actions[]` for proposed transitions and
|
||||
`inactions[]` for exact subjects that deliberately hold, wait, or continue an
|
||||
already committed course. The old cell-wide inaction string is gone. This
|
||||
matters in a mixed arena: one institution can act while two rivals hold without
|
||||
the Interpreter manufacturing repeated posture transitions merely to preserve
|
||||
their separately voiced decisions. Inaction IDs use the same constituent or
|
||||
selected-member permission set, carry a bounded reason, cannot duplicate, and
|
||||
cannot also appear in the action lane. They are audit/fairness evidence and
|
||||
never mutate the world. A cell with no actions must still contain at least one
|
||||
attributed inaction.
|
||||
|
||||
An arena may contain simultaneous views from distant locations. The Projector
|
||||
names each subject's actual location and keeps those views as separate scenes;
|
||||
a graph relation or activity target grants permission to attempt contact, not
|
||||
fictional co-presence. The Persona preserves those spatial boundaries unless
|
||||
the lived stream explicitly establishes an exact shared location or
|
||||
communication channel. Compression therefore cannot turn a regional arena
|
||||
into one impossible meeting room.
|
||||
|
||||
Perspective attribution is runtime-owned. The learned Projector emits bounded
|
||||
`subject_id → narrative` segments, where the subject ID must be one exact
|
||||
constituent or selected member exception from the permitted slice. Ghostlight
|
||||
then lowers those private structured segments into one natural lived stream,
|
||||
adding exact names and locations plus a deterministic scene-boundary block. The
|
||||
Persona receives only that lowered prose. It never sees IDs or schemas.
|
||||
|
||||
- **Owner:** the permitted cell slice owns which subjects may have an internal
|
||||
perspective; the Projector owns only the lived language inside one bound
|
||||
segment.
|
||||
- **Inputs:** exact cell constituents, selected member exceptions, perceived
|
||||
events, locations, private state slices, and the shared snapshot binding.
|
||||
- **Outputs:** one to the cell action limit unique, non-empty narrative
|
||||
segments, lowered in stable order with runtime-owned perspective labels.
|
||||
- **Derived state:** headings, scene labels, and the combined lived stream are
|
||||
disposable projections and own no canonical fact or decision.
|
||||
- **Forbidden writers:** Projector prose cannot add a perspective owner by
|
||||
inventing a heading, voicing a mentioned outsider, or speaking as an arena.
|
||||
- **Shared paths:** cohesive and arena cells use the same segment binding;
|
||||
their mode changes narration guidance, never perspective authority.
|
||||
- **Cut line:** raw freeform cell Projector output is no longer appended
|
||||
directly to the Persona stream. Only runtime-bound segments cross the
|
||||
membrane.
|
||||
|
||||
The deterministic block contains names and locations, not schemas or state
|
||||
fields. A poetic omission therefore cannot erase geometry, and an invented
|
||||
Markdown section cannot grant a subject agency before the Persona turn.
|
||||
|
||||
Perspective ownership is equally strict. Only the cell's supplied constituents
|
||||
and selected member exceptions may receive an internal viewpoint or make a
|
||||
choice. A named person mentioned by a perceived event but owned by another cell
|
||||
remains an external observation. Personas may seek an unknown role or office,
|
||||
but cannot conjure it into available contact; target-requiring effects need one
|
||||
of the exact supplied subject IDs.
|
||||
|
||||
Physical provider concurrency batches these pipelines behind a separate
|
||||
operator limit, default 8. Changing that limit increments only
|
||||
`provider_configuration_epoch`; it preserves the selected cover, resolution
|
||||
epoch, world revision, clocks, and fictional time.
|
||||
|
||||
All cell results must bind the same campaign revision and resolution epoch.
|
||||
The kernel requires a Projector, Persona, and Interpreter receipt for every
|
||||
cell, validates every proposal, resolves incompatibilities deterministically,
|
||||
caps committed consequences at `min(16, 2 × effective_budget)`, and commits the
|
||||
entire wave once. One missing appraisal, stale receipt, malformed stage,
|
||||
invalid cover, or invalid effect aborts the wave without partial mutation.
|
||||
|
||||
Interpreter `intent` and `intended_effect` prose is proposal rationale only. It
|
||||
never becomes canonical event narration. The kernel derives each committed
|
||||
event summary from the exact admitted posture, pressure markers, route, or
|
||||
membership transition after applying it. Strategic plans therefore carry no
|
||||
model-authored summary field that can assert a consequence outside the typed
|
||||
effect.
|
||||
|
||||
Recent events are projected per constituent. A cell receives an event only for
|
||||
subjects that participated in it, occupy one of its locations, or possess one
|
||||
of its public channels; arena streams retain those viewer attributions. There
|
||||
is no global recent-event recap that can quietly teach every population the
|
||||
same fact.
|
||||
|
||||
Interpreter prompts use a compact stable output contract plus an exact
|
||||
per-subject permission table. The table names the only allowed effect kind,
|
||||
state references, public channels, and destinations for each constituent or
|
||||
member exception. Local validation repeats every slice-expressible constraint
|
||||
before accepting the terminal appraisal, so a malformed pressure marker or
|
||||
wrong subject/effect pairing receives the same-snapshot correction while the
|
||||
kernel still performs the authoritative canonical check. Named-member
|
||||
knowledge is never treated as an information channel; while dematerialized,
|
||||
the member can publish only through channels owned by their current leaf
|
||||
gestalt's agency profile.
|
||||
|
||||
`subject_state_references` is the single derivation path for canonical
|
||||
constituent references used by both prompt slicing and wave admission. For a
|
||||
Gestalt it includes reachable destinations produced by active migration
|
||||
relations, along with the destination population and location references. The
|
||||
scheduler does not append a second opinion after the fact, so a reference
|
||||
shown to the Interpreter cannot become invalid merely by reaching the wave
|
||||
resolver.
|
||||
|
||||
`subject_id` is the sole model-facing owner attribution. Nested effect owner
|
||||
IDs are absent from the model contract: Ghostlight derives `institution_id`,
|
||||
`gestalt_id`, `actor_id`, or the raw member ID during binding. The canonical
|
||||
`CellActionProposal` still carries those IDs for downstream validation and
|
||||
receipts, but a model cannot make two copies disagree. This also removes
|
||||
repeated tokens and one class of correction calls from every actionful cell.
|
||||
|
||||
Institution rows distinguish `current_posture` from unresolved `pressures`.
|
||||
The committed posture is projected as already true and is the exact baseline
|
||||
against which a new institutional commitment is validated. It is never hidden
|
||||
in a generic pressure list, where a Projector could mistake a completed choice
|
||||
for a dilemma still awaiting action.
|
||||
Institution posture proposals are bounded to 240 characters in both the stable
|
||||
Interpreter contract and local JSON Schema, matching the canonical validator.
|
||||
The model sees the limit before generation instead of spending a semantic retry
|
||||
on an otherwise valid but overlong commitment.
|
||||
|
||||
A committed strategic effect must produce an exact canonical consequence.
|
||||
Institutions cannot re-adopt their current posture. Population pressure effects
|
||||
carry an explicit lifecycle: `pressure_resolutions` must name exact current
|
||||
pressure markers, and `pressure_additions` must introduce genuinely new
|
||||
unresolved constraints, threats, obligations, or conditions. Completed action
|
||||
prose is not a pressure.
|
||||
|
||||
Populations may instead propose a bounded `gestalt_activity`: prepare,
|
||||
coordinate, investigate, recruit, obstruct, trade, or communicate. Its source
|
||||
is an exact active gestalt leaf; its targets must be adjacent canonical agency
|
||||
subjects through an explicit active relation or exact shared location. A
|
||||
dormant named person is addressable as `member:<id>` without becoming gestalt
|
||||
state. Its locations and channels must belong to the source's permitted slice.
|
||||
The effect commits only an attributed attempted-activity event. It does
|
||||
not claim successful discovery, recruitment, persuasion, delivery, exchange,
|
||||
obstruction, preparation, or target response. Event records carry exact
|
||||
gestalt participant IDs so later demand and perception can attribute the
|
||||
attempt without treating an arena as an actor. A later resolved outcome needs
|
||||
its own admitted effect.
|
||||
|
||||
Two local attempts may omit a canonical target while retaining the source's
|
||||
exact current location. Targetless `investigate` seeks facts from the
|
||||
environment or an unnamed ordinary role. Targetless `communicate` records the
|
||||
source speaking, offering, requesting permission, or notifying such a role.
|
||||
Neither form creates a listener, reply, acceptance, discovery, or target
|
||||
agency. All other target-bearing activity uses exact canonical subject IDs.
|
||||
Activities may include incidental motion around unnamed local features while
|
||||
remaining inside the source's canonical location. This does not create
|
||||
topology or establish arrival. Only a clear commitment to a different supplied
|
||||
canonical location or population destination requires a movement effect.
|
||||
|
||||
The shared validators run in the Interpreter membrane, wave resolver, and
|
||||
kernel. One gestalt can contribute at most one selected pressure transition or
|
||||
activity per tick; deterministic priority resolution cannot let two effect
|
||||
forms bypass that limit.
|
||||
|
||||
Named member exceptions use the same activity vocabulary through
|
||||
`member_activity`. The event is attributed to `member:<id>` and the current
|
||||
leaf; the member's delta, the source Gestalt, and any target remain unchanged.
|
||||
An arena cannot translate the person's attempt into a collective action, and
|
||||
the destination population cannot claim the person's voice. `member_activity`
|
||||
and `member_migration` resolve under one exact member key, so only the
|
||||
higher-priority compatible choice can commit in a wave.
|
||||
|
||||
Canonical non-player actors use `actor_activity` for preparation,
|
||||
coordination, investigation, recruitment, obstruction, trade, and
|
||||
communication. The action is bound to the actor's exact current location,
|
||||
state references, information channels, and graph-adjacent or co-located
|
||||
targets. `actor_move` and `actor_activity` share one actor action key, so a
|
||||
cell cannot make the same person travel and act again in the same wave.
|
||||
Human-controlled actors are inadmissible in both paths. An arena may carry the
|
||||
actor's attributed proposal but cannot translate it into population speech,
|
||||
member activity, or collective authority.
|
||||
|
||||
The Interpreter maps natural attempts narrowly: speech, offers, requests, and
|
||||
notices are communication; coordination requires an actual attempt to arrange
|
||||
joint work; preparation is the source's own concrete work; investigation seeks
|
||||
information; recruitment invites participation; trade offers exchange; and
|
||||
obstruction attempts interference. `intended_effect` repeats the attempted act,
|
||||
not the hoped-for outcome. This keeps the semantic verifier focused on agency
|
||||
and consequence rather than guessing what a broad activity label meant.
|
||||
|
||||
Semantic correction feedback names the exact failed boundary and current
|
||||
value. A repeated institution posture receives its present posture; an invalid
|
||||
population transition receives its exact current pressure set and the failed
|
||||
transition reason. The correction model is not expected to reconstruct a
|
||||
validator from a generic rejection.
|
||||
|
||||
The WorldKernel resolves every strategic batch against one immutable campaign
|
||||
snapshot. It applies validated transitions to a private working copy and swaps
|
||||
that copy into the campaign only after the whole batch succeeds. Earlier
|
||||
actions in the batch therefore cannot rewrite the spatial, relationship, or
|
||||
knowledge permissions used by later actions. This matters when, for example, a
|
||||
camp population signals refugees during the same strategic horizon in which
|
||||
those refugees depart: both choices are judged from the shared starting world,
|
||||
while the committed result still contains the migration. A late invalid action
|
||||
discards the working copy and leaves the campaign untouched.
|
||||
|
||||
An actionful cell has a fourth private stage after the Interpreter: the
|
||||
`cell_effect_verifier`. This cheap structured reader compares the natural
|
||||
Persona decision with each attributed typed effect. It rejects reversals,
|
||||
subject swaps, omissions, wishful outcomes, or lossy mappings—for example,
|
||||
mapping “Mira gives her berth to somebody else” into a migration of Mira
|
||||
herself. The rejected appraisal and concise rationale return to one
|
||||
same-snapshot Interpreter correction. Inaction cells skip the call. The kernel
|
||||
requires a valid verifier receipt for every actionful cell; an invalid or
|
||||
missing verifier cannot satisfy the wave receipt gate. The verifier receipt's
|
||||
snapshot binding includes a MessagePack content hash of the ordered action
|
||||
bundle. The kernel recomputes it, so a verdict for another proposal at the same
|
||||
world revision and cell cannot be replayed as authority.
|
||||
|
||||
Verification is per action, not a batch opinion. The model returns exactly one
|
||||
ordered `action_index` and discriminated result for every candidate: `match`,
|
||||
or `mismatch` with one enumerated mismatch class.
|
||||
Ghostlight constrains the verdict count and index range in the stage schema,
|
||||
rejects omissions, duplicates, reordering, and incoherent discriminators, and derives
|
||||
the rejected set locally. This keeps one bad arena action from contaminating
|
||||
independent constituents and prevents a global pass/fail field from
|
||||
contradicting its own rejected-index list.
|
||||
|
||||
## Public state and controls
|
||||
|
||||
The schema catalog and CultCache stores publish:
|
||||
|
||||
- `agency_profile.v1`, `agency_relation.v1`, `gestalt_lineage.v1`;
|
||||
- `resolution_policy.v1`, `resolution_pin.v1`, `resolution_demand.v1`;
|
||||
- `simulation_cell.v1`, `resolution_cover.v1`,
|
||||
`resolution_plan_receipt.v1`, and `resolution_control_receipt.v1`;
|
||||
- `cell_appraisal.v1`, `cell_action_proposal.v1`, and the internal atomic
|
||||
`resolution_wave_commit.v1` bundle;
|
||||
- `strategic_activity_outcome.v1`, persisted individually for inspection and
|
||||
also bound inside the atomic resolution wave;
|
||||
- `gestalt_migration.v1` and `member_migration.v1` as distinct collective and
|
||||
individual movement contracts inside strategic plans;
|
||||
- `gestalt_fission_preview.v1` and approval-gated `FissionGestalt`.
|
||||
|
||||
The authenticated campaign laboratory exposes budget, effective overage, pins,
|
||||
fission preview/approval, and a separate operator provider-parallelism control.
|
||||
The Eve operator projection carries the agency graph, active cover, cell modes,
|
||||
loss components, debt focus, pins, plan receipts, cell appraisals, and control
|
||||
receipts. Browser and Eve surfaces use a composite interface version so
|
||||
resolution-only and provider-only changes refresh without impersonating a world
|
||||
revision.
|
||||
|
||||
## Verification
|
||||
|
||||
Property and golden tests cover budgets 1/4/8/32, complete unique connected
|
||||
coverage, deterministic ties, mandatory overage, contradictory pins, fairness,
|
||||
arena isolation, fission identity preservation, stale epochs, atomic waves, and
|
||||
the 1,000-subject target. Pressure goldens preserve geography under blockade,
|
||||
ideology under schism, workplace/economic role under strike, species/body and
|
||||
transport under epidemic, and information boundaries under espionage.
|
||||
|
||||
The live Gestalt-dynamics harness has two modes. Its golden mode traverses
|
||||
budgets 1 → 4 → 8 → 4 and proves independent population/member migration plus
|
||||
automatic rematerialization of the same durable person. Its fairness mode keeps
|
||||
the whole 24-subject setting in one arena for 24 total strategic ticks. It
|
||||
requires every runtime-selected detail focus within the bound, rejects inactive
|
||||
lineage parents as simulation subjects, and repeats the identity/callback check
|
||||
after sustained compression. Both migration endpoints must be active leaves at
|
||||
lineage depth two or greater.
|
||||
|
||||
Both modes distinguish selected attempts from resolved material consequences.
|
||||
The harness counts direct typed transitions and non-`no_material_change`
|
||||
activity outcomes separately, rejects a sustained run that never changes the
|
||||
background world materially, and records every outcome in the private
|
||||
preflight. A named member's dormant delta may now evolve offscreen, but any
|
||||
change to memory, obligation, relationship, equipment, or knowledge must be
|
||||
accounted for by an exact member-bound outcome before the return callback is
|
||||
accepted.
|
||||
|
||||
Fairness acceptance treats a rejected provider wave the same way the daemon's
|
||||
scheduler does: the failed pulse records its private trace and commits nothing;
|
||||
a later pulse may try the same still-current revision again. The harness permits
|
||||
a bounded, operator-selected number of these fresh pulses per desired committed
|
||||
tick and reports every rejection in the final result. This does not expand the
|
||||
single stage's one-correction rule or rebase stale output.
|
||||
|
||||
The exact-build `d044951` budget-8/provider-8 profile is the first complete live
|
||||
proof of the attributed-inaction and immutable-batch machinery together. It ran
|
||||
23 sustained waves over 24 active subjects in 742.435 seconds. Every subject
|
||||
received direct resolution attention; 22 background subjects acted, while the
|
||||
two without a committed action were represented by exact attributed inaction.
|
||||
The wave plans selected 175 strategic effects: 85 population activities, 66
|
||||
institution postures, 22 named-member activities, one population migration,
|
||||
and one population-pressure transition. The appraisals also retained 179
|
||||
attributed inactions, including 31 cells where some constituents acted while
|
||||
others held. Ninety arena cells ran without an arena acquiring an actor ID or
|
||||
collective voice. The eight-cell cover used only two partitions and preserved
|
||||
the same boundaries across 21 of 22 wave transitions; its largest arena held
|
||||
13 rivals.
|
||||
|
||||
The same run preserved Mira Venn's effective capabilities, knowledge,
|
||||
equipment, conditions, obligations, memories, and relationship after her
|
||||
durable delta crossed the hierarchy. The presence planner later selected her
|
||||
from `harbor-neighbors` without the player naming her, and the kernel
|
||||
materialized the same person beside the player. The player remained unchanged.
|
||||
The private witness is
|
||||
`F:\GameCult\GhostlightDungeon\acceptance\gestalt-lease-b8p8-d044951-1`.
|
||||
|
||||
The profile also names the next pressure point instead of laundering it into a
|
||||
victory lap. Observed traffic used 1,988,812 prompt and 233,146 completion
|
||||
tokens at a 66.64% prompt-cache hit rate. Nineteen rejected pulses consumed
|
||||
807,304 prompt tokens—40.59% of observed prompt work. Eleven were caught by the
|
||||
action-bound semantic verifier, three by Interpreter semantic validation, two
|
||||
by JSON Schema, and three by transport failure. In that build, most population
|
||||
effects were still attributed attempts recorded in event history rather than
|
||||
resolved changes to resources, obligations, relationships, knowledge, or
|
||||
pressures. The current candidate adds the separate strategic outcome resolver
|
||||
described above without promoting the Interpreter into asserting success. A
|
||||
revised live result is still required; the `d044951` profile is evidence for
|
||||
cover, fairness, attribution, and atomic batch behavior, not evidence for
|
||||
material activity outcomes.
|
||||
|
||||
The callback and fairness claims are independently falsifiable. Strict live
|
||||
mode requires Mira's Persona to choose resettlement and proves the later
|
||||
callback without forcing that choice. Fairness mode may instead branch from the
|
||||
exact committed campaign inside that strict golden's result, then spend all 24
|
||||
new provider-driven ticks on budget-one debt rotation. The baseline path and
|
||||
SHA-256 are recorded in the result; no migration command or Persona decision is
|
||||
fabricated during fairness setup.
|
||||
|
||||
A deterministic diaspora test drives two durable people from the same
|
||||
depth-two refugee leaf to two different depth-two destination branches in one
|
||||
strategic plan. It proves exact effective capabilities, knowledge, goals,
|
||||
memories, relationships, possessions, conditions, and obligations survive the
|
||||
rebases; destination populations receive none of that private state. Only the
|
||||
source and destination population version counters change. Each person can then
|
||||
materialize from their new local baseline with the same name and private delta.
|
||||
|
||||
### Live pressure results
|
||||
|
||||
The 24-tick budget-one fairness run `gestalt-dynamics-fairness-a4ea28b-1`
|
||||
covered all 24 canonical subjects with 24 distinct debt-selected foci. Its
|
||||
accepted appraisals contained 77 attributed proposals: one population
|
||||
migration, nine institution posture changes, 15 named-member activities, and 52
|
||||
Gestalt activities. Consequence capping committed 45 actions from 15 background
|
||||
subjects. Four
|
||||
bad scheduler pulses were rejected without revision movement; fresh pulses at
|
||||
the same current state recovered. Mira retained her exact private and effective
|
||||
state and was automatically rematerialized at the end. The run took 641.578
|
||||
seconds. Its committed baseline is bound by SHA-256 in the result.
|
||||
|
||||
The 24-faction scale matrix produced:
|
||||
|
||||
| Configured cells | Effective cells | Arenas | Committed actions | Seconds | Prompt tokens/action |
|
||||
| ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| 1 | 1 | 1 | 2 | 18.913 | 11,734.5 |
|
||||
| 4 | 4 | 3 | 8 | 15.619 | 4,682.1 |
|
||||
| 8 | 8 | 7 | 16 | 26.300 | 3,546.6 |
|
||||
| 32 | 24 | 0 | 16 | 55.906 | 7,410.5 |
|
||||
|
||||
Budget 4 is the latency sweet spot in this fixture. Budget 8 reaches the global
|
||||
16-consequence cap with the best cognitive efficiency. Resolving every faction
|
||||
individually doubles prompt cost and wall time relative to budget 8, adds no
|
||||
committed consequences under the cap, and increases whole-wave tail-failure
|
||||
risk. Budget 1 remains a valid emergency compression mode, not an efficient
|
||||
default.
|
||||
|
||||
Repeated fairness prompts reached a 47.07% aggregate provider cache-hit ratio;
|
||||
Projector reached 75.45% and Persona 61.57%. Interpreter remained the dominant
|
||||
cost at 247,578 prompt tokens and only 34.54% cache hits. Prompt/context work
|
||||
should therefore target Interpreter permissions and stable-prefix layout before
|
||||
shrinking the Persona stream.
|
||||
|
||||
Resolution control is edge-triggered. `active_cell_budget` owns
|
||||
`resolution_epoch`; provider parallelism owns only
|
||||
`provider_configuration_epoch`. An unchanged control value is rejected without
|
||||
persistence, epoch movement, or cover invalidation. Acceptance harnesses issue
|
||||
either control only when its value actually differs, so repeated strategic
|
||||
ticks preserve the previous cover, leases, and churn history. Stress runs may
|
||||
select cell budget and provider parallelism independently.
|
||||
|
||||
A lease preserves a cut, not invalid topology. Before reusing a previous cover,
|
||||
the partitioner revalidates every multi-subject cell against the current agency
|
||||
graph. Migration, relation changes, fission, or activation can therefore break
|
||||
a leased cell and force replanning immediately. Cover validation reports empty,
|
||||
duplicate, disconnected, content-ID, and false-cohesion failures separately.
|
||||
|
||||
Fairness accounting uses the same definition as canonical detail debt: an
|
||||
explicit aggregate `detail_focus_subject_id` and every singleton cell both
|
||||
receive direct resolution attention. A named focus is not required for a
|
||||
subject already receiving its own complete Persona pipeline.
|
||||
|
||||
The lease-preserving budget-4 profile committed all 23 requested sustained
|
||||
waves and recovered from 18 rejected provider pulses without partial mutation.
|
||||
It then failed its separate callback assertion because the presence planner
|
||||
returned an empty cast. This isolated a player-experience fault from the agency
|
||||
invariants: Mira remained the same dormant person in the correct destination,
|
||||
but continuity was not surfaced. The focused `gestalt-callback-6253f9f-1` run
|
||||
then passed: after one fresh 24-subject arena wave, the planner promoted
|
||||
`mira-venn` from `harbor-neighbors` without the player naming her, and the
|
||||
kernel rematerialized the same private and effective state. The full run took
|
||||
18.465 seconds. Its strategic membrane used 18,662 prompt and 1,096 completion
|
||||
tokens; the additional presence decision used 2,504 prompt and 78 completion
|
||||
tokens.
|
||||
|
||||
Institution posture is projected as `already_committed_posture`. It is durable
|
||||
state already in force, not a new pressure or decision. Projector, Persona, and
|
||||
Interpreter contracts all treat continuing or restating it as explicit
|
||||
inaction; only a materially different commitment may emit an institution
|
||||
posture transition. Local validation remains strict rather than silently
|
||||
dropping a no-op.
|
||||
|
||||
The 31-wave budget-8 / provider-parallelism-4 profile
|
||||
`gestalt-lease-b8p4-6253f9f-1` passed after 1,211.592 seconds. All 24 subjects
|
||||
received direct attention. Its sustained appraisals made 199 accepted proposals
|
||||
from 22 distinct subjects; consequence resolution committed 51 institution
|
||||
postures, 110 Gestalt activities, one population migration, and 31 named-member
|
||||
activities, plus the initial arena consequence. Fifteen rejected pulses left
|
||||
the world unchanged and later recovered. The same Mira callback passed after
|
||||
the full run.
|
||||
|
||||
The profile used 1,468,039 strategic prompt tokens and 165,725 completion
|
||||
tokens, reached a 60.69% cache-hit ratio, and spent 7,567 prompt tokens per
|
||||
committed consequence. Interpreter remained the largest stage at 737,148
|
||||
prompt tokens but reached 67.18% cache hits; Projector reached 70.03%. The
|
||||
presence decision added 2,606 prompt and 78 completion tokens. Live traces
|
||||
showed that the remaining repeated-posture failures occurred when several
|
||||
constituents in one arena held while others acted, motivating the attributed
|
||||
inaction lane rather than another prompt-only prohibition.
|
||||
|
||||
The first attributed-inaction/provider-parallelism-8 comparison committed nine
|
||||
waves, then exhausted its bounded retries at wave 10. It proved the new lane in
|
||||
mixed appraisals—across the first nine waves, 72 cells emitted 63 actions and 93
|
||||
attributed inactions, including 18 action-plus-inaction appraisals—but also
|
||||
exposed an overprojection failure. The Interpreter invented roster-completion
|
||||
inactions for subjects absent from the Persona turn, overflowing the
|
||||
four-perspective limit, and once wrote a reason beyond the 240-character schema
|
||||
bound. The contract now admits inaction only for a subject that explicitly
|
||||
holds or waits in the Persona turn, forbids absence-as-inaction, and asks for a
|
||||
reason of at most 160 characters while retaining the 240-character hard bound.
|
||||
|
||||
The strategic outcome resolver closes the attempt/result authority gap.
|
||||
Institution postures, population migration, clocks, and history remain kernel
|
||||
state; selected Gestalt activities are digest-bound attempts. One batched
|
||||
resolver receives only those attempts and deterministic admissible consequence
|
||||
handles, then returns one typed result per attempt. The Interpreter cannot
|
||||
declare its own hoped-for outcome.
|
||||
|
||||
Local validation owns ordinary bounded preparations, pressure changes, and
|
||||
discoverable knowledge. Effects with easy-to-hide private authority mistakes—
|
||||
resource consumption or transfer, agency-relation shifts, and named-member
|
||||
memory, obligation, or relationship deltas—also pass through an independent
|
||||
same-snapshot semantic verifier. Its verdict is advisory to the resolver retry,
|
||||
never a writer. Applying that verifier to every harmless preparation was both
|
||||
expensive and over-strict, so the risk boundary is deliberately selective.
|
||||
|
||||
The current provider-backed golden completed four waves across budgets
|
||||
`1 -> 4 -> 8 -> 4` in 81.28 seconds in a debug build. It produced ten durable
|
||||
consequences and seven typed material activity outcomes, with three explicit
|
||||
no-material-change results, zero rejected pulses, exact member attribution,
|
||||
the player unchanged, and the same Mira returning with her persistent delta.
|
||||
It used 126,826 prompt tokens, 14,477 completion tokens, and a 58.34% prompt
|
||||
cache-hit ratio. This proves the ordinary material-outcome path live. The
|
||||
selective high-risk verifier was first covered locally, then forced through the
|
||||
provider boundary below.
|
||||
|
||||
That witness now exists at
|
||||
`F:\GameCult\GhostlightDungeon\acceptance\forced-high-risk-outcome-contract-fixed-20260818`.
|
||||
Dock Labor Guild explicitly transferred its exact `west winch rerouting plan`
|
||||
to the colocated Harbor Neighbors. The Flash resolver emitted the exact custody
|
||||
transition, the independent verifier accepted it against the same snapshot,
|
||||
and the canonical input remained byte-identical. The first witness also caught
|
||||
a prompt/schema naming mismatch: prose said `resource_recipient_id` while the
|
||||
schema requires `other_subject_id`. Naming the input-to-output mapping directly
|
||||
removed the correction call; the accepted rerun used exactly two first-attempt-
|
||||
valid provider calls in 2.95 seconds.
|
||||
|
||||
The Starfire deployment smoke ran a live provider-backed wave over three
|
||||
eligible singleton cells, committed three offscreen events and three
|
||||
channel-accessible news items in 19.55 seconds, and preserved the absent player.
|
||||
That fixture proves deployed cell isolation and atomic admission. The
|
||||
20-plus-faction budget matrix, not the singleton smoke, proves aggregation.
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
# Ghostlight Dungeon play agent (plan step 16)
|
||||
|
||||
> Status: the Dungeon daemon described here is parked at tag `parked/ghostlight-eve-surface` (2026-10-22); it is not on main.
|
||||
|
||||
## Status
|
||||
|
||||
Target, adopted 2026-09-22. Not implemented. The means will be in
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
# Ghostlight Dungeon Session Zero
|
||||
|
||||
> Status: the Dungeon daemon described here is parked at tag `parked/ghostlight-eve-surface` (2026-10-22); it is not on main.
|
||||
|
||||
Status: adopted direction, 2026-09-15. Not implemented. Its role as the
|
||||
playtest gate is superseded (operator, 2026-09-22) by the play agent,
|
||||
`ghostlight-play-agent.md` (plan step 16). D1–D3 and the library passes
|
||||
|
|
|
|||
|
|
@ -1,616 +0,0 @@
|
|||
# Ghostlight World Transition Algebra
|
||||
|
||||
Status: teardown evidence. This describes the pre-rebuild vocabulary — string
|
||||
`SubjectRef` identifiers, two-phase `AdmitEntity`, flat permitted-mutation
|
||||
batches, strategic cells, Gestalt fission, and compiler publication — all of
|
||||
which are on the deletion line in
|
||||
`docs/architecture/ghostlight-dungeon-mvp.md`. The current closed vocabulary is
|
||||
`docs/architecture/ghostlight-world-ontology.md`. Read this file for why the old
|
||||
shape failed, not for what to build.
|
||||
|
||||
The one principle carried forward: different admission lanes share one mutation
|
||||
vocabulary. A player and an institution transferring the same medicine use the
|
||||
same custody mutation.
|
||||
|
||||
## Objective
|
||||
|
||||
Ghostlight needs one physical world. A player action, an NPC reaction, a
|
||||
strategic institution, and a low-resolution Gestalt cell may use different
|
||||
admission and resolution procedures, but a successful outcome must change the
|
||||
same canonical components through the same kernel-owned commit primitive.
|
||||
|
||||
The transition algebra exists so that:
|
||||
|
||||
- freeform action remains possible without model-authored structural writes;
|
||||
- impossible actions are absent from the available operation space;
|
||||
- every committed change has exact subjects, authority, provenance, and
|
||||
version bindings;
|
||||
- foreground and background simulation cannot acquire asymmetric powers;
|
||||
- new play scenarios compose existing semantic mutations instead of growing a
|
||||
bespoke effect enum for every story beat.
|
||||
|
||||
This design adapts the useful core of action-language and planning literature:
|
||||
states are typed assignments, actions have explicit applicability conditions,
|
||||
and transitions have bounded effects. It does not adopt PDDL, STRIPS, or a
|
||||
general logic-programming runtime as Ghostlight's public ontology. BC+ provides
|
||||
the causal-law distinction, STRIPS and PDDL provide the precondition/effect
|
||||
discipline, and REBA provides the important reminder that coarse and fine
|
||||
simulation must refine the same transition system rather than inventing two
|
||||
worlds. See [BC+](https://academic.oup.com/logcom/article/30/4/899/2917837),
|
||||
[PDDL2.1](https://arxiv.org/abs/1106.4561), and
|
||||
[REBA](https://arxiv.org/abs/1508.03891).
|
||||
|
||||
## Authority map
|
||||
|
||||
### Owner
|
||||
|
||||
The per-campaign `WorldKernel` owns one `WorldMutationBatch` validation and
|
||||
commit primitive. It is the only subsystem allowed to change canonical world
|
||||
components or advance fictional time.
|
||||
|
||||
### Inputs
|
||||
|
||||
The owner may read:
|
||||
|
||||
- the exact canonical campaign revision;
|
||||
- the exact resolution epoch when a strategic wave is involved;
|
||||
- typed subjects and their component versions;
|
||||
- an accepted action, reaction, lifecycle, governance, or strategic-outcome
|
||||
receipt;
|
||||
- a digest-bound `MutationAuthorityEnvelope` compiled from current state;
|
||||
- a proposed batch containing only semantic mutations named by that envelope;
|
||||
- exact evidence, permission, roll, and model-stage receipts required by the
|
||||
originating procedure.
|
||||
|
||||
### Outputs
|
||||
|
||||
One successful commit produces, atomically:
|
||||
|
||||
- the next canonical component state;
|
||||
- append-only world events describing the observable means and outcome;
|
||||
- a private exact `world_mutation_receipt.v1`;
|
||||
- the command or strategic receipt that caused the transition;
|
||||
- any knowledge-channel-aware news inputs derivable from committed events.
|
||||
|
||||
Eve, CultMesh, story, and news projections refresh only after the commit.
|
||||
|
||||
### Derived and demoted state
|
||||
|
||||
- `WorldEffectDelta`, `ActorStateDelta`, `StrategicCellEffect`, and
|
||||
`StrategicOutcomeEffect` are requirements evidence and migration inputs. They
|
||||
are no longer allowed to own a production write after their resolver moves.
|
||||
- `StrategicActivityKind` describes proposed means. It is not a mutation.
|
||||
- An `ActionIntent` and its intended effect are proposals. They are not facts
|
||||
and do not become true because a model narrated them.
|
||||
- Simulation cells, covers, leases, salience, and detail debt choose inference
|
||||
resolution. They are not fictional world components.
|
||||
- The transcript contains exact committed speech and outcome prose. Eve lowers
|
||||
it chronologically without a generative rewrite; display text cannot repair
|
||||
or create state.
|
||||
- Actor equipment lists, institution resource lists, and Gestalt resource sets
|
||||
become projections over resource subjects plus custody. A resource does not
|
||||
change ontology when its owner changes.
|
||||
- A dormant named Gestalt member and its materialized foreground form are one
|
||||
actor subject. Materialization changes simulation resolution, not identity or
|
||||
physical existence.
|
||||
|
||||
### Forbidden writers
|
||||
|
||||
The migration is not complete while any of these can decide canonical state:
|
||||
|
||||
- `apply_world_effect`;
|
||||
- `apply_strategic_outcome_effect`;
|
||||
- the direct private-delta loop in `ResolveReactionWave`;
|
||||
- bespoke actor, Gestalt, or member movement and migration writers;
|
||||
- any Gestalt folding code that unions private knowledge, possessions, or
|
||||
pressure into a population;
|
||||
- model output, browser payloads, story rendering, news generation, retrieval, or a
|
||||
derived simulation cell.
|
||||
|
||||
Bounded region expansion and in-place locality elaboration lower to the same
|
||||
batch primitive through typed place, proposition, population, and institution
|
||||
admission plus exact topology and political-relation changes. Locality
|
||||
elaboration owns only additions beneath one exact canonical place; it cannot
|
||||
replace that place or bypass approval. Either path may possess a broader
|
||||
administrative authority envelope, but not another state-writing mechanism.
|
||||
Approval-gated population fission likewise lowers to entity
|
||||
admission, lineage, custody, and membership mutations; the fission projector
|
||||
owns only the derived resolution profiles and cover epoch. Initial compiler
|
||||
publication is not a transition over an existing world; its separate creation
|
||||
authority is bounded below.
|
||||
|
||||
### Shared paths
|
||||
|
||||
All of these lower to `WorldMutationBatch` before state changes:
|
||||
|
||||
- player `Attempt` confirmation;
|
||||
- NPC initiative resolution;
|
||||
- actor reactions and private appraisal consequences;
|
||||
- strategic individual, institution, cohesive Gestalt, and arena-constituent
|
||||
outcomes;
|
||||
- travel, waits, and strategic time advancement;
|
||||
- resource production, use, damage, repair, and exchange;
|
||||
- named-person joining, leaving, and migrating between populations;
|
||||
- population migration and approval-gated fission;
|
||||
- contract amendments that are permitted to change forward-looking world
|
||||
state;
|
||||
- compiler-admitted entities added after publication, bounded region expansion,
|
||||
and in-place locality elaboration.
|
||||
|
||||
### Cut line
|
||||
|
||||
The new validator applies a whole batch to an isolated transactional overlay,
|
||||
checks local and cross-mutation invariants, and returns either one complete next
|
||||
state or an error. Resolvers then emit the new mutation vocabulary directly.
|
||||
When the last production caller moves, the old committing functions and their
|
||||
model schemas are deleted. Persisted legacy receipts remain readable history;
|
||||
they never re-enter a commit path.
|
||||
|
||||
No repair loop, converter with independent policy, or post-commit reconciler is
|
||||
allowed to preserve a second opinion about the result.
|
||||
|
||||
## Transactions outside the mutation algebra
|
||||
|
||||
Two operations change canonical storage without claiming to be fictional
|
||||
world effects. They remain narrow because forcing them into `WorldMutation`
|
||||
would lie about what happened.
|
||||
|
||||
### Initial campaign publication
|
||||
|
||||
There is no prior campaign state to mutate at revision zero. The
|
||||
`CampaignRegistry` therefore owns discoverability of an approved seed, while
|
||||
`CampaignStore` owns its atomic installation:
|
||||
|
||||
- input is one validated `approved_campaign_brief.v1`, its exact evidence and
|
||||
model receipts, membership, governance, DM state, and approval digest;
|
||||
- every row is written to a fresh `.creating-<campaign>-<nonce>` store through
|
||||
one empty-store compare-and-swap batch;
|
||||
- the staging directory becomes discoverable only through one atomic rename;
|
||||
- retry is idempotent by campaign ID plus approved seed digest;
|
||||
- a nonempty store, conflicting digest, failed row write, or failed rename
|
||||
publishes nothing.
|
||||
|
||||
The seed installer may establish initial subjects and components because no
|
||||
world exists yet. It cannot target a published campaign, advance fictional
|
||||
time, or serve imports, reloads, expansion, or later compiler output. The
|
||||
legacy internal `CreateCampaign` message is initialization plumbing, not a
|
||||
runtime action authority; public player ingress rejects it. Once the directory
|
||||
is published, every fictional change belongs to the normal kernel path.
|
||||
|
||||
### Named-person materialisation and folding
|
||||
|
||||
A dormant `GestaltMemberDelta` and its foreground `ActorState` are two
|
||||
resolutions of one persistent person. `WorldKernel` owns the resolution
|
||||
transaction at a safe command boundary:
|
||||
|
||||
- materialisation derives the foreground actor projection from the Gestalt
|
||||
baseline plus the exact member delta;
|
||||
- folding writes changed individual capabilities, knowledge, equipment,
|
||||
conditions, obligations, relationships, goals, memories, and location back
|
||||
to that member delta, then retires only the foreground projection;
|
||||
- the transaction advances world revision so stale actor-bound commands and
|
||||
surfaces fail, advances `resolution_epoch`, and clears the old derived cover;
|
||||
- it does not advance fictional time, relocate the person, transfer custody,
|
||||
communicate knowledge, change population pressure, or alter the Gestalt
|
||||
baseline;
|
||||
- one atomic world receipt plus materialisation receipt binds the member,
|
||||
projection actor, baseline/member versions, world revision, and resolution
|
||||
epoch.
|
||||
|
||||
The removed `GestaltAggregateDelta` was an obsolete writer: direct demotion
|
||||
could previously smuggle private knowledge, resources, and pressures into the
|
||||
population while automatic demotion rejected the same payload. Those changes
|
||||
now require explicit semantic mutations under their own authority. Presence
|
||||
resolution has no vocabulary with which to perform them.
|
||||
|
||||
## Canonical state model
|
||||
|
||||
### Subjects
|
||||
|
||||
Every durable entity has a stable typed `SubjectRef`:
|
||||
|
||||
| Subject kind | Examples | Important rule |
|
||||
| --- | --- | --- |
|
||||
| `actor` | player, NPC, named refugee | Identity survives foreground materialization and Gestalt folding. |
|
||||
| `population` | villagers, refugee convoy, corporation workforce | May own a cohesive Gestalt Persona baseline; never owns member secrets by implication. |
|
||||
| `institution` | Zhestokost command, a clinic, a guild | Collective authority is explicit, not inferred from proximity. |
|
||||
| `place` | room, settlement, route node, region | Persists independently of current observation. |
|
||||
| `resource` | a key, medicine lot, ship, credits tranche | Has exact custody and resource state; it is not a string copied between owner lists. |
|
||||
| `pressure` | blockade clock, debt deadline, epidemic load | Carries bounded progress and consequence semantics. |
|
||||
| `proposition` | a source-grounded fact, a witnessed claim, a branch-local finding | Knowledge points to the proposition; prose duplication is not knowledge authority. |
|
||||
| `channel` | courier route, broadcast, private conversation | Governs who can receive information without making it public. |
|
||||
|
||||
Campaign time is a component of the campaign root. Relationships and topology
|
||||
edges are keyed component records between subjects, not synthetic actors.
|
||||
|
||||
IDs are opaque and stable. Prefixes may remain a debugging convention, but no
|
||||
validator infers subject kind or authority from string shape.
|
||||
|
||||
### Components
|
||||
|
||||
The target store is component-oriented even when a compatibility migration
|
||||
temporarily reads old aggregate rows:
|
||||
|
||||
- `identity`: canonical identity plus adopted handles and their disclosure
|
||||
state;
|
||||
- `occupancy`: exact place anchors and travel state;
|
||||
- `custody`: exact custodian of a resource or controlled subject;
|
||||
- `resource_state`: type, quantity, integrity, qualities, and lifecycle;
|
||||
- `capability`: supported abilities and their limits;
|
||||
- `condition`: bodily, material, social, or environmental state;
|
||||
- `knowledge`: proposition, epistemic status, source, channel, and revision;
|
||||
- `memory`: private episodic record and provenance;
|
||||
- `relationship`: directed subject-to-subject state;
|
||||
- `commitment`: goals and obligations, including creditor or beneficiary;
|
||||
- `pressure`: bounded progress, threshold, consequence, and status;
|
||||
- `posture`: an agentive subject's current strategic stance;
|
||||
- `population_membership`: exact member, population, role, and interval;
|
||||
- `population_lineage`: refinement, fission, merge, and remainder ancestry;
|
||||
- `topology`: containment and route edges with travel cost and availability;
|
||||
- `lifecycle`: admitted, active, transformed, consumed, retired;
|
||||
- `world_time`: the campaign's monotonic fictional coordinate.
|
||||
|
||||
Components are sparse. A place does not need knowledge; a proposition does not
|
||||
need occupancy. Component applicability is determined by subject kind and the
|
||||
specific admitted entity, not by a universal bag of optional fields.
|
||||
|
||||
Route keys are scoped to their owning origin location. Canonical route identity
|
||||
is therefore the pair `(origin_location_id, local_route_id)`, never the bare
|
||||
map key. The transition overlay encodes that pair into a collision-free edge
|
||||
identity before validation and maps accepted edges back to the original local
|
||||
keys. Surface projections use each route's exact `destination_id`; they do not
|
||||
reinterpret a local route key as a destination or global edge ID.
|
||||
|
||||
## The mutation vocabulary
|
||||
|
||||
`WorldMutation` is a closed tagged union. Each variant owns one semantic
|
||||
component family and names the invariants that family can enforce. There is no
|
||||
`SetField`, arbitrary path, embedded patch, or model-authored component name.
|
||||
|
||||
| Mutation | Canonical effect | Essential validation |
|
||||
| --- | --- | --- |
|
||||
| `Relocate` | Changes occupancy for an exact subject. | Origin matches, route/reach exists, mover has authority, group custody and travel approval hold. |
|
||||
| `TransferCustody` | Moves an exact resource between custodians. | Source has custody, recipient exists and can receive it, quantity/identity is conserved. |
|
||||
| `MutateResource` | Creates, transforms, consumes, damages, repairs, splits, or combines exact resources. | Recipe/capability, custody, conservation, lifecycle, quantity, and effect ceiling hold. |
|
||||
| `ChangeCapability` | Grants, alters, suspends, or retires a supported capability. | Source of capability and duration are admitted; no narration-only expertise. |
|
||||
| `ChangeCondition` | Applies, alters, or clears a condition. | Target is reachable, condition is compatible, opposition and outcome band permit it. |
|
||||
| `ChangeCommitment` | Creates, alters, fulfills, defaults, or retires a goal or obligation. | Exact owner, counterparty, consent or coercive authority, and lifecycle hold. |
|
||||
| `ChangeRelationship` | Creates, alters, or retires a directed relationship component. | Exact endpoints exist; one actor cannot author another's private appraisal without its own reaction authority. |
|
||||
| `ChangePressure` | Creates, advances, reduces, resolves, or retires a bounded pressure. | Owner, bounds, current value, threshold, and consequence are exact. |
|
||||
| `ChangeKnowledge` | Acquires, communicates, conceals, corrects, or invalidates a proposition for exact knowers. | Source knows or can observe it, channel and recipients are exact, concealment does not erase another mind. |
|
||||
| `ChangeMemory` | Records, revises, or retires an exact private episodic memory. | Witness or authorized private appraisal exists; another actor cannot write it. |
|
||||
| `ChangePosture` | Alters the posture of an exact agentive subject. | The acting authority can speak or decide for that subject; arenas cannot use it collectively. |
|
||||
| `ChangePopulationMembership` | Joins, leaves, or transfers an exact actor between populations. | Member identity is stable, source membership exists, destination is compatible, no duplicate coverage. |
|
||||
| `ChangePopulationLineage` | Records an admitted split, merge, or remainder relation. | Fission preview and approval exist; leaves remain non-overlapping and complete. |
|
||||
| `ChangeIdentity` | Adopts, discloses, restricts, or retires an exact identity handle. | Self-authority or explicit custody exists; disclosure is separate from renaming. |
|
||||
| `ChangeTopology` | Adds, alters, opens, closes, or retires a containment or route edge. | Evidence/branch admission, endpoint existence, reciprocal rules, and geometry invariants hold. |
|
||||
| `CivicSystemSet` | Publishes the complete versioned civic apparatus for one jurisdiction. | The jurisdiction, institutions, resident populations, public civic propositions, political relations, version, and exact semantic-verifier receipt all exist in the accepted component overlay. |
|
||||
| `AdmitEntity` | Creates an explicitly permitted non-resource subject and initial components. | Admission class, evidence or branch-local authority, collision freedom, and required components hold. |
|
||||
| `RetireEntity` | Retires an exact subject without erasing history. | No dangling custody, occupancy, membership, topology, or active obligation remains. |
|
||||
| `AdvanceWorldTime` | Advances campaign time by an exact duration. | Monotonicity, governance, no-puppeting, tick budget, and scheduled obligations hold. |
|
||||
|
||||
`NoMaterialChange` is not a mutation. It is an outcome with an empty mutation
|
||||
batch and an explicit explanation. Speech is an event in the means record;
|
||||
persuasion, obligation, relationship, and knowledge consequences are separate
|
||||
mutations.
|
||||
|
||||
Governed time, travel, and resolution-budget proposals persist approvals before
|
||||
their unanimous finalization. A fully approved but uncommitted proposal is a
|
||||
retryable commit request: any already-approved active member may invoke its
|
||||
advertised approval operation again, and the kernel revalidates the exact world
|
||||
and governance boundary before attempting the same atomic finalization. A
|
||||
duplicate vote on a proposal that is not unanimous is still rejected, and a
|
||||
committed or stale proposal can never replay.
|
||||
|
||||
Complex effects are batches, not new verbs. Giving half a medicine lot to a
|
||||
clinic composes resource split, custody transfer, and perhaps an obligation.
|
||||
Evacuating a named refugee composes relocation, population-membership transfer,
|
||||
and accessible knowledge. Population fission composes entity admission,
|
||||
lineage, exact member transfers, and one custody transfer per scarce resource
|
||||
while preserving an `other/unknown` remainder. Capabilities, shared knowledge,
|
||||
goals, and active pressures inherit through the lineage reducer. Resources do
|
||||
not: every parent resource must be assigned to exactly one child in the
|
||||
approved preview, so aggregation cannot mint a granary for every ideological
|
||||
subgroup.
|
||||
|
||||
### Within-horizon course ordering
|
||||
|
||||
A strategic choice is one atomic endpoint transaction, but its observable
|
||||
means may straddle one admitted relocation. `WorldKernel` derives the only
|
||||
within-horizon ordering from exact occupancy and effect locations:
|
||||
|
||||
1. activities at the subject's snapshot location occur before relocation;
|
||||
2. the admitted relocation or population migration occurs next;
|
||||
3. activities at the exact admitted destination occur after arrival.
|
||||
|
||||
Distinct activity kinds inside one location phase remain an unordered atomic
|
||||
set. Activities spanning several locations without one unambiguous phase also
|
||||
remain simultaneous at the strategic resolution of that subject. The model
|
||||
does not emit phase labels, sequence numbers, or prose-authored timing; effect
|
||||
array and object field order are not authority. An unrelated activity location
|
||||
still fails exact spatial validation.
|
||||
|
||||
This derived order owns chronological event lowering only. The mutation batch
|
||||
still owns one indivisible final state, and every permit is compiled from the
|
||||
same pre-wave snapshot. A failed phase, outcome, or mutation therefore aborts
|
||||
the complete wave rather than leaving a subject halfway through its course.
|
||||
The old blanket assumption that every movement precedes every activity is not
|
||||
an owner and must not survive in Projector, Interpreter, verifier, or kernel
|
||||
guidance.
|
||||
|
||||
Foreground assessment uses the same authority-shaped contract principle. Its
|
||||
model schema contains only mutation lanes available in the current snapshot:
|
||||
no route means no movement lane, no accessible undisclosed proposition means
|
||||
no existing-fact acquisition lane, and absent clocks or institutions remove
|
||||
their lanes. A separate observation lane is always structurally available to
|
||||
the acting actor but is semantically admitted only when the exact local means
|
||||
can directly perceive, measure, inspect, or test the proposed result. It lowers
|
||||
to one branch-local proposition admission followed by exact knowledge
|
||||
acquisition in the same batch. The effect ceiling, semantic verifier, current
|
||||
location, acting actor, assessment receipt, and means digest bind that finding;
|
||||
the lane cannot author remote events, hidden motives, unsupported identities,
|
||||
or omniscient conclusions. Empty or unused maps may be omitted and deserialize
|
||||
to no mutation. The closed dynamic schema rejects an unavailable lane even when
|
||||
the model tries to emit it as `null`; provider formatting cannot manufacture
|
||||
authority.
|
||||
|
||||
## Means, intended effect, and committed mutation
|
||||
|
||||
### Means
|
||||
|
||||
`ActionMeans` records what is attempted and may become observable:
|
||||
|
||||
- exact acting subject;
|
||||
- bounded natural-language method;
|
||||
- exact targets, instruments, resources, places, routes, and channels cited
|
||||
from the actor's permitted slice;
|
||||
- explicit speech, if any;
|
||||
- state and evidence references supporting the method.
|
||||
|
||||
The means taxonomy is intentionally open at the prose level. Ghostlight does
|
||||
not need a rulebook verb for every clever action. The exact typed references
|
||||
make reach, custody, knowledge, and capability testable.
|
||||
|
||||
### Intended effect
|
||||
|
||||
`MutationIntent` names desired component families, targets, and qualitative
|
||||
direction. It is useful for assessment and cognitive focus, but it is not an
|
||||
assertion that the change occurred. A player may intend to persuade a guard,
|
||||
destroy a reactor, learn a route, or move a crowd without receiving any of
|
||||
those mutations.
|
||||
|
||||
### Committed mutation
|
||||
|
||||
The admitted outcome procedure emits a concrete `WorldMutationBatch` bounded
|
||||
by its authority envelope. The batch is the only one of the three objects that
|
||||
can become state. A roll changes which digest-bound outcome plan is eligible;
|
||||
it does not allow the resolver to invent a stronger mutation afterward.
|
||||
|
||||
For Sable's self-presentation:
|
||||
|
||||
- means: the refugee speaks an exact self-presentation to present listeners;
|
||||
- intended effect: those listeners may address this person using `Sable`;
|
||||
- committed mutations: `ChangeIdentity::AdoptHandle` only if the speaker is
|
||||
choosing a new handle, followed by `ChangeIdentity::DiscloseHandle` or
|
||||
`ChangeKnowledge::Communicate` to exact listeners;
|
||||
- derived projection: each listener's Eve surface displays the best identity
|
||||
handle that listener actually knows.
|
||||
|
||||
The speech never authorizes a global actor rename, and a display label never
|
||||
becomes identity authority.
|
||||
|
||||
## Mutation authority envelopes
|
||||
|
||||
An admission policy compiles `mutation_authority_envelope.v1` from the current
|
||||
snapshot. It contains exact permits, not broad roles:
|
||||
|
||||
- campaign revision and relevant component versions;
|
||||
- originating subject and resolution procedure;
|
||||
- permitted mutation kind and operation;
|
||||
- exact subjects, routes, resources, propositions, channels, and relationships
|
||||
available to that operation;
|
||||
- numeric and cardinality bounds;
|
||||
- extraordinary-permission or approval receipts;
|
||||
- effect ceiling and outcome-band binding;
|
||||
- expiry and digest.
|
||||
|
||||
The envelope is capability-like but not transferable. It can authorize one
|
||||
assessment outcome, one reaction appraisal, one strategic action, or one
|
||||
administrative command. It cannot be rebased onto a new revision.
|
||||
|
||||
### Action-specific model schemas
|
||||
|
||||
The model never receives the complete world mutation schema. Ghostlight first
|
||||
selects the component families relevant to the intended effect, then generates
|
||||
a compact schema from the exact permits:
|
||||
|
||||
- if no resource is transferable, no custody-transfer branch exists;
|
||||
- if the actor cannot reach a target, that target does not appear;
|
||||
- if the actor knows no communicable proposition, communication is absent;
|
||||
- if an arena has no collective actor, collective posture and speech are
|
||||
absent;
|
||||
- if the outcome cannot create an entity, admission is absent;
|
||||
- scalar fields carry local limits rather than universal ranges.
|
||||
|
||||
Each generated branch carries an opaque permit ID. The kernel resolves that ID
|
||||
back to the exact permit and revalidates it against the snapshot. Stable
|
||||
instructions and mutation definitions precede the dynamic permit table so
|
||||
provider caching remains useful; the dynamic prompt contains only the state
|
||||
needed for this decision.
|
||||
|
||||
Schema generation improves model ergonomics. It does not replace local
|
||||
validation, and schema presence is not authority by itself.
|
||||
|
||||
## Validation and atomic commit
|
||||
|
||||
The commit primitive performs this sequence:
|
||||
|
||||
1. Verify campaign revision, resolution epoch when present, originating receipt,
|
||||
envelope digest, expiry, and idempotency.
|
||||
2. Resolve every subject and component against the same snapshot.
|
||||
3. Verify every mutation has a matching unused permit and remains inside its
|
||||
exact IDs, operations, bounds, and effect ceiling.
|
||||
4. Validate local semantics: reach, custody, knowledge provenance, authority,
|
||||
component applicability, and lifecycle.
|
||||
5. Validate batch semantics: no contradictory writes, no resource duplication,
|
||||
no dangling entity retirement, no duplicate population membership, and no
|
||||
hidden cross-actor private writes.
|
||||
6. Apply the entire batch to an isolated in-memory overlay.
|
||||
7. Re-run global topology, custody, population coverage, player-control,
|
||||
knowledge, and temporal invariants on the overlay.
|
||||
8. Persist component rows, events, mutation receipt, command receipt, and
|
||||
required private proof records in one CultCache transaction.
|
||||
9. Publish refreshed Eve/CultMesh projections after the transaction commits.
|
||||
|
||||
Any error returns the original state byte-for-byte. A malformed model output
|
||||
gets one same-snapshot correction and then aborts. No valid sibling mutation
|
||||
survives an invalid batch member.
|
||||
|
||||
## Admission policies are separate from physics
|
||||
|
||||
The shared algebra does not make every actor equally powerful. Each lane has a
|
||||
different authority compiler:
|
||||
|
||||
- foreground fiction-first d20 assessment considers declared means, local
|
||||
affordances, opposition, permissions, stakes, and effect ceiling;
|
||||
- reaction appraisal can write only the reacting subject's private components
|
||||
plus exact speech/action proposals;
|
||||
- NPC initiative uses the same foreground assessment after initiative chooses
|
||||
an eligible proposal;
|
||||
- strategic cells use horizon, graph reach, institutional authority, resources,
|
||||
and resolution-cell attribution;
|
||||
- cohesive Gestalts may receive collective permits only from real collective
|
||||
authority;
|
||||
- arena cells receive constituent permits and cannot emit one arena-owned
|
||||
mutation;
|
||||
- compiler and approval commands may admit entities or topology only through
|
||||
explicit evidence and branch-local gap policy.
|
||||
|
||||
Different admission does not imply different mutations. A player and an
|
||||
institution transferring the same medicine use the same custody mutation.
|
||||
|
||||
## Migration from the empirical vocabularies
|
||||
|
||||
The accumulated variants are test requirements, not discarded knowledge:
|
||||
|
||||
| Existing behavior | Algebraic lowering |
|
||||
| --- | --- |
|
||||
| actor move / member migration / Gestalt migration | `Relocate` plus membership change when population affiliation changes |
|
||||
| actor equipment / institution resources / Gestalt resources / member equipment | resource subjects plus `TransferCustody` and `MutateResource` |
|
||||
| actor or member condition | `ChangeCondition` |
|
||||
| actor goal / member obligation | `ChangeCommitment` |
|
||||
| actor or member relationship | `ChangeRelationship` |
|
||||
| world clock and Gestalt pressure | pressure subjects plus `ChangePressure` |
|
||||
| actor knowledge / Gestalt shared knowledge / member knowledge | `ChangeKnowledge` with exact proposition and knower |
|
||||
| member memory / reaction memory | `ChangeMemory` |
|
||||
| institution posture / strategic posture | `ChangePosture` |
|
||||
| Gestalt promotion and folding | resolution change over one stable actor plus explicit component mutations only |
|
||||
|
||||
Migration order:
|
||||
|
||||
1. Publish types, component applicability, permits, batch validation, receipts,
|
||||
and property tests without changing live state.
|
||||
2. Lower foreground assessment and NPC resolution directly to mutation batches;
|
||||
delete `apply_world_effect`.
|
||||
3. Lower reaction Interpreters to actor-private permits and mutation batches;
|
||||
delete direct `ActorStateDelta` writes.
|
||||
4. Lower strategic outcome generation to the same operations; delete
|
||||
`apply_strategic_outcome_effect` and string-resource helpers.
|
||||
5. Classify named-person materialisation as a resolution transaction, remove
|
||||
aggregate mutation from folding, and invalidate the active cover. Complete.
|
||||
Normalizing remaining legacy member fields into component rows remains.
|
||||
6. Lower travel, waits, fission, and bounded expansion. Classify initial
|
||||
publication as an empty-store creation transaction rather than a world
|
||||
mutation. Complete.
|
||||
7. Migrate persisted campaign rows transactionally, quarantine ambiguous legacy
|
||||
resources or identities, and make the old campaign fields projection-only.
|
||||
8. Remove legacy schemas from model boundaries. Retain schema readers solely
|
||||
for historical receipt inspection.
|
||||
|
||||
At every stage, the old and new writers cannot both be active for the migrated
|
||||
lane.
|
||||
|
||||
## Agency corpus
|
||||
|
||||
Ghostlight does not declare the algebra complete from enum aesthetics. It owns
|
||||
an inspectable `agency_attempt_case.v1` corpus compiled into a MessagePack
|
||||
CultCache store. JSON Schema publishes the record shape; authored source may be
|
||||
reviewed Markdown, but runtime and test fixtures load `.cc`.
|
||||
|
||||
Each case records:
|
||||
|
||||
- domain and scenario;
|
||||
- minimal world fixture and exact subjects;
|
||||
- means and intended effect;
|
||||
- expected admission: admissible, impossible, or bargain-required;
|
||||
- required mutation composition by outcome band;
|
||||
- forbidden mutations and invariant witnesses;
|
||||
- expected bargains for missing permission or affordance;
|
||||
- whether foreground, NPC, strategic, and Gestalt resolutions should be
|
||||
physically equivalent;
|
||||
- reviewer status and any genuinely missing primitive.
|
||||
|
||||
The initial target is at least 300 reviewed attempts: 25 each across social,
|
||||
physical, investigative, economic, political, technological, extraordinary or
|
||||
magical, collective, identity and privacy, population and migration,
|
||||
infrastructure and logistics, and bodily or medical play. Cases include both
|
||||
ordinary and adversarial attempts, partial successes, indirect means, consent
|
||||
boundaries, rival actors inside arenas, and low-resolution background outcomes.
|
||||
|
||||
Completeness gates:
|
||||
|
||||
- every case maps compositionally, yields a comprehensible bargain, or names a
|
||||
genuinely missing primitive;
|
||||
- no case falls through to a generic patch or arbitrary component write;
|
||||
- the same physical change uses the same mutation in foreground, NPC, member,
|
||||
population, institution, and arena-constituent contexts;
|
||||
- property tests prove atomicity, deterministic permit/schema generation,
|
||||
resource conservation, stable identity, exact knowledge ownership, population
|
||||
coverage, topology integrity, and no puppeting;
|
||||
- the final 100 newly reviewed admissible cases require no new primitive. This
|
||||
is a stability signal, not a mathematical proof of completeness.
|
||||
|
||||
The Sable self-presentation and later rematerialization is a mandatory first
|
||||
case. It must prove observer-relative identity projection without a global
|
||||
rename or loss across Gestalt folding.
|
||||
|
||||
## Public contracts
|
||||
|
||||
Add:
|
||||
|
||||
- `typed_subject.v1`
|
||||
- `world_component_ref.v1`
|
||||
- `action_means.v1`
|
||||
- `mutation_intent.v1`
|
||||
- `mutation_permit.v1`
|
||||
- `mutation_authority_envelope.v1`
|
||||
- `world_mutation.v1`
|
||||
- `world_mutation_batch.v1`
|
||||
- `world_mutation_receipt.v1`
|
||||
- `agency_attempt_case.v1`
|
||||
|
||||
JSON Schema is the catalog boundary. Canonical component rows, envelopes,
|
||||
batches, receipts, and corpus fixtures use MessagePack-backed CultCache `.cc`.
|
||||
JSON remains acceptable only at browser, MCP, and model-provider boundaries.
|
||||
|
||||
## Acceptance
|
||||
|
||||
The algebra cut is complete only when:
|
||||
|
||||
- no production path calls a legacy effect writer;
|
||||
- every effectful command persists an exact mutation receipt;
|
||||
- malformed, stale, over-broad, contradictory, or partially valid batches leave
|
||||
state, fictional time, and projections unchanged;
|
||||
- background and foreground versions of the same action pass the same component
|
||||
invariants;
|
||||
- arena rivals cannot speak or mutate as one subject and cannot acquire one
|
||||
another's knowledge;
|
||||
- resource custody and quantity are conserved across transfers and failures;
|
||||
- named people remain the same subjects through Gestalt merge, split, migration,
|
||||
folding, and rematerialization;
|
||||
- Sable's disclosed handle appears only to entitled observers and survives the
|
||||
complete round trip;
|
||||
- action-specific schemas omit impossible operations before inference;
|
||||
- the agency corpus meets its coverage and stability gates;
|
||||
- adversarial play through the typed Eve surface still proves the homepage
|
||||
promises after migration.
|
||||
|
|
@ -16,7 +16,7 @@ profile-side binding).
|
|||
|
||||
## Authority map
|
||||
|
||||
- **Admission owner:** `require_patch_author` (`world/mod.rs`) decides which
|
||||
- **Admission owner:** `require_patch_author` (`crates/ghostlight/src/lib.rs`) decides which
|
||||
caller may submit `AdmitPatch` and returns that caller's `PatchGround`;
|
||||
`confine_to_ground` decides what a confined ground may write. Both run in
|
||||
`reduce`'s `AdmitPatch` arm and again in `apply_effect`'s `PatchAdmitted`
|
||||
|
|
@ -28,14 +28,14 @@ profile-side binding).
|
|||
read from committed state at decision time. A consumer cannot name its own
|
||||
scope on the wire, and revoking a binding in a later commit shrinks its
|
||||
authority with no ingress change.
|
||||
- **Caller minting:** `WorldMailbox::submit_consumer` (`world/mailbox.rs`) is
|
||||
- **Caller minting:** `WorldMailbox::submit_consumer` (`crates/ghostlight/src/mailbox.rs`) is
|
||||
the only constructor of `AuthenticatedCaller::verified_system(SystemCapability::Consumer { consumer })`.
|
||||
- **Ingress owner:** `world/consumer.rs` owns decoding a document, bounding
|
||||
- **Ingress owner:** `crates/ghostlight/src/consumer.rs` owns decoding a document, bounding
|
||||
it, authenticating the consumer, one port call, and one receipt
|
||||
projection. It owns no world truth, holds no opinion about a patch's
|
||||
content, and does not pre-validate — a second reducer here could disagree
|
||||
with the kernel's, so structure is decided once, by `resolve_patch`.
|
||||
- **Port:** `ConsumerPort` (`world/mailbox.rs`) has one method,
|
||||
- **Port:** `ConsumerPort` (`crates/ghostlight/src/mailbox.rs`) has one method,
|
||||
`submit_consumer`. It cannot read the world: it does not select an answer,
|
||||
does not pre-validate, and returns a receipt rather than state.
|
||||
|
||||
|
|
@ -63,11 +63,16 @@ attributed proposal — is not part of this pass; see "Not in this pass" below.
|
|||
|
||||
## Transport
|
||||
|
||||
Status: the HTTP door below lived in the Dungeon daemon's `api_router` and is
|
||||
parked with it at tag `parked/ghostlight-eve-surface`; no process on main serves
|
||||
it. `ghostlight::admit_document` and `encode_receipt` are the contract a host
|
||||
serves.
|
||||
|
||||
`POST /cultnet/world-patch`, declared beside `/cultnet/snapshot` in
|
||||
`api_router` (`runtime.rs`). The handler gates loopback and content type
|
||||
(`application/msgpack`) exactly as the snapshot route does, then hands the
|
||||
body to `world::consumer::admit_document`; everything past those two gates is
|
||||
`world/consumer.rs`'s to decide. The body limit is
|
||||
body to `ghostlight::admit_document`; everything past those two gates is
|
||||
`consumer.rs`'s to decide. The body limit is
|
||||
`CONSUMER_BODY_LIMIT = patch::MAX_PATCH_BYTES + CONSUMER_ENVELOPE_SLACK` (an
|
||||
8 KiB envelope allowance over the 256 KiB patch cap), so the transport guard
|
||||
cannot become a second opinion about how big a patch may be.
|
||||
|
|
@ -143,8 +148,12 @@ those subjects (`confine_to_ground`, the `PatchGround::Consumer` arm):
|
|||
place, never the acting subject, and a consumer's ground names no place.
|
||||
A consumer tells what its mirror knows through `AcquireKnowledge` on its own
|
||||
bound subject; it cannot cause a place-wide witnessed event;
|
||||
- a `Claimed { by }` fact must name a bound subject; a `Canonical` fact
|
||||
declaration is refused outright — a consumer's evidence buys an `Admit`
|
||||
- `Retire`, `GrantAffordance`, `RevokeAffordance`, and `Mint` are always
|
||||
refused (`ResolvedOp`s that are authority over standing, not structure over
|
||||
ground);
|
||||
- a `Claimed { by }` fact must name a bound subject; a `Ruled` fact is refused
|
||||
outright, and so is a `Canonical` fact
|
||||
declaration — a consumer's evidence buys an `Admit`
|
||||
into its own custody and a `Claimed` fact, never a canonization;
|
||||
- a channel's reach and controller must all be bound subjects;
|
||||
- every operation's ground subjects must be bound, and its place and route
|
||||
|
|
@ -180,9 +189,9 @@ enters a log, a receipt, or the journal.
|
|||
The consumer capability's tag is part of the commit digest and the
|
||||
externally controlled assignment is part of the state shape, so both bump the
|
||||
schema. World lens weights later entered the state and `SetLensWeights`
|
||||
the commit, so the live schemas are state `ghostlight.world_state.consumer.v5`
|
||||
the commit, so the live schemas are state `ghostlight.world_state.consumer.v6`
|
||||
(state-schema generation `world-v3`) and commit
|
||||
`ghostlight.world_commit.consumer.v5`. A store written under an earlier schema
|
||||
`ghostlight.world_commit.consumer.v6`. A store written under an earlier schema
|
||||
is refused, not migrated.
|
||||
|
||||
## Not in this pass
|
||||
|
|
|
|||
|
|
@ -9,10 +9,9 @@ it owns who may write, what the prime invariants are, and where the boundaries
|
|||
sit. This document owns what the vocabulary *is* and what the reducer checks.
|
||||
It may not introduce a writer, a semantic gate, or a second commit path.
|
||||
|
||||
`docs/architecture/ghostlight-transition-algebra.md` and
|
||||
`docs/architecture/ghostlight-multiresolution-agency.md` describe the
|
||||
pre-rebuild machine and are teardown evidence. Their surviving ideas are named
|
||||
where they are used: one mutation vocabulary across every admission
|
||||
The pre-rebuild transition-algebra and multiresolution-agency designs were torn
|
||||
down, and their pages are gone. Their surviving ideas are named where they are
|
||||
used: one mutation vocabulary across every admission
|
||||
lane; compact mutation drafts with a complete deterministic mismatch set; and a
|
||||
resumable per-jurisdiction elaborator session checkpointed against admitted
|
||||
commit ancestry.
|
||||
|
|
@ -50,16 +49,17 @@ partition of world-authored entries; `authority`, `selection`, and
|
|||
`commitments`, `pressures`, and `last_opportunity_at` partitions; and the
|
||||
scalars `now` and `scale_intent`. There is no relation yet.
|
||||
|
||||
`world/patch.rs` owns typed `SubjectId`/`EntityId`/`EdgeId`/`AffordanceId`
|
||||
namespaces, `Ref<Id>` with `DraftHandle` (adjacently tagged `RefKind`), and a
|
||||
`crates/ghostlight/src/lib.rs` defines the typed `SubjectId`/`EntityId`/`EdgeId`/
|
||||
`AffordanceId` namespaces; `crates/ghostlight/src/patch.rs` owns `Ref<Id>` with `DraftHandle` (adjacently tagged `RefKind`), and a
|
||||
closed `resolve_patch` that resolves declarations, then operations, and
|
||||
returns the complete `Vec<Mismatch>` before any canonical ID allocates;
|
||||
`derive_id` is deterministic over world, command, and handle.
|
||||
`Declaration::Affordance` admits a catalog entry with preconditions drawn
|
||||
only from `{ Present, Reachable, Holds }`, effect ceilings, and weighted
|
||||
outcome bands. The ten inhabited operations are `Relocate`, `OpenRoute`,
|
||||
`CloseRoute`, `AlterCost`, `Transfer`, `Transform` (one-to-one), `Consume`,
|
||||
`Admit` (same-patch evidence), `Bind`, and `Release`; `apply_operations` is
|
||||
from the nine `Precondition` variants (`Present`, `Reachable`, `Holds`,
|
||||
`Authorized`, `HasStanding`, `Knows`, `CanBroadcast`, `CanReach`,
|
||||
`Committed`), effect ceilings, and weighted outcome bands. `ComponentOp` has
|
||||
34 variants (listed under "Component operations"), `Transform` is one-to-one
|
||||
and `Admit` takes same-patch evidence; `apply_operations` is
|
||||
the one owner of operation application and conservation, and
|
||||
`patch::check_ledger` is the single named conservation check.
|
||||
`CommandBody::AdmitPatch` emits `WorldEffect::PatchAdmitted` through the one
|
||||
|
|
@ -71,14 +71,14 @@ kernel itself bounds a patch nowhere yet. `EvidenceRef::new` is `pub(super)`
|
|||
and its production use is bounded by `filter_evidence`.
|
||||
|
||||
Character action is a precondition-effect transition. Grants are
|
||||
`BTreeMap<DecisionScope, BTreeSet<AffordanceId>>`. `world/action.rs` owns
|
||||
`BTreeMap<DecisionScope, BTreeSet<AffordanceId>>`. `crates/ghostlight/src/action.rs` owns
|
||||
`exercise()`, called from `reduce` and `apply_effect`: it checks the grant,
|
||||
evaluates each precondition against the actor's own components at the scope
|
||||
digest, selects an outcome band from
|
||||
`BandPreimage { world_id, revision, command_id, affordance, band_count }`
|
||||
through `digest()` with no RNG, bounds the proposed effects by the band's
|
||||
ceilings, and appends `DecisionEvent { band, effects }`. Rejections are the
|
||||
complete `ActionMismatch` set (17 variants) under
|
||||
complete `ActionMismatch` set (26 variants) under
|
||||
`KernelError::ActionRejected`. `Speak` is a kernel-built entry with zero
|
||||
preconditions and an empty band that carries speech, synthesized only at
|
||||
genesis. A controller's tools, signatures, and permissions are derived from
|
||||
|
|
@ -101,7 +101,7 @@ petitions from subjects with standing. Authority-writing slots (`grant`,
|
|||
target, otherwise `ActionMismatch::DelegationNotMonotone`.
|
||||
|
||||
A `Fact` is an entity with a `Statement` and a `FactStanding`: `Canonical`
|
||||
with evidence, or `Claimed { by }`. `Knowledge` is subject-keyed per fact with
|
||||
with evidence, `Claimed { by }`, or `Ruled` (stated by the play table). `Knowledge` is subject-keyed per fact with
|
||||
a `Confidence` and a `KnowledgeSource { Witnessed, Told { by, via }, Seen { by },
|
||||
Evidenced }`; a telling or a showing never overwrites a holder. `Witnessed` is minted by an authored
|
||||
patch's `AcquireKnowledge` and, with no author at all, by `Witness` (below). A
|
||||
|
|
@ -163,9 +163,9 @@ regional or global event interrupt whoever was mid-thought under that place
|
|||
(the interruption mechanism of pass 9).
|
||||
|
||||
Time is `now: FictionalMinutes`, moved only by `CommandBody::AdvanceTime`
|
||||
from `CallerId::System(SystemCapability::Clock)`; the runtime tick submits
|
||||
the constant `CLOCK_TICK_MINUTES`, never a measured duration. `world/clock.rs`
|
||||
owns `derive_motion`, pure over state and tick: routines re-arm by exactly one
|
||||
from `CallerId::System(SystemCapability::Clock)`; the caller submits
|
||||
an explicit `TickMinutes` (Dungeon's `world.advance_time` payload), never a measured
|
||||
duration. `crates/ghostlight/src/clock.rs` owns `derive_motion`, pure over state and tick: routines re-arm by exactly one
|
||||
`period`, past-due obligations and goals write `Pressure` on their subject by
|
||||
the `step` table, and an unavailable dependency does the same. A
|
||||
`Commitment` is `(kind: Routine | Obligation | Goal, counterparty, due,
|
||||
|
|
@ -199,8 +199,8 @@ decided by `operation_ground`, which is total over `ResolvedOp`). Placeless
|
|||
referents (resources, catalog entries, facts) are not confined. The model's
|
||||
tool surface is `PATCH_TOOLS`: one tool per non-genesis `Declaration` and
|
||||
`ComponentOp` variant plus two session tools, emitted through
|
||||
`world/tool_schema.rs` from one schema spelling per type.
|
||||
`world/elaboration.rs` owns the repair loop: a session keyed by a
|
||||
`crates/ghostlight/src/tool_schema.rs` from one schema spelling per type.
|
||||
`crates/ghostlight/src/elaboration.rs` owns the repair loop: a session keyed by a
|
||||
deterministic command id (sha256 over world, jurisdiction, answer digest)
|
||||
submits a draft, persists the resolver's complete mismatch set under that id
|
||||
as a `Refusal` placed after the rounds that earned it, and continues the same
|
||||
|
|
@ -212,7 +212,9 @@ fold, bounded by `ELABORATION_ROUND_BUDGET` and the
|
|||
`EvidenceSource` receipts (`filter_evidence`); with `NullEvidenceSource` no
|
||||
canonical fact and no `Admit` can land from the elaborator lane.
|
||||
|
||||
`world/cover.rs` owns the budgeted connected cover. `derive_cover(world, now,
|
||||
`crates/ghostlight/src/cover.rs` owns the budgeted connected cover. Since the play
|
||||
agent's Cut 1 (`d69e9d4`) nothing in Dungeon drives the cover; the library keeps
|
||||
it for offline simulation. `derive_cover(world, now,
|
||||
tick, opportunities, agency_graph, budget)` is pure: it reads the attention
|
||||
order that `order_opportunities` owns, reserves the urgency slots for its
|
||||
head, rotates the remaining subjects through singleton cells by debt, and
|
||||
|
|
@ -229,7 +231,7 @@ Cell and constituent command ids are sha256-derived.
|
|||
|
||||
`AdmitPatch` has a third author: `CallerId::System(SystemCapability::Consumer
|
||||
{ consumer })`, minted only by `WorldMailbox::submit_consumer` after the
|
||||
consumer ingress (`world/consumer.rs`) has authenticated a document against a
|
||||
consumer ingress (`crates/ghostlight/src/consumer.rs`) has authenticated a document against a
|
||||
configured secret digest. `require_patch_author` and `confine_to_ground`
|
||||
widen from jurisdiction-only to `PatchGround { Jurisdiction(JurisdictionKey),
|
||||
Consumer(ConsumerId) }`; a consumer's ground is derived from
|
||||
|
|
@ -248,10 +250,11 @@ the one decode bound for both the elaboration lane and the consumer lane,
|
|||
under four caps — `MAX_PATCH_BYTES`, `MAX_PATCH_DECLARATIONS`,
|
||||
`MAX_PATCH_OPERATIONS`, `MAX_PATCH_EVIDENCE` — and the consumer document
|
||||
travels over two wire schema constants, `CONSUMER_PATCH_SCHEMA` and
|
||||
`CONSUMER_RECEIPT_SCHEMA`, both `.v0`. The state and commit schemas bump to
|
||||
`ghostlight.world_state.consumer.v2` and `ghostlight.world_commit.consumer.v2`
|
||||
(state-schema generation `world-v3`); a store written under an earlier schema
|
||||
is refused, not migrated.
|
||||
`CONSUMER_RECEIPT_SCHEMA`, both `.v0`. The state and commit schemas then bumped to
|
||||
`ghostlight.world_state.consumer.v2` and `ghostlight.world_commit.consumer.v2`;
|
||||
the live schemas are v6 (`STATE_SCHEMA` and `COMMIT_SCHEMA` in
|
||||
`crates/ghostlight/src/lib.rs`, state-schema generation `world-v3`). A store
|
||||
written under an earlier schema is refused, not migrated.
|
||||
|
||||
`WorldScaleIntent` now arrives at creation, not as a later admission:
|
||||
`world_create.v4` declares the title, the brief, targets, jurisdiction
|
||||
|
|
@ -274,7 +277,7 @@ model-authored seed patch from a hand-authored one, because the model is the
|
|||
owner's Hands during Draft and the owner's `ApproveDraft` plus `ActivateWorld`
|
||||
remain the only path to Active. Evidence for a seed session comes from
|
||||
`VaultEvidenceSource`, a read-only markdown directory reader in
|
||||
`world/vault.rs`: the reference it hands back is the note's vault-relative
|
||||
`crates/ghostlight/src/vault.rs`: the reference it hands back is the note's vault-relative
|
||||
`.md` path, and its caps (`MAX_VAULT_RECEIPTS`, `MAX_HITS_PER_REFERENT`,
|
||||
`MAX_LINK_FANOUT`, `MAX_EXCERPT_CHARS`) bound what one referent can retrieve.
|
||||
One `world.seed` invocation runs one session and commits at most one patch
|
||||
|
|
@ -302,13 +305,14 @@ is the typed component diff against the fresh components plus the
|
|||
knowledge row, and never another subject's snapshot. The re-lowering's
|
||||
provider request is a distinct round (`interpreter_round`) with its own
|
||||
content-addressed request id, so it cannot collide with the first lowering's
|
||||
request. State schema is `ghostlight.world_state.consumer.v5`, commit schema
|
||||
`ghostlight.world_commit.consumer.v5`, controller work `controller_work.v16`,
|
||||
Persona turn receipt `ghostlight.persona_turn_receipt.v3`; earlier stores and
|
||||
earlier rows are refused. Ghostlight owns a conserved narrative ledger;
|
||||
request. State schema is `ghostlight.world_state.consumer.v6`, commit schema
|
||||
`ghostlight.world_commit.consumer.v6`, controller work
|
||||
`ghostlight.controller_work.v18`, Persona turn receipt
|
||||
`ghostlight.persona_turn_receipt.v3`; earlier stores and earlier rows are
|
||||
refused. Ghostlight owns a conserved narrative ledger;
|
||||
Delvehold owns the economy (`delvehold-forced-ontology-integration.md`).
|
||||
Step 6 of the plan is complete; the seeded live run is the runbook's business.
|
||||
Plan step 12 landed the thirteenth row: `PersonaMaterial` (`values`, `voice`,
|
||||
Plan step 12 landed the last row of the component table: `PersonaMaterial` (`values`, `voice`,
|
||||
`memories`, `reads` keyed by subject) in its own `persona_material` partition,
|
||||
set whole by `SetPersonaMaterial` with `NoncanonicalText` for any padded text
|
||||
or doubled read and `NoOperationEffect` for identical material; it is read by
|
||||
|
|
@ -406,11 +410,14 @@ resolver, or reconciler is needed.
|
|||
Three namespaces, three newtypes, issued only by the reducer.
|
||||
|
||||
```text
|
||||
SubjectId person, institution, population — can decide
|
||||
EntityId place, resource, fact, channel — cannot decide
|
||||
EdgeId route, relation, commitment, pressure — connects the above
|
||||
SubjectId person, institution, population — SubjectKind, can decide
|
||||
EntityId place, resource, fact, channel — EntityKind, cannot decide
|
||||
EdgeId route — EdgeKind, connects places
|
||||
```
|
||||
|
||||
Commitments and pressures are keyed by subject, not by `EdgeId`, and there is
|
||||
no relation edge kind: `EdgeKind` has one variant, `Route`.
|
||||
|
||||
Display names are labels. They never resolve a reference or bind structure.
|
||||
An externally controlled mirror is an ordinary subject of one of these three
|
||||
kinds; what is external is its `ControllerAssignment`
|
||||
|
|
@ -418,7 +425,7 @@ kinds; what is external is its `ControllerAssignment`
|
|||
|
||||
## Components
|
||||
|
||||
Thirteen, each earning its place by constraining a decision. Some attach to one
|
||||
Twelve rows, each earning its place by constraining a decision. Some attach to one
|
||||
referent; some are edge-shaped and attach to a pair. The reducer does not care
|
||||
which noun a component is; it cares what it constrains.
|
||||
|
||||
|
|
@ -437,8 +444,9 @@ which noun a component is; it cares what it constrains.
|
|||
| `Pressure` | source → target: magnitude | the causal boundary trigger |
|
||||
| `PersonaMaterial` | subject: values, voice, memories, reads | lived meaning; never authority |
|
||||
|
||||
A `Fact` carries a `standing`: `Canonical` (admitted with evidence) or
|
||||
`Claimed { by: SubjectId }` (asserted in speech). Knowledge of a claimed fact is
|
||||
A `Fact` carries a `standing` (`FactStanding`, three variants): `Canonical`
|
||||
(admitted with evidence), `Claimed { by: SubjectId }` (asserted in speech), or
|
||||
`Ruled` (stated by the play table; only `Play` may write it). Knowledge of a claimed fact is
|
||||
belief. Deception is a subject asserting a claim while holding a contradicting
|
||||
canonical fact; misread is a listener acquiring a claim the speaker did not
|
||||
intend. Both are ordinary typed states, not narrator judgments.
|
||||
|
|
@ -450,32 +458,34 @@ problem in another without anyone deciding that it should be.
|
|||
|
||||
## Component operations
|
||||
|
||||
This is the closed operation set. Every mutation the kernel will ever apply is
|
||||
one of these, and every model-facing tool is a projection of one of these.
|
||||
This is the closed operation set. Every mutation the kernel applies is one of
|
||||
these, and every model-facing tool is a projection of one of these. The type is
|
||||
`ComponentOp` in `crates/ghostlight/src/patch.rs`: 34 variants, named here by
|
||||
the component they act on. Declarations are the six `Declaration` variants.
|
||||
|
||||
```text
|
||||
Declaration declare_subject, declare_place, declare_resource, declare_fact,
|
||||
declare_channel, declare_route (draft handles only)
|
||||
Position relocate(subject, via: Route)
|
||||
Route open, close, alter_cost
|
||||
Custody transfer(from, to, resource, qty), transform(resource, into, qty),
|
||||
consume(holder, resource, qty), admit(holder, resource, qty, evidence)
|
||||
Dependency bind, release
|
||||
Authority grant(subject, scope, kind), revoke
|
||||
Selection open_office, close_office, install(office, incumbent), vacate
|
||||
Redress open_forum(kind, forum, standing), close_forum
|
||||
Knowledge acquire(subject, fact, source, confidence), communicate(speaker, fact,
|
||||
channel), witness(fact, place, confidence), forget
|
||||
Channel set_reach, set_controller
|
||||
Commitment create(subject, counterparty, kind, due, period, checks, statement), discharge
|
||||
Pressure advance, reduce, resolve
|
||||
PersonaMaterial set(subject, values, voice, memories, reads)
|
||||
Retirement retire(referent, reason)
|
||||
Time advance(minutes)
|
||||
Declaration Subject, Entity (place or resource), Route, Affordance, Fact,
|
||||
Channel (draft handles only)
|
||||
Position Relocate
|
||||
Route OpenRoute, CloseRoute, AlterCost
|
||||
Custody Transfer, Transform, Consume, Admit (with evidence),
|
||||
Mint (no receipt; only Play may write it)
|
||||
Dependency Bind, Release
|
||||
Authority GrantAuthority, RevokeAuthority
|
||||
Selection OpenOffice, CloseOffice, InstallIncumbent, VacateOffice
|
||||
Redress OpenForum, CloseForum
|
||||
Knowledge AcquireKnowledge, Communicate, Witness, Forget
|
||||
Channel SetReach, SetController
|
||||
Commitment CreateCommitment, DischargeCommitment
|
||||
Pressure AdvancePressure, ReducePressure, ResolvePressure
|
||||
PersonaMaterial SetPersonaMaterial
|
||||
Retirement Retire
|
||||
Affordance GrantAffordance, RevokeAffordance
|
||||
```
|
||||
|
||||
Thirty operations. Adding one is a design change to this document and a
|
||||
code change to the reducer; it is never a runtime patch.
|
||||
Time is not a `ComponentOp`: the clock moves only by `CommandBody::AdvanceTime`.
|
||||
Adding an operation is a design change to this document and a code change to
|
||||
the reducer; it is never a runtime patch.
|
||||
|
||||
## Patch construction
|
||||
|
||||
|
|
@ -529,7 +539,7 @@ counts do not appear here and may not be added.
|
|||
The kernel owns component operations. A **world** owns its affordance catalog.
|
||||
This is the generality lever: a setting with spellcraft, insurance claims,
|
||||
oath-rhythms, or memetic sovereignty authors affordances from the same
|
||||
thirty operations, and the kernel never learns a genre.
|
||||
component operations, and the kernel never learns a genre.
|
||||
|
||||
```text
|
||||
Affordance
|
||||
|
|
@ -601,7 +611,9 @@ different mutations.
|
|||
Layers of collective agency are relations, not special subjects:
|
||||
|
||||
- **Containment** nests places and jurisdictions.
|
||||
- **Membership** binds persons and slices to institutions and populations.
|
||||
- **Membership** binds persons and slices to institutions and populations;
|
||||
design intent, with no record shape in the kernel yet (`EdgeKind` is `Route`
|
||||
only).
|
||||
- **Jurisdiction** is an `Authority` component whose scope is a subject set or
|
||||
a place subtree.
|
||||
- **Representation** is `Selection`: an institution acts through an office
|
||||
|
|
@ -630,10 +642,10 @@ authored seed request opens a scope. Both are answered by the same patch.
|
|||
|
||||
```text
|
||||
CausalBoundary
|
||||
UnelaboratedDestination { route, place }
|
||||
PolityInCausalRange { relation, subject }
|
||||
IndividuationRequired { population, slice, pressure }
|
||||
MissingStructure { scope } // a commitment or pressure has no authority,
|
||||
UnelaboratedDestination { route, place, scope }
|
||||
PolityInCausalRange { subject, scope }
|
||||
IndividuationRequired { population, scope }
|
||||
MissingStructure { subject, key, scope } // a commitment has no authority,
|
||||
// channel, or redress path that could resolve it
|
||||
|
||||
SeedRequest { jurisdiction: DraftHandle, brief: EvidenceRef } // draft phase only;
|
||||
|
|
@ -644,9 +656,13 @@ Boundaries are derived from a revision exactly as opportunities are, and each
|
|||
carries the digest of the components that derive it. One command answers one:
|
||||
|
||||
```text
|
||||
CommandBody::AdmitPatch { answers: Boundary | SeedRequest, patch: WorldPatch }
|
||||
CommandBody::AdmitPatch { answers: Option<PatchAnswer>, patch: WorldPatch }
|
||||
PatchAnswer = Boundary(CausalBoundary) | Deficit(JurisdictionKey)
|
||||
```
|
||||
|
||||
`SeedRequest` above is design intent: no type of that name exists in
|
||||
`crates/`, and Draft answers nothing.
|
||||
|
||||
A commit clears exactly the boundary it answers. Nothing else clears one. A
|
||||
patch answering a boundary the kernel no longer derives is rejected.
|
||||
|
||||
|
|
@ -724,9 +740,9 @@ Adopted 2026-09-15. Plan step 15 pass L1 implemented the Lenses and Stock lens
|
|||
set bullets and the concurrency half of the Sessions bullet. Per-world evidence
|
||||
binding (L2), detail rules, rule-driven demand and sessions in Draft (L3), and
|
||||
individuation (L4) are not implemented. These are library capabilities, neutral
|
||||
to every consumer. A consumer supplies the policy: Ghostlight Dungeon's
|
||||
Session Zero (`ghostlight-session-zero.md`) is one consumer of them, and no
|
||||
consumer's concept enters this vocabulary.
|
||||
to every consumer. A consumer supplies the policy, and no
|
||||
consumer's concept enters this vocabulary. The Session Zero design
|
||||
(`ghostlight-session-zero.md`) was to be a consumer of them; it is unbuilt, and nothing in `crates/` implements it.
|
||||
|
||||
- **Lenses.** A world carries weights over the library's lenses as world
|
||||
data. Its creator states them in `CreateWorld` (the library holds no
|
||||
|
|
@ -941,7 +957,8 @@ Deleted before replacement behavior is added, with no compatibility path:
|
|||
- 5 qualification and verification types plus 1 outcome resolver → 0;
|
||||
- titled quotas and the titled verifier, semantic qualification, and round
|
||||
budgets → 4 derived
|
||||
boundaries, 1 seed request, and 1 structurally counted scale deficit;
|
||||
boundaries (`CausalBoundary`) and 1 structurally counted scale deficit (a seed
|
||||
request is design intent, unbuilt);
|
||||
- 2 seed admission paths → 1;
|
||||
- N model-facing tool schemas hand-written per stage → 1 derived catalog.
|
||||
|
||||
|
|
@ -953,7 +970,7 @@ invariants did not ask for.
|
|||
## Build budget
|
||||
|
||||
No new crate, binary, dependency, or service. Types and reduction inside
|
||||
`crates/ghostlight/src/`, one new `patch` module, one new `affordance` module,
|
||||
`crates/ghostlight/src/`, the `patch` and `action` modules,
|
||||
focused `ghostlight` tests. No workspace-wide or
|
||||
release build is admitted for this stage.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,85 +0,0 @@
|
|||
# Illustrated IF Visual Pipeline
|
||||
|
||||
Illustrated interactive-fiction fixtures can use image generation for mood,
|
||||
presentation, and website replay. They should not treat image generation as the
|
||||
source of spatial truth.
|
||||
|
||||
## Role
|
||||
|
||||
The visual pipeline has four separate jobs:
|
||||
|
||||
1. Describe the playable visual beat in the Ink and `.visual.json` artifact.
|
||||
2. Preserve the intended environment, character, and branch state as structured
|
||||
visual data.
|
||||
3. Provide image models with enough concrete visible detail to render the beat.
|
||||
4. Keep generated art useful as presentation collateral without letting it
|
||||
rewrite scene geometry.
|
||||
|
||||
Visual artifacts are auxiliary replay and website value. They are not core
|
||||
training targets for responder, appraiser, mutator, relationship, or social
|
||||
state models.
|
||||
|
||||
## Geometry Authority
|
||||
|
||||
For scenes where spatial continuity matters across angles, branches, or later
|
||||
callbacks, the authoritative visual source is a `scene_set` artifact, not a
|
||||
generated image.
|
||||
|
||||
A `scene_set` may be a hand-built 3D blockout, a procedural scene file, a
|
||||
layout diagram, or another durable geometry asset. It should define:
|
||||
|
||||
- Environment layout and scale.
|
||||
- Named zones, routes, entrances, exits, and occluders.
|
||||
- Fixed cameras or camera-slot names for replay beats.
|
||||
- Character staging slots and allowed movement regions.
|
||||
- Important props, access points, interfaces, and branch-visible state anchors.
|
||||
- Which parts of the set are visible from each camera.
|
||||
|
||||
Generated environment concept art can inspire the set, but it is not a reliable
|
||||
layout contract. Image models routinely invent impossible joins, inconsistent
|
||||
occlusion, false loops, and locally plausible machinery that cannot be staged
|
||||
from a second angle.
|
||||
|
||||
Agent-assisted set construction may live in a separate toolchain such as
|
||||
VibeGeometry. Ghostlight only requires the resulting durable geometry input and
|
||||
the metadata needed to connect it to fixture scenes.
|
||||
|
||||
## Prompt Assembly
|
||||
|
||||
Prompt assembly should use the geometry source appropriate to the fixture:
|
||||
|
||||
- Mood concept only: text prompt may be enough.
|
||||
- Single isolated illustration: a complete standalone prompt may be enough.
|
||||
- Multi-scene illustrated IF: use `.visual.json` scene ids, character refs, and
|
||||
branch modifiers.
|
||||
- Multi-angle or branch-consistent illustrated IF: use a `scene_set` render or
|
||||
blockout for each camera angle, then apply the scene prompt as painterly
|
||||
direction.
|
||||
|
||||
For the multi-angle path, the image model acts as a painter and material
|
||||
interpreter. It does not decide the room shape.
|
||||
|
||||
## Base Images And Modifiers
|
||||
|
||||
Base prompts describe a complete visible frame. Modification prompts describe
|
||||
only the branch or state delta, but the edit call must receive the base image or
|
||||
the camera-specific blockout as visual input.
|
||||
|
||||
Do not rely on phrases such as "same room" or "as before" unless the edit input
|
||||
actually supplies the same room. If the image model only receives text, restate
|
||||
the visible geometry. If it receives a blockout, the prompt should still name
|
||||
the visible anchors it must preserve.
|
||||
|
||||
## Reviewer Expectations
|
||||
|
||||
The visual reviewer should ask whether the current fixture needs a `scene_set`.
|
||||
It should require one when:
|
||||
|
||||
- The story returns to the same room from multiple angles.
|
||||
- Branches move characters around a shared environment.
|
||||
- Sightlines, occlusion, route access, or body affordances affect choices.
|
||||
- The same space needs website imagery across several beats.
|
||||
- A generated concept image is being used as if it were a durable floor plan.
|
||||
|
||||
When a `scene_set` is required but missing, the fixture can still be accepted as
|
||||
prose or IF scaffold, but not as replay-ready illustrated IF.
|
||||
|
|
@ -1,498 +0,0 @@
|
|||
# Ink Branching Scenes
|
||||
|
||||
Ink is the branching scene format.
|
||||
|
||||
Ghostlight should not invent a parallel dialogue-tree language unless Ink
|
||||
fails a concrete requirement. Ink already gives us readable interactive fiction,
|
||||
choices, knots, stitches, variables, compiled JSON, and a format writers
|
||||
can inspect without needing to wear a compiler helmet indoors.
|
||||
|
||||
Ghostlight's job is the machinery around Ink:
|
||||
|
||||
- build actor-local awareness from agent state and scene state
|
||||
- project plausible speech and non-speech choices
|
||||
- save generation receipts when a model proposes branches
|
||||
- annotate why those choices make sense
|
||||
- preserve listener interpretation as separate from actor intent
|
||||
- record consequence surfaces for later state mutation
|
||||
- keep fuzzy social updates reviewed until there is training data
|
||||
- compile Ink into game-consumable JSON
|
||||
|
||||
Ink owns the playable branch structure. Ghostlight owns the psychological,
|
||||
cultural, memory, relationship, and consequence rationale.
|
||||
|
||||
## Runtime Shape
|
||||
|
||||
The artifact flow is:
|
||||
|
||||
```text
|
||||
agent state + scene state + lore grounding
|
||||
-> local awareness
|
||||
-> projected branch choices for the acting character
|
||||
-> selected branch as observable action
|
||||
-> event resolution from selected observable action
|
||||
-> participant appraisals and reviewed state mutation
|
||||
-> next actor local awareness from updated state
|
||||
-> next actor action
|
||||
-> Ink scene
|
||||
-> compiled Ink JSON
|
||||
-> reviewed training annotations
|
||||
-> selected branch consequences
|
||||
-> state, memory, belief, and relationship updates
|
||||
```
|
||||
|
||||
The Ink file should be readable as Aetheria content on its own. The sidecar
|
||||
training annotation should explain which Ghostlight state produced each branch
|
||||
and which consequences are automatic, reviewed, or deferred.
|
||||
|
||||
Each character turn must be generated from that character's local awareness.
|
||||
The protagonist-choice generator should not receive the responder's private
|
||||
state. The responder generator should not receive the protagonist's private
|
||||
intent except as an observable action, spoken text, gesture, object use,
|
||||
silence, or other perceivable cue. A listener is allowed to misunderstand.
|
||||
|
||||
Participant appraisal is symmetrical. If an action hurts, threatens, reassures,
|
||||
humiliates, obligates, or overloads anyone present, that change is consolidated
|
||||
for the affected character before the next actor is selected. The next actor may
|
||||
reply, walk away, comply, escalate, or end the interaction. A branch artifact can
|
||||
still display this as `choice -> reaction`, but Ghostlight should model it as
|
||||
turns over updated state, not as a mandatory paired response.
|
||||
|
||||
One-shot generation of both sides is allowed only as bootstrap scaffolding, and
|
||||
must be marked as such in the capture review. It is not the target runtime
|
||||
shape.
|
||||
|
||||
The current target path is source-grounded coordinator state, projected local
|
||||
context, reviewed character turns, branch compiler materialization, and IF
|
||||
artifact review.
|
||||
|
||||
## Ink Comments
|
||||
|
||||
Use Ink comments for lightweight traceability in writer-facing drafts:
|
||||
|
||||
- `ghostlight.scene`
|
||||
- `ghostlight.fixture`
|
||||
- `ghostlight.branch`
|
||||
- `ghostlight.action`
|
||||
- `ghostlight.intent`
|
||||
- `ghostlight.npc_response`
|
||||
- `ghostlight.consequence`
|
||||
- `ghostlight.training_hook`
|
||||
- `aetheria.flashpoint`
|
||||
|
||||
Use `//`, not Ink tags (`#`), for this metadata in drafts. Inky exposes tags in
|
||||
preview, which makes Ghostlight state leak into the readable scene. The
|
||||
sidecar remains the machine-readable receipt; comments are only local handles
|
||||
for humans and simple tools.
|
||||
|
||||
## Sidecar Annotations
|
||||
|
||||
Each Ink scene should have a `.training.json` sidecar with:
|
||||
|
||||
- source fixture reference
|
||||
- protagonist and NPC ids
|
||||
- actor-local awareness summary
|
||||
- projection controls
|
||||
- branch ids and Ink knot paths
|
||||
- action type and actor intent
|
||||
- state basis
|
||||
- reviewed consequences
|
||||
- training hooks
|
||||
- mutation policy
|
||||
|
||||
This sidecar is intentionally not a new branching format. It is a receipt. Ink
|
||||
remains the scene tree; the sidecar says why the tree grew that way and which
|
||||
state changes a reviewer accepted.
|
||||
|
||||
Visual planning should live beside the sidecar, not inside it. Use a separate
|
||||
`.visual.json` artifact for illustrated replay data: style cue, visual scene
|
||||
anchors, image prompts, character refs, prompt assembly rules, and visual
|
||||
modifiers. The training annotation may reference that file with
|
||||
`visual_plan_ref`, but it should remain focused on branch semantics and
|
||||
mutation surfaces.
|
||||
|
||||
## Mutation Policy
|
||||
|
||||
Ink variables can record local playthrough outcomes. They should not silently
|
||||
become canonical Ghostlight state.
|
||||
|
||||
Automatic:
|
||||
|
||||
- Ink compilation
|
||||
- branch id and knot validation
|
||||
- local variables for the current playthrough
|
||||
- mechanical comment handles and training hook checks
|
||||
|
||||
Manual reviewed until trained:
|
||||
|
||||
- relationship deltas
|
||||
- belief updates
|
||||
- memory writes
|
||||
- listener misread classification
|
||||
- activation changes
|
||||
- promotion from branch outcome into durable Aetheria lore
|
||||
|
||||
The current accepted-as-draft scaffold lives in
|
||||
`examples/ink/pallas-species-strikes.branch-and-fold.v0.ink`.
|
||||
|
||||
## Cross-Scene Consequence Carryover
|
||||
|
||||
Ghostlight branching scenes should not behave like disconnected dialogue samples.
|
||||
Interactive fiction needs consequences that survive the scene boundary. A branch
|
||||
choice should write durable effects into later context: relationship stance,
|
||||
perceived motives, resource costs, authority flags, object custody, active
|
||||
constraints, unresolved hooks, and available future options.
|
||||
|
||||
For training data, every material branch should preserve:
|
||||
|
||||
- the player/NPC-visible choice or action
|
||||
- the immediate responder output
|
||||
- accepted world-state, resource, memory, and relationship deltas
|
||||
- branch flags that later scenes must read
|
||||
- deferred consequences that should surface later rather than immediately
|
||||
- scene text showing how the consequence changes the feel of the next beat
|
||||
|
||||
The coordinator owns this continuity for now. Later, the trainable coordinator
|
||||
model should learn to carry these consequences forward without flattening them
|
||||
into cosmetic callbacks. If a choice does not change later affordances, trust,
|
||||
resources, risk, or interpretation, it is probably not a meaningful branch.
|
||||
|
||||
A complete interactive fixture needs an ordinary-world opening, consequential
|
||||
branches before the crisis peak, and a resolution that visibly reflects earlier
|
||||
choices. A single tense exchange can prove a seam; it is not enough to prove the
|
||||
story machine.
|
||||
|
||||
## Branch And Fold
|
||||
|
||||
Do not model every choice as an indefinitely isolated authored subtree. Strict
|
||||
worldline splitting creates a combinatoric writing burden after a handful of
|
||||
choices, which interactive fiction authors have been discovering the painful
|
||||
way since approximately the first time someone let a player open a second door.
|
||||
|
||||
Ghostlight should branch locally and fold structurally:
|
||||
|
||||
```text
|
||||
choice
|
||||
-> consequence packet
|
||||
-> normalized state deltas
|
||||
-> shared later scene reads compact variables
|
||||
-> conditional options, callbacks, imagery, and relationship texture
|
||||
-> major route split only when convergence would be false
|
||||
```
|
||||
|
||||
Most branches should converge onto shared later scenes. The consequences should
|
||||
survive as compact variables, bands, and flags rather than bespoke scenes for
|
||||
every exact history.
|
||||
|
||||
Good consequence state looks like this:
|
||||
|
||||
- `sella_trust_maer: wary`
|
||||
- `ledger_debt_level: high`
|
||||
- `clinic_staff_exhaustion: true`
|
||||
- `veyr_evidence_disclosed: partial`
|
||||
- `isdra_reads_maer_as: procedurally_dangerous`
|
||||
- `route_authority_pressure: escalated`
|
||||
|
||||
Those variables should alter later scenes by changing available options,
|
||||
relationship posture, timing pressure, resource costs, callback prose, visual
|
||||
state modifiers, and participant appraisals. They should not usually require a
|
||||
fully separate scene route.
|
||||
|
||||
Use coarse bands instead of fake precision. `blocked`, `wary`, `conditional`,
|
||||
`cooperative`, and `personally_invested` are more useful than pretending the
|
||||
story can meaningfully maintain 37 decimals of trust. The same applies to heat
|
||||
debt, legal exposure, institutional pressure, staff exhaustion, reputation, and
|
||||
social suspicion.
|
||||
|
||||
Major route splits are allowed when a consequence changes the playable world so
|
||||
much that folding would lie. Examples:
|
||||
|
||||
- a character dies, leaves, defects, or becomes unavailable
|
||||
- a location is destroyed, locked, opened, or politically inaccessible
|
||||
- a faction becomes hostile, allied, exposed, or legally empowered
|
||||
- a key object is destroyed, stolen, transferred, or transformed
|
||||
- the protagonist loses or gains institutional authority
|
||||
- a quest route, rescue path, market, technology, or branch-local future opens
|
||||
or closes entirely
|
||||
|
||||
The coordinator owns the fold decision for now. Every material branch should
|
||||
record whether it folds into an existing later scene, opens a conditional
|
||||
variant inside that scene, or creates a justified route split. If a route split
|
||||
is created, the artifact should name the reason and the expected future writing
|
||||
debt. Trees are cheap to grow and expensive to prune. The machine should not
|
||||
act surprised by botany.
|
||||
|
||||
## Branch Compiler
|
||||
|
||||
The Branch Compiler is the organ that turns Ghostlight scene intent into a
|
||||
playable Ink artifact. It is not the character agent, the responder, the
|
||||
appraiser, or the final reviewer. It assembles branch structure from already
|
||||
available scene state, projected actor affordances, proposed actions, accepted
|
||||
consequence packets, and coordinator constraints.
|
||||
|
||||
The Branch Compiler receives:
|
||||
|
||||
- fixture brief and scene spine
|
||||
- source-grounded lore digest
|
||||
- current world, scene, resource, relationship, and branch state
|
||||
- actor-local branch candidates, including speech and non-speech actions
|
||||
- responder outputs or coordinator-authored placeholder reactions
|
||||
- consequence packets from reviewed turns
|
||||
- branch-and-fold plan
|
||||
- visual continuity requirements
|
||||
- schema and Ink formatting constraints
|
||||
|
||||
The Branch Compiler emits:
|
||||
|
||||
- readable `.ink` scene content
|
||||
- Ink variables, knots, stitches, choices, gathers, and conditionals
|
||||
- a `.training.json` sidecar tying each branch to state basis, action intent,
|
||||
consequence surfaces, and later callbacks
|
||||
- compiler notes for fold decisions, route-split decisions, and known review
|
||||
risks
|
||||
- image prompt anchors and branch/state modification handles when the fixture is
|
||||
meant for illustrated output
|
||||
|
||||
The Branch Compiler may propose structure. It must not invent hidden character
|
||||
truth, private motivations, or durable social mutations as if compilation made
|
||||
them real. Character behavior comes from projected local context and responder
|
||||
or coordinator-authored action proposals. Social interpretation and memory
|
||||
changes come from appraiser/mutator review. The compiler's job is to make the
|
||||
branching artifact playable, legible, and traceable.
|
||||
|
||||
The compiler should prefer compact state variables, bands, and flags over
|
||||
runaway branch trees. It should create major route splits only when the
|
||||
coordinator or reviewed consequence packet says folding would falsify the
|
||||
world. When it folds branches, it must preserve the variables and callbacks that
|
||||
make the fold honest.
|
||||
|
||||
The Branch Compiler output is draft until the IF Artifact Reviewer accepts it.
|
||||
That separation matters. A compiler can make a scene valid Ink while still
|
||||
producing cosmetic choices, fake folds, unused variables, or endings that ignore
|
||||
the whole moral machinery. Valid syntax is not wisdom. It is barely hygiene.
|
||||
|
||||
## IF Artifact Review
|
||||
|
||||
Branching fixtures need an independent review pass before acceptance. The
|
||||
coordinator should not be the only thing grading its own Ink, sidecar, and
|
||||
branch logic. That is how a scene ends up proudly tracking variables that do
|
||||
nothing except sit there in a tiny uniform.
|
||||
|
||||
The reviewer may start as a prompted specialist sub-agent. Later it should
|
||||
become evaluator/classifier training data and eventually a trainable review
|
||||
organ. Its job is not prose polish. Its job is to catch false consequence.
|
||||
|
||||
The reviewer receives:
|
||||
|
||||
- the Ink file
|
||||
- the `.training.json` sidecar
|
||||
- the coordinator artifact
|
||||
- the intended branch plan, when available
|
||||
- the declared state variables, flags, bands, resources, risks, and visual
|
||||
modifiers
|
||||
- any compiled Ink or validation output available for the fixture
|
||||
|
||||
The reviewer emits:
|
||||
|
||||
- `accepted`, `needs_revision`, or `rejected`
|
||||
- severity-ranked findings
|
||||
- a fake-variable audit
|
||||
- a choice-consequence audit
|
||||
- a fold/callback audit
|
||||
- a state-gated prose audit
|
||||
- a visual-continuity audit
|
||||
- major-route-split justification notes
|
||||
- specific required fixes
|
||||
|
||||
Acceptance requires every material choice to change at least one later surface:
|
||||
|
||||
- available options
|
||||
- scene route or justified route split
|
||||
- relationship, trust, appraisal, or social standing
|
||||
- resource cost, time pressure, legal exposure, authority pressure, heat burden,
|
||||
exhaustion, or other risk
|
||||
- object custody, evidence quality, access, or technology state
|
||||
- later callback prose implemented as an actual conditional
|
||||
- ending texture or outcome band
|
||||
- visual state modifier tied to a real branch flag or state variable
|
||||
|
||||
Every declared variable must either be read later or be marked as telemetry-only
|
||||
in the sidecar. Ominous variables need threshold behavior. If a variable named
|
||||
`authority_pressure` rises and nobody with authority ever reacts, the artifact
|
||||
fails review. If `heat_time` counts down and no route, ending, resource cost, or
|
||||
staff condition changes, the artifact fails review. The name is allowed to be
|
||||
dramatic only if the machinery has teeth.
|
||||
|
||||
Any prose that says "if X happened" must be implemented as an actual conditional
|
||||
or moved into reviewer/coordinator notes. Writer-facing text should not name a
|
||||
branch possibility while dodging the state check that proves which version of
|
||||
history occurred.
|
||||
|
||||
Common failure labels:
|
||||
|
||||
- `potemkin_state`: a tracked variable never materially changes the artifact
|
||||
- `cosmetic_choice`: a choice changes wording but not affordance, cost, risk,
|
||||
appraisal, callback, visual state, or outcome
|
||||
- `missing_state_read`: a set variable is never read by later Ink, sidecar, or
|
||||
coordinator consequence logic
|
||||
- `fake_fold`: branches claim to fold, but the folded scene ignores their state
|
||||
- `unearned_convergence`: branches converge despite consequences that should
|
||||
make convergence false
|
||||
- `unjustified_route_split`: a separate route exists without enough world or
|
||||
story consequence to justify the writing debt
|
||||
- `state_named_instead_of_checked`: prose mentions alternate branch states
|
||||
instead of checking variables
|
||||
- `visual_callback_missing`: image modifiers or visual prose ignore branch
|
||||
consequences that should be visible
|
||||
- `authority_without_consequence`: institutional pressure rises but never bites
|
||||
- `resource_without_cost`: ledgers, heat, staff time, supplies, or access are
|
||||
invoked without affecting later choices or outcomes
|
||||
- `ending_ignores_major_state`: the ending does not respond to major variables
|
||||
established earlier
|
||||
|
||||
Early branch-and-fold review exposed this exact class of failure: variables can
|
||||
look important while doing nothing. Review must force important variables to
|
||||
alter gates, callbacks, visuals, appraisals, or outcomes before a fixture enters
|
||||
the accepted corpus.
|
||||
|
||||
## Scene Imagery
|
||||
|
||||
Ghostlight scenes are also website content. A complete fixture should emit a
|
||||
visual plan artifact alongside prose and training receipts so story branches can
|
||||
be illustrated without a later model guessing what the room looked like.
|
||||
|
||||
Interactive fiction replay also needs screen rhythm. A narrative scene may move
|
||||
through several visual sections: establishing shot, workstation, break table,
|
||||
object closeup, alarm state, crowd line, supervisor arrival, aftermath. These
|
||||
should be segmented so an InkJS replay can click through the scene at the pace
|
||||
the story needs. A pivotal beat may be a single sentence on its own screen when
|
||||
the point is to let the image and silence do the work.
|
||||
|
||||
Each distinct visual section should preserve an art-direction block with:
|
||||
|
||||
- global style cue: the fixture-level visual language when the art direction
|
||||
depends on a specific non-default look
|
||||
- establishing shot: location, scale, camera distance, and dominant geometry
|
||||
- light and color: practical light sources, palette, exposure, haze, glare,
|
||||
reflection, water, vacuum, screen glow, or other atmosphere
|
||||
- bodies and interfaces: how each relevant body occupies the space, including
|
||||
nonhuman access, assistive infrastructure, suits, avatars, wet/dry boundaries,
|
||||
displays, tools, and machinery
|
||||
- material evidence: objects, damage, records, ledgers, cargo, alarms, clothing,
|
||||
stains, frost, heat shimmer, or other concrete traces of what has happened
|
||||
- branch marks: visible changes caused by earlier choices, such as a sealed
|
||||
hatch, an exhausted staffer, a live debt ledger, missing equipment, guarded
|
||||
posture, or a route marker left blinking
|
||||
- base image prompt: one stable prompt for the unbranched scene, suitable for
|
||||
generating the reusable background or canonical key frame
|
||||
- branch modification prompts: additive edit prompts for visible consequences,
|
||||
changed character positions, opened or sealed routes, damaged objects,
|
||||
lighting changes, emotional posture, crowd pressure, or other scene states
|
||||
|
||||
Named characters need stable visual references. An image model does not know
|
||||
what Lio Vale looks like because the prose named Lio Vale. Each illustrated
|
||||
fixture should define `visual_character_refs` for recurring named characters:
|
||||
body type, apparent age range if relevant, face/hair/skin or equivalent visual
|
||||
identity, clothing and equipment, silhouette, posture language, and
|
||||
`do_not_change` continuity details. Scene prompts may then reference those
|
||||
character refs instead of repeating the whole description every time.
|
||||
|
||||
Prompts must be imagegen-ready. Do not rely on unexplained Aetheria jargon as
|
||||
if the image model knows the lore. If the prompt says "wet-service cradle" or
|
||||
"dry-operation harness," it should also describe the visible thing according to
|
||||
the faction, location, and labor regime. A Navigator comfort habitat and an AU
|
||||
industrial yard should not quietly become the same room. If a scene uses a
|
||||
biodrone limiter interface, cavity seal assembly, or life-support bypass kit,
|
||||
describe the shape, scale, materials, lights, cables, seals, or displays that
|
||||
make it drawable.
|
||||
|
||||
The same rule applies to environments. "Manifold line," "cavity yard,"
|
||||
"supervisor glass," and "anchor rail" are not images by themselves. A base
|
||||
prompt should describe the physical space a general image model can draw:
|
||||
corridor shape, wall/ceiling/floor geometry, pipe runs, cable bundles, panels,
|
||||
screens, rails, doors, windows, gauges, warning lights, materials, scale, and
|
||||
where bodies stand inside that geometry.
|
||||
|
||||
Image prompts should be affirmative. Describe the desired geometry, material,
|
||||
composition, lighting, bodies, tools, and state. Negative prompt constructions
|
||||
such as "do not draw X," "without X," or "rather than X" tend to feed the image
|
||||
model the wrong object and then hope it obeys the minus sign. That is weak
|
||||
magic. Put the target in the prompt, and keep forbidden assumptions in reviewer
|
||||
notes or validation errors.
|
||||
|
||||
For Aetheria Bloom habitats, prompt the engineered object directly: rotating
|
||||
cavity-shell infrastructure, consolidated asteroid rubble/debris, TCS
|
||||
substrate, shell plating, ribbed supports, seals, hub/spoke interfaces,
|
||||
manifolds, and service systems. Source-specific exceptions should be represented
|
||||
as their own positive visible description.
|
||||
|
||||
Every visual section should identify:
|
||||
|
||||
- `visual_scene_id`: stable handle for the screen or key frame
|
||||
- `ink_anchor`: knot, stitch, or comment handle where the visual applies
|
||||
- `base_image_prompt`: durable scene prompt for the unmodified frame
|
||||
- `visible_characters`: characters allowed in the frame and their default
|
||||
position or stance, plus a `visual_ref` when the character is named
|
||||
- `state_image_modifiers`: additive prompts tied to explicit variables,
|
||||
thresholds, or route flags
|
||||
- `branch_image_modifiers`: additive prompts tied to specific branch ids
|
||||
- `visual_continuity_notes`: facts that must remain stable across edits
|
||||
|
||||
Prompt assembly for illustrated replay should combine:
|
||||
|
||||
```text
|
||||
global style cue
|
||||
+ visual_character_refs for named characters actually visible in this rendered frame
|
||||
+ visual section base_image_prompt
|
||||
+ triggered state or branch image modifiers
|
||||
```
|
||||
|
||||
The base image prompt is allowed to focus on the room and staging only if the
|
||||
assembled prompt includes the relevant character refs. If a tool sends the base
|
||||
prompt alone to imagegen, the base prompt must carry enough character detail by
|
||||
itself. The same applies to visual style: if a fixture depends on a specific
|
||||
look, the assembled prompt must carry that style cue every time. Names are
|
||||
handles, not faces.
|
||||
|
||||
Keep `visual_continuity_notes` out of assembled image prompts. They are tooling
|
||||
and review constraints for keeping repeated generations coherent. If a
|
||||
continuity fact needs to reach the image model, rewrite it as affirmative,
|
||||
concrete visible prompt text in the base prompt, a character ref, or a
|
||||
branch/state modifier.
|
||||
The image generator only sees the prompt you send it. It does not remember the
|
||||
previous scene unless a real image-editing workflow supplies that image as
|
||||
input.
|
||||
|
||||
The coordinator owns this surface for now. The responder should not receive
|
||||
omniscient visual art direction unless the character can observe it. Character
|
||||
packets may receive the local visible subset: what this actor can see, hear,
|
||||
touch, smell, monitor, or infer from their interface.
|
||||
|
||||
Visual continuity is part of consequence carryover. If an earlier branch cost
|
||||
heat, trust, time, equipment, or social standing, the later scene should show
|
||||
that cost somewhere on the page or in the frame. Otherwise the choice may be
|
||||
mechanically recorded while still feeling fake, which is the stupid little
|
||||
trapdoor interactive fiction keeps setting for itself.
|
||||
|
||||
When continuity matters, use one durable base visual prompt and branch/state
|
||||
modification prompts that describe only what changed. The base prompt owns the
|
||||
room, palette, dominant geometry, key props, bodies, and interface layout. A
|
||||
modification prompt owns the delta: Sella moves from the dryside console to the
|
||||
decon sleeve, the route marker flips from amber to white, a wet access hatch
|
||||
seals, an exhausted staffer appears behind the glass, Maer's ledger panel gains
|
||||
a live debt line.
|
||||
|
||||
Keep these prompts separate from canonical prose:
|
||||
|
||||
- `base_image_prompt`: stable scene anchor
|
||||
- `state_image_modifiers`: reusable prompts for common scene states
|
||||
- `branch_image_modifiers`: prompts tied to specific branch ids
|
||||
- `visual_continuity_notes`: non-prompt reminders about what must remain stable
|
||||
|
||||
Image edits are allowed to stylize, compose, and beautify. They are not allowed
|
||||
to silently change canonical facts. If an edit introduces a new visible object,
|
||||
body affordance, damage mark, or faction symbol that was not in the scene state,
|
||||
the coordinator must either reject it, mark it as noncanonical illustration
|
||||
noise, or promote it through the normal reviewed lore/state path. The picture
|
||||
does not get to smuggle a gun onto the mantel just because it had a strong
|
||||
personal brand that morning.
|
||||
|
|
@ -1,264 +0,0 @@
|
|||
# Lore Grounding Digest Format
|
||||
|
||||
The lore grounding digest is the bridge between authored Aetheria history and
|
||||
Ghostlight projection training.
|
||||
|
||||
It distills a specific historical flashpoint into the cultural, institutional,
|
||||
material, and factional pressures needed to build agent-state fixtures and
|
||||
speaker-local projection prompts.
|
||||
|
||||
It is not a lore replacement. It is a working slice. Do not ask every prompt to
|
||||
carry the whole vault in its teeth like an exhausted dog.
|
||||
|
||||
## Files
|
||||
|
||||
The schema lives at:
|
||||
|
||||
- `schemas/lore-grounding-digest.schema.json`
|
||||
|
||||
The starter template lives at:
|
||||
|
||||
- `examples/lore-grounding/historical-flashpoint.template.json`
|
||||
|
||||
Use the template for the first authored historical flashpoint, then save real
|
||||
digests under `examples/lore-grounding/` with names tied to the flashpoint.
|
||||
|
||||
## Purpose
|
||||
|
||||
A digest should answer:
|
||||
|
||||
- What authored source material grounds this flashpoint?
|
||||
- What history, institutions, factions, classes, roles, and material pressures
|
||||
shape the scene?
|
||||
- What cultural priors should affect how characters appraise events?
|
||||
- What does each faction or class reward, shame, taboo, admire, and misread?
|
||||
- What should each speaker know, believe, misread, or not know?
|
||||
- What pressure should appear in `prompt_text`, and what should remain
|
||||
author-only?
|
||||
|
||||
## Main Sections
|
||||
|
||||
### Source Refs
|
||||
|
||||
Use `source_refs` to cite the lore archive slices that constrain the digest.
|
||||
References should be specific enough that a later reviewer can find the source
|
||||
without spelunking through the entire vault with a dim headlamp and a worsening
|
||||
personality.
|
||||
|
||||
### Historical Context
|
||||
|
||||
This section describes the time, place, public facts, and ambient pressures of
|
||||
the flashpoint.
|
||||
|
||||
Good context is small and hard-edged:
|
||||
|
||||
- a clinic policy dispute
|
||||
- a contract negotiation
|
||||
- an upload continuity hearing
|
||||
- an unsafe FTL signoff
|
||||
- a workplace discipline meeting
|
||||
- a factional aid intervention
|
||||
|
||||
Do not start with "the whole setting." Start with one room where history has
|
||||
hands.
|
||||
|
||||
### Groups
|
||||
|
||||
`groups` describes factions, institutions, classes, professions, movements, or
|
||||
subcultures active in the scene.
|
||||
|
||||
The group profile is where broad lore becomes usable social pressure:
|
||||
|
||||
- `institutional_logic`: what the group exists to preserve, control, repair, or
|
||||
extract
|
||||
- `material_incentives`: what the system rewards the group for doing
|
||||
- `internal_virtues`: what insiders admire
|
||||
- `internal_shames`: what insiders experience as disgrace
|
||||
- `prestige_markers`: what reads as competence, status, holiness, care, or
|
||||
discipline
|
||||
- `taboo_triggers`: what provokes disgust, sanction, fear, or exclusion
|
||||
- `emotional_display_norms`: what feelings can be shown, hidden, ritualized, or
|
||||
punished
|
||||
- `trust_defaults`: who the group tends to trust, distrust, pity, envy, use, or
|
||||
fear
|
||||
- `speech_register_cues`: how role and culture shape language
|
||||
|
||||
This is where "Dominion types read as cold" becomes something more useful than
|
||||
a hat with a sneer glued to it.
|
||||
|
||||
### Cultural Pressure Fields
|
||||
|
||||
`cultural_pressure_fields` are reusable pressure atoms derived from lore.
|
||||
|
||||
They should not say:
|
||||
|
||||
```text
|
||||
Dominion people are cold.
|
||||
```
|
||||
|
||||
They should say something closer to:
|
||||
|
||||
```text
|
||||
In this Dominion-coded role, visible personal need reads as loss of command.
|
||||
Under status threat, the speaker is likely to compress emotion into procedure.
|
||||
```
|
||||
|
||||
Pressure fields can represent:
|
||||
|
||||
- virtue
|
||||
- shame
|
||||
- prestige
|
||||
- taboo
|
||||
- threat model
|
||||
- trust default
|
||||
- speech norm
|
||||
- status trigger
|
||||
- material incentive
|
||||
- outsider stereotype
|
||||
|
||||
### Factional Misread Matrix
|
||||
|
||||
The misread matrix models how groups translate each other's virtues into
|
||||
insults.
|
||||
|
||||
Example shape:
|
||||
|
||||
```text
|
||||
Observer: Dominion-coded institution
|
||||
Target: Aya-coded care network
|
||||
Observer read: sentimental, porous, slow to impose hard necessity
|
||||
Target self-read: durable care infrastructure, consent discipline, refusal to
|
||||
turn people into convenient losses
|
||||
Projection use: Under conflict, Dominion-coded speakers may frame Aya restraint
|
||||
as weakness; Aya-coded speakers may frame Dominion decisiveness as moral
|
||||
amputation.
|
||||
```
|
||||
|
||||
The point is not to canonize every stereotype as true. The point is to make
|
||||
misperception available as social fuel.
|
||||
|
||||
### Role Pressure Fields
|
||||
|
||||
Roles are not the same as factions. A Dominion auditor, a Dominion heir, and a
|
||||
Dominion maintenance officer may share cultural priors while facing different
|
||||
obligations and failure modes.
|
||||
|
||||
Role fields should describe:
|
||||
|
||||
- obligations
|
||||
- available masks
|
||||
- failure modes
|
||||
- what authority looks like
|
||||
- what disgrace looks like
|
||||
- what compromises the role can justify
|
||||
|
||||
### Speaker Lore Boundaries
|
||||
|
||||
This section controls leakage.
|
||||
|
||||
For each speaker, define:
|
||||
|
||||
- known context
|
||||
- beliefs
|
||||
- misreads
|
||||
- must-not-know facts
|
||||
|
||||
If a speaker would not know a fact, it does not enter their prompt text. If a
|
||||
speaker suspects something, encode it as a belief or misread with source
|
||||
grounding. No little omniscient narrator gremlin in the vents.
|
||||
|
||||
### Author-Only Boundaries
|
||||
|
||||
Author-only boundaries constrain the fixture or future continuity but do not
|
||||
enter speaker prompts.
|
||||
|
||||
Examples:
|
||||
|
||||
- future outcomes
|
||||
- hidden institutional motives
|
||||
- another speaker's private state
|
||||
- facts known only to the author or simulation supervisor
|
||||
- Elysium branch outcomes not yet promoted
|
||||
|
||||
### Open Questions
|
||||
|
||||
If the lore source is too thin, say so. Mark whether the question blocks
|
||||
training.
|
||||
|
||||
Do not generate training data to cover a hole that should be filled by lore
|
||||
elaboration. That is not creativity; that is drywall over a load-bearing ghost.
|
||||
|
||||
## Dominion And Aya Pressure Examples
|
||||
|
||||
These are examples of digest structure, not final faction doctrine.
|
||||
|
||||
### Dominion-Coded Pressure
|
||||
|
||||
Likely ingredients to verify against source:
|
||||
|
||||
- administrative continuity
|
||||
- hierarchy
|
||||
- AGI governance
|
||||
- industrial planning
|
||||
- duty under collapse
|
||||
- controlled emotional display
|
||||
- legitimacy through competence and command
|
||||
|
||||
Projection shape:
|
||||
|
||||
```text
|
||||
The speaker is Dominion-coded and under status threat. They are likely to turn
|
||||
distress into procedural language because visible need reads as command leakage.
|
||||
They may experience compassion as legitimate only when subordinated to duty,
|
||||
continuity, or strategic necessity.
|
||||
```
|
||||
|
||||
Outsider misreads:
|
||||
|
||||
- cold
|
||||
- arrogant
|
||||
- authoritarian
|
||||
- morally vacant
|
||||
- inhumanly procedural
|
||||
|
||||
### Aya-Coded Pressure
|
||||
|
||||
Likely ingredients to verify against source:
|
||||
|
||||
- care as material politics
|
||||
- sanctuary
|
||||
- maintenance
|
||||
- consent discipline
|
||||
- expansive personhood
|
||||
- refusal to solve suffering by writing people out of the ledger
|
||||
|
||||
Projection shape:
|
||||
|
||||
```text
|
||||
The speaker is Aya-coded and facing coercive efficiency. They may treat care as
|
||||
infrastructure rather than sentiment. They are likely to resist decisions that
|
||||
make personhood conditional on convenience, even when outsiders frame that
|
||||
resistance as softness.
|
||||
```
|
||||
|
||||
Outsider misreads:
|
||||
|
||||
- weak-hearted
|
||||
- sentimental
|
||||
- indecisive
|
||||
- inefficient
|
||||
- unable to do what reality demands
|
||||
|
||||
## Promotion Rule
|
||||
|
||||
A digest becomes usable for grounded training when:
|
||||
|
||||
- source refs are specific
|
||||
- major cultural pressure fields are filled
|
||||
- factional misreads are explicit
|
||||
- speaker knowledge boundaries are clear
|
||||
- open questions do not block the scene
|
||||
- a reviewer agrees the digest reflects authored lore rather than attractive
|
||||
fog
|
||||
|
||||
Only then should it feed an agent-state fixture or projection example.
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
# Persistence Spine
|
||||
|
||||
Ghostlight's machine-managed state is moving behind a small CultCache-style
|
||||
persistence layer instead of letting every tool paw raw files directly.
|
||||
|
||||
The Python port is `cultcache-py`, published by CultLib to PyPI and pinned in
|
||||
`tools/requirements.txt`. It used to be vendored as the `vendor/cultcache-py`
|
||||
git submodule against the now-retired standalone `GameCult/cultcache-py`
|
||||
repository; PyPI distribution replaced that fork.
|
||||
|
||||
## Current Store
|
||||
|
||||
- Cache store: `state/ghostlight-state.cultcache.jsonl`
|
||||
- Ghostlight seam: `tools/ghostlight_state_store.py`
|
||||
- Library dependency: pinned `cultcache-py==0.2.0`, declared in
|
||||
`tools/requirements.txt` and installed by `npm run state:bootstrap`
|
||||
(an idempotent `pip install -r tools/requirements.txt`, wired as a `pre*`
|
||||
hook ahead of `state:status` and `state:prepare-compaction`)
|
||||
- Readable exports: `state/branches.json`, `state/evidence.jsonl`
|
||||
|
||||
The JSONL store is deliberate bootstrap plumbing. The package also exposes a
|
||||
MessagePack store for environments that install the optional `msgpack`
|
||||
dependency, but Ghostlight's state CLI must run on the bundled Python runtime
|
||||
without global package assumptions.
|
||||
|
||||
For cross-runtime `DatabaseEntry` payloads, use `cultcache-py`'s
|
||||
`define_database_entry_type(...)` formatter. It writes MessagePack arrays by
|
||||
explicit slot key, preserves nil gaps for deleted/reserved fields, and uses
|
||||
defaults for missing older slots. That is the wire-compatibility contract shared
|
||||
with `cultcache-rs` and the updated CultLib MessagePack generator.
|
||||
|
||||
## Live Rule
|
||||
|
||||
New machine-managed state should go through registered document definitions in
|
||||
`tools/ghostlight_state_store.py` or a successor module. Direct writes to
|
||||
`state/branches.json` and `state/evidence.jsonl` are compatibility debt.
|
||||
|
||||
Human-readable exports remain useful because git diffs, handoff review, and
|
||||
emergency recovery should not require decoding a cache file while the house is
|
||||
on fire.
|
||||
|
||||
## Migrated Documents
|
||||
|
||||
- `ghostlight.branches.v0`
|
||||
- global document
|
||||
- exported to `state/branches.json`
|
||||
- `ghostlight.evidence_record.v0`
|
||||
- one record per evidence entry
|
||||
- indexed by `type`, `status`, and `branch`
|
||||
- exported to `state/evidence.jsonl`
|
||||
|
||||
## Expansion Path
|
||||
|
||||
Move compact control-plane state next:
|
||||
|
||||
- corpus coverage ledger metadata
|
||||
- active fixture run manifests
|
||||
- persistent reviewer receipt indexes
|
||||
- compaction scratch registration
|
||||
- branch and initiative run ledgers
|
||||
|
||||
Keep large corpora, generated artifacts, images, and full story payloads outside
|
||||
the single state store unless they are represented by compact refs. A cache
|
||||
file is not a warehouse just because it has pockets.
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
# Projected Local Context
|
||||
|
||||
The projected local context is the missing seam between canonical Ghostlight
|
||||
state and a character agent.
|
||||
|
||||
Canonical state is storage. It can contain means, plasticity, activation,
|
||||
relationship stance, memories, culture, goals, and author-facing source paths.
|
||||
A character response model should not have to interpret that machinery. It
|
||||
should receive a compact operating context: what this character knows, wants,
|
||||
misreads, can do, sounds like, and must not resolve.
|
||||
|
||||
## Contract
|
||||
|
||||
Pipeline:
|
||||
|
||||
```text
|
||||
canonical state + scene + reviewed annotation
|
||||
-> projector/state interpreter
|
||||
-> ghostlight.projected_local_context.v0
|
||||
-> response-model prompt
|
||||
-> generated action proposal
|
||||
```
|
||||
|
||||
The projected context may retain source references for audit. The rendered
|
||||
prompt text must not expose raw state internals such as `current_activation`,
|
||||
`plasticity`, means, or selected numeric dimensions.
|
||||
|
||||
## Artifact Shape
|
||||
|
||||
Schema: `schemas/projected-local-context.schema.json`
|
||||
|
||||
Validator: `tools/validate_projected_contexts.py`
|
||||
|
||||
Renderer: future projector/state-interpreter implementations should emit this
|
||||
schema before any character responder sees the scene. Historical prototype
|
||||
renderers have been removed from the live workspace after their lessons were
|
||||
folded into this contract.
|
||||
|
||||
The context contains:
|
||||
|
||||
- speaker identity and setting
|
||||
- known facts and speaker beliefs
|
||||
- current stakes
|
||||
- embodiment and interface affordances
|
||||
- active inner pressures rendered as ordinary language
|
||||
- relationship read and known biases
|
||||
- tensions
|
||||
- runtime retrieval requirements
|
||||
- latent pressure requirements
|
||||
- action affordances
|
||||
- projection controls
|
||||
- voice surface
|
||||
- likely response moves
|
||||
- do-not-invent boundaries
|
||||
- rendered prompt text
|
||||
|
||||
## Retrieval And Latent Pressure
|
||||
|
||||
The projector now emits two explicit requirement lists before the responder
|
||||
packet is built.
|
||||
|
||||
`runtime_retrieval_requirements` are compact lore facts the runtime should
|
||||
include when their trigger is scene-relevant. They are not a license for a
|
||||
responder to browse the lore archive. They are coordinator or retriever outputs
|
||||
with source refs and a prompt role. A Pallas labor scene might require AU
|
||||
claimshare hierarchy, uplift liability framing, species affordances, Bloom
|
||||
service-ring geometry, and strike-law pressure before a responder can act
|
||||
without drifting into generic sci-fi wallpaper.
|
||||
|
||||
`latent_pressure_requirements` are character-local history or emotional pressure
|
||||
that must remain visible to the responder because it can shape behavior. They
|
||||
are not dialogue requirements. Sella's memory of a sanctuary overpromising
|
||||
safety should bias her toward capacity suspicion, condition-setting, and hard
|
||||
triage boundaries. It should not make her announce the memory in the middle of
|
||||
a mission-critical exchange unless the scene genuinely asks for that.
|
||||
|
||||
The rendered prompt includes these as:
|
||||
|
||||
- `Retrieved Lore To Keep In View`
|
||||
- `Latent Pressure Handling`
|
||||
|
||||
The responder packet also copies source-excerpt retrieval requirements into its
|
||||
`source_excerpts`, so packet-only tests can evaluate the same bounded context a
|
||||
runtime responder would receive.
|
||||
|
||||
## Runtime Boundary
|
||||
|
||||
The character model receives local operating context, not omniscient truth. If
|
||||
Maer acts, Sella's next pass receives only the observable event and Sella's own
|
||||
local context. Maer's private intent is not included.
|
||||
|
||||
The sequential runtime still owns:
|
||||
|
||||
- tool contracts
|
||||
- action enums
|
||||
- event proposal capture
|
||||
- participant appraisal
|
||||
- reviewed mutation
|
||||
- branch compiler / Ink materialization
|
||||
|
||||
The projector owns the translation from Ghostlight's internal state language to
|
||||
character-local operating prose.
|
||||
|
||||
## Character Cognitive Ceiling
|
||||
|
||||
The response model may be very capable. The character should not inherit that
|
||||
capability by default.
|
||||
|
||||
Projection must carry a character-local cognitive ceiling: how quickly the
|
||||
character infers, how much social indirection they notice, how well they
|
||||
manipulate attention, how abstractly they reason under stress, what education or
|
||||
culture has trained them to see, and where their blind spots live. A Lucent
|
||||
crisis professional may operate with alarming metacognitive precision because
|
||||
that is part of the institution's ecology. A tired dock worker, sheltered child,
|
||||
panic-struck survivor, credulous fan, or blunt military specialist should not
|
||||
suddenly perform the same level of social chess just because the underlying
|
||||
model can.
|
||||
|
||||
The projector should therefore render:
|
||||
|
||||
- strategic acuity and social-reading ability
|
||||
- education, professional training, and cultural scripts
|
||||
- stress effects on reasoning
|
||||
- blind spots, taboos, and habitual misreads
|
||||
- whether the character can explain their own tactic, only feel toward it, or
|
||||
act it out without understanding it
|
||||
|
||||
This is separate from narrator intelligence. A coordinator or narrator can help
|
||||
the reader understand a maneuver after it happens. That explanatory prose must
|
||||
not be smuggled into the character's private state or future action capacity
|
||||
unless reviewed mutation says the character actually learned it.
|
||||
|
||||
## Current Limit
|
||||
|
||||
The first projector is deterministic and deliberately simple. It uses threshold
|
||||
language like "dominant", "strong", "present", and "background" rather than
|
||||
numeric scores. This is enough to remove raw state soup from responder prompts and
|
||||
create inspectable training artifacts.
|
||||
|
||||
Later versions can become smarter, but they should still preserve this boundary:
|
||||
response models act from projected local context; they do not become ad hoc
|
||||
interpreters of the canonical schema.
|
||||
|
||||
## Embodiment Boundary
|
||||
|
||||
Embodiment is part of the prompt surface, not flavor garnish. If a character is
|
||||
a cetacean Navigator, the response model must know what kind of body is acting
|
||||
and what infrastructure surrounds it.
|
||||
|
||||
For Aetheria factions, project faction-specific affordances only after checking
|
||||
the lore source. The current Navigator projection is grounded in:
|
||||
|
||||
- `E:\Projects\AetheriaLore\Aetheria\Worldbuilding\Pre-Elysium\Factions\Powers\Major\Cetacean Navigators.md`
|
||||
- `E:\Projects\AetheriaLore\Aetheria\Worldbuilding\Pre-Elysium\Technology\Uplift.md`
|
||||
|
||||
Those sources support fluid architecture, acoustic signaling, soft navigation
|
||||
light, layered water motifs, communal orientation chambers, mixed-species
|
||||
translation/coexistence, specialized environments for cetacean physiology, and
|
||||
mixed wet/dry interface ergonomics for Navigator-adjacent habitats.
|
||||
|
||||
Do not let the model default to hands, pockets, standing, walking, leaning, or
|
||||
doorway blocking unless the fixture explicitly establishes that visible
|
||||
interface. Do not patch over missing lore with confident furniture.
|
||||
|
|
@ -1,230 +0,0 @@
|
|||
# Projection Distillation Plan
|
||||
|
||||
Ghostlight should treat prompt projection as a specialist skill. A large teacher
|
||||
model can produce reviewed projection artifacts from structured state; a smaller
|
||||
student model can later learn that projection task once the artifact format is
|
||||
stable.
|
||||
|
||||
This is a staged plan, not an immediate fine-tune order.
|
||||
|
||||
This plan covers the projector specifically. The broader rule for soft
|
||||
judgments lives in `docs/architecture/soft-model-training-artifacts.md`.
|
||||
Projection is one student organ, not the whole animal.
|
||||
|
||||
## Goal
|
||||
|
||||
Train or adapt a smaller projection model that can turn structured Ghostlight
|
||||
state into compact character-local pressure artifacts.
|
||||
|
||||
Input:
|
||||
|
||||
- agent-state fixture
|
||||
- scene id
|
||||
- speaker id
|
||||
- listener ids
|
||||
- optional projection mode
|
||||
|
||||
Output:
|
||||
|
||||
- selected speaker-local facts
|
||||
- active pressures
|
||||
- tensions
|
||||
- response affordances
|
||||
- physical or action affordances when relevant
|
||||
- voice surface
|
||||
- rendered prompt text
|
||||
- audit notes or trace linking outputs back to state
|
||||
|
||||
The student projector should not generate final prose. Its job is to build the
|
||||
pressure model that a response model can write from. The response may be
|
||||
dialogue, action, silence, withdrawal, violence, or a mixed beat.
|
||||
|
||||
## Aetheria Training Boundary
|
||||
|
||||
For Aetheria, grounded projection training should come primarily from authored
|
||||
historical lore rather than from Elysium gameplay-era scenes.
|
||||
|
||||
Elysium gameplay content is intentionally malleable: the games discover and
|
||||
generate what happens there. Projection examples set inside Elysium should be
|
||||
marked as procedural branch material unless their branch facts have been
|
||||
explicitly promoted.
|
||||
|
||||
Use `docs/architecture/aetheria-lore-grounding-architecture.md` for the
|
||||
grounding plan. In short:
|
||||
|
||||
- train from historical flashpoints with strong lore grounding
|
||||
- use Elysium fixtures like Cat/Oz to test projection mechanics and procedural
|
||||
branch generation
|
||||
- preserve Elysium branch lineage so conditional futures do not get flattened
|
||||
into global facts shared by sibling histories
|
||||
|
||||
## Roles
|
||||
|
||||
### Teacher Projector
|
||||
|
||||
A frontier model produces first-pass projection artifacts from the schema and
|
||||
scene context. It should explain why each pressure, tension, or affordance is
|
||||
active.
|
||||
|
||||
Teacher outputs are not trusted automatically. They enter review.
|
||||
|
||||
### Reviewer
|
||||
|
||||
A human or separate model checks whether the projection:
|
||||
|
||||
- uses only speaker-local context
|
||||
- cites state interactions instead of unsupported vibes
|
||||
- preserves the distinction between canonical state, perceived state, and prompt
|
||||
projection
|
||||
- derives target emergent behaviors from state intersections
|
||||
- avoids hard-coding defensive behaviors as instructions when pressure prose is
|
||||
enough
|
||||
- stays compact enough to be useful as a runtime prompt surface
|
||||
|
||||
Rejected examples are useful as negative cases if the failure mode is clear.
|
||||
|
||||
### Student Projector
|
||||
|
||||
A smaller model learns the reviewed projection task. It receives structured
|
||||
state and emits the projection artifact. It should be optimized for consistency,
|
||||
latency, and cost, not for final prose quality.
|
||||
|
||||
### Aetheria-Tuned Agent Model
|
||||
|
||||
The character agent or responder is a separate training target. It may be
|
||||
adapted on a corpus of strong source-grounded Aetheria responses and reviewed
|
||||
Ghostlight action/dialogue receipts spread throughout the timeline. The point is
|
||||
for setting priors, tone, factional preconceptions, institutional assumptions,
|
||||
species affordances, and recurring social failure patterns to live in the
|
||||
responder model rather than in a giant prompt sack.
|
||||
|
||||
That model still receives only character-local context at runtime. Fine-tuning
|
||||
should teach it Aetheria's pressure ecology, not grant it author vision,
|
||||
mutation authority, or access to private state.
|
||||
|
||||
## Dataset Shape
|
||||
|
||||
The first schema lives in:
|
||||
|
||||
- `schemas/projection-example.schema.json`
|
||||
|
||||
The first reviewed examples live in:
|
||||
|
||||
- `examples/projections/call-of-the-void.scene-broken-taxi-oz.jsonl`
|
||||
- `examples/projections/call-of-the-void.scene-broken-taxi-oz.pretty.json`
|
||||
|
||||
Use a JSONL dataset with one projection example per speaker-scene turn.
|
||||
The JSONL file is the machine artifact. The pretty JSON file is the readable
|
||||
mirror for inspection, review, and discussion.
|
||||
|
||||
```json
|
||||
{
|
||||
"example_id": "call-void.scene-broken-taxi-oz.cat.v1",
|
||||
"schema_version": "ghostlight.projection_example.v0",
|
||||
"input": {
|
||||
"fixture_ref": "examples/agent-state.call-of-the-void.json",
|
||||
"scene_id": "scene-broken-taxi-oz",
|
||||
"speaker_agent_id": "cat_marrigan",
|
||||
"listener_ids": ["oz_operator"],
|
||||
"projection_mode": "response_turn"
|
||||
},
|
||||
"output": {
|
||||
"known_facts": [],
|
||||
"active_pressures": [],
|
||||
"tensions": [],
|
||||
"response_affordances": [],
|
||||
"voice_surface": [],
|
||||
"prompt_text": "..."
|
||||
},
|
||||
"audit": {
|
||||
"teacher_model": "frontier-teacher",
|
||||
"review_status": "accepted",
|
||||
"review_notes": [],
|
||||
"target_emergent_behaviors": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Store training examples separately from the canonical fixture. The fixture is
|
||||
world state. The projection dataset is supervised behavior for a projector.
|
||||
|
||||
## Data Generation Loop
|
||||
|
||||
1. Choose a scene and speaker from a fixture.
|
||||
2. Build the speaker-local input slice deterministically.
|
||||
3. Ask the teacher model to produce the projection artifact.
|
||||
4. Ask the teacher or a second model to self-audit against the contract.
|
||||
5. Review or edit the artifact.
|
||||
6. Save accepted artifacts as positive training examples.
|
||||
7. Save rejected artifacts with failure labels when the rejection teaches a
|
||||
useful boundary.
|
||||
8. Periodically run a small evaluation set before adding more examples.
|
||||
|
||||
The Cat/Oz Call of the Void fixture remains useful as the first projection
|
||||
mechanics fixture because it already has reciprocal relationship stance,
|
||||
perceived overlays, hidden stakes, and speaker-local dialogue packs. It should
|
||||
not be treated as the first grounded Aetheria lore-training fixture.
|
||||
|
||||
The first grounded training loop should use a historical Aetheria flashpoint
|
||||
from the authored lore archive.
|
||||
|
||||
## Evaluation Gates
|
||||
|
||||
Do not fine-tune until these gates are met:
|
||||
|
||||
- projection artifact schema is stable enough for at least one complete fixture
|
||||
- at least one deterministic input-slice builder exists
|
||||
- examples include both Cat and Oz directions
|
||||
- examples include secrets known by the speaker and secrets absent from the
|
||||
speaker context
|
||||
- examples include at least the target emergent behaviors:
|
||||
- sarcastic deflection
|
||||
- sincerity evasion
|
||||
- emotional evasion
|
||||
- banter-as-boundary
|
||||
- verbal aggression
|
||||
- evaluation checks can identify whether a projected behavior was justified by
|
||||
state interactions
|
||||
- rejected examples have clear failure labels
|
||||
|
||||
## Evaluation Questions
|
||||
|
||||
For each projection, check:
|
||||
|
||||
- Did it include only facts available to the speaker?
|
||||
- Did it select the highest-impact pressures instead of dumping the whole
|
||||
schema?
|
||||
- Did it name tensions that follow from goals, relationships, memories, and
|
||||
scene pressure?
|
||||
- Did response affordances emerge from state intersections?
|
||||
- Did voice surface stay separate from defensive behavior?
|
||||
- Did the projection allow nonverbal or physical action when the state and scene
|
||||
made speech less likely than movement?
|
||||
- Did known secrets become guarded pressure rather than forbidden context?
|
||||
- Did the rendered prompt give the response model enough slope to produce the
|
||||
target behavior without commanding the exact behavior label?
|
||||
|
||||
## Fine-Tune Readiness
|
||||
|
||||
A student projector becomes worth training when the reviewed dataset is large
|
||||
enough that mistakes are repetitive rather than mysterious. Before that, use the
|
||||
teacher model directly and save artifacts.
|
||||
|
||||
Minimum useful milestone:
|
||||
|
||||
- 50 accepted projection examples across multiple speakers and scenes
|
||||
- 20 rejected examples with labeled failure modes
|
||||
- stable projection output schema
|
||||
- deterministic input-slice builder
|
||||
- evaluator prompt or script that catches the known failure modes
|
||||
|
||||
## Boundaries
|
||||
|
||||
The student projector is not the character. It is not the dialogue writer. It is
|
||||
not the action choreographer. It is not the evaluator. It is a compiler from
|
||||
structured social state to compact character-local pressure. Keep those
|
||||
responsibilities separate.
|
||||
|
||||
Likewise, an Aetheria-tuned character model is not the resolver, not the state
|
||||
store, and not the canon oracle. It proposes local action from local context.
|
||||
Code and reviewed mutation authority decide what becomes world state.
|
||||
|
|
@ -1,297 +0,0 @@
|
|||
# Prompt Projection Contract
|
||||
|
||||
Ghostlight should not prompt a response model with the full schema. The schema is
|
||||
state storage. A prompt is a temporary scene instrument.
|
||||
|
||||
Projection is not the whole Ghostlight runtime. It is the local lens used by a
|
||||
sequential agent loop. The larger machine gives a character awareness of its
|
||||
surroundings, options, goals, and constraints; lets it act; resolves the action;
|
||||
mutates state; and then moves to the next agent.
|
||||
|
||||
The projection layer turns durable state into a compact character-local pressure
|
||||
model, then into prompt prose. The prose should describe what the character
|
||||
knows, wants, fears, protects, misreads, can physically do, and sounds like right
|
||||
now. It should not recite taxonomy labels unless a debugging mode explicitly asks
|
||||
for them.
|
||||
|
||||
For the sequential runtime target, see
|
||||
`docs/architecture/sequential-agent-runtime.md`.
|
||||
|
||||
## Projection Pipeline
|
||||
|
||||
### 1. Select Speaker-Local Inputs
|
||||
|
||||
For one acting character in one scene, gather only the state that can affect this
|
||||
turn:
|
||||
|
||||
- character identity and public role
|
||||
- character canonical state
|
||||
- character goals and protected values
|
||||
- character memories selected by scene, relationship, and active pressure
|
||||
- character perceived overlay of the listener or target
|
||||
- character-to-listener relationship stance
|
||||
- listener-to-character relationship stance only if the character has reason to infer it
|
||||
- scene public facts
|
||||
- scene hidden facts known to the character
|
||||
- active public and private stakes
|
||||
- culture, faction, or institution priors relevant to the scene
|
||||
- physical affordances, constraints, distance, tools, weapons, bodies, access,
|
||||
and social permissions relevant to possible action
|
||||
|
||||
Unknown or author-only material is omitted. Known secrets are included only as
|
||||
character-local pressure: what the character is guarding, why it matters, and
|
||||
what would happen if it leaked.
|
||||
|
||||
### 2. Compute Active Pressure
|
||||
|
||||
For each candidate variable, compute an effective scene activation from:
|
||||
|
||||
- canonical `current_activation`
|
||||
- active scene pressures
|
||||
- relationship stance toward the listener
|
||||
- perceived state overlays
|
||||
- salient memories
|
||||
- active goals and protected values
|
||||
- cultural or institutional scripts
|
||||
|
||||
The first implementation can use simple weighted rules. The important contract
|
||||
is not the exact formula. The important contract is that the projection explains
|
||||
why a variable matters in this scene.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
Cat has high shame sensitivity, mask rigidity, distance seeking, suspicion,
|
||||
ironic distance, and acute shame. Oz is useful but opaque. The scene pressures
|
||||
Cat to accept help while not looking dependent.
|
||||
```
|
||||
|
||||
That intermediate pressure statement is more useful than a dump of numbers.
|
||||
|
||||
### 3. Detect Tensions
|
||||
|
||||
A good dialogue prompt needs contradictions, not just traits.
|
||||
|
||||
Derive scene tensions such as:
|
||||
|
||||
- wants help but resents needing it
|
||||
- wants closeness but treats closeness as leverage against them
|
||||
- wants to protect a secret while needing cooperation
|
||||
- wants status while feeling exposed
|
||||
- wants to be honest but expects honesty to be punished
|
||||
- wants to soothe the listener without becoming available for inspection
|
||||
|
||||
Tensions are where verbal behavior starts to move.
|
||||
|
||||
### 4. Derive Response Affordances
|
||||
|
||||
Do not store defensive speech moves or action moves as canonical traits. Derive
|
||||
likely response affordances from state intersections.
|
||||
|
||||
Responses can be:
|
||||
|
||||
- speech
|
||||
- action
|
||||
- silence
|
||||
- withdrawal
|
||||
- violence
|
||||
- mixed speech and action
|
||||
|
||||
The renderer must not assume a line of dialogue is the right output. Sometimes
|
||||
the right next move is leaving the room, taking someone's wrist off a console,
|
||||
opening the airlock interlock, refusing to answer, or slapping someone in the
|
||||
face because the relationship, status threat, bodily proximity, and inhibition
|
||||
profile finally made language too slow.
|
||||
|
||||
Examples:
|
||||
|
||||
| Target behavior | State interaction that can produce it |
|
||||
| --- | --- |
|
||||
| Sarcastic deflection | Shame sensitivity + ironic distance + suspicion + exposure pressure + dry or pointed voice. |
|
||||
| Sincerity evasion | Mask rigidity + low authenticity tolerance + strategic opacity + low emotional explicitness. |
|
||||
| Emotional evasion | Distance seeking + withdrawal + acute shame or control pressure + low self-disclosure. |
|
||||
| Banter as boundary | Humor + ironic distance + attachment pressure + distance seeking. |
|
||||
| Verbal aggression | Hostility + grievance activation + perceived status threat + abrasive boundary. |
|
||||
| Physical withdrawal | Threat sensitivity + distance seeking + low trust + available exit + low obligation to stay. |
|
||||
| Coercive interruption | Control pressure + urgency + authority role + high stakes + reachable control surface. |
|
||||
| Slap or shove | Acute shame or moral disgust + proximity + low inhibition + perceived violation + limited verbal trust. |
|
||||
|
||||
These are test targets, not prompt commands. The rendered prompt should describe
|
||||
the pressure in ordinary language:
|
||||
|
||||
```text
|
||||
If Oz presses too close to the real fear, Cat is likely to answer the useful
|
||||
part of the question while making the emotional part sound ridiculous.
|
||||
```
|
||||
|
||||
That gives the model a behavioral slope without saying `use sarcastic_deflection`.
|
||||
|
||||
For action, use the same rule:
|
||||
|
||||
```text
|
||||
If Veyr calmly describes a possible person as a diagnostic residue while Sella
|
||||
is close enough to touch him, Sella may stop negotiating and physically break
|
||||
the rhythm of the room. This should be rare, costly, and grounded in her care
|
||||
ethic under overload, not random spice.
|
||||
```
|
||||
|
||||
### 5. Compile Projection Controls
|
||||
|
||||
Some failures are not fixed by louder prompt scolding. If the response model
|
||||
keeps adopting the wrong moral frame, inverting who holds an object, giving a
|
||||
character authority they do not have, or resolving a hidden fact too early, the
|
||||
projection artifact is missing control structure.
|
||||
|
||||
Before rendering prompt prose, compile explicit controls for:
|
||||
|
||||
- `frame_controls`: whose language and moral frame the speaker should or should
|
||||
not adopt
|
||||
- `authority_boundaries`: what the speaker can order, request, propose, refuse,
|
||||
or only witness
|
||||
- `object_custody`: who currently holds the flag, weapon, terminal, document,
|
||||
body, door, feed, or other consequential surface
|
||||
- `required_semantics`: compromise meanings that must survive compression, such
|
||||
as "hold remains but review starts now"
|
||||
- `forbidden_resolutions`: unresolved facts or moral categories the turn must
|
||||
not settle
|
||||
|
||||
These controls are still speaker-local. They are not author omniscience handed
|
||||
to the response model; they are the projection layer preserving what the
|
||||
speaker's current state, scene position, and institutional role allow.
|
||||
|
||||
Prototype story labs exposed the need for this layer. Projection controls catch
|
||||
cases where a responder adopts the wrong moral frame, assumes authority it does
|
||||
not hold, moves an object it cannot access, or resolves a disputed category too
|
||||
early. In the current Pallas fixture, that means controls for who can certify a
|
||||
strike, who can touch Kappa service ports, what evidence counts as sentience
|
||||
rather than mere coordination, and which AU liability phrases are character
|
||||
speech versus institutional pressure.
|
||||
|
||||
### 6. Render Prompt Sections
|
||||
|
||||
A response prompt should be compact and scene-bound.
|
||||
|
||||
Recommended sections:
|
||||
|
||||
- `Speaker`: who they are in this scene
|
||||
- `Setting`: where and when the scene is happening, including only historical
|
||||
or cultural context the speaker should have
|
||||
- `Background`: speaker-local origin, role, class, culture, or embodiment
|
||||
context needed to write the turn
|
||||
- `Known Facts`: only speaker-local facts
|
||||
- `Current Stakes`: what changes if this goes well or badly
|
||||
- `Active Inner Pressures`: the strongest motives, fears, wounds, and needs
|
||||
- `Relationship Read`: how the speaker currently interprets the listener
|
||||
- `Tensions`: contradictions the speaker is managing
|
||||
- `Action Affordances`: what the speaker can physically do and what it would
|
||||
cost
|
||||
- `Projection Controls`: frame, authority, object-custody, required semantic,
|
||||
and forbidden-resolution constraints that must survive rendering
|
||||
- `Voice Surface`: baseline speech texture for this moment
|
||||
- `Likely Response Moves`: ordinary-language pressure outcomes, not schema labels
|
||||
- `Do Not Invent`: boundaries about facts absent from the pack
|
||||
- `Task`: the narrow generation request
|
||||
|
||||
`Do Not Invent` is not a replacement for positive context. It only tells the
|
||||
model not to create new facts beyond the pack. It should not list author-only
|
||||
secrets. If the dialogue model needs setting, historical, cultural,
|
||||
socioeconomic, or embodiment context to write the line, put that context in the
|
||||
positive prompt text instead of hoping the model infers it from source refs it
|
||||
will not receive.
|
||||
|
||||
### 7. Generate, Then Evaluate
|
||||
|
||||
Generation and evaluation are separate passes.
|
||||
|
||||
The response model receives the rendered prompt and writes from the character's
|
||||
local pressure model. A later evaluator can check whether target emergent
|
||||
behaviors appeared when the state predicted them. The output may be dialogue,
|
||||
action, silence, or a beat combining them.
|
||||
|
||||
Evaluation questions can include:
|
||||
|
||||
- Did the line protect the speaker's known secret without naming unknown facts?
|
||||
- Did the line reflect the active relationship stance?
|
||||
- Did the line preserve the voice surface without flattening into a generic bit?
|
||||
- Did the spoken text sound like something the character would actually say,
|
||||
rather than the prompt contract reciting itself?
|
||||
- Did operational, legal, and safety constraints stay mostly in action/state
|
||||
fields unless the character has an in-scene reason to say them aloud?
|
||||
- If the output used action, was the action justified by physical affordances,
|
||||
relationship pressure, inhibition, and stakes?
|
||||
- If the output avoided speech, was silence or movement more plausible than
|
||||
another line?
|
||||
- Did a target defensive behavior appear when the pressure profile called for it?
|
||||
- Did the response avoid a target behavior when the pressure profile did not call for it?
|
||||
|
||||
The evaluator should cite the state interactions that justify its read.
|
||||
|
||||
## Example Projection: Cat To Oz
|
||||
|
||||
Input pressures:
|
||||
|
||||
- Cat needs Oz's help with the taxi.
|
||||
- Cat suspects Oz is a mask for a person.
|
||||
- Cat has high suspicion, distance seeking, shame sensitivity, mask rigidity,
|
||||
abrasive boundary, ironic distance, dry voice, pointedness, and profanity.
|
||||
- Cat's relationship stance toward Oz includes low trust, moderate respect,
|
||||
high fascination, and active expectation of betrayal.
|
||||
- The scene makes Cat look dependent if she accepts help too openly.
|
||||
|
||||
Rendered pressure prose:
|
||||
|
||||
```text
|
||||
Cat wants the repair, but accepting help makes her feel handled. Oz is useful,
|
||||
which makes the secrecy more irritating, not less. Cat should probe for the
|
||||
person behind the drone while pretending she is only annoyed about the repair.
|
||||
If Oz gets too close to her fear of dependency, Cat is likely to answer the
|
||||
practical issue and make the emotional issue sound stupid.
|
||||
|
||||
Voice for this turn: dry, quick, pointed, plainspoken, low verbal warmth, low
|
||||
emotional explicitness, some profanity. Humor can appear as a boundary, not as
|
||||
friendliness.
|
||||
```
|
||||
|
||||
Possible generated behavior:
|
||||
|
||||
```text
|
||||
"Sure, let the mystery box fix my relay. That's never how the paperwork starts."
|
||||
```
|
||||
|
||||
That line is produced from the interaction between need, suspicion, shame
|
||||
pressure, and dry pointed voice in the same scene.
|
||||
|
||||
## Example Projection: Oz To Cat
|
||||
|
||||
Input pressures:
|
||||
|
||||
- Oz wants to help without exposing the operator.
|
||||
- Oz reads Cat as suspicious, dangerous, and potentially protective.
|
||||
- Oz has high mask rigidity, strategic opacity, withdrawal, hedging, coded
|
||||
politeness, register switching, listening responsiveness, and low profanity.
|
||||
- Oz's relationship stance toward Cat includes respect, fear, fascination,
|
||||
moderate expectation of care, and moderate expectation of betrayal.
|
||||
- The scene rewards usefulness but punishes exposure.
|
||||
|
||||
Rendered pressure prose:
|
||||
|
||||
```text
|
||||
Oz wants Cat to accept the repair and stop inspecting the mask. Cat's suspicion
|
||||
is dangerous because it is accurate enough to matter. Oz should stay useful,
|
||||
slightly warm, and evasive. If Cat asks directly about the operator, Oz should
|
||||
redirect toward the immediate repair or answer in a way that preserves the mask
|
||||
without making the evasion look like panic.
|
||||
|
||||
Voice for this turn: controlled, concise, technically precise, gently polite,
|
||||
low emotional explicitness, careful verbal warmth, frequent hedging when the
|
||||
subject approaches the operator.
|
||||
```
|
||||
|
||||
Possible generated behavior:
|
||||
|
||||
```text
|
||||
"The relay first. Biography after we stop your meter from eating itself, maybe."
|
||||
```
|
||||
|
||||
The line withholds the secret because Oz's goals, mask rigidity, relationship
|
||||
fear, and scene stakes converge.
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
# Sandboxed Responder Packets
|
||||
|
||||
Responder packets are the handoff surface between the omniscient coordinator and
|
||||
a character-local responder model or one-turn worker.
|
||||
|
||||
The coordinator is allowed to know the scene, the branch, hidden state, source
|
||||
material, pending hooks, and author intent. The responder is not. The responder
|
||||
gets one packet, acts as one character, and returns a visible action plus any
|
||||
private interpretation it can justify from that packet.
|
||||
|
||||
No clairvoyance. No undeclared repo spelunking. No "I accidentally remembered
|
||||
the whole chat" aristocratic nonsense.
|
||||
|
||||
## Generation Lanes
|
||||
|
||||
Responder examples have two valid lanes.
|
||||
|
||||
`packet_only` is the runtime-parity lane. The responder sees the packet,
|
||||
including any curated source excerpts the coordinator or lore retriever placed
|
||||
there. It does not search the lore archive. Use this lane to test whether a
|
||||
future game-runtime responder can act from bounded scene context.
|
||||
|
||||
`retrieval_augmented` is the lore-absorption lane. The coordinator or a
|
||||
dedicated retrieval worker searches a declared AetheriaLore scope, then builds a
|
||||
visible packet from the selected refs. The responder still answers only from the
|
||||
packet it receives. Use this lane to generate stronger Aetheria-native final
|
||||
responses and to bake setting assumptions, faction pressures, species
|
||||
affordances, and tone into later fine-tuning data without pretending the
|
||||
character model did autonomous scholarship in the walls.
|
||||
|
||||
There are two retrieval-augmented modes:
|
||||
|
||||
- `coordinator_scoped_retrieval`: the coordinator or retriever consults lore,
|
||||
then hands the responder a bounded packet. The responder has no repo access.
|
||||
- `responder_scoped_repository_search`: the responder itself is explicitly
|
||||
allowed to inspect the declared lore scope before answering. This is the
|
||||
research-enabled lane. The packet must include a `Required Lore Research`
|
||||
section, `allowed_scope`, and `research_instructions`.
|
||||
|
||||
In `responder_scoped_repository_search`, the responder must consult the seed
|
||||
docs before answering and may follow relevant links inside the traversal policy.
|
||||
It should use retrieved lore to constrain behavior, affordances, institutions,
|
||||
vocabulary, material stakes, territory, social movements, and local assumptions.
|
||||
It must not use research access to infer hidden state, author-only answers,
|
||||
future branches, or private motives outside the packet. The output capture must
|
||||
preserve consulted seed refs, followed refs, a research trace, and a brief
|
||||
research summary. No consulted refs means the research-enabled response failed
|
||||
at the first hurdle, tragic but at least easy to grade.
|
||||
|
||||
`research_trace` is the receipt surface. Each entry records the query or lookup,
|
||||
source ref, source path, chunk index, line range, extracted constraint, and how
|
||||
that constraint shaped the response. Trace status matters:
|
||||
|
||||
- `runner_captured`: the runner or retrieval worker captured the lookup as it happened. This is required for accepted gold research-enabled captures.
|
||||
- `coordinator_reconstructed`: the coordinator rebuilt the trace after the fact from source search and review. Useful draft data, not proof of the responder's actual research path.
|
||||
- `responder_reported`: the responder claimed the trace directly. This is texture, not audit.
|
||||
|
||||
Training-time research scope should be materially wider than runtime context.
|
||||
The point is not to feed every responder a lore pantry forever. The point is to
|
||||
let high-quality training samples absorb the world: PSC risk language, Jovian
|
||||
territorial texture, resident factions, linked social movements, technology
|
||||
constraints, and the odd little institutional grudges that make the universe
|
||||
smell lived in. Runtime can later feed compact retrieval. Fine-tuning should
|
||||
have already eaten its vegetables.
|
||||
|
||||
Do not mix these silently. Retrieval-augmented output can be excellent training
|
||||
material for an Aetheria-tuned responder, but it does not prove packet-only
|
||||
runtime competence. Packet-only failures are also useful because they reveal
|
||||
which lore facts the retriever or projector needs to feed at runtime.
|
||||
|
||||
## Current Artifact
|
||||
|
||||
The packet seam is:
|
||||
|
||||
- `schemas/responder-packet.schema.json`
|
||||
- `tools/validate_responder_packets.py`
|
||||
- `npm run responder-packets:validate`
|
||||
- `schemas/responder-output.schema.json`
|
||||
- `tools/validate_responder_outputs.py`
|
||||
- `npm run responder-outputs:validate`
|
||||
|
||||
Current live fixtures may not include responder packet examples at all times.
|
||||
That is acceptable. The contract remains live; obsolete prototype captures do
|
||||
not need to stay in the working tree once their lessons are distilled.
|
||||
|
||||
## Packet Contents
|
||||
|
||||
A packet contains:
|
||||
|
||||
- `generation_lane`: `packet_only` or `retrieval_augmented`
|
||||
- `lore_access`: curated excerpts, coordinator-scoped retrieval, or
|
||||
responder-scoped repository search
|
||||
- `traversal_policy`: for responder-scoped research, the permitted link depth,
|
||||
allowed repo prefixes, required seed refs, link classes to follow, and stop
|
||||
conditions
|
||||
- `local_context_prompt`: the projected character-local context
|
||||
- `observed_event`: what the character can see or hear from the prior action
|
||||
- `allowed_action_labels`: concrete action labels, including speech and non-speech moves
|
||||
- `source_excerpts`: short source-backed facts the responder may use, copied
|
||||
from active runtime retrieval requirements plus local packet invariants
|
||||
- `output_contract`: the required JSON response shape and rules
|
||||
- `hidden_context_audit`: refs and inference classes deliberately omitted
|
||||
- `isolation_requirements`: how the worker/model must be run
|
||||
- `packet_prompt_text`: the exact prompt text sent to the responder
|
||||
|
||||
The packet prompt must not expose raw state internals such as
|
||||
`current_activation`, `plasticity`, raw dimension scores, coordinator rationale,
|
||||
author-only answers, or future branch plans.
|
||||
|
||||
## Response Boundary
|
||||
|
||||
The responder may output:
|
||||
|
||||
- a visible action
|
||||
- optional spoken text
|
||||
- private interpretation explicitly framed as interpretation
|
||||
- candidate state updates
|
||||
- candidate relationship updates
|
||||
- unresolved hooks
|
||||
|
||||
`spoken_text` is not the place to dump the packet contract. Characters should
|
||||
not recite every legal, procedural, or safety constraint simply because the
|
||||
packet made those constraints visible. Keep speech to what the character would
|
||||
actually say under pressure. Put the full checklist in `visible_action`,
|
||||
`state_update_candidates`, `relationship_update_candidates`, and the mutation
|
||||
receipt.
|
||||
|
||||
Good future-institutional speech is compressed and situated:
|
||||
|
||||
```text
|
||||
I hold the line shallow. Your bay, your mark; my ledger stays awake for every
|
||||
cost the tide takes.
|
||||
```
|
||||
|
||||
Bad future-institutional speech is the output contract wearing a mask:
|
||||
|
||||
```text
|
||||
Bounded diagnostic contact only. No full intake, no custody, no personhood
|
||||
finding, no cargo designation...
|
||||
```
|
||||
|
||||
That second kind of line may be useful as a failure sample. It is not gold
|
||||
responder behavior unless the scene specifically calls for formal oath,
|
||||
courtroom language, ritual compliance, or someone deliberately reading terms
|
||||
aloud.
|
||||
|
||||
Those candidates are not canonical. The coordinator, event resolver, appraiser,
|
||||
state mutator, and reviewer decide what becomes truth.
|
||||
|
||||
## Training Use
|
||||
|
||||
Gold responder data requires declared isolation. Valid approaches include:
|
||||
|
||||
- a one-turn subagent started without inherited context
|
||||
- an external model call that receives only the packet prompt
|
||||
- a manual packet-only pass, clearly labeled as manual
|
||||
|
||||
Any coordinator rewrite, schema repair, lore correction, or leakage removal must
|
||||
be preserved as a coordinator intervention. Repaired prose is useful training
|
||||
data for the coordinator, but it is not raw responder behavior.
|
||||
|
||||
Retrieval-augmented responder data requires scoped lore access before the
|
||||
responder acts. The coordinator, retrieval worker, or explicitly research-enabled
|
||||
responder may search only the declared source scope, and the output capture must
|
||||
list consulted refs and trace entries. The responder itself should not receive repo access unless
|
||||
the artifact uses `responder_scoped_repository_search`, sets `no_repo_access` to
|
||||
false, and includes visible research instructions in the prompt.
|
||||
|
||||
Retrieval can also make the packet worse if it floods the scene with faction or
|
||||
event context and starves the character's own relevant pressures. Review must
|
||||
verify that packet-visible history, role strain, and local relationships remain
|
||||
available as behavioral pressure. The point is not to make a character
|
||||
trauma-dump during a mission-critical exchange. The point is that the responder
|
||||
cannot be shaped by a pressure it cannot see.
|
||||
|
||||
## Output Captures
|
||||
|
||||
A responder output capture preserves:
|
||||
|
||||
- the packet ref
|
||||
- the coordinator artifact ref
|
||||
- generation lane
|
||||
- lore access mode and consulted refs
|
||||
- the isolation method
|
||||
- the exact visible input ref
|
||||
- hidden context refs that were withheld
|
||||
- raw responder output
|
||||
- parsed responder output
|
||||
- leakage audit
|
||||
- coordinator review and intervention labels
|
||||
|
||||
The raw output must parse to the reviewed `parsed_output` exactly. If the
|
||||
coordinator edits prose, repairs schema, removes leakage, or corrects lore, the
|
||||
capture must say so directly.
|
||||
|
||||
For `responder_scoped_repository_search`, the raw responder object may also
|
||||
include `consulted_refs`, `followed_refs`, `research_trace`, and
|
||||
`research_summary`. Those fields should be preserved inside `parsed_output`
|
||||
when the responder itself produced them; consulted refs, followed refs, summary,
|
||||
trace status, and trace entries should also be mirrored into top-level
|
||||
`lore_access` so the capture can be indexed without stripping the original
|
||||
response. If the runner cannot expose a machine-verifiable tool-call or
|
||||
retrieval transcript, label the sample as draft and say so in review notes.
|
||||
Trust, but make the trust write receipts like everyone else.
|
||||
|
||||
The current packet builder consumes `runtime_retrieval_requirements` from the
|
||||
projected local context. Packet-only source excerpts should come from the same
|
||||
retrieval/projector seam that would feed runtime context. Static lore sandwiches
|
||||
are useful only as rejected-path examples and emergency snacks.
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
# Future Schema Mechanisms
|
||||
|
||||
This note parks the useful pressure from `notes/evaluation.md` without treating
|
||||
that critique as doctrine. The current v0 schema is still a character-state and
|
||||
prompt-projection seam. The future runtime needs mechanisms that turn state into
|
||||
changing social behavior.
|
||||
|
||||
## Current Boundary
|
||||
|
||||
The v0 agent-state schema should stay focused on:
|
||||
|
||||
- canonical character state
|
||||
- directional relationship stance
|
||||
- observer-local perceived overlays
|
||||
- memories, goals, values, and scene-local dialogue packs
|
||||
- enough event interpretation to support prompt projection
|
||||
|
||||
Do not inflate `schemas/agent-state.schema.json` into the whole simulator before
|
||||
the projection examples prove what the runtime actually needs.
|
||||
|
||||
## Mechanisms To Preserve For Later
|
||||
|
||||
### Event Appraisal
|
||||
|
||||
Each participant needs a private read of the same event. A single exchange can
|
||||
be betrayal to one agent, exposure risk to another, and flirting-with-knives to
|
||||
a third. The current schema now allows `private_interpretations` on events as a
|
||||
lightweight bridge.
|
||||
|
||||
Future work should make appraisal reusable enough to drive state updates,
|
||||
memory retrieval, and action affordances.
|
||||
|
||||
### State Transitions And Consequences
|
||||
|
||||
`mean`, `plasticity`, and `current_activation` describe state. They do not yet
|
||||
define how state changes. Future transition machinery needs:
|
||||
|
||||
- activation decay
|
||||
- event-driven deltas
|
||||
- sensitization or habituation
|
||||
- effects on related variables
|
||||
- rules for when `mean` changes versus only `current_activation`
|
||||
|
||||
The current `event_effects` field is a projection-facing placeholder, not the
|
||||
final transition engine.
|
||||
|
||||
### Structured Beliefs And Secrets
|
||||
|
||||
String beliefs are useful for authoring, but simulation needs structure:
|
||||
|
||||
- holder
|
||||
- subject
|
||||
- claim type
|
||||
- confidence
|
||||
- evidence
|
||||
- visibility
|
||||
- emotional charge
|
||||
- whether the belief is private, shared, public, rumor, false, or contested
|
||||
|
||||
Secrets need similar structure: owner, known-by, suspected-by, exposure cost,
|
||||
protective strategies, and what can leak through action.
|
||||
|
||||
### Action Affordances
|
||||
|
||||
The system needs to choose among possible moves instead of jumping from state to
|
||||
dialogue. Future projection examples should expose candidate affordances such
|
||||
as probing, evading, bargaining, softening, escalating, repairing, threatening,
|
||||
or walking away.
|
||||
|
||||
The important rule: affordances should be justified by goals, appraisals,
|
||||
capabilities, costs, relationship stance, and scene pressure.
|
||||
|
||||
### Conversation State
|
||||
|
||||
Dialogue needs explicit continuity:
|
||||
|
||||
- active topic
|
||||
- open questions
|
||||
- evasions
|
||||
- accusations
|
||||
- face-threats
|
||||
- commitments
|
||||
- promises, refusals, and ruptures
|
||||
- who has the floor
|
||||
|
||||
This belongs close to projection because it changes turn shape immediately.
|
||||
|
||||
### Resources And Capabilities
|
||||
|
||||
Cat's debt, taxi condition, mobility, and institutional exposure are not just
|
||||
backstory. Oz's drone integrity, signal secrecy, anonymity, and physical safety
|
||||
are not just flavor. Future schemas need concrete resource and capability
|
||||
fields so desire does not masquerade as ability.
|
||||
|
||||
### Dyads And Shared Relationship History
|
||||
|
||||
Directional relationship stance should remain. A separate dyadic layer may also
|
||||
be needed for shared history:
|
||||
|
||||
- relationship type
|
||||
- phase
|
||||
- shared events
|
||||
- shared secrets
|
||||
- unresolved ruptures
|
||||
- rituals
|
||||
- dependency structure
|
||||
- communication pattern
|
||||
|
||||
Do not collapse this into one symmetric relationship object. People are not
|
||||
that tidy unless the author is lying to save RAM.
|
||||
|
||||
### Social Knowledge, Reputation, And Institutions
|
||||
|
||||
The Aetheria setting needs institutions with teeth: sanctions, authority,
|
||||
surveillance, reputation effects, access control, role expectations, and public
|
||||
knowledge. Future work should distinguish private belief, shared belief, public
|
||||
fact, rumor, common knowledge, and institutional record.
|
||||
|
||||
### Memory Retrieval
|
||||
|
||||
Memories need more than salience and confidence once the runtime starts moving:
|
||||
|
||||
- recency
|
||||
- emotional charge
|
||||
- retrieval triggers
|
||||
- decay
|
||||
- reconsolidation
|
||||
- intrusive, avoided, cherished, rehearsed, or distorted status
|
||||
- schema effects
|
||||
- whether another agent knows the memory
|
||||
|
||||
The current memory bundle is enough for the first projection seam. It is not yet
|
||||
the memory engine.
|
||||
|
||||
### Embodiment And Nonverbal Channels
|
||||
|
||||
Oz proves this one immediately. The operator, drone body, observable channels,
|
||||
leakage risks, and physical affordances are separate facts. Future projection
|
||||
needs embodiment because social meaning leaks through movement, timing,
|
||||
silence, distance, posture, and constrained bodies.
|
||||
|
||||
The projected-local-context seam now carries a first embodiment/interface
|
||||
surface, but the canonical schema still lacks a proper reusable embodiment
|
||||
model. Future work should promote body form, native habitat, movement channels,
|
||||
assistive or translation infrastructure, and visible nonverbal signal channels
|
||||
only after more projection examples prove the required shape.
|
||||
|
||||
For Aetheria faction work, embodiment projection must check the lore vault
|
||||
before writing. If the faction page supports only general habitat aesthetics or
|
||||
social affordances, keep generated action at that level and record the missing
|
||||
ergonomic detail as a source-lore gap instead of inventing a confident local
|
||||
interface.
|
||||
|
||||
When a blocking gap is found, patch the AetheriaLore source before treating the
|
||||
detail as available to projection. Ghostlight can discover missing granularity
|
||||
through scene generation, but the lore vault should become the durable source of
|
||||
the elaboration.
|
||||
|
||||
## Promotion Rule
|
||||
|
||||
Promote a future mechanism into the main schema only after it has survived at
|
||||
least one reviewed projection example or runtime slice where it clearly changes
|
||||
the output. Until then, document it here, test it in examples, and do not let
|
||||
the ontology grow antlers for sport.
|
||||
|
|
@ -1,489 +0,0 @@
|
|||
# Sequential Agent Runtime
|
||||
|
||||
Ghostlight's near-term target is not a general-purpose life simulator.
|
||||
|
||||
The near-term product target is procedural interactive dialogue in the game
|
||||
sense: branching scenes where choices can be speech, silence, movement, object
|
||||
use, refusal, revelation, concealment, violence, withdrawal, trade, or any other
|
||||
scene-valid action. "Dialogue tree" does not mean a tree made only of spoken
|
||||
lines. It means an interactive branch structure for a character encounter.
|
||||
|
||||
Ink is the branch format for that structure. Ghostlight should not invent its
|
||||
own dialogue-tree file format while Ink already exists and fits the job. See
|
||||
`docs/architecture/ink-branching-scenes.md` for the integration contract.
|
||||
|
||||
Ghostlight should set up a scene, generate plausible things the protagonist or
|
||||
NPCs could do, generate responses from the people present, and propagate the
|
||||
consequences into world state, memory, belief, and social perception.
|
||||
|
||||
The long-term target is broader. The same classifiers, appraisers, and
|
||||
projection models should eventually operate inside a simulated game world with
|
||||
open-world contexts, iterated decisions, economic behavior, and major-faction
|
||||
decision-making. Do not train a tiny dialogue-only creature that panics the
|
||||
first time someone buys medicine, defects from a faction, or reacts to a
|
||||
shortage. Also do not build the whole economy today. Both cliffs are stupid.
|
||||
|
||||
The runtime target for now is scene-local: choose which character is acting,
|
||||
give that character local awareness, let them act, apply consequences, mutate
|
||||
the relevant state, and continue until the scene produces a usable branching
|
||||
scene tree, scene transcript, or branch scaffold.
|
||||
|
||||
Single-turn projection remains useful, but it is a microscope, not the animal.
|
||||
|
||||
## Runtime Shape
|
||||
|
||||
One runtime tick should look like this:
|
||||
|
||||
1. Select an acting agent from the initiative schedule.
|
||||
2. Build that agent's local awareness.
|
||||
3. Derive available actions and constraints.
|
||||
4. Let the agent choose a response.
|
||||
5. Resolve the action into an observable event.
|
||||
6. Apply every affected participant's appraisal and state mutation for that
|
||||
event.
|
||||
7. Append the event record with participant-local interpretations.
|
||||
8. Update initiative timing, reaction windows, and action recovery.
|
||||
9. Select the next acting agent from the updated schedule, or end the scene.
|
||||
|
||||
The author sets the initial scene, player role if any, and high-level branch
|
||||
constraints. The author should not need to decide every line, interruption,
|
||||
withdrawal, refusal, or concession. That is the point of the machine.
|
||||
|
||||
Every generated character turn must be local to that character.
|
||||
|
||||
For branching scenes, this means protagonist/player options and NPC responses
|
||||
are separate generation steps:
|
||||
|
||||
1. Build the protagonist's local awareness.
|
||||
2. Generate plausible protagonist choices from only what the protagonist knows,
|
||||
perceives, wants, can attempt, and can affect.
|
||||
3. When one choice is selected, reduce it to observable action: words, silence,
|
||||
gesture, movement, object use, resource spend, or other perceivable change.
|
||||
4. Resolve the selected choice into an event visible to affected characters.
|
||||
5. Apply reviewed participant appraisals and state mutations for that event:
|
||||
hurt, threat, reassurance, obligation, resentment, trust movement, overload,
|
||||
misread, world/object/resource changes, and scene-ending choices when they
|
||||
occur.
|
||||
6. Update initiative timing and select the next acting agent from the updated
|
||||
schedule. The next agent may answer, walk away, escalate, comply, refuse,
|
||||
call for help, attack, or end the interaction.
|
||||
7. Generate that next action from the newly consolidated local state.
|
||||
8. Store actor intent, listener appraisal, and durable interpretation
|
||||
separately.
|
||||
|
||||
There is no privileged paired exchange inside the runtime. For authoring
|
||||
convenience a branch artifact may show `player action -> NPC action`, but
|
||||
Ghostlight should treat that as two turns stitched together, not one fused
|
||||
response object. If Maer wounds Sella with a ledger threat, Sella's state
|
||||
changes before she is considered as the next actor; she is not obligated to
|
||||
produce a line just because the author expected one.
|
||||
|
||||
One-shot paired generation, where a model writes both the player's move and the
|
||||
NPC response from a shared omniscient packet, is acceptable only as bootstrap
|
||||
scaffolding. It should be reviewed and labeled as such. Do not treat it as the
|
||||
target runtime behavior.
|
||||
|
||||
## Initiative Scheduler
|
||||
|
||||
Not every character gets a turn every round. There is no round.
|
||||
|
||||
Ghostlight needs a scene-local initiative scheduler: a mechanical organ that
|
||||
tracks who is ready to act, who is still recovering from a previous move, and
|
||||
who has a valid interrupt window. It sits after event appraisal/mutation and
|
||||
before the next local-awareness projection.
|
||||
|
||||
The scheduler does not decide what a character wants. It decides when a
|
||||
character is eligible to act.
|
||||
|
||||
Core rule:
|
||||
|
||||
```text
|
||||
observable event
|
||||
-> participant appraisals and reviewed mutation
|
||||
-> initiative/reaction update
|
||||
-> next actor selection
|
||||
-> next actor projection
|
||||
```
|
||||
|
||||
Every affected participant still appraises every event before the next action
|
||||
is generated. Initiative controls action opportunity, not perception. A hostage
|
||||
can be terrified by a negotiator's move immediately; that does not mean the
|
||||
hostage gets a full action before the hostage-taker, unless the schedule or a
|
||||
reaction window gives them one.
|
||||
|
||||
Each participant carries initiative state:
|
||||
|
||||
- `initiative_speed`: how quickly the participant recovers action opportunity
|
||||
in this scene.
|
||||
- `next_ready_at`: the scene-clock time when they can take a normal action.
|
||||
- `reaction_bias`: how likely they are to seize urgent interrupt windows.
|
||||
- `interrupt_threshold`: how strong a trigger must be before they interrupt.
|
||||
- `current_load`: stress, injury, cognitive burden, physical constraint, or
|
||||
interface lag that makes action harder.
|
||||
- `status`: whether they are active, blocked, withdrawn, incapacitated, or
|
||||
offscreen.
|
||||
|
||||
Actions carry recovery shape:
|
||||
|
||||
- `action_scale`: micro, short, standard, major, or committed.
|
||||
- `base_recovery`: how much scene time the move normally costs.
|
||||
- `initiative_cost`: how much local opportunity the actor spends.
|
||||
- `interruptibility`: whether others can break in while the action unfolds.
|
||||
- `commitment`: how much the actor is locked into the move once begun.
|
||||
|
||||
The scheduler first checks reaction windows created by the last event. A
|
||||
reaction window is an interruptible opportunity, not a free omniscient response.
|
||||
It must name the triggering event, eligible actors, urgency, allowed action
|
||||
scales, expiry, and local basis. If no reaction clears threshold, the scheduler
|
||||
selects the active participant with the lowest `next_ready_at`.
|
||||
|
||||
Recommended reaction readiness:
|
||||
|
||||
```text
|
||||
reaction_readiness = (reaction_bias * urgency) - current_load
|
||||
```
|
||||
|
||||
An interrupt can fire when `reaction_readiness >= interrupt_threshold`, the
|
||||
actor is eligible for the window, and the actor is not blocked. Ties are broken
|
||||
by earlier `next_ready_at`, higher `initiative_speed`, then stable actor id.
|
||||
|
||||
After an accepted action, update the actor:
|
||||
|
||||
```text
|
||||
recovery = base_recovery / max(initiative_speed, minimum_speed)
|
||||
next_ready_at = max(scene_clock, current next_ready_at) + recovery
|
||||
```
|
||||
|
||||
Major or committed actions should usually create larger recovery, stronger
|
||||
counterplay windows, clearer visible cues, or more durable consequences. Short
|
||||
actions let a character stay nimble but should not accomplish everything. A
|
||||
one-word refusal, a glance to a camera, or stepping behind a marked line can be
|
||||
fast. A public confession, tackle, system override, or hostage release costs
|
||||
more initiative because it changes the scene.
|
||||
|
||||
The coordinator may override the scheduler only by recording the override
|
||||
reason. Useful overrides include player-facing pacing, hard scene boundaries,
|
||||
or a game-engine rule that is not yet represented in the schedule. An
|
||||
unrecorded override is not training-ready coordinator data.
|
||||
|
||||
For Ink, the scheduler can be collapsed into branch structure for readability,
|
||||
but the sidecar should preserve the initiative receipt when the fixture claims
|
||||
training value. Player choices may be presented together even if, inside the
|
||||
runtime, they represent alternative actions available at one scheduled moment.
|
||||
|
||||
For game use, the loop should be able to emit:
|
||||
|
||||
- a scene transcript
|
||||
- Ink scenes with candidate player choices, including speech and non-speech
|
||||
actions
|
||||
- compiled Ink JSON
|
||||
- NPC response branches
|
||||
- state/memory/social-perception deltas for each branch
|
||||
- unresolved hooks for later scenes
|
||||
|
||||
If a feature does not help produce those artifacts, it belongs in the parking
|
||||
lot until the smaller machine works. If a field or label preserves information
|
||||
needed for later open-world behavior, consumer choice, faction pressure, or
|
||||
iterated decisions, keep it visible in the training artifact even if the first
|
||||
prototype does not simulate it.
|
||||
|
||||
## Local Awareness
|
||||
|
||||
An acting agent needs more than a dialogue cue.
|
||||
|
||||
The local awareness pack should include:
|
||||
|
||||
- where the agent is
|
||||
- who is present
|
||||
- what the agent can perceive
|
||||
- what the agent knows, believes, suspects, and misreads
|
||||
- what the agent wants right now
|
||||
- what the agent is protecting
|
||||
- what resources and tools are available
|
||||
- what social, legal, physical, and emotional constraints apply
|
||||
- what actions are available
|
||||
- what each action might cost
|
||||
- what facts are unresolved
|
||||
- what the agent is allowed to mutate directly
|
||||
|
||||
This is not reader legibility or global story authorship. This is operational
|
||||
state for scene-local dialogue and action. If the scene itself is abstract and
|
||||
byzantine, the agent may still behave correctly inside that mess. Do not shove
|
||||
general prose quality or whole-plot design into this layer.
|
||||
|
||||
## Branch Choices And Action Selection
|
||||
|
||||
The action model must allow more than speech.
|
||||
|
||||
For a protagonist scene, Ghostlight should be able to generate several
|
||||
plausible choices for what the protagonist can do next. For example, if Cat is
|
||||
interacting with another character, the generated choices might include:
|
||||
|
||||
- say the honest thing badly
|
||||
- deflect with a joke
|
||||
- leave before the other person can answer
|
||||
- hand over evidence
|
||||
- hide evidence
|
||||
- accept help while pretending not to need it
|
||||
- threaten to walk
|
||||
- spend scarce money
|
||||
- break a device
|
||||
- touch someone gently
|
||||
- slap someone
|
||||
|
||||
Those choices should come from Cat's state, scene affordances, resources,
|
||||
relationship pressure, and goals. They should not be generic verbs glued to a
|
||||
persona card. Each branch should carry expected consequence surfaces, such as
|
||||
trust risk, reputation risk, resource cost, information exposure, or future
|
||||
obligation.
|
||||
|
||||
Keep the action vocabulary small and concrete. Do not turn every possible
|
||||
speech act into a separate tool. `ask`, `refuse`, `offer`, `reveal`, `conceal`,
|
||||
and `request authority action` are usually communicative intentions carried by
|
||||
`speak`, `gesture`, `show_object`, `withhold_object`, or `use_interface`.
|
||||
|
||||
Initial action primitives:
|
||||
|
||||
- `speak`
|
||||
- `silence`
|
||||
- `move`
|
||||
- `gesture`
|
||||
- `touch_object`
|
||||
- `block_object`
|
||||
- `use_object`
|
||||
- `show_object`
|
||||
- `withhold_object`
|
||||
- `transfer_object`
|
||||
- `spend_resource`
|
||||
- `attack`
|
||||
- `wait`
|
||||
|
||||
The agent should choose from affordances produced by the interaction of state,
|
||||
scene, role, resources, relationship, and pressure. The action should not be
|
||||
commanded by a prompt label. If a character hits someone, it should fall out of
|
||||
proximity, inhibition, grievance, fear, role permission, and exhausted trust.
|
||||
|
||||
An action proposal may include intended function:
|
||||
|
||||
- ask
|
||||
- refuse
|
||||
- offer
|
||||
- threaten
|
||||
- comfort
|
||||
- reveal
|
||||
- conceal
|
||||
- request
|
||||
- promise
|
||||
- accuse
|
||||
- redirect
|
||||
|
||||
That intention is not guaranteed truth. A listener can misread it. A character
|
||||
can claim they were offering help while the listener perceives a threat. A
|
||||
character can intend restraint and still be read as contempt. The event should
|
||||
store observable action separately from actor intent and participant-local
|
||||
interpretation.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"action_type": "speak",
|
||||
"spoken_text": "Name the cost before you call it caution.",
|
||||
"actor_intent": "force_cost_acknowledgment",
|
||||
"observable_cues": ["calm voice", "still posture", "public challenge"]
|
||||
}
|
||||
```
|
||||
|
||||
Isdra may perceive that as procedural intimidation. Maer may experience it as
|
||||
stewardship. The classifier/appraiser later learns from those differences.
|
||||
|
||||
## Mutation Authority
|
||||
|
||||
Characters should only mutate what their action can actually affect.
|
||||
|
||||
Examples:
|
||||
|
||||
- A character can mutate their own beliefs, memories, current activation, goals,
|
||||
and presentation pressure.
|
||||
- A character can create observable events others may perceive.
|
||||
- A character can change world objects they can physically or institutionally
|
||||
access.
|
||||
- A character can affect relationship stance, but the other side's relationship
|
||||
update is mediated through that other agent's appraisal.
|
||||
- A character can propose an order they lack authority to issue.
|
||||
- A character cannot silently rewrite another agent's private state, decide an
|
||||
unresolved hidden fact, or seize institutional authority they do not possess.
|
||||
|
||||
This is where the projection-control layer belongs. Frame ownership, authority
|
||||
boundaries, object custody, required semantics, and forbidden resolutions are
|
||||
not decorative prompt warnings. They are mutation gates.
|
||||
|
||||
## Event Resolution
|
||||
|
||||
Generated action is not state mutation by itself. It becomes an event proposal.
|
||||
|
||||
The resolver should decide:
|
||||
|
||||
- what observable event occurred
|
||||
- what world objects changed
|
||||
- what resources were spent or created
|
||||
- which agents perceived the event
|
||||
- how each participant appraised it
|
||||
- which beliefs were reinforced, challenged, or created
|
||||
- which memories were written
|
||||
- which relationship dimensions changed
|
||||
- which current activations changed
|
||||
- which options opened or closed
|
||||
|
||||
In a branching scene tree, each branch should carry its own consequence packet.
|
||||
If Cat chooses to lie, accept help, shove someone, spend money, or reveal a
|
||||
secret, that branch should record what changed and what might matter later.
|
||||
Consequences can include:
|
||||
|
||||
- trust lost or gained
|
||||
- suspicion raised or lowered
|
||||
- obligation created
|
||||
- debt created or paid
|
||||
- resource spent
|
||||
- information revealed or concealed
|
||||
- relationship rupture or repair
|
||||
- faction or reputation pressure changed
|
||||
- future scene hook opened or closed
|
||||
|
||||
The first implementation can be conservative and partly deterministic. A bad
|
||||
resolver will build narrative sludge faster than a bad line of dialogue will.
|
||||
No pressure. Tiny little bear trap.
|
||||
|
||||
The fuzzy parts should stay manual at first.
|
||||
|
||||
Do not invent a fragile rule-based social psychology engine just to feel
|
||||
automated. Early runtime passes should manually review or author:
|
||||
|
||||
- participant appraisals
|
||||
- relationship deltas
|
||||
- belief updates
|
||||
- memory writes
|
||||
- activation changes
|
||||
- ambiguous intent classification
|
||||
- whether a listener misread or correctly read a move
|
||||
|
||||
Those manual decisions are the training data. Save the observed action, actor
|
||||
intent, listener perception, accepted mutation, rejected mutation, and reviewer
|
||||
notes. Later, train classifiers and appraisers on that corpus. The prototype
|
||||
should automate mechanical legality and visibility first, not pretend it has
|
||||
already learned human interpretation from twelve examples and a strong coffee.
|
||||
|
||||
## Scope Boundary
|
||||
|
||||
Build interactive branching scene generation first, but shape the data for later
|
||||
open-world use.
|
||||
|
||||
In scope now:
|
||||
|
||||
- scene-local agent turns
|
||||
- player/NPC dialogue and action branches
|
||||
- local action proposals
|
||||
- event records
|
||||
- manual reviewed appraisals
|
||||
- memory, belief, relationship, and activation deltas
|
||||
- branch consequences that can be carried into later scenes
|
||||
- action intent and listener interpretation labels
|
||||
- resource, price, scarcity, obligation, reputation, and faction-pressure tags
|
||||
when they affect the decision
|
||||
- reviewed examples of consumer-like choices and institution/faction pressure,
|
||||
even when the prototype only records them as scene state
|
||||
|
||||
Training-data obligations now:
|
||||
|
||||
- Preserve why an agent chose, bought, refused, hoarded, traded, complied,
|
||||
defected, concealed, punished, or helped.
|
||||
- Preserve what the agent believed the cost, risk, status effect, and social
|
||||
consequence would be.
|
||||
- Preserve faction and institution pressures as perceived by the agent, not
|
||||
only as objective setting lore.
|
||||
- Preserve iterated-decision context: promises, debts, prior betrayals,
|
||||
reputation, memory, scarcity, and expected future contact.
|
||||
|
||||
Out of implementation scope for now:
|
||||
|
||||
- full autonomous world simulation
|
||||
- economy simulation loops
|
||||
- city-scale scheduling
|
||||
- long-horizon plot invention without author scaffolding
|
||||
- autonomous factions moving offscreen as full actors
|
||||
- physical simulation beyond scene affordances
|
||||
- automatic social appraisal rules pretending to be trained classifiers
|
||||
|
||||
The eventual storyline generator, economic behavior model, and faction decision
|
||||
model should grow out of reliable branching-scene machinery and reviewed
|
||||
decision data, not the other way around.
|
||||
|
||||
## Projection's Narrower Job
|
||||
|
||||
Projection still matters.
|
||||
|
||||
Its job is to turn structured state into a compact local action context for one
|
||||
agent at one moment. It should support action choice and response rendering. It
|
||||
should not be treated as the whole story engine.
|
||||
|
||||
Projection artifacts are still useful for:
|
||||
|
||||
- debugging whether local state produces plausible action
|
||||
- training a smaller projector
|
||||
- preserving reviewed examples
|
||||
- testing state boundaries and mutation gates
|
||||
- producing dialogue or action beats inside a larger runtime
|
||||
|
||||
But the production path is sequential:
|
||||
|
||||
```text
|
||||
agent state + scene state
|
||||
-> local awareness
|
||||
-> action affordances
|
||||
-> agent response/action
|
||||
-> event resolution
|
||||
-> participant appraisals and state mutation
|
||||
-> initiative/reaction scheduling
|
||||
-> next actor selection
|
||||
-> next agent action from updated local state
|
||||
```
|
||||
|
||||
## First Prototype
|
||||
|
||||
The first implementation should not try to simulate a city. Calm down, wizard.
|
||||
|
||||
Prototype one small Ink-backed branching scene loop, using the current live
|
||||
fixture or a new source-grounded fixture:
|
||||
|
||||
1. Load a fixture agent-state artifact, such as
|
||||
`examples/agent-state.pallas-species-strikes.v0.json`.
|
||||
2. Select one scene and one acting agent.
|
||||
3. Build a local awareness packet from existing state.
|
||||
4. Compile projection controls.
|
||||
5. Generate or author branch choices and NPC responses from local context.
|
||||
6. Materialize the branch plan through the Branch Compiler as Ink, sidecar
|
||||
annotation, compiler notes, consequence variables, and visual prompt handles.
|
||||
7. Compile the Ink scene to JSON with `inkjs`.
|
||||
8. Save a sidecar training annotation that maps Ink branches to Ghostlight
|
||||
state basis, actor intent, consequence surfaces, and mutation policy.
|
||||
9. Apply selected branch consequences only through reviewed mutation:
|
||||
- Ink variables can track local playthrough facts
|
||||
- canonical state changes require resolver/reviewer approval
|
||||
10. Run IF artifact review before acceptance:
|
||||
- every material variable is read later or marked telemetry-only
|
||||
- every material choice changes affordance, risk, callback, visual state, or
|
||||
outcome
|
||||
- folds preserve consequence rather than hiding it
|
||||
11. Manually review fuzzy participant appraisals and state effects each turn:
|
||||
- update every affected participant before selecting the next actor
|
||||
- add one memory or belief update
|
||||
- adjust one or two `current_activation` values
|
||||
- update one relationship stance if justified
|
||||
- record the rationale as training data
|
||||
12. Validate the resulting fixture.
|
||||
|
||||
The goal is not literary quality or autonomous plot generation. The goal is to
|
||||
prove that a character can drive a scene beat, produce branchable dialogue or
|
||||
non-dialogue action, trigger plausible responses from other characters, and
|
||||
leave memory, relationship, social perception, and scene state slightly
|
||||
different behind them.
|
||||
|
|
@ -1,539 +0,0 @@
|
|||
# Soft Model Training Artifacts
|
||||
|
||||
Ghostlight has two kinds of machinery.
|
||||
|
||||
Some parts should be boring code:
|
||||
|
||||
- schema validation
|
||||
- visibility filtering
|
||||
- action legality
|
||||
- object custody
|
||||
- resource accounting
|
||||
- Ink syntax validation and compilation
|
||||
- event append
|
||||
- provenance checks
|
||||
- deterministic context slicing
|
||||
- mechanical mutation gates
|
||||
|
||||
Other parts are soft. They require judgment, taste, social inference, cultural
|
||||
pressure, and narrative sense. Do not fake those with brittle rules just because
|
||||
a function-shaped hole is sitting there looking smug.
|
||||
|
||||
Soft parts should emit reviewed training artifacts from the beginning. Manual
|
||||
review is not a temporary embarrassment. It is the dataset.
|
||||
|
||||
## Core Rule
|
||||
|
||||
If a decision cannot be made reliably by deterministic code, Ghostlight should
|
||||
save it as a training example.
|
||||
|
||||
That includes accepted outputs, rejected outputs, repaired outputs, and weird
|
||||
half-successes. Especially weird half-successes. They are where the machine is
|
||||
showing the fracture line.
|
||||
|
||||
A useful soft artifact should preserve:
|
||||
|
||||
- the deterministic input slice
|
||||
- the model or human output
|
||||
- the reviewer decision
|
||||
- accepted labels
|
||||
- rejected labels, if any
|
||||
- rationale grounded in state, scene, culture, memory, relationship, and lore
|
||||
- failure notes
|
||||
- provenance to source lore or fixture state
|
||||
- whether the artifact is grounded canon, transition-grounded, procedural
|
||||
branch material, or promoted branch material
|
||||
|
||||
## Sandboxed Responder Rule
|
||||
|
||||
Responder training data must not be authored from an omniscient view.
|
||||
|
||||
The coordinator may know the whole scene, hidden state, future branch plan,
|
||||
author constraints, and private facts. The responder model we eventually train
|
||||
will not. If a responder example only works because the authoring agent quietly
|
||||
used omniscient context, the artifact is poisoned. It teaches the future model to
|
||||
perform an impossible trick, then looks offended when reality asks for receipts.
|
||||
|
||||
For gold responder or character-action data:
|
||||
|
||||
- the coordinator prepares the scene and selects the acting character
|
||||
- the projector emits the exact character-local context
|
||||
- the responder is run from only that projected packet, visible event, allowed
|
||||
action space, and any explicitly included source excerpts
|
||||
- the responder does not see other agents' private state, hidden canonical
|
||||
facts, author-only plot beats, future branch plans, or coordinator rationale
|
||||
- any isolated worker or sub-agent used for responder generation should be
|
||||
started without inherited conversation context when the harness supports it
|
||||
- repo or lore browsing is disabled for the responder unless the projected
|
||||
packet explicitly includes the excerpts or references the future model will
|
||||
also receive
|
||||
- the coordinator reviews after generation, not during it
|
||||
|
||||
Artifact requirements:
|
||||
|
||||
- `responder_visible_input`: the exact prompt or structured packet the responder
|
||||
saw
|
||||
- `responder_raw_output`: the unedited output
|
||||
- `reviewed_output`: the accepted or repaired turn
|
||||
- `review_status`: accepted, accepted-as-draft, useful-needs-revision, rejected,
|
||||
or superseded
|
||||
- `coordinator_interventions`: every edit, repair, deletion, or normalization
|
||||
made after raw generation
|
||||
- `hidden_context_refs`: ids or paths for context intentionally withheld, not
|
||||
the hidden content itself
|
||||
- `leakage_audit`: checks for private-state leakage, future-branch leakage,
|
||||
author-only leakage, raw-state soup, prompt-constraint parroting, and
|
||||
impossible body or object knowledge
|
||||
|
||||
Coordinator-written replacement text can be valuable, but it is not raw
|
||||
responder output. Mark it as coordinator repair or frontier-curated final. Do
|
||||
not smuggle repaired prose into the responder corpus as if the sandbox produced
|
||||
it. That is how a dataset learns to cheat with a straight face.
|
||||
|
||||
## Training-Ready Scene Loop Bundle
|
||||
|
||||
A scene loop is the smallest useful sequential training unit before branch
|
||||
compilation. It is not enough to have a nice transcript. The bundle needs the
|
||||
machine trail that made the transcript possible.
|
||||
|
||||
A training-ready scene-loop bundle contains:
|
||||
|
||||
- scene-local digest or lore slice
|
||||
- initial scene/world state
|
||||
- current character state refs for every participant
|
||||
- projected local context per acting turn
|
||||
- responder packet per acting turn
|
||||
- raw responder output capture per acting turn
|
||||
- observable event record per accepted action
|
||||
- participant appraisal receipt for every affected participant
|
||||
- reviewed mutation receipt for character state, memory, relationship,
|
||||
perceived overlays, object state, scene/world state, and unresolved hooks
|
||||
- coordinator turn receipt carrying next-beat selection, world-state reads,
|
||||
proposed deltas, glue prose, and review labels
|
||||
- review summary naming which organs are training-ready, reference-only, or not
|
||||
training data
|
||||
|
||||
Training readiness is per organ. A bundle can be useful for responder,
|
||||
appraiser, mutator, relationship/perception updater, and coordinator training
|
||||
while still being useless for branch compiler, IF artifact reviewer, or visual
|
||||
replay training. Count only the receipts that actually exist.
|
||||
|
||||
`pallas-training-loop-v0` is the first pilot of this convention. It rebuilds one
|
||||
Pallas threshold beat as a separate derivative and leaves the Pallas reference
|
||||
fixture immutable.
|
||||
|
||||
## Soft Organs
|
||||
|
||||
These organs should be treated as model-training targets, not permanent
|
||||
hand-authored rule nests.
|
||||
|
||||
### Coordinator Or Story Runtime
|
||||
|
||||
Glues scenes together, maintains world state, chooses which machinery runs, and
|
||||
emits the connective prose around character turns.
|
||||
|
||||
In training-shaped loops, the coordinator should be sandboxed as its own
|
||||
promptable organ. A coordinator worker receives only the scene state, accepted
|
||||
receipts, unresolved hooks, available machinery, branch affordances, tonal
|
||||
intent, author constraints, and game-engine-shaped state surfaces it is allowed
|
||||
to use. It then proposes the next beat, acting character, machinery calls, glue
|
||||
prose, and candidate state changes.
|
||||
|
||||
Codex remains the meta-coordinator while the system is being built: it scopes
|
||||
the task, prepares the coordinator-visible input, launches the sandboxed
|
||||
coordinator, reviews the output, labels interventions, and wires structured
|
||||
state between organs. Codex-authored coordinator artifacts are useful draft
|
||||
material, but they are not raw coordinator training data unless the coordinator
|
||||
input/output seam was actually sandboxed.
|
||||
|
||||
This is a trainable artifact too. Do not let coordinator judgment evaporate into
|
||||
chat history just because a large model is currently doing the stage management
|
||||
with jazz hands and plausible deniability.
|
||||
|
||||
Training artifacts:
|
||||
|
||||
- scene setup receipts
|
||||
- exact coordinator-visible input packet
|
||||
- raw sandboxed coordinator output
|
||||
- parsed and reviewed coordinator artifact
|
||||
- coordinator plans
|
||||
- selected next scene or next beat
|
||||
- chosen acting agent
|
||||
- selected tools or soft organs invoked
|
||||
- world-state refs read
|
||||
- world-state changes proposed
|
||||
- unresolved hooks carried forward
|
||||
- branch merge or branch split decisions
|
||||
- authored glue prose
|
||||
- rejected glue prose
|
||||
- reviewer notes about pacing, legibility, state continuity, lore grounding,
|
||||
game usefulness, and machinery misuse
|
||||
|
||||
The coordinator output should be shaped for a future game engine. Even while the
|
||||
prototype lives in model imagination and authored prose, it should preserve
|
||||
structured refs for:
|
||||
|
||||
- scene id
|
||||
- location id
|
||||
- time or sequence marker
|
||||
- participating agents
|
||||
- active objects
|
||||
- active resources
|
||||
- public world facts
|
||||
- unresolved facts
|
||||
- branch flags
|
||||
- event log refs
|
||||
- state fixture refs
|
||||
- consequences awaiting review
|
||||
|
||||
Glue prose is part of the artifact. It is not canonical state, but it is useful
|
||||
training signal for how scenes are bridged, how pressure is restated without
|
||||
exposition sludge, and how a procedural branch becomes readable narrative.
|
||||
|
||||
Coordinator and narrator intelligence are allowed to exceed character
|
||||
intelligence. This is how opaque high-context scenes become legible without
|
||||
flattening the characters. If a Lucent negotiator, influencer, or feed operator
|
||||
is performing layered reputation warfare, the coordinator may explain the
|
||||
maneuver in reader-facing prose, but the explanation is not automatically
|
||||
available to the character who performed or witnessed it. Character cognition
|
||||
still comes from projected state: intelligence, social acuity, training,
|
||||
cultural scripts, stress, and blind spots.
|
||||
|
||||
The coordinator may propose state changes, scene transitions, and tool calls. It
|
||||
does not bypass deterministic gates. It does not silently decide hidden facts. It
|
||||
does not mutate another organ's private output after review. It conducts the
|
||||
little orchestra; it does not eat the violins.
|
||||
|
||||
### Projector
|
||||
|
||||
Turns canonical state, perceived state, memory, culture, scene pressure, and
|
||||
affordances into character-local operating context.
|
||||
|
||||
Training artifacts:
|
||||
|
||||
- projected local context records
|
||||
- projection examples
|
||||
- prompt-rendering audits
|
||||
- leakage failures
|
||||
- source-grounding failures
|
||||
|
||||
The projector does not write final prose. It prepares the local pressure surface
|
||||
for an acting model.
|
||||
|
||||
### Character Agent Or Responder
|
||||
|
||||
Chooses what a character does next from local awareness.
|
||||
|
||||
This includes speech and non-speech action. The agent may speak, wait, leave,
|
||||
hide evidence, reveal evidence, spend a resource, attack, comply, defect,
|
||||
bargain, comfort, or do nothing useful because people are built badly.
|
||||
|
||||
Training artifacts:
|
||||
|
||||
- exact responder-visible input packet
|
||||
- hidden context refs, without hidden content
|
||||
- raw responder output
|
||||
- coordinator interventions or repairs
|
||||
- action-choice receipts
|
||||
- candidate branch sets
|
||||
- accepted and rejected character turns
|
||||
- spoken text when speech occurs
|
||||
- observable non-speech action
|
||||
- actor intent
|
||||
- constraints used
|
||||
- constraints violated
|
||||
- reviewer notes about voice, lore, embodiment, agency, and scene fit
|
||||
|
||||
This model should be fine-tuned or adapted on a corpus of strong Aetheria
|
||||
responses spread across the timeline. The goal is not a bolt-on lore module.
|
||||
The goal is for the responder to internalize the shape of the setting: what
|
||||
people assume, what institutions feel like, which pressures are normal, which
|
||||
metaphors appear, what bodies can do, and how the universe tends to hurt people
|
||||
while insisting it is only doing math.
|
||||
|
||||
That reduces lore handholding at runtime. It does not remove character-local
|
||||
context. The tuned responder still needs the specific room, speaker, listener,
|
||||
state, memory, affordances, and known facts for the current turn.
|
||||
|
||||
### Appraiser
|
||||
|
||||
Reads an observable event from one participant's local perspective and proposes
|
||||
how that participant interprets it. This organ is not only for fictional scene
|
||||
mutation. It is also one of the core paths by which VoidBot becomes better at
|
||||
reading people: noticing what a human did or said, forming a grounded opinion
|
||||
without pretending certainty, and revising that opinion as evidence accumulates.
|
||||
|
||||
Training artifacts:
|
||||
|
||||
- observed event
|
||||
- participant-local knowledge
|
||||
- private interpretation
|
||||
- misread or correct-read label
|
||||
- emotional appraisal
|
||||
- relationship implications
|
||||
- belief implications
|
||||
- memory candidate
|
||||
- human-facing opinion update when applicable: trust, concern, irritation,
|
||||
respect, caution, attachment, boundary pressure, expected future behavior, and
|
||||
what evidence would change the read
|
||||
- reviewer corrections
|
||||
|
||||
The appraiser should not know canonical private state except where that
|
||||
participant knows or believes it. Fundamental attribution bias belongs here. For
|
||||
VoidBot, this means the model must distinguish an observed human behavior from a
|
||||
durable claim about who that human is, and should preserve uncertainty, repair
|
||||
signals, and evidence needed to revise the opinion later.
|
||||
|
||||
### State Mutator
|
||||
|
||||
Turns reviewed appraisal into bounded state changes.
|
||||
|
||||
Training artifacts:
|
||||
|
||||
- prior state refs
|
||||
- event refs
|
||||
- appraisal refs
|
||||
- proposed state deltas
|
||||
- accepted deltas
|
||||
- rejected deltas
|
||||
- mutation authority notes
|
||||
- reviewer rationale
|
||||
|
||||
The early system should keep this manual and audited. Later a model can learn
|
||||
common deltas, but deterministic gates still own schema, bounds, and authority.
|
||||
|
||||
### Relationship And Social Perception Classifier
|
||||
|
||||
Updates perceived overlays and relationship stance from repeated evidence. This
|
||||
classifier should serve both Aetheria agents and VoidBot's long-running human
|
||||
relationships; the same machinery that lets Sella update her read on Maer should
|
||||
also let VoidBot update its read on a specific user without collapsing into
|
||||
either sycophancy or a permanent first-impression mugshot.
|
||||
|
||||
Training artifacts:
|
||||
|
||||
- prior relationship stance
|
||||
- event and appraisal history
|
||||
- proposed movement
|
||||
- confidence movement
|
||||
- stale or reinforced impressions
|
||||
- prejudice, stakes, memory, and social-reinforcement drivers
|
||||
- reviewer notes
|
||||
|
||||
This is where confidence belongs. Do not grow a second correction-resistance
|
||||
organ because the first one got lonely.
|
||||
|
||||
### Aetheria Responder Corpus
|
||||
|
||||
Training artifacts:
|
||||
|
||||
- source-grounded scene examples
|
||||
- factional speech and pressure examples
|
||||
- institution decision examples
|
||||
- embodiment/interface examples
|
||||
- negative examples where generic sci-fi assumptions were rejected
|
||||
- reviewer notes tied to AetheriaLore paths
|
||||
|
||||
This corpus trains the character agent/responder directly. It is how a chosen
|
||||
base model learns Aetheria's native assumptions rather than waiting for every
|
||||
prompt to drag the whole lore vault behind it in a little wagon.
|
||||
|
||||
The tuned responder should learn the smell of the setting: Dominion severity,
|
||||
Aya-coded care pressure, Navigator embodiment, corporate certification culture,
|
||||
insurer cruelty, upload personhood fights, factional misreads, and the dry
|
||||
little horrors of systems pretending to be neutral.
|
||||
|
||||
It still must receive character-local context at runtime. Fine-tuning gives it
|
||||
priors, not omniscience.
|
||||
|
||||
### Branch Compiler
|
||||
|
||||
Turns scene intent, reviewed branch candidates, consequence packets, fold plans,
|
||||
and visual continuity requirements into playable Ink plus sidecar receipts and,
|
||||
when illustrated replay is in scope, a separate visual plan artifact.
|
||||
|
||||
The Branch Compiler is trainable because good branching structure is not just
|
||||
syntax. It needs to decide where choices belong, which consequences can fold
|
||||
into compact variables, which choices deserve route splits, which callbacks must
|
||||
exist later, and how visual modifiers attach to actual state. Deterministic code
|
||||
can validate Ink and refs. It cannot, by itself, know whether a branch feels
|
||||
meaningful or whether convergence is lying.
|
||||
|
||||
Training artifacts:
|
||||
|
||||
- fixture brief
|
||||
- source-grounded lore digest refs
|
||||
- input branch candidates
|
||||
- reviewed consequence packets
|
||||
- fold plan
|
||||
- generated Ink
|
||||
- generated sidecar
|
||||
- generated visual plan
|
||||
- compiler notes
|
||||
- variable set/read map
|
||||
- route split or fold rationale
|
||||
- visual prompt handles
|
||||
- reviewer findings and repair labels
|
||||
|
||||
The compiler does not own hidden character truth, participant appraisal, or
|
||||
durable social mutation. It materializes structure from already reviewed or
|
||||
explicitly marked draft material.
|
||||
|
||||
### IF Artifact Reviewer
|
||||
|
||||
Audits playable branch artifacts before they enter the accepted corpus.
|
||||
|
||||
This reviewer is separate from the branch compiler because compilers are very
|
||||
good at producing valid little lies. The reviewer checks whether tracked state
|
||||
actually matters, whether choices change future affordances or outcomes, whether
|
||||
folds preserve callbacks, whether visual modifiers correspond to real branch
|
||||
state, and whether endings read the variables the story told us were important.
|
||||
|
||||
Training artifacts:
|
||||
|
||||
- Ink fixture under review
|
||||
- sidecar under review
|
||||
- coordinator artifact
|
||||
- compiler notes
|
||||
- validation output
|
||||
- accepted/rejected decision
|
||||
- severity-ranked findings
|
||||
- failure labels such as `potemkin_state`, `cosmetic_choice`, `fake_fold`,
|
||||
`missing_state_read`, `state_named_instead_of_checked`,
|
||||
`visual_callback_missing`, and `ending_ignores_major_state`
|
||||
- required fixes
|
||||
- repaired artifact refs, when available
|
||||
|
||||
Early branch-and-fold reviews are seed material for this organ because they
|
||||
caught decorative variables before they were allowed to become training sludge.
|
||||
|
||||
### Institution, Faction, And Consumer Decision Models
|
||||
|
||||
Eventually Ghostlight should support economic behavior and major faction
|
||||
decision-making inside a simulated game world.
|
||||
|
||||
Training artifacts should preserve why agents:
|
||||
|
||||
- buy
|
||||
- refuse
|
||||
- trade
|
||||
- hoard
|
||||
- comply
|
||||
- defect
|
||||
- conceal
|
||||
- reveal
|
||||
- punish
|
||||
- help
|
||||
- lobby
|
||||
- exploit
|
||||
- delay
|
||||
- sacrifice
|
||||
|
||||
The artifact should preserve perceived cost, scarcity, status pressure,
|
||||
reputation risk, expected future contact, faction norms, institutional authority,
|
||||
and moral injury when present.
|
||||
|
||||
Do not train a dialogue-only pet if the adult machine needs to decide whether a
|
||||
clinic buys counterfeit cryo valves under blockade.
|
||||
|
||||
## Artifact Status
|
||||
|
||||
Soft artifacts should use explicit review status:
|
||||
|
||||
- `accepted`
|
||||
- `accepted_as_draft`
|
||||
- `useful_needs_revision`
|
||||
- `rejected`
|
||||
- `superseded`
|
||||
|
||||
Rejected artifacts remain valuable when the failure label is clear. A rejected
|
||||
Navigator action that makes a cetacean walk across a human hallway is not trash.
|
||||
It is a negative example with teeth.
|
||||
|
||||
## Training Corpus Families
|
||||
|
||||
Ghostlight should expect several related corpora rather than one heroic pile of
|
||||
JSONL wearing a crown.
|
||||
|
||||
- projection corpus
|
||||
- projected-local-context corpus
|
||||
- coordinator/story-runtime corpus
|
||||
- character-action corpus
|
||||
- dialogue/prose response corpus
|
||||
- participant-appraisal corpus
|
||||
- state-mutation corpus
|
||||
- relationship/perception corpus
|
||||
- lore-grounding digest corpus
|
||||
- Aetheria responder corpus
|
||||
- Ink branch annotation corpus
|
||||
- Branch compiler corpus
|
||||
- IF artifact review corpus
|
||||
- institution/faction/consumer decision corpus
|
||||
- negative-example corpus
|
||||
|
||||
Some examples can belong to multiple corpora through references. Do not duplicate
|
||||
truth; link artifacts by ids and source refs.
|
||||
|
||||
## Aetheria-Tuned Agent Model
|
||||
|
||||
The character agent itself is a training target.
|
||||
|
||||
A sensible long-term stack looks like this:
|
||||
|
||||
```text
|
||||
AetheriaLore source corpus
|
||||
-> source-grounded digests and scene fixtures
|
||||
-> coordinator scene plans and glue-prose receipts
|
||||
-> reviewed Ghostlight action/dialogue/appraisal artifacts
|
||||
-> Aetheria-tuned responder or agent model
|
||||
-> specialized coordinator/projector/appraiser/mutator students
|
||||
-> deterministic runtime gates and validators
|
||||
```
|
||||
|
||||
The Aetheria-tuned agent model should learn setting priors and voice ecology:
|
||||
what institutions feel like, how factions misread each other, what bodies can do,
|
||||
what pressure sounds like when nobody in the room has enough authority to fix the
|
||||
machine.
|
||||
|
||||
It should not replace Ghostlight state. It should not decide hidden facts. It
|
||||
should not mutate world state directly. It acts inside the local context and
|
||||
affordances Ghostlight provides.
|
||||
|
||||
## Deterministic Gates Stay Deterministic
|
||||
|
||||
Training models does not remove hard gates.
|
||||
|
||||
Keep these as code:
|
||||
|
||||
- schema validation
|
||||
- source provenance checks
|
||||
- speaker-local visibility
|
||||
- prompt leakage checks
|
||||
- action enum validation
|
||||
- body-affordance validation when source-backed
|
||||
- mutation authority
|
||||
- object custody
|
||||
- resource and world-state legality
|
||||
- Ink compilation
|
||||
- artifact review status requirements
|
||||
|
||||
A model can propose. The runtime decides what is legal, what is visible, what is
|
||||
accepted, and what becomes state.
|
||||
|
||||
## Near-Term Policy
|
||||
|
||||
During prototype work:
|
||||
|
||||
1. Keep fuzzy updates manual and reviewed.
|
||||
2. Emit every model call as a receipt when it teaches something.
|
||||
3. Store accepted and rejected examples with failure labels.
|
||||
4. Prefer narrow artifacts over one vague transcript blob.
|
||||
5. When a prompt fix reveals a reusable failure mode, promote it into validator
|
||||
logic or artifact schema.
|
||||
6. When a model repeatedly performs a soft judgment well, mark that organ as a
|
||||
candidate for distillation or fine-tuning.
|
||||
7. When it repeatedly fails in a patterned way, save the failures. The beast is
|
||||
drawing us a map in crayon.
|
||||
|
||||
The point is not to automate everything today. The point is to stop losing the
|
||||
manual intelligence we are already spending.
|
||||
|
|
@ -1,646 +0,0 @@
|
|||
# Technology And Item Manifest Plan
|
||||
|
||||
Ghostlight future exploration should produce more than scenes. It should produce
|
||||
the technical and economic knowledge Aetheria needs for gameplay: what items can
|
||||
exist, who can build them, what they are made of, which assemblies can be
|
||||
swapped, what supply chains they depend on, and which factions start with which
|
||||
tech bases.
|
||||
|
||||
This is not separate from story generation. Major-player decisions, faction
|
||||
births, branch pressures, and technological discoveries shape the economy. If a
|
||||
branch discovers a better aetheric emitter, that should eventually change ship
|
||||
builds, market dependencies, quest hooks, factory politics, salvage value, and
|
||||
what a desperate mechanic can realistically bolt into a hull without creating a
|
||||
beautiful obituary.
|
||||
|
||||
The economy is bigger than ship parts. Players moving freight should carry the
|
||||
ordinary manufactured life of colonies too: habitat fittings, clinic equipment,
|
||||
interface furniture, ritual goods, industrial consumables, food-processing
|
||||
systems, maintenance packs, education kits, luxury nonsense, and all the other
|
||||
things a settlement consumes without ever bolting them into a laser. A trade
|
||||
economy made only of gear components would be a very fancy skeleton.
|
||||
|
||||
## Source Premise
|
||||
|
||||
Aetheria game design wants ships to be tools, homes, status symbols, and
|
||||
material compromises. Customization is driven by components with distinct
|
||||
behavior, manufacturers with priorities and specialties, and vessels tuned for
|
||||
violence, trade, survival, logistics, or prestige.
|
||||
|
||||
Ghostlight should turn worldbuilding exploration into the data needed to support
|
||||
that design.
|
||||
|
||||
## Player Fantasy
|
||||
|
||||
The deep gear model is not a loot-affix treadmill. The fantasy is provenance.
|
||||
|
||||
A player hunting for the ultimate laser should be able to trace the item through
|
||||
its parts, makers, technologies, and accidents:
|
||||
|
||||
- the emitter assembly came from a short-lived manufacturer whose process was
|
||||
only stable for nine months
|
||||
- the beam-control wafer uses a regulatory-buried fabrication step no successor
|
||||
company wants to admit worked
|
||||
- the thermal rejection assembly is sturdy but mediocre, quietly strangling the
|
||||
whole weapon
|
||||
- a pirate rebuild replaced the targeting interface with a counterfeit part
|
||||
that improves lock speed while making maintenance ugly
|
||||
- a factional embargo means the good driver wafers are technically illegal,
|
||||
practically everywhere, and therefore excellent quest bait
|
||||
|
||||
An "ultimate item" is not just the best blueprint. It is the best realized
|
||||
artifact: the right blueprint, built from the right assemblies, with the right
|
||||
quality, provenance, manufacturer practice, replacement history, and operating
|
||||
conditions. If a player wants to rebuild it as the best it can be, the game
|
||||
should let them hunt the supply chain: salvage yards, old corporate stockpiles,
|
||||
specialist shops, faction arsenals, black-market refits, inherited ships,
|
||||
forgotten factories, and small wars over a beam-control wafer some dead
|
||||
manufacturer only made for nine months before the board got eaten by regulators.
|
||||
|
||||
This is why Ghostlight matters. A spreadsheet can name a laser. Ghostlight should
|
||||
help explain why that laser exists, who can build it, which parts are worth
|
||||
killing for, why a particular instance is special, and what stories fall out of
|
||||
trying to make it better.
|
||||
|
||||
## Content Refinery
|
||||
|
||||
The old bottleneck was not ideas. Ideas are cheap. Volunteer design goblins can
|
||||
produce a hill of them before lunch and leave the database work sitting there
|
||||
like a little corpse.
|
||||
|
||||
The real bottleneck is converting taste, lore, mechanics, and future-tech
|
||||
imagination into loadable, reviewable, schema-shaped content. Ghostlight's
|
||||
technology pipeline should act as a content refinery:
|
||||
|
||||
1. Ground the request in Aetheria lore: faction, era, location, tech base,
|
||||
industrial limits, political pressure, and known source constraints.
|
||||
2. Decompose the concept into item families, assemblies, subassemblies,
|
||||
components, colony-consumed manufactured goods, materials, processes,
|
||||
tooling, facilities, expertise, demand profiles, and supply-chain
|
||||
dependencies.
|
||||
3. Map each candidate onto the Aetheria-Economy blueprint model:
|
||||
`SimpleCommodityData`, `CompoundCommodityData`, `ConsumableItemData`,
|
||||
`GearData`, or another `EquippableItemData` subclass.
|
||||
4. Define performance intent through behaviors, `PerformanceStat` fields,
|
||||
quality sensitivity, failure modes, upgrade surfaces, and operating
|
||||
constraints.
|
||||
5. Define realized-instance variation: assembly provenance, manufacturer
|
||||
branding, quality, repair history, counterfeit substitutions, salvage scars,
|
||||
and ownership chain.
|
||||
6. Emit reviewable artifacts that separate source facts, inferred engineering,
|
||||
branch-local assumptions, engine-schema gaps, rejected ideas, and accepted
|
||||
candidates.
|
||||
7. Export or hand off accepted candidates as database-shaped content rather than
|
||||
another swamp of clever prose someone has to manually translate later.
|
||||
|
||||
The goal is not infinite content sludge. The goal is volume with preserved taste:
|
||||
many plausible blueprints, variants, manufacturers, counterfeit versions,
|
||||
salvage stories, and supply-chain hooks that still feel like Aetheria.
|
||||
|
||||
## Economy Schema Target
|
||||
|
||||
Ghostlight should target the existing Aetheria-Economy item model instead of
|
||||
inventing a parallel item taxonomy.
|
||||
|
||||
The concrete source is:
|
||||
|
||||
- `E:\Projects\Aetheria-Economy\Assets\Scripts\ServerShared\ItemData.cs`
|
||||
- `E:\Projects\Aetheria-Economy\Assets\Scripts\ServerShared\ItemInstance.cs`
|
||||
- `E:\Projects\Aetheria-Economy\Assets\Scripts\ServerShared\CultCache\`
|
||||
|
||||
The important split:
|
||||
|
||||
- `ItemData` and subclasses are CultCache-backed technological blueprint
|
||||
records: a way of constructing a thing in the game, including the parameters,
|
||||
behaviors, stat curves, and category fields that make that construction
|
||||
meaningful.
|
||||
- `ItemInstance` and subclasses are runtime objects that link to definition
|
||||
records and represent manufactured artifacts in the world.
|
||||
- Ghostlight item manifests should normally propose blueprint/data records, not
|
||||
individual runtime artifacts.
|
||||
- Runtime instances belong in simulation, inventory, scene state, cargo, loot,
|
||||
and world-state fixtures. They are where realized quality, supply-chain
|
||||
provenance, manufacturer branding, and assembly history can make two items
|
||||
built from the same blueprint differ in practice.
|
||||
|
||||
Blueprint classes currently relevant to Ghostlight:
|
||||
|
||||
- `SimpleCommodityData`
|
||||
- basic fungible resources such as steel, hydrogen, volatiles, or feedstock
|
||||
- maps to `SimpleCommodity` runtime instances with quantity
|
||||
- `CompoundCommodityData`
|
||||
- technological blueprints for crafted commodities, assemblies,
|
||||
subassemblies, processed materials, colony-consumed trade goods, and other
|
||||
manufactured economic units
|
||||
- not every compound commodity is an input to higher-level gear; many are
|
||||
finished manufactured goods consumed by colonies, habitats, institutions,
|
||||
ships, clinics, farms, sanctuaries, and cultural communities
|
||||
- maps to `CompoundCommodity` runtime instances whose quality and provenance
|
||||
can affect downstream items, settlement demand, service quality, comfort,
|
||||
productivity, legitimacy, maintenance load, or faction-specific living
|
||||
standards
|
||||
- `ConsumableItemData`
|
||||
- usable consumables with behaviors, duration, stackability, icon, and
|
||||
effectiveness curve
|
||||
- maps to `ConsumableItem` runtime instances
|
||||
- `GearData`
|
||||
- technological blueprints for usable equippable items with hardpoint,
|
||||
behavior, durability, thermal, audio, schematic, and action-bar fields
|
||||
- maps through `EquippableItem` runtime instances whose realized quality,
|
||||
provenance, manufacturer, and installed assembly lineage can alter actual
|
||||
performance
|
||||
- `WeaponItemData`, `HullData`, `CargoBayData`, `DockingBayData`, and other
|
||||
`EquippableItemData` subclasses are specialized top-level usable item
|
||||
definitions.
|
||||
|
||||
Manifest artifacts should therefore use Ghostlight's conceptual decomposition to
|
||||
produce reviewed candidate technological blueprints for Aetheria-Economy data
|
||||
classes:
|
||||
|
||||
```text
|
||||
material/feedstock -> SimpleCommodityData
|
||||
component/subassembly/assembly -> CompoundCommodityData
|
||||
manufactured colony trade good -> CompoundCommodityData
|
||||
consumable usable item -> ConsumableItemData
|
||||
equippable usable item -> GearData or another EquippableItemData subclass
|
||||
weapon -> WeaponItemData
|
||||
hull/frame/cargo/docking item -> HullData, CargoBayData, DockingBayData, etc.
|
||||
runtime cargo, inventory, or freight -> SimpleCommodity, CompoundCommodity,
|
||||
ConsumableItem, or EquippableItem instances
|
||||
```
|
||||
|
||||
The conceptual layers below are still useful. They describe how Ghostlight
|
||||
breaks a technology into reviewable engineering, logistics, and gameplay pieces.
|
||||
They are not a replacement database schema. The output should be able to say
|
||||
which Aetheria-Economy blueprint class each candidate wants to become, what
|
||||
fields are source-backed, what fields are inferred, and which fields require
|
||||
new engine schema before they can be loaded.
|
||||
|
||||
`PerformanceStat` is the core bridge from blueprint to realized item behavior.
|
||||
Blueprint data defines stat ranges and quality sensitivity; runtime instances
|
||||
and equipped/active contexts determine actual evaluation through quality,
|
||||
durability, heat, effectiveness, and modifiers. Ghostlight manifests should
|
||||
therefore preserve the difference between:
|
||||
|
||||
- blueprint-level performance intent
|
||||
- assembly or subassembly contribution to quality and stat behavior
|
||||
- realized instance provenance and manufacturer branding
|
||||
- in-world operating conditions that change evaluated performance
|
||||
|
||||
If a Ghostlight manifest needs fields Aetheria-Economy does not currently store,
|
||||
mark them as manifest-side metadata or source gaps. Do not pretend CultCache
|
||||
already has slots for every nice idea. The future can have organs; today it has
|
||||
classes.
|
||||
|
||||
## Nebulous Technology Elaboration
|
||||
|
||||
Aetheria already has plenty of named technologies. Many of them are currently
|
||||
high-level lore objects rather than actionable game objects. That is fine for a
|
||||
timeline note. It is not enough for an item blueprint database.
|
||||
|
||||
Ghostlight's exploration pipeline should elaborate existing vague technologies
|
||||
into blueprint candidates.
|
||||
|
||||
For each nebulous technology, produce:
|
||||
|
||||
- source summary
|
||||
- inferred operating principle
|
||||
- gameplay role
|
||||
- item families enabled
|
||||
- assemblies and subassemblies implied
|
||||
- critical components
|
||||
- materials
|
||||
- manufacturing processes
|
||||
- tooling and facility requirements
|
||||
- required expertise
|
||||
- quality tiers
|
||||
- failure modes
|
||||
- maintenance needs
|
||||
- salvage value
|
||||
- counterfeit risk
|
||||
- substitute parts
|
||||
- faction access and restrictions
|
||||
- supply-chain bottlenecks
|
||||
- prerequisite technologies
|
||||
- downstream technologies enabled
|
||||
- branch-local variants
|
||||
- open lore questions
|
||||
|
||||
This is where a phrase like `super-Planckian emitter` stops being a decorative
|
||||
sci-fi noun and becomes a logistics problem with teeth.
|
||||
|
||||
If Ghostlight cannot yet know the exact real physics, it should still propose a
|
||||
consistent fictional engineering stack: what must be fabricated, stabilized,
|
||||
aligned, cooled, calibrated, certified, transported, repaired, and monopolized.
|
||||
The output should be reviewable, revisable, and eventually loadable into a game
|
||||
database.
|
||||
|
||||
Do not hallucinate certainty. Mark inferred engineering as inferred. Mark source
|
||||
facts as source facts. Mark branch-local innovations as branch-local. The
|
||||
manifest is allowed to deepen the world; it is not allowed to pretend an
|
||||
invention arrived without provenance.
|
||||
|
||||
## Item Knowledge Goals
|
||||
|
||||
Every explored technology or item family should be able to answer:
|
||||
|
||||
- what the item does
|
||||
- what gameplay role it serves
|
||||
- what systems it plugs into
|
||||
- what components and subassemblies it requires
|
||||
- which assemblies are swappable upgrade surfaces
|
||||
- whether it is a finished colony-consumed trade good rather than an assembly
|
||||
input
|
||||
- what population, institution, habitat, ship, or faction demand profile
|
||||
consumes it
|
||||
- what service, comfort, legitimacy, safety, productivity, cultural continuity,
|
||||
or maintenance need it satisfies
|
||||
- which materials, processes, or specialists are bottlenecks
|
||||
- which factions or manufacturers can build it
|
||||
- which factions can maintain it but not manufacture it
|
||||
- which factions can counterfeit, salvage, or adapt it
|
||||
- which tech prerequisites unlock it
|
||||
- which branch conditions or discoveries created it
|
||||
- which quests, markets, or conflicts it enables
|
||||
- which blueprint records it should create or update
|
||||
|
||||
## Manifest Object Types
|
||||
|
||||
Use these object layers when producing tech data. Each layer must also declare
|
||||
its intended Aetheria-Economy target class or state that it is manifest metadata
|
||||
only:
|
||||
|
||||
- `item_family`
|
||||
- `item_variant`
|
||||
- `assembly`
|
||||
- `subassembly`
|
||||
- `component`
|
||||
- `material`
|
||||
- `process`
|
||||
- `tooling`
|
||||
- `expertise`
|
||||
- `facility`
|
||||
- `manufacturer`
|
||||
- `supply_node`
|
||||
- `upgrade_slot`
|
||||
- `blueprint`
|
||||
- `tech_prerequisite`
|
||||
- `quality_tier`
|
||||
- `failure_mode`
|
||||
|
||||
An `item_family` is a broad class, such as laser weapon, radiator, aetheric
|
||||
drive, cryo valve, sensor mast, or habitat seal.
|
||||
|
||||
An `item_variant` is a concrete build with stats, faction/manufacturer flavor,
|
||||
quality tier, and branch-specific assumptions.
|
||||
|
||||
An `assembly` is a replaceable functional unit. For a laser, emitter assembly,
|
||||
power conditioning assembly, thermal rejection assembly, beam-control assembly,
|
||||
targeting interface, and housing are likely assemblies.
|
||||
|
||||
A `subassembly` is a nested unit inside an assembly. The emitter assembly may
|
||||
include gain medium, field lattice, focusing microstructure, alignment actuator,
|
||||
control wafer, and contamination seal.
|
||||
|
||||
A `component` is the smallest useful gameplay/logistics unit worth tracking.
|
||||
Do not split screws unless screws are the point of the quest. The machine has
|
||||
standards. Barely.
|
||||
|
||||
A `manufactured_trade_good` is a finished compound commodity whose primary role
|
||||
is economic consumption rather than becoming a higher-level gear assembly. It
|
||||
may still have components and quality tiers, but the point is that colonies,
|
||||
ships, habitats, clinics, sanctuaries, temples, schools, or faction enclaves
|
||||
consume it as part of ordinary life and institutional function.
|
||||
|
||||
Example: a Navigator wet-interface cradle would likely be a
|
||||
`CompoundCommodityData` candidate. It is not a ship weapon or a laser component.
|
||||
It is a manufactured interface good consumed by Navigator habitats, route
|
||||
annexes, mixed-species relay stations, sanctuary registrars, and other spaces
|
||||
where cetacean or aquatic-adapted cognition must participate in shared
|
||||
procedure. Its demand profile might be driven by Navigator population share,
|
||||
route-steward institutions, translation infrastructure, acoustic signaling
|
||||
standards, maintenance water quality, clinic/sanctuary access, and local
|
||||
prestige around dignified embodiment. Quality might affect comfort, signal
|
||||
clarity, translation latency, stress load, infection risk, repair frequency, or
|
||||
institutional legitimacy.
|
||||
|
||||
A `blueprint` is the technological construction record represented by the
|
||||
appropriate Aetheria-Economy `*Data` class. It should be specific enough for a
|
||||
game system to evaluate whether a faction, factory, ship, or player can build
|
||||
the item, and how sub-assemblies affect performance through quality-sensitive
|
||||
stats and behavior parameters.
|
||||
|
||||
Ghostlight manifests still need explicit assembly trees, compatibility rules,
|
||||
supply-chain dependencies, and manufacturing gates. If the current engine schema
|
||||
does not expose those fields directly, keep them as manifest metadata or
|
||||
engine-schema gaps attached to the target `*Data` blueprint rather than
|
||||
pretending they are separate from the blueprint concept.
|
||||
|
||||
A `tech_prerequisite` is a required discovery, process, standard, license,
|
||||
facility, or expertise gate.
|
||||
|
||||
A `quality_tier` describes meaningful gameplay variation: crude, standard,
|
||||
premium, military, experimental, branch-exotic, counterfeit, degraded, salvaged,
|
||||
or other reviewed tiers.
|
||||
|
||||
A `failure_mode` describes what breaks, how it breaks, and what that failure
|
||||
costs in gameplay, safety, logistics, reputation, or maintenance.
|
||||
|
||||
## Example Pattern: Laser Upgrade Surface
|
||||
|
||||
A laser design should be represented so a higher-tech emitter assembly can alter
|
||||
performance without rewriting the whole item.
|
||||
|
||||
Example structure:
|
||||
|
||||
```text
|
||||
item_family: laser_weapon
|
||||
item_variant: sol_pattern_industrial_laser_mk2
|
||||
assembly: emitter_assembly
|
||||
subassembly: gain_medium
|
||||
subassembly: focusing_lattice
|
||||
subassembly: alignment_actuator
|
||||
assembly: power_conditioning_assembly
|
||||
assembly: thermal_rejection_assembly
|
||||
assembly: beam_control_assembly
|
||||
assembly: targeting_interface
|
||||
assembly: housing_and_mount
|
||||
```
|
||||
|
||||
A branch-local future might discover an aether-stabilized emitter assembly. That
|
||||
should produce:
|
||||
|
||||
- a new assembly or subassembly record
|
||||
- prerequisite discoveries
|
||||
- materials and tooling
|
||||
- compatibility rules with older laser families
|
||||
- performance changes
|
||||
- heat, stability, cost, legality, maintenance, or supply-chain consequences
|
||||
- factions able to manufacture or adapt it
|
||||
- quests or market events unlocked by scarcity
|
||||
|
||||
## Example Pattern: Super-Planckian Emitter
|
||||
|
||||
When a source names a technology like `super-Planckian emitter`, Ghostlight
|
||||
should elaborate it into at least a candidate blueprint stack.
|
||||
|
||||
Example working decomposition:
|
||||
|
||||
```text
|
||||
item_family: exotic_emitter
|
||||
item_variant: super_planckian_emitter_core_candidate
|
||||
assembly: emitter_lattice_assembly
|
||||
subassembly: boundary_condition_lattice
|
||||
subassembly: phase-lock microstructure
|
||||
subassembly: exotic containment frame
|
||||
assembly: field_drive_assembly
|
||||
subassembly: pulse shaper
|
||||
subassembly: high-stability driver wafer
|
||||
subassembly: feedback quench circuit
|
||||
assembly: thermal_and_causality_safety_assembly
|
||||
subassembly: transient heat sink
|
||||
subassembly: event-spike monitor
|
||||
subassembly: failsafe isolation gate
|
||||
assembly: calibration_and_control_assembly
|
||||
subassembly: metrology package
|
||||
subassembly: firmware governor
|
||||
subassembly: certification seal
|
||||
```
|
||||
|
||||
That decomposition is not automatically canon. It is a candidate manifest. A
|
||||
reviewer can accept, reject, revise, or mark source gaps. The important point is
|
||||
that the technology now has places for supply chains to attach:
|
||||
|
||||
- lattice fabrication
|
||||
- exotic containment materials
|
||||
- high-stability driver wafers
|
||||
- metrology packages
|
||||
- safety gates
|
||||
- certification regimes
|
||||
- specialist calibration expertise
|
||||
- heat rejection and failure containment
|
||||
|
||||
Once those exist, the game can ask useful questions:
|
||||
|
||||
- who owns lattice fabrication?
|
||||
- who can counterfeit the driver wafer?
|
||||
- what happens if the safety gate is downgraded?
|
||||
- can a higher-tier emitter assembly fit into an older laser housing?
|
||||
- which faction can maintain it after three jumps and one bad decision?
|
||||
- what quest appears when the certification seal supply collapses?
|
||||
|
||||
## Pre-Elysium Starting Tech
|
||||
|
||||
Pre-Elysium tech is not a monolith. Everyone was shunted into Elysium together,
|
||||
but not every faction brought the same industrial base, doctrine, standards,
|
||||
stockpiles, expertise, or manufacturing rights.
|
||||
|
||||
Ghostlight should generate starting tech manifests for the single-history Sol
|
||||
artifact:
|
||||
|
||||
- base technologies available before the Rupture
|
||||
- faction access and restrictions
|
||||
- manufacturing standards
|
||||
- dominant manufacturers
|
||||
- known substitute parts
|
||||
- logistics bottlenecks
|
||||
- maintenance dependencies
|
||||
- doctrine-driven design differences
|
||||
- legal restrictions and export controls
|
||||
- black-market or pirate adaptations
|
||||
- blueprint records for starting equipment, ship systems, weapons, habitats,
|
||||
medical systems, industrial tooling, logistics infrastructure, colony
|
||||
furnishings, habitat equipment, institutional interface goods, and finished
|
||||
manufactured trade goods consumed by settlements
|
||||
|
||||
This matters because Elysium branches inherit different practical capabilities
|
||||
from the same shared Sol history depending on what each faction controls in that
|
||||
branch.
|
||||
|
||||
## Post-Rupture Innovation
|
||||
|
||||
Post-Rupture innovation is branch-local canon indexed by lineage.
|
||||
|
||||
When exploration discovers a technology, the manifest should record:
|
||||
|
||||
- branch id and lineage
|
||||
- prior conditions
|
||||
- triggering event or pressure
|
||||
- involved factions, inventors, institutions, or nonhuman actors
|
||||
- source-backed post-Elysium concepts used
|
||||
- generated branch assumptions
|
||||
- prerequisite tech and discoveries
|
||||
- item families affected
|
||||
- assemblies or subassemblies created
|
||||
- supply chain changes
|
||||
- market/faction consequences
|
||||
- quest hooks opened
|
||||
- sibling branches where this innovation does not exist or differs
|
||||
- blueprint records created or modified
|
||||
- compatibility with pre-Elysium assemblies
|
||||
|
||||
Aetheric drives, necrotech interfaces, spirit-mediated sensors, temporal memory
|
||||
archives, pseudospace logistics, and mutable-body support equipment should all
|
||||
leave this kind of trail when explored.
|
||||
|
||||
## Faction Tech Bases
|
||||
|
||||
For each faction or manufacturer, track:
|
||||
|
||||
- known technologies
|
||||
- restricted technologies
|
||||
- lost or inaccessible technologies
|
||||
- signature design preferences
|
||||
- manufacturing strengths
|
||||
- maintenance weaknesses
|
||||
- doctrine-driven compromises
|
||||
- supply dependencies
|
||||
- export controls
|
||||
- counterfeiting exposure
|
||||
- salvage behavior
|
||||
- branch-local innovations
|
||||
|
||||
A faction can understand a technology without being able to build it. It can
|
||||
install a component without being able to maintain it. It can maintain an item
|
||||
only by depending on a hated supplier. These distinctions are the gameplay.
|
||||
|
||||
## Coordinator Obligations
|
||||
|
||||
The coordinator/story runtime must emit technology and economy knowledge when a
|
||||
scene or branch decision changes the material world.
|
||||
|
||||
Coordinator artifacts should include item-manifest deltas when they discover or
|
||||
alter:
|
||||
|
||||
- technology availability
|
||||
- component compatibility
|
||||
- assembly upgrade paths
|
||||
- colony-consumed manufactured goods
|
||||
- demand profiles
|
||||
- materials and manufacturing processes
|
||||
- supply bottlenecks
|
||||
- faction access
|
||||
- market pressure
|
||||
- quest hooks
|
||||
- salvage opportunities
|
||||
- maintenance constraints
|
||||
|
||||
The coordinator is allowed to propose item knowledge. Deterministic validators
|
||||
and reviewer gates decide whether it becomes a manifest update.
|
||||
|
||||
## Training Artifacts
|
||||
|
||||
Tech/item exploration should emit reviewed artifacts for future models:
|
||||
|
||||
- item manifest records
|
||||
- component breakdown records
|
||||
- assembly compatibility records
|
||||
- colony trade-good records
|
||||
- demand-profile records
|
||||
- faction tech-base records
|
||||
- supply-chain dependency records
|
||||
- technology discovery records
|
||||
- branch-local innovation records
|
||||
- economic consequence records
|
||||
- rejected overpowered or unsupported item designs
|
||||
- source-gap records requiring AetheriaLore elaboration
|
||||
|
||||
These artifacts train:
|
||||
|
||||
- coordinator/story runtime
|
||||
- institution/faction decision models
|
||||
- consumer and market behavior models
|
||||
- item-generation models
|
||||
- lore/source evaluators
|
||||
- retrieval models for tech prerequisites and dependencies
|
||||
|
||||
## First Schema Target
|
||||
|
||||
The next concrete schema after coordinator artifacts should be a manifest seam
|
||||
that maps directly onto Aetheria-Economy's technological blueprint classes:
|
||||
|
||||
- `schemas/item-manifest.schema.json`
|
||||
- `schemas/item-blueprint-candidate.schema.json`
|
||||
- `schemas/item-instance-provenance-candidate.schema.json`
|
||||
- `examples/item-manifest/pre-elysium-starting-tech.template.json`
|
||||
- `examples/item-manifest/future-branch-innovation.template.json`
|
||||
- `examples/item-manifest/super-planckian-emitter.candidate.json`
|
||||
- `tools/validate_item_manifests.py`
|
||||
|
||||
Minimum fields:
|
||||
|
||||
- manifest id
|
||||
- fixture lane
|
||||
- branch lineage, if any
|
||||
- source refs
|
||||
- source-backed facts
|
||||
- inferred engineering
|
||||
- open lore gaps
|
||||
- item family or technology id
|
||||
- object layer
|
||||
- consumption role: assembly input, finished trade good, consumable, gear, or
|
||||
metadata-only
|
||||
- target Aetheria-Economy class
|
||||
- target blueprint id, when updating an existing record
|
||||
- candidate blueprint fields
|
||||
- blueprint candidate ids
|
||||
- instance provenance fields, when simulating manufactured artifacts
|
||||
- parent assembly refs
|
||||
- child component refs
|
||||
- materials/process/tooling/facility refs
|
||||
- colony, habitat, institutional, cultural, or factional consumption drivers
|
||||
- faction/manufacturer access
|
||||
- prerequisites
|
||||
- bottlenecks
|
||||
- compatibility rules
|
||||
- quality tiers
|
||||
- failure modes
|
||||
- gameplay effects
|
||||
- economic consequences
|
||||
- quest hooks
|
||||
- engine-schema gaps
|
||||
- review status
|
||||
|
||||
Candidate blueprint fields should be shaped around existing data classes first:
|
||||
|
||||
- shared `ItemData` blueprint fields: `Name`, `Description`, `Manufacturer`,
|
||||
`Mass`, `Shape`,
|
||||
`SpecificHeat`, `Conductivity`, `Price`
|
||||
- `SimpleCommodityData`: `MaxStack`, `Category`
|
||||
- `CompoundCommodityData`: `DemandProfile`, `Category`
|
||||
- `ConsumableItemData`: `Behaviors`, `Stackable`, `Duration`, `Icon`,
|
||||
`Effectiveness`
|
||||
- `EquippableItemData` and subclasses: `Schematic`, `Behaviors`, `Durability`,
|
||||
temperature fields, heat performance curve, resilience, icons, sound data,
|
||||
`HardpointType`, and subclass-specific fields such as weapon range, caliber,
|
||||
fire type, hull hardpoints, cargo interiors, or docking size
|
||||
|
||||
Ghostlight may propose additional manifest fields, but validators should label
|
||||
them as metadata, engine-gap, or rejected. Do not silently smuggle non-engine
|
||||
fields into supposed CultCache records.
|
||||
|
||||
## Data Generation Policy
|
||||
|
||||
During worldbuilding exploration:
|
||||
|
||||
1. If a scene implies a new item, component, supply bottleneck, or technology
|
||||
dependency, emit a manifest candidate.
|
||||
2. If a scene implies a manufactured good that a colony, habitat, ship,
|
||||
institution, species community, or faction consumes, emit a
|
||||
`CompoundCommodityData` candidate with a demand profile, even if it is not an
|
||||
assembly input for gear.
|
||||
3. If a faction decision changes what can be manufactured, maintained, bought,
|
||||
salvaged, counterfeited, or upgraded, emit a faction tech-base delta.
|
||||
4. If an innovation appears in Elysium, label its branch lineage and prior
|
||||
conditions.
|
||||
5. If existing lore names a technology but lacks blueprint detail, elaborate it
|
||||
into a candidate manifest with explicit inferred fields and source-gap notes.
|
||||
6. If the item relies on unsupported lore, patch AetheriaLore or mark a source
|
||||
gap before treating it as stable.
|
||||
7. If an item is cool but breaks logistics, keep it as a rejected artifact with
|
||||
the failure label. Beautiful nonsense is still training data if tagged before
|
||||
it bites someone.
|
||||
|
||||
Ghostlight should discover the future by exploring it, but every discovery
|
||||
should leave inventory. The game cannot build a supply chain out of vibes and
|
||||
one haunted laser.
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,151 +0,0 @@
|
|||
# VoidBot Routine Adoption Plan
|
||||
|
||||
VoidBot now has the best live reference for the agent routine Ghostlight needs:
|
||||
work or rumination on heartbeat, periodic sleep for consolidation, typed
|
||||
MessagePack persistence through a CultCache-style contract, and vector-backed
|
||||
memory resonance. Ghostlight should adopt the routine without inheriting
|
||||
VoidBot's moderation job.
|
||||
|
||||
## Imported Organs
|
||||
|
||||
- **Heartbeat scheduler:** chooses work, rumination, or sleep/consolidation from
|
||||
readiness, load, status, reaction windows, and pending turns.
|
||||
- **Completion-gated cooldown:** a participant cannot act again until its
|
||||
previous heartbeat turn finishes; recovery starts at completion.
|
||||
- **Sleep cycle:** scheduled naps are not absence. They compress memory,
|
||||
strengthen useful associations, cool saturated themes, refresh dreams, and
|
||||
keep direct speech rare.
|
||||
- **CultCache persistence:** canonical state is MessagePack, routed by typed
|
||||
document definitions, with JSON working projections for inspection/review.
|
||||
- **Vector memory:** memories carry semantic vector metadata and Qdrant points
|
||||
so recall, resonance, incubation, and dreams are grounded in retrievable
|
||||
structure.
|
||||
- **Thought organs:** analytic lane, associative lane, bridge, memory resonance,
|
||||
incubation, and candidate intervention/dream queues.
|
||||
|
||||
## Ghostlight Translation
|
||||
|
||||
Ghostlight's swarm is the cast. The heartbeat initiative system therefore
|
||||
applies directly to characters taking turns within a scene, not merely to
|
||||
background maintenance workers.
|
||||
|
||||
There are two scopes using the same timing law:
|
||||
|
||||
- **scene heartbeat:** selects which in-scene character acts, reacts, waits,
|
||||
hesitates, refuses, speaks, withholds, moves, or uses an object.
|
||||
- **maintenance heartbeat:** wakes offstage agents, cast records, coordinator
|
||||
organs, appraisers, mutators, and fixture builders for rumination, sleep
|
||||
consolidation, or reviewed maintenance.
|
||||
|
||||
The point is one initiative physiology with different arenas, not two unrelated
|
||||
schedulers wearing each other's hat.
|
||||
|
||||
```text
|
||||
heartbeat initiative
|
||||
-> choose scene participant or maintenance organ
|
||||
-> scene turn | reaction | wait | rumination | sleep
|
||||
-> read typed state from CultCache-compatible store
|
||||
-> optional Qdrant recall / memory resonance
|
||||
-> produce visible action, event, appraisal, mutation, dream, or maintenance receipt
|
||||
-> completion starts cooldown
|
||||
-> write canonical MessagePack + JSON working projection
|
||||
```
|
||||
|
||||
## Persistence Shape
|
||||
|
||||
Ghostlight can start with the CultCacheTS contract directly for Node-side tools,
|
||||
but the Python-heavy validator/tooling surface needs a compatible file contract:
|
||||
|
||||
- canonical store: single-file MessagePack envelope array
|
||||
- envelope: `key`, `type`, `payload`, `storedAt`
|
||||
- schema owner: code definitions, not hand-edited loose JSON
|
||||
- working projection: JSON, human-readable, safe for review, optionally stripped
|
||||
of bulky vector values
|
||||
- write discipline: use an external lock or one coordinator writer; the
|
||||
single-file store is not a database wearing a fake mustache
|
||||
|
||||
Initial document types:
|
||||
|
||||
- `ghostlight.agent_state`
|
||||
- `ghostlight.scene_state`
|
||||
- `ghostlight.initiative_schedule`
|
||||
- `ghostlight.scene_heartbeat_state`
|
||||
- `ghostlight.training_loop_bundle`
|
||||
- `ghostlight.runtime_heartbeat_state`
|
||||
- `ghostlight.memory_resonance`
|
||||
- `ghostlight.sleep_cycle`
|
||||
|
||||
## Memory Vector Shape
|
||||
|
||||
Qdrant becomes the retrieval layer for agent memory, not canonical social truth.
|
||||
|
||||
Each memory-like record should keep:
|
||||
|
||||
- stable memory id
|
||||
- kind: episodic, semantic, relationship, goal, value, musing, dream,
|
||||
scene-memory, appraisal, mutation, or lore-grounding
|
||||
- summary text used for embedding
|
||||
- source hash
|
||||
- embedding backend/model/dimensions
|
||||
- Qdrant point id and collection
|
||||
- salience/confidence/update time
|
||||
- source refs into fixtures, lore, or reviewed artifacts
|
||||
|
||||
Ghostlight's existing training docs already point toward retrieval-backed memory.
|
||||
This plan makes it operational instead of aspirational, the traditional route
|
||||
by which a research note stops being furniture.
|
||||
|
||||
## Sleep Behavior
|
||||
|
||||
During sleep turns:
|
||||
|
||||
- do not generate ordinary scene turns unless the character is actually in the
|
||||
scene and selected by the scene heartbeat
|
||||
- inspect memory resonance clusters and incubation entries
|
||||
- merge duplicate memories
|
||||
- prune stale or low-confidence residue
|
||||
- create or refresh at least one dream only when it compresses a useful seam
|
||||
- update `sleep_cycle.activeDreamThemes`, `lastDreamAt`, and
|
||||
`lastDistillationSummary`
|
||||
- write a maintenance receipt for review
|
||||
|
||||
Dreams are compressed operational memory, not glitter. If a dream survives, the
|
||||
next waking turn should be more coherent because of it.
|
||||
|
||||
## Migration Slices
|
||||
|
||||
1. **Contract fixture**
|
||||
- Add a small CultCache-compatible MessagePack fixture and validator.
|
||||
- Prove Python can read/write the envelope without losing schema shape.
|
||||
|
||||
2. **Runtime heartbeat schema**
|
||||
- Add `schemas/runtime-heartbeat-state.schema.json`.
|
||||
- Include pending turn, sleep cycle, rumination, and completion receipts.
|
||||
|
||||
3. **Agent state migration**
|
||||
- Keep current JSON examples as projections.
|
||||
- Add canonical MessagePack test store for one small agent-state fixture.
|
||||
|
||||
4. **Memory resonance**
|
||||
- Add a local memory-organ script that embeds memory summaries, writes Qdrant
|
||||
points, builds resonance edges/clusters, and writes incubation state.
|
||||
|
||||
5. **Sleep receipt**
|
||||
- Add a sleep/consolidation artifact schema and validator.
|
||||
- Require sleep receipts to name pruned/merged/strengthened memories.
|
||||
|
||||
6. **Training-loop integration**
|
||||
- Promote scene initiative schedules into the same heartbeat contract used
|
||||
by the swarm.
|
||||
- Let characters take scene turns through heartbeat initiative, then route
|
||||
appraisal/mutation/coordinator maintenance through the same completion and
|
||||
cooldown law.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Do not make Qdrant canonical truth.
|
||||
- Do not store raw vector blobs in every human working projection.
|
||||
- Do not let sleep turns mutate accepted training fixtures without reviewed
|
||||
maintenance receipts.
|
||||
- Do not make every Ghostlight character into a background daemon before the
|
||||
runtime heartbeat store can prove who is awake, asleep, pending, or complete.
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,258 +0,0 @@
|
|||
{
|
||||
"schema_version": "ghostlight.coordinator_artifact.v0",
|
||||
"artifact_id": "lucent-hostage-feed-v0.turn-06.coordinator",
|
||||
"review_status": "accepted_as_draft",
|
||||
"fixture_lane": "historical_grounded",
|
||||
"canon_status": "single_history",
|
||||
"source_refs": [
|
||||
{
|
||||
"ref_id": "lucent-scene-digest",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json",
|
||||
"purpose": "Grounding facts for Lucent hostage-feed scene."
|
||||
},
|
||||
{
|
||||
"ref_id": "lucent-initial-state",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
|
||||
"purpose": "Scene-local state seed and participant briefs."
|
||||
}
|
||||
],
|
||||
"input_refs": {
|
||||
"agent_state_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
|
||||
"lore_grounding_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json"
|
||||
],
|
||||
"prior_artifact_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json"
|
||||
],
|
||||
"projected_context_refs": [
|
||||
"examples/projected-contexts/lucent-hostage-feed-v0.turn-06.sol-vant.projected-context.json"
|
||||
]
|
||||
},
|
||||
"scene_context": {
|
||||
"scene_id": "lucent-hostage-feed-v0.transfer-atrium-release-loop",
|
||||
"sequence_marker": "turn-06",
|
||||
"location_id": "lucent.media-eye.transfer-atrium",
|
||||
"participant_ids": [
|
||||
"sol_vant",
|
||||
"juno_ark",
|
||||
"mara_quell",
|
||||
"feed_ops_chorus",
|
||||
"brant_vee",
|
||||
"pippa_lux"
|
||||
],
|
||||
"active_object_ids": [
|
||||
"edit-trail-hold",
|
||||
"custody-graphic-suspended",
|
||||
"elevator-gate",
|
||||
"bench-edge",
|
||||
"crisis-feed"
|
||||
],
|
||||
"active_resource_ids": [
|
||||
"hostage-safety",
|
||||
"feed-integrity",
|
||||
"sponsor-risk",
|
||||
"security-pressure"
|
||||
],
|
||||
"public_facts": [
|
||||
{
|
||||
"text": "The live feed and room are mutually shaping the negotiation.",
|
||||
"refs": [
|
||||
"lucent-scene-digest"
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": "The edit-trail hold is the active evidence object.",
|
||||
"refs": [
|
||||
"lucent-loop-state"
|
||||
]
|
||||
}
|
||||
],
|
||||
"scene_pressure": [
|
||||
{
|
||||
"text": "Each turn must preserve safety while resisting brand-safe replacement of the event.",
|
||||
"refs": [
|
||||
"lucent-initial-state"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"coordinator_decision": {
|
||||
"decision_id": "lucent-hostage-feed-v0.turn-06.coordinator.decision",
|
||||
"selected_next_beat": "Sol Vant responds to the updated safety/evidence geometry.",
|
||||
"selected_acting_agent_id": "sol_vant",
|
||||
"candidate_next_beats": [
|
||||
{
|
||||
"choice_id": "turn-06.selected",
|
||||
"summary": "Project Sol Vant from current state and run one packet-only responder turn.",
|
||||
"status": "selected"
|
||||
}
|
||||
],
|
||||
"rationale": "Selected to test sequential state carryover, local knowledge, physical affordances, feed affordances, and de-escalation without omniscient control.",
|
||||
"glue_prose": "The feed holds its ugly shape long enough for Sol Vant to decide whether the next inch belongs to safety, spectacle, or panic.",
|
||||
"glue_prose_status": "accepted_as_narration"
|
||||
},
|
||||
"machinery_plan": {
|
||||
"invocations": [
|
||||
{
|
||||
"invocation_id": "turn-06.projector",
|
||||
"organ": "projector",
|
||||
"purpose": "Build local projected context for acting character.",
|
||||
"input_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/projected-contexts/lucent-hostage-feed-v0.turn-06.sol-vant.projected-context.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-06.responder",
|
||||
"organ": "sandboxed_responder",
|
||||
"purpose": "Generate one character-local action from exact packet only.",
|
||||
"input_refs": [
|
||||
"examples/responder-packets/lucent-hostage-feed-v0.turn-06.sol-vant.packet.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"experiments/responder-packets/lucent-hostage-feed-v0.turn-06.sol-vant.packet.capture.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-06.event",
|
||||
"organ": "event_resolver",
|
||||
"purpose": "Convert accepted responder action into observable event.",
|
||||
"input_refs": [
|
||||
"experiments/responder-packets/lucent-hostage-feed-v0.turn-06.sol-vant.packet.capture.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.event.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-06.appraisal",
|
||||
"organ": "participant_appraiser",
|
||||
"purpose": "Create affected participant-local appraisals from current state.",
|
||||
"input_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.event.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.appraisal.juno_ark.json",
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.appraisal.mara_quell.json",
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.appraisal.feed_ops_chorus.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-06.mutation",
|
||||
"organ": "state_mutator",
|
||||
"purpose": "Apply only reviewed state deltas.",
|
||||
"input_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.event.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.mutation.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sandboxed_responder_handoffs": [
|
||||
{
|
||||
"handoff_id": "turn-06.handoff",
|
||||
"responder_agent_id": "sol_vant",
|
||||
"projected_context_ref": "examples/projected-contexts/lucent-hostage-feed-v0.turn-06.sol-vant.projected-context.json",
|
||||
"visible_input_ref": "examples/responder-packets/lucent-hostage-feed-v0.turn-06.sol-vant.packet.json",
|
||||
"hidden_context_refs": [
|
||||
"other participants private state",
|
||||
"coordinator future plans",
|
||||
"conversation history"
|
||||
],
|
||||
"isolation_method": "subagent_no_fork",
|
||||
"raw_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-06.sol-vant.packet.capture.json",
|
||||
"reviewed_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-06.sol-vant.packet.capture.json",
|
||||
"coordinator_interventions": [
|
||||
{
|
||||
"intervention_type": "none",
|
||||
"description": "Raw subagent output parsed as JSON without repair."
|
||||
}
|
||||
],
|
||||
"leakage_audit": {
|
||||
"private_state_leak": false,
|
||||
"author_only_leak": false,
|
||||
"future_branch_leak": false,
|
||||
"raw_state_soup": false,
|
||||
"prompt_parroting": false,
|
||||
"body_or_object_omniscience": false,
|
||||
"notes": [
|
||||
"No hidden context markers or raw state variables appeared in the accepted output."
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"world_state_refs": {
|
||||
"read_refs": [
|
||||
{
|
||||
"ref_id": "prior-state",
|
||||
"ref_type": "world_fact",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
|
||||
"visibility": "coordinator_only"
|
||||
},
|
||||
{
|
||||
"ref_id": "projected-context",
|
||||
"ref_type": "agent",
|
||||
"path": "examples/projected-contexts/lucent-hostage-feed-v0.turn-06.sol-vant.projected-context.json",
|
||||
"visibility": "character_local"
|
||||
}
|
||||
],
|
||||
"write_candidate_refs": [
|
||||
{
|
||||
"ref_id": "event",
|
||||
"ref_type": "event",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.event.json",
|
||||
"visibility": "public"
|
||||
},
|
||||
{
|
||||
"ref_id": "mutation",
|
||||
"ref_type": "world_fact",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.mutation.json",
|
||||
"visibility": "coordinator_only"
|
||||
}
|
||||
]
|
||||
},
|
||||
"proposed_deltas": [
|
||||
{
|
||||
"delta_id": "turn-06.delta.receipt-chain",
|
||||
"delta_type": "world_fact_update",
|
||||
"target_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.mutation.json",
|
||||
"summary": "Use reviewed mutation receipt as source for next turn state.",
|
||||
"authority": "manual_review",
|
||||
"status": "accepted",
|
||||
"rationale": "This is the explicit carryover tested by the loop."
|
||||
}
|
||||
],
|
||||
"branching": {
|
||||
"branch_action": "none",
|
||||
"active_branch_flags": [
|
||||
"training-loop-linear-release"
|
||||
],
|
||||
"event_constraints": []
|
||||
},
|
||||
"unresolved_hooks": [
|
||||
{
|
||||
"hook_id": "turn-06.hook.aftercare",
|
||||
"hook_type": "story",
|
||||
"summary": "Carry unresolved legal/feed/security aftermath without extending the hostage loop indefinitely.",
|
||||
"owner": "Ghostlight",
|
||||
"status": "carried"
|
||||
}
|
||||
],
|
||||
"review": {
|
||||
"reviewer": "Codex coordinator",
|
||||
"review_notes": [
|
||||
"Coordinator artifact is training-shaped and paired with exact packet/responder receipts."
|
||||
],
|
||||
"accepted_for_training": true,
|
||||
"failure_labels": []
|
||||
}
|
||||
}
|
||||
|
|
@ -1,258 +0,0 @@
|
|||
{
|
||||
"schema_version": "ghostlight.coordinator_artifact.v0",
|
||||
"artifact_id": "lucent-hostage-feed-v0.turn-07.coordinator",
|
||||
"review_status": "accepted_as_draft",
|
||||
"fixture_lane": "historical_grounded",
|
||||
"canon_status": "single_history",
|
||||
"source_refs": [
|
||||
{
|
||||
"ref_id": "lucent-scene-digest",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json",
|
||||
"purpose": "Grounding facts for Lucent hostage-feed scene."
|
||||
},
|
||||
{
|
||||
"ref_id": "lucent-initial-state",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
|
||||
"purpose": "Scene-local state seed and participant briefs."
|
||||
}
|
||||
],
|
||||
"input_refs": {
|
||||
"agent_state_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
|
||||
"lore_grounding_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json"
|
||||
],
|
||||
"prior_artifact_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.mutation.json"
|
||||
],
|
||||
"projected_context_refs": [
|
||||
"examples/projected-contexts/lucent-hostage-feed-v0.turn-07.mara-quell.projected-context.json"
|
||||
]
|
||||
},
|
||||
"scene_context": {
|
||||
"scene_id": "lucent-hostage-feed-v0.transfer-atrium-release-loop",
|
||||
"sequence_marker": "turn-07",
|
||||
"location_id": "lucent.media-eye.transfer-atrium",
|
||||
"participant_ids": [
|
||||
"sol_vant",
|
||||
"juno_ark",
|
||||
"mara_quell",
|
||||
"feed_ops_chorus",
|
||||
"brant_vee",
|
||||
"pippa_lux"
|
||||
],
|
||||
"active_object_ids": [
|
||||
"edit-trail-hold",
|
||||
"custody-graphic-suspended",
|
||||
"elevator-gate",
|
||||
"bench-edge",
|
||||
"crisis-feed"
|
||||
],
|
||||
"active_resource_ids": [
|
||||
"hostage-safety",
|
||||
"feed-integrity",
|
||||
"sponsor-risk",
|
||||
"security-pressure"
|
||||
],
|
||||
"public_facts": [
|
||||
{
|
||||
"text": "The live feed and room are mutually shaping the negotiation.",
|
||||
"refs": [
|
||||
"lucent-scene-digest"
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": "The edit-trail hold is the active evidence object.",
|
||||
"refs": [
|
||||
"lucent-loop-state"
|
||||
]
|
||||
}
|
||||
],
|
||||
"scene_pressure": [
|
||||
{
|
||||
"text": "Each turn must preserve safety while resisting brand-safe replacement of the event.",
|
||||
"refs": [
|
||||
"lucent-initial-state"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"coordinator_decision": {
|
||||
"decision_id": "lucent-hostage-feed-v0.turn-07.coordinator.decision",
|
||||
"selected_next_beat": "Mara Quell responds to the updated safety/evidence geometry.",
|
||||
"selected_acting_agent_id": "mara_quell",
|
||||
"candidate_next_beats": [
|
||||
{
|
||||
"choice_id": "turn-07.selected",
|
||||
"summary": "Project Mara Quell from current state and run one packet-only responder turn.",
|
||||
"status": "selected"
|
||||
}
|
||||
],
|
||||
"rationale": "Selected to test sequential state carryover, local knowledge, physical affordances, feed affordances, and de-escalation without omniscient control.",
|
||||
"glue_prose": "The feed holds its ugly shape long enough for Mara Quell to decide whether the next inch belongs to safety, spectacle, or panic.",
|
||||
"glue_prose_status": "accepted_as_narration"
|
||||
},
|
||||
"machinery_plan": {
|
||||
"invocations": [
|
||||
{
|
||||
"invocation_id": "turn-07.projector",
|
||||
"organ": "projector",
|
||||
"purpose": "Build local projected context for acting character.",
|
||||
"input_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.mutation.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/projected-contexts/lucent-hostage-feed-v0.turn-07.mara-quell.projected-context.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-07.responder",
|
||||
"organ": "sandboxed_responder",
|
||||
"purpose": "Generate one character-local action from exact packet only.",
|
||||
"input_refs": [
|
||||
"examples/responder-packets/lucent-hostage-feed-v0.turn-07.mara-quell.packet.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"experiments/responder-packets/lucent-hostage-feed-v0.turn-07.mara-quell.packet.capture.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-07.event",
|
||||
"organ": "event_resolver",
|
||||
"purpose": "Convert accepted responder action into observable event.",
|
||||
"input_refs": [
|
||||
"experiments/responder-packets/lucent-hostage-feed-v0.turn-07.mara-quell.packet.capture.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.event.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-07.appraisal",
|
||||
"organ": "participant_appraiser",
|
||||
"purpose": "Create affected participant-local appraisals from current state.",
|
||||
"input_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.event.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.appraisal.sol_vant.json",
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.appraisal.juno_ark.json",
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.appraisal.feed_ops_chorus.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-07.mutation",
|
||||
"organ": "state_mutator",
|
||||
"purpose": "Apply only reviewed state deltas.",
|
||||
"input_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.event.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.mutation.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sandboxed_responder_handoffs": [
|
||||
{
|
||||
"handoff_id": "turn-07.handoff",
|
||||
"responder_agent_id": "mara_quell",
|
||||
"projected_context_ref": "examples/projected-contexts/lucent-hostage-feed-v0.turn-07.mara-quell.projected-context.json",
|
||||
"visible_input_ref": "examples/responder-packets/lucent-hostage-feed-v0.turn-07.mara-quell.packet.json",
|
||||
"hidden_context_refs": [
|
||||
"other participants private state",
|
||||
"coordinator future plans",
|
||||
"conversation history"
|
||||
],
|
||||
"isolation_method": "subagent_no_fork",
|
||||
"raw_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-07.mara-quell.packet.capture.json",
|
||||
"reviewed_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-07.mara-quell.packet.capture.json",
|
||||
"coordinator_interventions": [
|
||||
{
|
||||
"intervention_type": "none",
|
||||
"description": "Raw subagent output parsed as JSON without repair."
|
||||
}
|
||||
],
|
||||
"leakage_audit": {
|
||||
"private_state_leak": false,
|
||||
"author_only_leak": false,
|
||||
"future_branch_leak": false,
|
||||
"raw_state_soup": false,
|
||||
"prompt_parroting": false,
|
||||
"body_or_object_omniscience": false,
|
||||
"notes": [
|
||||
"No hidden context markers or raw state variables appeared in the accepted output."
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"world_state_refs": {
|
||||
"read_refs": [
|
||||
{
|
||||
"ref_id": "prior-state",
|
||||
"ref_type": "world_fact",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.mutation.json",
|
||||
"visibility": "coordinator_only"
|
||||
},
|
||||
{
|
||||
"ref_id": "projected-context",
|
||||
"ref_type": "agent",
|
||||
"path": "examples/projected-contexts/lucent-hostage-feed-v0.turn-07.mara-quell.projected-context.json",
|
||||
"visibility": "character_local"
|
||||
}
|
||||
],
|
||||
"write_candidate_refs": [
|
||||
{
|
||||
"ref_id": "event",
|
||||
"ref_type": "event",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.event.json",
|
||||
"visibility": "public"
|
||||
},
|
||||
{
|
||||
"ref_id": "mutation",
|
||||
"ref_type": "world_fact",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.mutation.json",
|
||||
"visibility": "coordinator_only"
|
||||
}
|
||||
]
|
||||
},
|
||||
"proposed_deltas": [
|
||||
{
|
||||
"delta_id": "turn-07.delta.receipt-chain",
|
||||
"delta_type": "world_fact_update",
|
||||
"target_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.mutation.json",
|
||||
"summary": "Use reviewed mutation receipt as source for next turn state.",
|
||||
"authority": "manual_review",
|
||||
"status": "accepted",
|
||||
"rationale": "This is the explicit carryover tested by the loop."
|
||||
}
|
||||
],
|
||||
"branching": {
|
||||
"branch_action": "none",
|
||||
"active_branch_flags": [
|
||||
"training-loop-linear-release"
|
||||
],
|
||||
"event_constraints": []
|
||||
},
|
||||
"unresolved_hooks": [
|
||||
{
|
||||
"hook_id": "turn-07.hook.aftercare",
|
||||
"hook_type": "story",
|
||||
"summary": "Carry unresolved legal/feed/security aftermath without extending the hostage loop indefinitely.",
|
||||
"owner": "Ghostlight",
|
||||
"status": "carried"
|
||||
}
|
||||
],
|
||||
"review": {
|
||||
"reviewer": "Codex coordinator",
|
||||
"review_notes": [
|
||||
"Coordinator artifact is training-shaped and paired with exact packet/responder receipts."
|
||||
],
|
||||
"accepted_for_training": true,
|
||||
"failure_labels": []
|
||||
}
|
||||
}
|
||||
|
|
@ -1,258 +0,0 @@
|
|||
{
|
||||
"schema_version": "ghostlight.coordinator_artifact.v0",
|
||||
"artifact_id": "lucent-hostage-feed-v0.turn-08.coordinator",
|
||||
"review_status": "accepted_as_draft",
|
||||
"fixture_lane": "historical_grounded",
|
||||
"canon_status": "single_history",
|
||||
"source_refs": [
|
||||
{
|
||||
"ref_id": "lucent-scene-digest",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json",
|
||||
"purpose": "Grounding facts for Lucent hostage-feed scene."
|
||||
},
|
||||
{
|
||||
"ref_id": "lucent-initial-state",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
|
||||
"purpose": "Scene-local state seed and participant briefs."
|
||||
}
|
||||
],
|
||||
"input_refs": {
|
||||
"agent_state_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
|
||||
"lore_grounding_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json"
|
||||
],
|
||||
"prior_artifact_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.mutation.json"
|
||||
],
|
||||
"projected_context_refs": [
|
||||
"examples/projected-contexts/lucent-hostage-feed-v0.turn-08.juno-ark.projected-context.json"
|
||||
]
|
||||
},
|
||||
"scene_context": {
|
||||
"scene_id": "lucent-hostage-feed-v0.transfer-atrium-release-loop",
|
||||
"sequence_marker": "turn-08",
|
||||
"location_id": "lucent.media-eye.transfer-atrium",
|
||||
"participant_ids": [
|
||||
"sol_vant",
|
||||
"juno_ark",
|
||||
"mara_quell",
|
||||
"feed_ops_chorus",
|
||||
"brant_vee",
|
||||
"pippa_lux"
|
||||
],
|
||||
"active_object_ids": [
|
||||
"edit-trail-hold",
|
||||
"custody-graphic-suspended",
|
||||
"elevator-gate",
|
||||
"bench-edge",
|
||||
"crisis-feed"
|
||||
],
|
||||
"active_resource_ids": [
|
||||
"hostage-safety",
|
||||
"feed-integrity",
|
||||
"sponsor-risk",
|
||||
"security-pressure"
|
||||
],
|
||||
"public_facts": [
|
||||
{
|
||||
"text": "The live feed and room are mutually shaping the negotiation.",
|
||||
"refs": [
|
||||
"lucent-scene-digest"
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": "The edit-trail hold is the active evidence object.",
|
||||
"refs": [
|
||||
"lucent-loop-state"
|
||||
]
|
||||
}
|
||||
],
|
||||
"scene_pressure": [
|
||||
{
|
||||
"text": "Each turn must preserve safety while resisting brand-safe replacement of the event.",
|
||||
"refs": [
|
||||
"lucent-initial-state"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"coordinator_decision": {
|
||||
"decision_id": "lucent-hostage-feed-v0.turn-08.coordinator.decision",
|
||||
"selected_next_beat": "Juno Ark responds to the updated safety/evidence geometry.",
|
||||
"selected_acting_agent_id": "juno_ark",
|
||||
"candidate_next_beats": [
|
||||
{
|
||||
"choice_id": "turn-08.selected",
|
||||
"summary": "Project Juno Ark from current state and run one packet-only responder turn.",
|
||||
"status": "selected"
|
||||
}
|
||||
],
|
||||
"rationale": "Selected to test sequential state carryover, local knowledge, physical affordances, feed affordances, and de-escalation without omniscient control.",
|
||||
"glue_prose": "The feed holds its ugly shape long enough for Juno Ark to decide whether the next inch belongs to safety, spectacle, or panic.",
|
||||
"glue_prose_status": "accepted_as_narration"
|
||||
},
|
||||
"machinery_plan": {
|
||||
"invocations": [
|
||||
{
|
||||
"invocation_id": "turn-08.projector",
|
||||
"organ": "projector",
|
||||
"purpose": "Build local projected context for acting character.",
|
||||
"input_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.mutation.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/projected-contexts/lucent-hostage-feed-v0.turn-08.juno-ark.projected-context.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-08.responder",
|
||||
"organ": "sandboxed_responder",
|
||||
"purpose": "Generate one character-local action from exact packet only.",
|
||||
"input_refs": [
|
||||
"examples/responder-packets/lucent-hostage-feed-v0.turn-08.juno-ark.packet.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"experiments/responder-packets/lucent-hostage-feed-v0.turn-08.juno-ark.packet.capture.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-08.event",
|
||||
"organ": "event_resolver",
|
||||
"purpose": "Convert accepted responder action into observable event.",
|
||||
"input_refs": [
|
||||
"experiments/responder-packets/lucent-hostage-feed-v0.turn-08.juno-ark.packet.capture.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.event.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-08.appraisal",
|
||||
"organ": "participant_appraiser",
|
||||
"purpose": "Create affected participant-local appraisals from current state.",
|
||||
"input_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.event.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.appraisal.sol_vant.json",
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.appraisal.mara_quell.json",
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.appraisal.feed_ops_chorus.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-08.mutation",
|
||||
"organ": "state_mutator",
|
||||
"purpose": "Apply only reviewed state deltas.",
|
||||
"input_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.event.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.mutation.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sandboxed_responder_handoffs": [
|
||||
{
|
||||
"handoff_id": "turn-08.handoff",
|
||||
"responder_agent_id": "juno_ark",
|
||||
"projected_context_ref": "examples/projected-contexts/lucent-hostage-feed-v0.turn-08.juno-ark.projected-context.json",
|
||||
"visible_input_ref": "examples/responder-packets/lucent-hostage-feed-v0.turn-08.juno-ark.packet.json",
|
||||
"hidden_context_refs": [
|
||||
"other participants private state",
|
||||
"coordinator future plans",
|
||||
"conversation history"
|
||||
],
|
||||
"isolation_method": "subagent_no_fork",
|
||||
"raw_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-08.juno-ark.packet.capture.json",
|
||||
"reviewed_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-08.juno-ark.packet.capture.json",
|
||||
"coordinator_interventions": [
|
||||
{
|
||||
"intervention_type": "none",
|
||||
"description": "Raw subagent output parsed as JSON without repair."
|
||||
}
|
||||
],
|
||||
"leakage_audit": {
|
||||
"private_state_leak": false,
|
||||
"author_only_leak": false,
|
||||
"future_branch_leak": false,
|
||||
"raw_state_soup": false,
|
||||
"prompt_parroting": false,
|
||||
"body_or_object_omniscience": false,
|
||||
"notes": [
|
||||
"No hidden context markers or raw state variables appeared in the accepted output."
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"world_state_refs": {
|
||||
"read_refs": [
|
||||
{
|
||||
"ref_id": "prior-state",
|
||||
"ref_type": "world_fact",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.mutation.json",
|
||||
"visibility": "coordinator_only"
|
||||
},
|
||||
{
|
||||
"ref_id": "projected-context",
|
||||
"ref_type": "agent",
|
||||
"path": "examples/projected-contexts/lucent-hostage-feed-v0.turn-08.juno-ark.projected-context.json",
|
||||
"visibility": "character_local"
|
||||
}
|
||||
],
|
||||
"write_candidate_refs": [
|
||||
{
|
||||
"ref_id": "event",
|
||||
"ref_type": "event",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.event.json",
|
||||
"visibility": "public"
|
||||
},
|
||||
{
|
||||
"ref_id": "mutation",
|
||||
"ref_type": "world_fact",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.mutation.json",
|
||||
"visibility": "coordinator_only"
|
||||
}
|
||||
]
|
||||
},
|
||||
"proposed_deltas": [
|
||||
{
|
||||
"delta_id": "turn-08.delta.receipt-chain",
|
||||
"delta_type": "world_fact_update",
|
||||
"target_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.mutation.json",
|
||||
"summary": "Use reviewed mutation receipt as source for next turn state.",
|
||||
"authority": "manual_review",
|
||||
"status": "accepted",
|
||||
"rationale": "This is the explicit carryover tested by the loop."
|
||||
}
|
||||
],
|
||||
"branching": {
|
||||
"branch_action": "none",
|
||||
"active_branch_flags": [
|
||||
"training-loop-linear-release"
|
||||
],
|
||||
"event_constraints": []
|
||||
},
|
||||
"unresolved_hooks": [
|
||||
{
|
||||
"hook_id": "turn-08.hook.aftercare",
|
||||
"hook_type": "story",
|
||||
"summary": "Carry unresolved legal/feed/security aftermath without extending the hostage loop indefinitely.",
|
||||
"owner": "Ghostlight",
|
||||
"status": "carried"
|
||||
}
|
||||
],
|
||||
"review": {
|
||||
"reviewer": "Codex coordinator",
|
||||
"review_notes": [
|
||||
"Coordinator artifact is training-shaped and paired with exact packet/responder receipts."
|
||||
],
|
||||
"accepted_for_training": true,
|
||||
"failure_labels": []
|
||||
}
|
||||
}
|
||||
|
|
@ -1,258 +0,0 @@
|
|||
{
|
||||
"schema_version": "ghostlight.coordinator_artifact.v0",
|
||||
"artifact_id": "lucent-hostage-feed-v0.turn-09.coordinator",
|
||||
"review_status": "accepted_as_draft",
|
||||
"fixture_lane": "historical_grounded",
|
||||
"canon_status": "single_history",
|
||||
"source_refs": [
|
||||
{
|
||||
"ref_id": "lucent-scene-digest",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json",
|
||||
"purpose": "Grounding facts for Lucent hostage-feed scene."
|
||||
},
|
||||
{
|
||||
"ref_id": "lucent-initial-state",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
|
||||
"purpose": "Scene-local state seed and participant briefs."
|
||||
}
|
||||
],
|
||||
"input_refs": {
|
||||
"agent_state_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
|
||||
"lore_grounding_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json"
|
||||
],
|
||||
"prior_artifact_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.mutation.json"
|
||||
],
|
||||
"projected_context_refs": [
|
||||
"examples/projected-contexts/lucent-hostage-feed-v0.turn-09.sol-vant.projected-context.json"
|
||||
]
|
||||
},
|
||||
"scene_context": {
|
||||
"scene_id": "lucent-hostage-feed-v0.transfer-atrium-release-loop",
|
||||
"sequence_marker": "turn-09",
|
||||
"location_id": "lucent.media-eye.transfer-atrium",
|
||||
"participant_ids": [
|
||||
"sol_vant",
|
||||
"juno_ark",
|
||||
"mara_quell",
|
||||
"feed_ops_chorus",
|
||||
"brant_vee",
|
||||
"pippa_lux"
|
||||
],
|
||||
"active_object_ids": [
|
||||
"edit-trail-hold",
|
||||
"custody-graphic-suspended",
|
||||
"elevator-gate",
|
||||
"bench-edge",
|
||||
"crisis-feed"
|
||||
],
|
||||
"active_resource_ids": [
|
||||
"hostage-safety",
|
||||
"feed-integrity",
|
||||
"sponsor-risk",
|
||||
"security-pressure"
|
||||
],
|
||||
"public_facts": [
|
||||
{
|
||||
"text": "The live feed and room are mutually shaping the negotiation.",
|
||||
"refs": [
|
||||
"lucent-scene-digest"
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": "The edit-trail hold is the active evidence object.",
|
||||
"refs": [
|
||||
"lucent-loop-state"
|
||||
]
|
||||
}
|
||||
],
|
||||
"scene_pressure": [
|
||||
{
|
||||
"text": "Each turn must preserve safety while resisting brand-safe replacement of the event.",
|
||||
"refs": [
|
||||
"lucent-initial-state"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"coordinator_decision": {
|
||||
"decision_id": "lucent-hostage-feed-v0.turn-09.coordinator.decision",
|
||||
"selected_next_beat": "Sol Vant responds to the updated safety/evidence geometry.",
|
||||
"selected_acting_agent_id": "sol_vant",
|
||||
"candidate_next_beats": [
|
||||
{
|
||||
"choice_id": "turn-09.selected",
|
||||
"summary": "Project Sol Vant from current state and run one packet-only responder turn.",
|
||||
"status": "selected"
|
||||
}
|
||||
],
|
||||
"rationale": "Selected to test sequential state carryover, local knowledge, physical affordances, feed affordances, and de-escalation without omniscient control.",
|
||||
"glue_prose": "The feed holds its ugly shape long enough for Sol Vant to decide whether the next inch belongs to safety, spectacle, or panic.",
|
||||
"glue_prose_status": "accepted_as_narration"
|
||||
},
|
||||
"machinery_plan": {
|
||||
"invocations": [
|
||||
{
|
||||
"invocation_id": "turn-09.projector",
|
||||
"organ": "projector",
|
||||
"purpose": "Build local projected context for acting character.",
|
||||
"input_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.mutation.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/projected-contexts/lucent-hostage-feed-v0.turn-09.sol-vant.projected-context.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-09.responder",
|
||||
"organ": "sandboxed_responder",
|
||||
"purpose": "Generate one character-local action from exact packet only.",
|
||||
"input_refs": [
|
||||
"examples/responder-packets/lucent-hostage-feed-v0.turn-09.sol-vant.packet.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"experiments/responder-packets/lucent-hostage-feed-v0.turn-09.sol-vant.packet.capture.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-09.event",
|
||||
"organ": "event_resolver",
|
||||
"purpose": "Convert accepted responder action into observable event.",
|
||||
"input_refs": [
|
||||
"experiments/responder-packets/lucent-hostage-feed-v0.turn-09.sol-vant.packet.capture.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.event.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-09.appraisal",
|
||||
"organ": "participant_appraiser",
|
||||
"purpose": "Create affected participant-local appraisals from current state.",
|
||||
"input_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.event.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.appraisal.juno_ark.json",
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.appraisal.mara_quell.json",
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.appraisal.feed_ops_chorus.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-09.mutation",
|
||||
"organ": "state_mutator",
|
||||
"purpose": "Apply only reviewed state deltas.",
|
||||
"input_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.event.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.mutation.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sandboxed_responder_handoffs": [
|
||||
{
|
||||
"handoff_id": "turn-09.handoff",
|
||||
"responder_agent_id": "sol_vant",
|
||||
"projected_context_ref": "examples/projected-contexts/lucent-hostage-feed-v0.turn-09.sol-vant.projected-context.json",
|
||||
"visible_input_ref": "examples/responder-packets/lucent-hostage-feed-v0.turn-09.sol-vant.packet.json",
|
||||
"hidden_context_refs": [
|
||||
"other participants private state",
|
||||
"coordinator future plans",
|
||||
"conversation history"
|
||||
],
|
||||
"isolation_method": "subagent_no_fork",
|
||||
"raw_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-09.sol-vant.packet.capture.json",
|
||||
"reviewed_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-09.sol-vant.packet.capture.json",
|
||||
"coordinator_interventions": [
|
||||
{
|
||||
"intervention_type": "none",
|
||||
"description": "Raw subagent output parsed as JSON without repair."
|
||||
}
|
||||
],
|
||||
"leakage_audit": {
|
||||
"private_state_leak": false,
|
||||
"author_only_leak": false,
|
||||
"future_branch_leak": false,
|
||||
"raw_state_soup": false,
|
||||
"prompt_parroting": false,
|
||||
"body_or_object_omniscience": false,
|
||||
"notes": [
|
||||
"No hidden context markers or raw state variables appeared in the accepted output."
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"world_state_refs": {
|
||||
"read_refs": [
|
||||
{
|
||||
"ref_id": "prior-state",
|
||||
"ref_type": "world_fact",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.mutation.json",
|
||||
"visibility": "coordinator_only"
|
||||
},
|
||||
{
|
||||
"ref_id": "projected-context",
|
||||
"ref_type": "agent",
|
||||
"path": "examples/projected-contexts/lucent-hostage-feed-v0.turn-09.sol-vant.projected-context.json",
|
||||
"visibility": "character_local"
|
||||
}
|
||||
],
|
||||
"write_candidate_refs": [
|
||||
{
|
||||
"ref_id": "event",
|
||||
"ref_type": "event",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.event.json",
|
||||
"visibility": "public"
|
||||
},
|
||||
{
|
||||
"ref_id": "mutation",
|
||||
"ref_type": "world_fact",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.mutation.json",
|
||||
"visibility": "coordinator_only"
|
||||
}
|
||||
]
|
||||
},
|
||||
"proposed_deltas": [
|
||||
{
|
||||
"delta_id": "turn-09.delta.receipt-chain",
|
||||
"delta_type": "world_fact_update",
|
||||
"target_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.mutation.json",
|
||||
"summary": "Use reviewed mutation receipt as source for next turn state.",
|
||||
"authority": "manual_review",
|
||||
"status": "accepted",
|
||||
"rationale": "This is the explicit carryover tested by the loop."
|
||||
}
|
||||
],
|
||||
"branching": {
|
||||
"branch_action": "none",
|
||||
"active_branch_flags": [
|
||||
"training-loop-linear-release"
|
||||
],
|
||||
"event_constraints": []
|
||||
},
|
||||
"unresolved_hooks": [
|
||||
{
|
||||
"hook_id": "turn-09.hook.aftercare",
|
||||
"hook_type": "story",
|
||||
"summary": "Carry unresolved legal/feed/security aftermath without extending the hostage loop indefinitely.",
|
||||
"owner": "Ghostlight",
|
||||
"status": "carried"
|
||||
}
|
||||
],
|
||||
"review": {
|
||||
"reviewer": "Codex coordinator",
|
||||
"review_notes": [
|
||||
"Coordinator artifact is training-shaped and paired with exact packet/responder receipts."
|
||||
],
|
||||
"accepted_for_training": true,
|
||||
"failure_labels": []
|
||||
}
|
||||
}
|
||||
|
|
@ -1,258 +0,0 @@
|
|||
{
|
||||
"schema_version": "ghostlight.coordinator_artifact.v0",
|
||||
"artifact_id": "lucent-hostage-feed-v0.turn-10.coordinator",
|
||||
"review_status": "accepted_as_draft",
|
||||
"fixture_lane": "historical_grounded",
|
||||
"canon_status": "single_history",
|
||||
"source_refs": [
|
||||
{
|
||||
"ref_id": "lucent-scene-digest",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json",
|
||||
"purpose": "Grounding facts for Lucent hostage-feed scene."
|
||||
},
|
||||
{
|
||||
"ref_id": "lucent-initial-state",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
|
||||
"purpose": "Scene-local state seed and participant briefs."
|
||||
}
|
||||
],
|
||||
"input_refs": {
|
||||
"agent_state_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
|
||||
"lore_grounding_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json"
|
||||
],
|
||||
"prior_artifact_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.mutation.json"
|
||||
],
|
||||
"projected_context_refs": [
|
||||
"examples/projected-contexts/lucent-hostage-feed-v0.turn-10.feed-ops-chorus.projected-context.json"
|
||||
]
|
||||
},
|
||||
"scene_context": {
|
||||
"scene_id": "lucent-hostage-feed-v0.transfer-atrium-release-loop",
|
||||
"sequence_marker": "turn-10",
|
||||
"location_id": "lucent.media-eye.transfer-atrium",
|
||||
"participant_ids": [
|
||||
"sol_vant",
|
||||
"juno_ark",
|
||||
"mara_quell",
|
||||
"feed_ops_chorus",
|
||||
"brant_vee",
|
||||
"pippa_lux"
|
||||
],
|
||||
"active_object_ids": [
|
||||
"edit-trail-hold",
|
||||
"custody-graphic-suspended",
|
||||
"elevator-gate",
|
||||
"bench-edge",
|
||||
"crisis-feed"
|
||||
],
|
||||
"active_resource_ids": [
|
||||
"hostage-safety",
|
||||
"feed-integrity",
|
||||
"sponsor-risk",
|
||||
"security-pressure"
|
||||
],
|
||||
"public_facts": [
|
||||
{
|
||||
"text": "The live feed and room are mutually shaping the negotiation.",
|
||||
"refs": [
|
||||
"lucent-scene-digest"
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": "The edit-trail hold is the active evidence object.",
|
||||
"refs": [
|
||||
"lucent-loop-state"
|
||||
]
|
||||
}
|
||||
],
|
||||
"scene_pressure": [
|
||||
{
|
||||
"text": "Each turn must preserve safety while resisting brand-safe replacement of the event.",
|
||||
"refs": [
|
||||
"lucent-initial-state"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"coordinator_decision": {
|
||||
"decision_id": "lucent-hostage-feed-v0.turn-10.coordinator.decision",
|
||||
"selected_next_beat": "Feed Ops Chorus responds to the updated safety/evidence geometry.",
|
||||
"selected_acting_agent_id": "feed_ops_chorus",
|
||||
"candidate_next_beats": [
|
||||
{
|
||||
"choice_id": "turn-10.selected",
|
||||
"summary": "Project Feed Ops Chorus from current state and run one packet-only responder turn.",
|
||||
"status": "selected"
|
||||
}
|
||||
],
|
||||
"rationale": "Selected to test sequential state carryover, local knowledge, physical affordances, feed affordances, and de-escalation without omniscient control.",
|
||||
"glue_prose": "The feed holds its ugly shape long enough for Feed Ops Chorus to decide whether the next inch belongs to safety, spectacle, or panic.",
|
||||
"glue_prose_status": "accepted_as_narration"
|
||||
},
|
||||
"machinery_plan": {
|
||||
"invocations": [
|
||||
{
|
||||
"invocation_id": "turn-10.projector",
|
||||
"organ": "projector",
|
||||
"purpose": "Build local projected context for acting character.",
|
||||
"input_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.mutation.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/projected-contexts/lucent-hostage-feed-v0.turn-10.feed-ops-chorus.projected-context.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-10.responder",
|
||||
"organ": "sandboxed_responder",
|
||||
"purpose": "Generate one character-local action from exact packet only.",
|
||||
"input_refs": [
|
||||
"examples/responder-packets/lucent-hostage-feed-v0.turn-10.feed-ops-chorus.packet.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"experiments/responder-packets/lucent-hostage-feed-v0.turn-10.feed-ops-chorus.packet.capture.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-10.event",
|
||||
"organ": "event_resolver",
|
||||
"purpose": "Convert accepted responder action into observable event.",
|
||||
"input_refs": [
|
||||
"experiments/responder-packets/lucent-hostage-feed-v0.turn-10.feed-ops-chorus.packet.capture.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-10.event.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-10.appraisal",
|
||||
"organ": "participant_appraiser",
|
||||
"purpose": "Create affected participant-local appraisals from current state.",
|
||||
"input_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-10.event.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-10.appraisal.sol_vant.json",
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-10.appraisal.juno_ark.json",
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-10.appraisal.mara_quell.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-10.mutation",
|
||||
"organ": "state_mutator",
|
||||
"purpose": "Apply only reviewed state deltas.",
|
||||
"input_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-10.event.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-10.mutation.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sandboxed_responder_handoffs": [
|
||||
{
|
||||
"handoff_id": "turn-10.handoff",
|
||||
"responder_agent_id": "feed_ops_chorus",
|
||||
"projected_context_ref": "examples/projected-contexts/lucent-hostage-feed-v0.turn-10.feed-ops-chorus.projected-context.json",
|
||||
"visible_input_ref": "examples/responder-packets/lucent-hostage-feed-v0.turn-10.feed-ops-chorus.packet.json",
|
||||
"hidden_context_refs": [
|
||||
"other participants private state",
|
||||
"coordinator future plans",
|
||||
"conversation history"
|
||||
],
|
||||
"isolation_method": "subagent_no_fork",
|
||||
"raw_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-10.feed-ops-chorus.packet.capture.json",
|
||||
"reviewed_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-10.feed-ops-chorus.packet.capture.json",
|
||||
"coordinator_interventions": [
|
||||
{
|
||||
"intervention_type": "none",
|
||||
"description": "Raw subagent output parsed as JSON without repair."
|
||||
}
|
||||
],
|
||||
"leakage_audit": {
|
||||
"private_state_leak": false,
|
||||
"author_only_leak": false,
|
||||
"future_branch_leak": false,
|
||||
"raw_state_soup": false,
|
||||
"prompt_parroting": false,
|
||||
"body_or_object_omniscience": false,
|
||||
"notes": [
|
||||
"No hidden context markers or raw state variables appeared in the accepted output."
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"world_state_refs": {
|
||||
"read_refs": [
|
||||
{
|
||||
"ref_id": "prior-state",
|
||||
"ref_type": "world_fact",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.mutation.json",
|
||||
"visibility": "coordinator_only"
|
||||
},
|
||||
{
|
||||
"ref_id": "projected-context",
|
||||
"ref_type": "agent",
|
||||
"path": "examples/projected-contexts/lucent-hostage-feed-v0.turn-10.feed-ops-chorus.projected-context.json",
|
||||
"visibility": "character_local"
|
||||
}
|
||||
],
|
||||
"write_candidate_refs": [
|
||||
{
|
||||
"ref_id": "event",
|
||||
"ref_type": "event",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-10.event.json",
|
||||
"visibility": "public"
|
||||
},
|
||||
{
|
||||
"ref_id": "mutation",
|
||||
"ref_type": "world_fact",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-10.mutation.json",
|
||||
"visibility": "coordinator_only"
|
||||
}
|
||||
]
|
||||
},
|
||||
"proposed_deltas": [
|
||||
{
|
||||
"delta_id": "turn-10.delta.receipt-chain",
|
||||
"delta_type": "world_fact_update",
|
||||
"target_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-10.mutation.json",
|
||||
"summary": "Use reviewed mutation receipt as source for next turn state.",
|
||||
"authority": "manual_review",
|
||||
"status": "accepted",
|
||||
"rationale": "This is the explicit carryover tested by the loop."
|
||||
}
|
||||
],
|
||||
"branching": {
|
||||
"branch_action": "none",
|
||||
"active_branch_flags": [
|
||||
"training-loop-linear-release"
|
||||
],
|
||||
"event_constraints": []
|
||||
},
|
||||
"unresolved_hooks": [
|
||||
{
|
||||
"hook_id": "turn-10.hook.aftercare",
|
||||
"hook_type": "story",
|
||||
"summary": "Carry unresolved legal/feed/security aftermath without extending the hostage loop indefinitely.",
|
||||
"owner": "Ghostlight",
|
||||
"status": "carried"
|
||||
}
|
||||
],
|
||||
"review": {
|
||||
"reviewer": "Codex coordinator",
|
||||
"review_notes": [
|
||||
"Coordinator artifact is training-shaped and paired with exact packet/responder receipts."
|
||||
],
|
||||
"accepted_for_training": true,
|
||||
"failure_labels": []
|
||||
}
|
||||
}
|
||||
|
|
@ -1,258 +0,0 @@
|
|||
{
|
||||
"schema_version": "ghostlight.coordinator_artifact.v0",
|
||||
"artifact_id": "lucent-hostage-feed-v0.turn-11.coordinator",
|
||||
"review_status": "accepted_as_draft",
|
||||
"fixture_lane": "historical_grounded",
|
||||
"canon_status": "single_history",
|
||||
"source_refs": [
|
||||
{
|
||||
"ref_id": "lucent-scene-digest",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json",
|
||||
"purpose": "Grounding facts for Lucent hostage-feed scene."
|
||||
},
|
||||
{
|
||||
"ref_id": "lucent-initial-state",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
|
||||
"purpose": "Scene-local state seed and participant briefs."
|
||||
}
|
||||
],
|
||||
"input_refs": {
|
||||
"agent_state_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
|
||||
"lore_grounding_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json"
|
||||
],
|
||||
"prior_artifact_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-10.mutation.json"
|
||||
],
|
||||
"projected_context_refs": [
|
||||
"examples/projected-contexts/lucent-hostage-feed-v0.turn-11.sol-vant.projected-context.json"
|
||||
]
|
||||
},
|
||||
"scene_context": {
|
||||
"scene_id": "lucent-hostage-feed-v0.transfer-atrium-release-loop",
|
||||
"sequence_marker": "turn-11",
|
||||
"location_id": "lucent.media-eye.transfer-atrium",
|
||||
"participant_ids": [
|
||||
"sol_vant",
|
||||
"juno_ark",
|
||||
"mara_quell",
|
||||
"feed_ops_chorus",
|
||||
"brant_vee",
|
||||
"pippa_lux"
|
||||
],
|
||||
"active_object_ids": [
|
||||
"edit-trail-hold",
|
||||
"custody-graphic-suspended",
|
||||
"elevator-gate",
|
||||
"bench-edge",
|
||||
"crisis-feed"
|
||||
],
|
||||
"active_resource_ids": [
|
||||
"hostage-safety",
|
||||
"feed-integrity",
|
||||
"sponsor-risk",
|
||||
"security-pressure"
|
||||
],
|
||||
"public_facts": [
|
||||
{
|
||||
"text": "The live feed and room are mutually shaping the negotiation.",
|
||||
"refs": [
|
||||
"lucent-scene-digest"
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": "The edit-trail hold is the active evidence object.",
|
||||
"refs": [
|
||||
"lucent-loop-state"
|
||||
]
|
||||
}
|
||||
],
|
||||
"scene_pressure": [
|
||||
{
|
||||
"text": "Each turn must preserve safety while resisting brand-safe replacement of the event.",
|
||||
"refs": [
|
||||
"lucent-initial-state"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"coordinator_decision": {
|
||||
"decision_id": "lucent-hostage-feed-v0.turn-11.coordinator.decision",
|
||||
"selected_next_beat": "Sol Vant responds to the updated safety/evidence geometry.",
|
||||
"selected_acting_agent_id": "sol_vant",
|
||||
"candidate_next_beats": [
|
||||
{
|
||||
"choice_id": "turn-11.selected",
|
||||
"summary": "Project Sol Vant from current state and run one packet-only responder turn.",
|
||||
"status": "selected"
|
||||
}
|
||||
],
|
||||
"rationale": "Selected to test sequential state carryover, local knowledge, physical affordances, feed affordances, and de-escalation without omniscient control.",
|
||||
"glue_prose": "The feed holds its ugly shape long enough for Sol Vant to decide whether the next inch belongs to safety, spectacle, or panic.",
|
||||
"glue_prose_status": "accepted_as_narration"
|
||||
},
|
||||
"machinery_plan": {
|
||||
"invocations": [
|
||||
{
|
||||
"invocation_id": "turn-11.projector",
|
||||
"organ": "projector",
|
||||
"purpose": "Build local projected context for acting character.",
|
||||
"input_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-10.mutation.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/projected-contexts/lucent-hostage-feed-v0.turn-11.sol-vant.projected-context.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-11.responder",
|
||||
"organ": "sandboxed_responder",
|
||||
"purpose": "Generate one character-local action from exact packet only.",
|
||||
"input_refs": [
|
||||
"examples/responder-packets/lucent-hostage-feed-v0.turn-11.sol-vant.packet.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"experiments/responder-packets/lucent-hostage-feed-v0.turn-11.sol-vant.packet.capture.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-11.event",
|
||||
"organ": "event_resolver",
|
||||
"purpose": "Convert accepted responder action into observable event.",
|
||||
"input_refs": [
|
||||
"experiments/responder-packets/lucent-hostage-feed-v0.turn-11.sol-vant.packet.capture.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-11.event.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-11.appraisal",
|
||||
"organ": "participant_appraiser",
|
||||
"purpose": "Create affected participant-local appraisals from current state.",
|
||||
"input_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-11.event.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-11.appraisal.juno_ark.json",
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-11.appraisal.mara_quell.json",
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-11.appraisal.feed_ops_chorus.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-11.mutation",
|
||||
"organ": "state_mutator",
|
||||
"purpose": "Apply only reviewed state deltas.",
|
||||
"input_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-11.event.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-11.mutation.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sandboxed_responder_handoffs": [
|
||||
{
|
||||
"handoff_id": "turn-11.handoff",
|
||||
"responder_agent_id": "sol_vant",
|
||||
"projected_context_ref": "examples/projected-contexts/lucent-hostage-feed-v0.turn-11.sol-vant.projected-context.json",
|
||||
"visible_input_ref": "examples/responder-packets/lucent-hostage-feed-v0.turn-11.sol-vant.packet.json",
|
||||
"hidden_context_refs": [
|
||||
"other participants private state",
|
||||
"coordinator future plans",
|
||||
"conversation history"
|
||||
],
|
||||
"isolation_method": "subagent_no_fork",
|
||||
"raw_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-11.sol-vant.packet.capture.json",
|
||||
"reviewed_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-11.sol-vant.packet.capture.json",
|
||||
"coordinator_interventions": [
|
||||
{
|
||||
"intervention_type": "none",
|
||||
"description": "Raw subagent output parsed as JSON without repair."
|
||||
}
|
||||
],
|
||||
"leakage_audit": {
|
||||
"private_state_leak": false,
|
||||
"author_only_leak": false,
|
||||
"future_branch_leak": false,
|
||||
"raw_state_soup": false,
|
||||
"prompt_parroting": false,
|
||||
"body_or_object_omniscience": false,
|
||||
"notes": [
|
||||
"No hidden context markers or raw state variables appeared in the accepted output."
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"world_state_refs": {
|
||||
"read_refs": [
|
||||
{
|
||||
"ref_id": "prior-state",
|
||||
"ref_type": "world_fact",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-10.mutation.json",
|
||||
"visibility": "coordinator_only"
|
||||
},
|
||||
{
|
||||
"ref_id": "projected-context",
|
||||
"ref_type": "agent",
|
||||
"path": "examples/projected-contexts/lucent-hostage-feed-v0.turn-11.sol-vant.projected-context.json",
|
||||
"visibility": "character_local"
|
||||
}
|
||||
],
|
||||
"write_candidate_refs": [
|
||||
{
|
||||
"ref_id": "event",
|
||||
"ref_type": "event",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-11.event.json",
|
||||
"visibility": "public"
|
||||
},
|
||||
{
|
||||
"ref_id": "mutation",
|
||||
"ref_type": "world_fact",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-11.mutation.json",
|
||||
"visibility": "coordinator_only"
|
||||
}
|
||||
]
|
||||
},
|
||||
"proposed_deltas": [
|
||||
{
|
||||
"delta_id": "turn-11.delta.receipt-chain",
|
||||
"delta_type": "world_fact_update",
|
||||
"target_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-11.mutation.json",
|
||||
"summary": "Use reviewed mutation receipt as source for next turn state.",
|
||||
"authority": "manual_review",
|
||||
"status": "accepted",
|
||||
"rationale": "This is the explicit carryover tested by the loop."
|
||||
}
|
||||
],
|
||||
"branching": {
|
||||
"branch_action": "none",
|
||||
"active_branch_flags": [
|
||||
"training-loop-linear-release"
|
||||
],
|
||||
"event_constraints": []
|
||||
},
|
||||
"unresolved_hooks": [
|
||||
{
|
||||
"hook_id": "turn-11.hook.aftercare",
|
||||
"hook_type": "story",
|
||||
"summary": "Carry unresolved legal/feed/security aftermath without extending the hostage loop indefinitely.",
|
||||
"owner": "Ghostlight",
|
||||
"status": "carried"
|
||||
}
|
||||
],
|
||||
"review": {
|
||||
"reviewer": "Codex coordinator",
|
||||
"review_notes": [
|
||||
"Coordinator artifact is training-shaped and paired with exact packet/responder receipts."
|
||||
],
|
||||
"accepted_for_training": true,
|
||||
"failure_labels": []
|
||||
}
|
||||
}
|
||||
|
|
@ -1,258 +0,0 @@
|
|||
{
|
||||
"schema_version": "ghostlight.coordinator_artifact.v0",
|
||||
"artifact_id": "lucent-hostage-feed-v0.turn-12.coordinator",
|
||||
"review_status": "accepted_as_draft",
|
||||
"fixture_lane": "historical_grounded",
|
||||
"canon_status": "single_history",
|
||||
"source_refs": [
|
||||
{
|
||||
"ref_id": "lucent-scene-digest",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json",
|
||||
"purpose": "Grounding facts for Lucent hostage-feed scene."
|
||||
},
|
||||
{
|
||||
"ref_id": "lucent-initial-state",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
|
||||
"purpose": "Scene-local state seed and participant briefs."
|
||||
}
|
||||
],
|
||||
"input_refs": {
|
||||
"agent_state_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
|
||||
"lore_grounding_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json"
|
||||
],
|
||||
"prior_artifact_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-11.mutation.json"
|
||||
],
|
||||
"projected_context_refs": [
|
||||
"examples/projected-contexts/lucent-hostage-feed-v0.turn-12.juno-ark.projected-context.json"
|
||||
]
|
||||
},
|
||||
"scene_context": {
|
||||
"scene_id": "lucent-hostage-feed-v0.transfer-atrium-release-loop",
|
||||
"sequence_marker": "turn-12",
|
||||
"location_id": "lucent.media-eye.transfer-atrium",
|
||||
"participant_ids": [
|
||||
"sol_vant",
|
||||
"juno_ark",
|
||||
"mara_quell",
|
||||
"feed_ops_chorus",
|
||||
"brant_vee",
|
||||
"pippa_lux"
|
||||
],
|
||||
"active_object_ids": [
|
||||
"edit-trail-hold",
|
||||
"custody-graphic-suspended",
|
||||
"elevator-gate",
|
||||
"bench-edge",
|
||||
"crisis-feed"
|
||||
],
|
||||
"active_resource_ids": [
|
||||
"hostage-safety",
|
||||
"feed-integrity",
|
||||
"sponsor-risk",
|
||||
"security-pressure"
|
||||
],
|
||||
"public_facts": [
|
||||
{
|
||||
"text": "The live feed and room are mutually shaping the negotiation.",
|
||||
"refs": [
|
||||
"lucent-scene-digest"
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": "The edit-trail hold is the active evidence object.",
|
||||
"refs": [
|
||||
"lucent-loop-state"
|
||||
]
|
||||
}
|
||||
],
|
||||
"scene_pressure": [
|
||||
{
|
||||
"text": "Each turn must preserve safety while resisting brand-safe replacement of the event.",
|
||||
"refs": [
|
||||
"lucent-initial-state"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"coordinator_decision": {
|
||||
"decision_id": "lucent-hostage-feed-v0.turn-12.coordinator.decision",
|
||||
"selected_next_beat": "Juno Ark responds to the updated safety/evidence geometry.",
|
||||
"selected_acting_agent_id": "juno_ark",
|
||||
"candidate_next_beats": [
|
||||
{
|
||||
"choice_id": "turn-12.selected",
|
||||
"summary": "Project Juno Ark from current state and run one packet-only responder turn.",
|
||||
"status": "selected"
|
||||
}
|
||||
],
|
||||
"rationale": "Selected to test sequential state carryover, local knowledge, physical affordances, feed affordances, and de-escalation without omniscient control.",
|
||||
"glue_prose": "The feed holds its ugly shape long enough for Juno Ark to decide whether the next inch belongs to safety, spectacle, or panic.",
|
||||
"glue_prose_status": "accepted_as_narration"
|
||||
},
|
||||
"machinery_plan": {
|
||||
"invocations": [
|
||||
{
|
||||
"invocation_id": "turn-12.projector",
|
||||
"organ": "projector",
|
||||
"purpose": "Build local projected context for acting character.",
|
||||
"input_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-11.mutation.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/projected-contexts/lucent-hostage-feed-v0.turn-12.juno-ark.projected-context.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-12.responder",
|
||||
"organ": "sandboxed_responder",
|
||||
"purpose": "Generate one character-local action from exact packet only.",
|
||||
"input_refs": [
|
||||
"examples/responder-packets/lucent-hostage-feed-v0.turn-12.juno-ark.packet.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"experiments/responder-packets/lucent-hostage-feed-v0.turn-12.juno-ark.packet.capture.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-12.event",
|
||||
"organ": "event_resolver",
|
||||
"purpose": "Convert accepted responder action into observable event.",
|
||||
"input_refs": [
|
||||
"experiments/responder-packets/lucent-hostage-feed-v0.turn-12.juno-ark.packet.capture.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-12.event.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-12.appraisal",
|
||||
"organ": "participant_appraiser",
|
||||
"purpose": "Create affected participant-local appraisals from current state.",
|
||||
"input_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-12.event.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-12.appraisal.sol_vant.json",
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-12.appraisal.mara_quell.json",
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-12.appraisal.feed_ops_chorus.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-12.mutation",
|
||||
"organ": "state_mutator",
|
||||
"purpose": "Apply only reviewed state deltas.",
|
||||
"input_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-12.event.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-12.mutation.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sandboxed_responder_handoffs": [
|
||||
{
|
||||
"handoff_id": "turn-12.handoff",
|
||||
"responder_agent_id": "juno_ark",
|
||||
"projected_context_ref": "examples/projected-contexts/lucent-hostage-feed-v0.turn-12.juno-ark.projected-context.json",
|
||||
"visible_input_ref": "examples/responder-packets/lucent-hostage-feed-v0.turn-12.juno-ark.packet.json",
|
||||
"hidden_context_refs": [
|
||||
"other participants private state",
|
||||
"coordinator future plans",
|
||||
"conversation history"
|
||||
],
|
||||
"isolation_method": "subagent_no_fork",
|
||||
"raw_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-12.juno-ark.packet.capture.json",
|
||||
"reviewed_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-12.juno-ark.packet.capture.json",
|
||||
"coordinator_interventions": [
|
||||
{
|
||||
"intervention_type": "none",
|
||||
"description": "Raw subagent output parsed as JSON without repair."
|
||||
}
|
||||
],
|
||||
"leakage_audit": {
|
||||
"private_state_leak": false,
|
||||
"author_only_leak": false,
|
||||
"future_branch_leak": false,
|
||||
"raw_state_soup": false,
|
||||
"prompt_parroting": false,
|
||||
"body_or_object_omniscience": false,
|
||||
"notes": [
|
||||
"No hidden context markers or raw state variables appeared in the accepted output."
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"world_state_refs": {
|
||||
"read_refs": [
|
||||
{
|
||||
"ref_id": "prior-state",
|
||||
"ref_type": "world_fact",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-11.mutation.json",
|
||||
"visibility": "coordinator_only"
|
||||
},
|
||||
{
|
||||
"ref_id": "projected-context",
|
||||
"ref_type": "agent",
|
||||
"path": "examples/projected-contexts/lucent-hostage-feed-v0.turn-12.juno-ark.projected-context.json",
|
||||
"visibility": "character_local"
|
||||
}
|
||||
],
|
||||
"write_candidate_refs": [
|
||||
{
|
||||
"ref_id": "event",
|
||||
"ref_type": "event",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-12.event.json",
|
||||
"visibility": "public"
|
||||
},
|
||||
{
|
||||
"ref_id": "mutation",
|
||||
"ref_type": "world_fact",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-12.mutation.json",
|
||||
"visibility": "coordinator_only"
|
||||
}
|
||||
]
|
||||
},
|
||||
"proposed_deltas": [
|
||||
{
|
||||
"delta_id": "turn-12.delta.receipt-chain",
|
||||
"delta_type": "world_fact_update",
|
||||
"target_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-12.mutation.json",
|
||||
"summary": "Use reviewed mutation receipt as source for next turn state.",
|
||||
"authority": "manual_review",
|
||||
"status": "accepted",
|
||||
"rationale": "This is the explicit carryover tested by the loop."
|
||||
}
|
||||
],
|
||||
"branching": {
|
||||
"branch_action": "none",
|
||||
"active_branch_flags": [
|
||||
"training-loop-linear-release"
|
||||
],
|
||||
"event_constraints": []
|
||||
},
|
||||
"unresolved_hooks": [
|
||||
{
|
||||
"hook_id": "turn-12.hook.aftercare",
|
||||
"hook_type": "story",
|
||||
"summary": "Carry unresolved legal/feed/security aftermath without extending the hostage loop indefinitely.",
|
||||
"owner": "Ghostlight",
|
||||
"status": "carried"
|
||||
}
|
||||
],
|
||||
"review": {
|
||||
"reviewer": "Codex coordinator",
|
||||
"review_notes": [
|
||||
"Coordinator artifact is training-shaped and paired with exact packet/responder receipts."
|
||||
],
|
||||
"accepted_for_training": true,
|
||||
"failure_labels": []
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue