From 64595b443ac31bc443b8bdf2eb6a545b26c8a27f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 16 Aug 2026 21:45:50 +0200 Subject: [PATCH] Extract Ghostlight Persona projection membrane --- Cargo.lock | 9 ++ Cargo.toml | 1 + crates/ghostlight-dungeon/Cargo.toml | 1 + crates/ghostlight-dungeon/src/persona.rs | 42 +++++--- .../ghostlight-persona-projection/Cargo.toml | 10 ++ .../ghostlight-persona-projection/src/lib.rs | 96 +++++++++++++++++++ 6 files changed, 144 insertions(+), 15 deletions(-) create mode 100644 crates/ghostlight-persona-projection/Cargo.toml create mode 100644 crates/ghostlight-persona-projection/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index db8c099..b93d4fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index e8d958a..31ccf7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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", diff --git a/crates/ghostlight-dungeon/Cargo.toml b/crates/ghostlight-dungeon/Cargo.toml index 2268372..e2edeef 100644 --- a/crates/ghostlight-dungeon/Cargo.toml +++ b/crates/ghostlight-dungeon/Cargo.toml @@ -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 diff --git a/crates/ghostlight-dungeon/src/persona.rs b/crates/ghostlight-dungeon/src/persona.rs index 4618bc4..a374b09 100644 --- a/crates/ghostlight-dungeon/src/persona.rs +++ b/crates/ghostlight-dungeon/src/persona.rs @@ -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::*; diff --git a/crates/ghostlight-persona-projection/Cargo.toml b/crates/ghostlight-persona-projection/Cargo.toml new file mode 100644 index 0000000..7918732 --- /dev/null +++ b/crates/ghostlight-persona-projection/Cargo.toml @@ -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 + diff --git a/crates/ghostlight-persona-projection/src/lib.rs b/crates/ghostlight-persona-projection/src/lib.rs new file mode 100644 index 0000000..209a4fe --- /dev/null +++ b/crates/ghostlight-persona-projection/src/lib.rs @@ -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!( + "\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!( + "\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!( + "\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 }")); + } +}