Extract Ghostlight Persona projection membrane

This commit is contained in:
Meta Vens 2026-08-16 21:45:50 +02:00
commit 64595b443a
6 changed files with 144 additions and 15 deletions

9
Cargo.lock generated
View file

@ -686,6 +686,7 @@ dependencies = [
"cultcache-rs 0.1.0 (git+https://github.com/GameCult/cultcache-rs.git?rev=0ba87453115e3d44e8fcb7419866cd7dd70fb096)",
"cultmesh-rs",
"cultnet-rs",
"ghostlight-persona-projection",
"jsonschema",
"rand 0.9.5",
"reqwest",
@ -705,6 +706,14 @@ dependencies = [
"zeroize",
]
[[package]]
name = "ghostlight-persona-projection"
version = "0.1.0"
dependencies = [
"serde",
"sha2",
]
[[package]]
name = "hashbrown"
version = "0.17.1"

View file

@ -1,6 +1,7 @@
[workspace]
members = [
"crates/ghostlight-dungeon",
"crates/ghostlight-persona-projection",
"vendor/cultcache-rs",
"vendor/cultcache-rs/cultcache-rs-derive",
"vendor/cultnet-rs",

View file

@ -14,6 +14,7 @@ cultcache-rs.workspace = true
cultnet-rs.workspace = true
cultmesh-rs.workspace = true
jsonschema.workspace = true
ghostlight-persona-projection = { path = "../ghostlight-persona-projection" }
rand.workspace = true
reqwest.workspace = true
rmp-serde.workspace = true

View file

@ -1,6 +1,10 @@
use crate::model::{ModelPort, ModelStageRequest, run_validated_stage};
use anyhow::{Result, anyhow};
use async_trait::async_trait;
use ghostlight_persona_projection::{
InterpreterPrompt, PersonaPrompt, ProjectorPrompt, build_interpreter_prompt,
build_persona_prompt, build_projector_prompt, narrative_stream_is_clean,
};
use schemars::{JsonSchema, schema_for};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
@ -72,10 +76,14 @@ impl PersonaProjectionEngine {
self.permit
.require(&slice.actor_id, &slice.snapshot_binding, "projector")
.await?;
let projector_prompt = format!(
"Project this permitted typed experience into one private lived narrative stream. Emit narrative only; no JSON, action syntax, schemas, or raw field labels.\n\n{}",
serde_json::to_string(&slice)?
);
let typed_context = serde_json::to_string(&slice)?;
let visible_stimulus = slice.perceived_events.join("\n");
let projector_prompt = build_projector_prompt(&ProjectorPrompt {
identity: &slice.actor_id,
typed_context: &typed_context,
visible_stimulus: &visible_stimulus,
domain_guidance: "The runtime is a persistent fictional world. Distances, knowledge, capabilities, relationships, and custody remain bounded by the supplied slice.",
});
let projected = run_validated_stage(
self.model.as_ref(),
&ModelStageRequest {
@ -88,7 +96,9 @@ impl PersonaProjectionEngine {
},
)
.await?;
require_narrative_membrane(&projected.narrative)?;
if !narrative_stream_is_clean(&projected.narrative) {
return Err(anyhow!("projector violated lived-narrative membrane"));
}
let lived = LivedNarrativeStream {
text: projected.narrative.clone(),
snapshot_binding: slice.snapshot_binding.clone(),
@ -105,7 +115,11 @@ impl PersonaProjectionEngine {
stage: "persona".into(),
model: self.persona_model.clone(),
snapshot_binding: slice.snapshot_binding.clone(),
lived_stream: lived.text.clone(),
lived_stream: build_persona_prompt(&PersonaPrompt {
identity: &slice.actor_id,
lived_stream: &lived.text,
domain_guidance: "Respond as a situated character. Speech and attempted effects are distinct; the world kernel resolves consequences.",
}),
output_schema: None,
source_receipt_ids: vec![],
},
@ -122,7 +136,13 @@ impl PersonaProjectionEngine {
stage: "interpreter".into(),
model: self.interpreter_model.clone(),
snapshot_binding: slice.snapshot_binding.clone(),
lived_stream: format!("Interpret the Persona output into the exact JSON schema below. Record only private changes supported by the lived stream. World actions are attempts, not completed effects. Every world action actor_id must be {}.\nSCHEMA:\n{}\nLIVED STREAM:\n{}\n\nPERSONA OUTPUT:\n{}",slice.actor_id,serde_json::to_string_pretty(&schema)?,lived.text, persona.narrative),
lived_stream: build_interpreter_prompt(&InterpreterPrompt {
identity: &slice.actor_id,
lived_stream: &lived.text,
persona_output: &persona.narrative,
output_schema: &serde_json::to_string_pretty(&schema)?,
domain_guidance: "Record only private changes supported by the lived stream. World actions are attempts, not completed effects. Every world action actor_id must match the identity.",
}),
output_schema: Some(schema),
source_receipt_ids: slice.source_receipt_ids,
},
@ -146,14 +166,6 @@ impl PersonaProjectionEngine {
}
}
fn require_narrative_membrane(text: &str) -> Result<()> {
let trimmed = text.trim();
if trimmed.is_empty() || trimmed.starts_with('{') || trimmed.contains("```json") {
return Err(anyhow!("projector violated lived-narrative membrane"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -0,0 +1,10 @@
[package]
name = "ghostlight-persona-projection"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
sha2.workspace = true

View file

@ -0,0 +1,96 @@
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub const MEMBRANE_SCHEMA: &str = "ghostlight.persona_projection_membrane.v1";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectorPrompt<'a> {
pub identity: &'a str,
pub typed_context: &'a str,
pub visible_stimulus: &'a str,
pub domain_guidance: &'a str,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PersonaPrompt<'a> {
pub identity: &'a str,
pub lived_stream: &'a str,
pub domain_guidance: &'a str,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct InterpreterPrompt<'a> {
pub identity: &'a str,
pub lived_stream: &'a str,
pub persona_output: &'a str,
pub output_schema: &'a str,
pub domain_guidance: &'a str,
}
pub fn build_projector_prompt(input: &ProjectorPrompt<'_>) -> String {
format!(
"<!-- membrane:{MEMBRANE_SCHEMA}:projector -->\nYou are the private Projector for {identity}. Convert the permitted typed context and visible stimulus into one lived narrative stream. Project what this person perceives, remembers, wants, fears, knows, and does not know. Do not choose actions, emit schemas, expose field names, or claim world effects.\n\nDomain guidance:\n{guidance}\n\nPermitted typed context:\n{context}\n\nVisible stimulus:\n{stimulus}\n\nReturn only the lived narrative stream.",
identity = input.identity,
guidance = input.domain_guidance,
context = input.typed_context,
stimulus = input.visible_stimulus,
)
}
pub fn build_persona_prompt(input: &PersonaPrompt<'_>) -> String {
format!(
"<!-- membrane:{MEMBRANE_SCHEMA}:persona -->\nYou are {identity}. The text below is your complete lived stream for this turn. Respond naturally from inside it. You may speak, remain silent, wonder, decide, or attempt something, but do not emit JSON, schemas, action DSL, tool calls, state patches, or claims that an external consequence already occurred.\n\nDomain guidance:\n{guidance}\n\nLived stream:\n{stream}\n\nReturn only the natural Persona turn.",
identity = input.identity,
guidance = input.domain_guidance,
stream = input.lived_stream,
)
}
pub fn build_interpreter_prompt(input: &InterpreterPrompt<'_>) -> String {
format!(
"<!-- membrane:{MEMBRANE_SCHEMA}:interpreter -->\nYou are the private Interpreter for {identity}. Convert the natural Persona turn into typed candidate effects supported by the lived stream. Candidates are proposals only; the owning runtime validates and commits them. Do not invent knowledge, capability, custody, perception, or completed consequences.\n\nDomain guidance:\n{guidance}\n\nLived stream:\n{stream}\n\nPersona turn:\n{output}\n\nReturn exactly one JSON object matching this schema:\n{schema}",
identity = input.identity,
guidance = input.domain_guidance,
stream = input.lived_stream,
output = input.persona_output,
schema = input.output_schema,
)
}
pub fn narrative_stream_is_clean(value: &str) -> bool {
let trimmed = value.trim();
!trimmed.is_empty()
&& !trimmed.starts_with('{')
&& !trimmed.starts_with('[')
&& !trimmed.contains("```json")
&& !trimmed.contains("STATE NOTE")
&& !trimmed.contains("SAY {")
}
pub fn sha256(value: &str) -> String {
format!("sha256:{:x}", Sha256::digest(value.as_bytes()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn persona_prompt_has_only_one_context_authority() {
let prompt = build_persona_prompt(&PersonaPrompt {
identity: "John",
lived_stream: "The forge is hot and the traveler looks worried.",
domain_guidance: "Speak as a villager.",
});
assert!(prompt.contains("The forge is hot"));
assert!(!prompt.contains("typed context"));
assert!(!prompt.contains("output schema"));
}
#[test]
fn projector_stream_rejects_schema_and_action_leaks() {
assert!(narrative_stream_is_clean("The forge smells of coal."));
assert!(!narrative_stream_is_clean(r#"{"conditions":[]}"#));
assert!(!narrative_stream_is_clean("SAY { channel: room }"));
}
}