Bind direct speech to exact Persona responses

This commit is contained in:
Meta Vens 2026-08-24 07:33:48 +02:00
commit fb9c42c266
13 changed files with 732 additions and 25 deletions

View file

@ -41,6 +41,7 @@ async fn main() -> anyhow::Result<()> {
at: campaign.world_time,
speaker: "world".into(),
text: event_summary.clone(),
persona_response_actor_ids: Default::default(),
});
let store = CampaignStore::open(root.join("campaign.cc"))?;
store.create_campaign(&campaign, &[], &[])?;

View file

@ -2918,6 +2918,7 @@ fn seed_to_campaign(seed: CompiledSeed, receipts: &[VaultEvidenceReceipt]) -> Re
at: now,
speaker: "world".into(),
text: seed.opening_narration,
persona_response_actor_ids: BTreeSet::new(),
}],
last_player_activity: now,
pending_ticks: 0,

View file

@ -646,6 +646,11 @@ pub struct WorldActionProposal {
pub struct ActorReaction {
pub actor_id: String,
pub speech: Option<String>,
/// A typed, actor-owned refusal to answer. The kernel lowers this to a
/// deterministic visible transcript turn, so an Interpreter cannot smuggle
/// an unassessed physical effect through free-form reaction prose.
#[serde(default)]
pub deliberate_silence: bool,
pub private_delta: ActorStateDelta,
pub action_proposals: Vec<WorldActionProposal>,
}
@ -846,6 +851,11 @@ pub struct NarrativeTurn {
pub at: DateTime<Utc>,
pub speaker: String,
pub text: String,
/// Exact model-controlled actors whom this committed speech asks to
/// respond. Other present actors still perceive and appraise the turn, but
/// they are not response-bound. Human-controlled actors never appear here.
#[serde(default)]
pub persona_response_actor_ids: BTreeSet<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
@ -890,6 +900,11 @@ pub enum WorldCommand {
actor_id: String,
text: String,
intended_effect: Option<String>,
/// Filled by the server-side scene-address resolver from the exact
/// present-actor catalog. Player boundaries must not accept authority
/// IDs in this field.
#[serde(default)]
persona_response_actor_ids: BTreeSet<String>,
},
Assess {
expected_revision: u64,

View file

@ -481,6 +481,7 @@ mod tests {
at: chrono::Utc::now(),
speaker: campaign.player_actor_id.clone(),
text: "I ask the unnamed porter for their name.".into(),
persona_response_actor_ids: BTreeSet::new(),
});
let exact = format!(
"{} says: I ask the unnamed porter for their name.",
@ -496,6 +497,7 @@ mod tests {
at: chrono::Utc::now(),
speaker: "world".into(),
text: "They agree to help.".into(),
persona_response_actor_ids: BTreeSet::new(),
});
campaign.revision = 8;
assert!(automatic_individuation_stimulus(&campaign).is_none());

View file

@ -348,6 +348,7 @@ fn execute(
at: Utc::now(),
speaker: "world".into(),
text: text.clone(),
persona_response_actor_ids: BTreeSet::new(),
});
commit_mutation_transition(
store,
@ -364,20 +365,40 @@ fn execute(
actor_id,
text,
intended_effect,
persona_response_actor_ids,
} => {
require_revision(&campaign, expected_revision)?;
validate_bounded_text("speech", &text, 4_000)?;
if let Some(effect) = &intended_effect {
validate_bounded_text("intended effect", effect, 1_000)?;
}
if !campaign.actors.contains_key(&actor_id) {
return Err(KernelError::Invalid("unknown actor".into()));
let speaker = campaign
.actors
.get(&actor_id)
.ok_or_else(|| KernelError::Invalid("unknown actor".into()))?;
for response_actor_id in &persona_response_actor_ids {
let response_actor = campaign.actors.get(response_actor_id).ok_or_else(|| {
KernelError::Invalid("speech response actor is unknown".into())
})?;
if response_actor_id == &actor_id
|| response_actor.location_id != speaker.location_id
|| campaign
.agency_profiles
.get(response_actor_id)
.is_some_and(|profile| !profile.simulation_eligible)
{
return Err(KernelError::Invalid(
"speech response actor is not an eligible present Persona".into(),
));
}
}
let response_actor_ids = persona_response_actor_ids.clone();
campaign.transcript.push(NarrativeTurn {
revision: campaign.revision + 1,
at: Utc::now(),
speaker: actor_id.clone(),
text,
persona_response_actor_ids,
});
if let Some(effect) = intended_effect {
campaign.transcript.push(NarrativeTurn {
@ -385,11 +406,13 @@ fn execute(
at: Utc::now(),
speaker: "system".into(),
text: format!("Intended effect requires assessment: {effect}"),
persona_response_actor_ids: BTreeSet::new(),
});
}
refresh_materialized_member_relevance(
&mut campaign,
std::iter::once(actor_id.as_str()),
std::iter::once(actor_id.as_str())
.chain(response_actor_ids.iter().map(String::as_str)),
);
if is_human_controlled_actor(&campaign, &actor_id) {
campaign.last_player_activity = Utc::now();
@ -1265,7 +1288,9 @@ fn execute(
reactions,
} => {
require_revision(&campaign, expected_revision)?;
let witnessed_memory = canonical_witnessed_memory(&campaign, &event_summary)?;
let witnessed_turn = canonical_witnessed_turn(&campaign, &event_summary)?;
let response_expected_actor_ids = witnessed_turn.persona_response_actor_ids.clone();
let witnessed_memory = format!("Witnessed: {}", canonical_turn_text(witnessed_turn));
let player_location = campaign.actors[&campaign.player_actor_id]
.location_id
.clone();
@ -1288,10 +1313,33 @@ fn execute(
"reaction Interpreter cannot write actor memory".into(),
));
}
if let Some(speech) = &reaction.speech {
validate_bounded_text("reaction speech", speech, 1_000)?;
}
for proposal in &reaction.action_proposals {
validate_world_proposal(actor, proposal)?;
}
}
for response_actor_id in &response_expected_actor_ids {
let reaction = reactions
.iter()
.find(|reaction| &reaction.actor_id == response_actor_id)
.ok_or_else(|| {
KernelError::Invalid(
"directly addressed Persona is absent from the reaction wave".into(),
)
})?;
if reaction
.speech
.as_deref()
.is_none_or(|value| value.trim().is_empty())
&& !reaction.deliberate_silence
{
return Err(KernelError::Invalid(
"directly addressed Persona produced no observable response".into(),
));
}
}
let source_receipt_id = format!(
"reaction-input:{}",
crate::legacy_transition::digest_serializable(&reactions)
@ -1319,6 +1367,16 @@ fn execute(
at: Utc::now(),
speaker: reaction.actor_id.clone(),
text: speech,
persona_response_actor_ids: BTreeSet::new(),
});
}
if reaction.deliberate_silence {
campaign.transcript.push(NarrativeTurn {
revision: campaign.revision + 1,
at: Utc::now(),
speaker: reaction.actor_id.clone(),
text: "deliberately does not answer.".into(),
persona_response_actor_ids: BTreeSet::new(),
});
}
campaign
@ -1450,6 +1508,7 @@ fn execute(
at: Utc::now(),
speaker: "world".into(),
text: text.clone(),
persona_response_actor_ids: BTreeSet::new(),
});
Some((roll, transition, mutation_receipt))
} else {
@ -1708,21 +1767,16 @@ fn validate_world_proposal(
Ok(())
}
fn canonical_witnessed_memory(
campaign: &Campaign,
fn canonical_witnessed_turn<'a>(
campaign: &'a Campaign,
event_summary: &str,
) -> Result<String, KernelError> {
) -> Result<&'a NarrativeTurn, KernelError> {
let event_summary = event_summary.trim();
let witnessed = campaign.transcript.iter().rev().find_map(|turn| {
let canonical = if turn.speaker == "world" {
turn.text.trim().to_owned()
} else {
format!("{} says: {}", turn.speaker, turn.text.trim())
};
(canonical == event_summary).then_some(canonical)
});
witnessed
.map(|canonical| format!("Witnessed: {canonical}"))
campaign
.transcript
.iter()
.rev()
.find(|turn| canonical_turn_text(turn) == event_summary)
.ok_or_else(|| {
KernelError::Invalid(
"reaction stimulus does not match a committed transcript turn".into(),
@ -1730,6 +1784,14 @@ fn canonical_witnessed_memory(
})
}
fn canonical_turn_text(turn: &NarrativeTurn) -> String {
if turn.speaker == "world" {
turn.text.trim().to_owned()
} else {
format!("{} says: {}", turn.speaker, turn.text.trim())
}
}
fn overlay(
base: &BTreeSet<String>,
add: &BTreeSet<String>,
@ -3383,6 +3445,7 @@ mod tests {
at: Utc::now(),
speaker: "world".into(),
text: "They agree to help design the relay.".into(),
persona_response_actor_ids: BTreeSet::new(),
});
seed.gestalts.insert(
"refugees".into(),
@ -5020,6 +5083,7 @@ mod tests {
actor_id: "player".into(),
text: "x".repeat(4_001),
intended_effect: None,
persona_response_actor_ids: BTreeSet::new(),
})
.await
.is_err()
@ -5059,6 +5123,7 @@ mod tests {
actor_id: "npc".into(),
text: "The world does not wait.".into(),
intended_effect: None,
persona_response_actor_ids: BTreeSet::new(),
})
.await
.unwrap();
@ -5224,6 +5289,7 @@ mod tests {
actor_id: "player".into(),
text: "Wait.".into(),
intended_effect: None,
persona_response_actor_ids: BTreeSet::new(),
})
.await
.unwrap();
@ -5764,6 +5830,94 @@ mod tests {
assert_eq!(receipts[2].resolution_epoch, 3);
}
#[tokio::test]
async fn directly_addressed_persona_must_commit_an_observable_response() {
let dir = tempfile::tempdir().unwrap();
let store = CampaignStore::open(dir.path().join("campaign.cc")).unwrap();
let kernel = WorldKernel::start(store.clone());
let mut seed = campaign();
seed.actors.insert(
"anna".into(),
ActorState {
id: "anna".into(),
name: "Anna".into(),
location_id: "room".into(),
capabilities: BTreeSet::new(),
knowledge: BTreeSet::new(),
equipment: BTreeSet::new(),
conditions: BTreeSet::new(),
obligations: BTreeSet::new(),
relationships: BTreeMap::new(),
goals: vec![],
memories: vec![],
},
);
seed.transcript.push(NarrativeTurn {
revision: 0,
at: seed.world_time,
speaker: "player".into(),
text: "Anna, answer me.".into(),
persona_response_actor_ids: BTreeSet::from(["anna".into()]),
});
kernel
.command(WorldCommand::CreateCampaign {
campaign: seed.clone(),
evidence_receipts: vec![],
model_stage_receipts: vec![],
})
.await
.unwrap();
let silent = ActorReaction {
actor_id: "anna".into(),
speech: None,
deliberate_silence: false,
private_delta: ActorStateDelta::default(),
action_proposals: vec![],
};
let error = kernel
.command(WorldCommand::ResolveReactionWave {
expected_revision: 0,
event_summary: "player says: Anna, answer me.".into(),
reactions: vec![silent],
})
.await
.unwrap_err();
assert!(error.to_string().contains("no observable response"));
assert_eq!(
store
.load::<Campaign>("campaign.v1", &seed.id.to_string())
.unwrap()
.unwrap()
.1
.revision,
0
);
let committed = kernel
.command(WorldCommand::ResolveReactionWave {
expected_revision: 0,
event_summary: "player says: Anna, answer me.".into(),
reactions: vec![ActorReaction {
actor_id: "anna".into(),
speech: None,
deliberate_silence: true,
private_delta: ActorStateDelta::default(),
action_proposals: vec![],
}],
})
.await
.unwrap();
let CommandResult::Committed { campaign, .. } = committed else {
panic!()
};
assert_eq!(campaign.revision, 1);
assert_eq!(
campaign.transcript.last().unwrap().text,
"deliberately does not answer."
);
}
#[tokio::test]
async fn invalid_reaction_rejects_the_entire_wave_without_mutation() {
let dir = tempfile::tempdir().unwrap();
@ -5800,6 +5954,7 @@ mod tests {
ActorReaction {
actor_id: "anna".into(),
speech: Some("I saw that.".into()),
deliberate_silence: false,
private_delta: ActorStateDelta {
memories_add: vec!["saw the event".into()],
..Default::default()
@ -5809,6 +5964,7 @@ mod tests {
ActorReaction {
actor_id: "bert".into(),
speech: None,
deliberate_silence: false,
private_delta: ActorStateDelta::default(),
action_proposals: vec![WorldActionProposal {
actor_id: "bert".into(),
@ -5851,6 +6007,7 @@ mod tests {
at: seed.world_time,
speaker: "player".into(),
text: "Tell me which seal I repaired.".into(),
persona_response_actor_ids: BTreeSet::new(),
});
seed.actors.insert(
"anna".into(),
@ -5884,6 +6041,7 @@ mod tests {
reactions: vec![ActorReaction {
actor_id: "anna".into(),
speech: None,
deliberate_silence: false,
private_delta: ActorStateDelta {
memories_add: vec!["I repaired the seal.".into()],
..Default::default()
@ -5915,6 +6073,7 @@ mod tests {
at: seed.world_time,
speaker: "player".into(),
text: "Tell me which seal I repaired.".into(),
persona_response_actor_ids: BTreeSet::new(),
});
seed.actors.insert(
"anna".into(),
@ -5948,6 +6107,7 @@ mod tests {
reactions: vec![ActorReaction {
actor_id: "anna".into(),
speech: Some("I did not witness it.".into()),
deliberate_silence: false,
private_delta: ActorStateDelta::default(),
action_proposals: vec![],
}],
@ -5977,6 +6137,7 @@ mod tests {
at: seed.world_time,
speaker: "world".into(),
text: "a disturbance".into(),
persona_response_actor_ids: BTreeSet::new(),
});
seed.last_player_activity = Utc::now() - Duration::hours(2);
for id in ["anna", "bert"] {
@ -6024,12 +6185,14 @@ mod tests {
ActorReaction {
actor_id: "anna".into(),
speech: None,
deliberate_silence: false,
private_delta: ActorStateDelta::default(),
action_proposals: vec![anna.clone()],
},
ActorReaction {
actor_id: "bert".into(),
speech: None,
deliberate_silence: false,
private_delta: ActorStateDelta::default(),
action_proposals: vec![bert.clone()],
},
@ -6138,6 +6301,7 @@ mod tests {
at: seed.world_time,
speaker: "world".into(),
text: "a disturbance".into(),
persona_response_actor_ids: BTreeSet::new(),
});
seed.actors.insert(
"anna".into(),
@ -6178,6 +6342,7 @@ mod tests {
reactions: vec![ActorReaction {
actor_id: "anna".into(),
speech: None,
deliberate_silence: false,
private_delta: ActorStateDelta::default(),
action_proposals: vec![proposal.clone()],
}],
@ -6190,6 +6355,7 @@ mod tests {
actor_id: "player".into(),
text: "I move on before Anna acts.".into(),
intended_effect: None,
persona_response_actor_ids: BTreeSet::new(),
})
.await
.unwrap();
@ -6226,6 +6392,7 @@ mod tests {
reactions: vec![ActorReaction {
actor_id: "anna".into(),
speech: None,
deliberate_silence: false,
private_delta: ActorStateDelta::default(),
action_proposals: vec![],
}],

View file

@ -42,7 +42,7 @@ use ghostlight_dungeon::{
surface::{
campaign_interface_version, player_surface_for_actor, rebase_campaign_surface_revision,
},
turn::{SnapshotPermit, appraise_present},
turn::{SnapshotPermit, appraise_present, resolve_speech_addresses},
vault::VoidBotMcpVault,
};
use serde::Deserialize;
@ -1468,6 +1468,7 @@ async fn dispatch_eve_product_command(
actor_id,
text: required_string!("text"),
intended_effect: None,
persona_response_actor_ids: BTreeSet::new(),
},
"world.assess" => WorldCommand::Assess {
expected_revision: required_u64!("expected_revision"),
@ -4205,6 +4206,65 @@ async fn command(
let body = player_safe_strategic_failure(&error);
return (StatusCode::CONFLICT, Json(body)).into_response();
}
if let WorldCommand::Speak {
expected_revision,
actor_id,
text,
persona_response_actor_ids,
..
} = &mut command
{
let campaign = match load_campaign(&runtime.store) {
Ok(value) => value,
Err(error) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorBody {
error: error.to_string(),
}),
)
.into_response();
}
};
if campaign.revision != *expected_revision {
return (
StatusCode::CONFLICT,
Json(ErrorBody {
error: format!(
"stale revision: expected {expected_revision}, actual {}",
campaign.revision
),
}),
)
.into_response();
}
if let Some(model) = &state.model {
match resolve_speech_addresses(model.clone(), MODEL_FAST, &campaign, actor_id, text)
.await
{
Ok((plan, receipts)) => {
for receipt in receipts {
let _ = runtime.store.insert(
"persona_stage_receipt.v1",
"ghostlight.persona_stage_receipt.v1",
receipt.storage_key(),
&receipt,
);
}
*persona_response_actor_ids = plan.persona_response_actor_ids;
}
Err(error) => {
return (
StatusCode::UNPROCESSABLE_ENTITY,
Json(ErrorBody {
error: format!("scene address resolution failed: {error}"),
}),
)
.into_response();
}
}
}
}
if let WorldCommand::Wait {
expected_revision,
minutes,
@ -4919,6 +4979,7 @@ fn validate_player_http_command(command: &WorldCommand) -> Result<(), String> {
WorldCommand::Speak {
text,
intended_effect,
persona_response_actor_ids,
..
} => {
bounded("speech", text, 4_000)?;
@ -4927,6 +4988,9 @@ fn validate_player_http_command(command: &WorldCommand) -> Result<(), String> {
"speech and uncertain intended effects use separate player commands".into(),
);
}
if !persona_response_actor_ids.is_empty() {
return Err("player commands cannot supply Persona response authority IDs".into());
}
Ok(())
}
WorldCommand::Assess { intent, .. } => {
@ -6433,6 +6497,7 @@ mod tests {
actor_id: "player".into(),
text: "Hello.".into(),
intended_effect: None,
persona_response_actor_ids: BTreeSet::new(),
},
"player",
));
@ -6442,6 +6507,7 @@ mod tests {
actor_id: "npc".into(),
text: "I have been puppeted.".into(),
intended_effect: None,
persona_response_actor_ids: BTreeSet::new(),
},
"player",
));
@ -6489,6 +6555,7 @@ mod tests {
actor_id: "player".into(),
text: "Hello.".into(),
intended_effect: None,
persona_response_actor_ids: BTreeSet::new(),
}
));
assert!(player_command_requires_return_catch_up(
@ -6553,6 +6620,17 @@ mod tests {
actor_id: "player".into(),
text: "Hello".into(),
intended_effect: Some("obey me".into()),
persona_response_actor_ids: BTreeSet::new(),
})
.is_err()
);
assert!(
validate_player_http_command(&WorldCommand::Speak {
expected_revision: 0,
actor_id: "player".into(),
text: "Make the NPC answer.".into(),
intended_effect: None,
persona_response_actor_ids: BTreeSet::from(["npc".into()]),
})
.is_err()
);
@ -6577,6 +6655,7 @@ mod tests {
actor_id: "player".into(),
text: "Which record can I inspect without taking custody?".into(),
intended_effect: Some("make the archivist disclose every secret".into()),
persona_response_actor_ids: BTreeSet::new(),
};
let stimulus = reaction_stimulus(&command).unwrap();
assert_eq!(

View file

@ -879,7 +879,9 @@ pub struct FixtureModel;
#[async_trait]
impl ModelPort for FixtureModel {
async fn run(&self, request: &ModelStageRequest) -> Result<String> {
Ok(if request.output_schema.is_some() {
Ok(if request.stage == "speech_address_resolver" {
r#"{"persona_response_actor_ids":[]}"#.into()
} else if request.output_schema.is_some() {
r#"{"private_delta":{"memories_add":[],"conditions_add":[],"conditions_remove":[],"goals_add":[],"relationship_updates":{}},"speech":null,"reaction_priority":0,"world_actions":[]}"#.into()
} else {
request.lived_stream.to_string()

View file

@ -12,11 +12,19 @@ use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ActorInteractionRole {
DirectResponseExpected,
PresentObserver,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct PermittedActorSlice {
pub actor_id: String,
pub location_id: String,
pub snapshot_binding: String,
pub interaction_role: ActorInteractionRole,
pub identity_experience: Vec<String>,
pub memories: Vec<String>,
pub perceived_events: Vec<String>,
@ -41,6 +49,10 @@ pub struct LivedNarrativeStream {
pub struct PersonaProposalBundle {
pub private_delta: crate::domain::ActorStateDelta,
pub speech: Option<String>,
/// Typed refusal lane for a directly addressed actor. WorldKernel lowers
/// it deterministically; this field cannot carry free-form effects.
#[serde(default)]
pub deliberate_silence: bool,
pub reaction_priority: i16,
pub world_actions: Vec<crate::domain::WorldActionProposal>,
}
@ -362,7 +374,7 @@ impl PersonaProjectionEngine {
lived_stream: build_persona_prompt(&PersonaPrompt {
identity: &slice.actor_id,
lived_stream: &lived.text,
domain_guidance: "Respond as a situated character. Answer direct questions at human conversational length. Speech and attempted effects are distinct; the world kernel resolves consequences. Asking, inviting, persuading, threatening, or demanding completes only your own speech: never supply the other person's answer, choice, consent, belief, disclosure, or obedience.",
domain_guidance: "Respond as a situated character. When the lived stream says a response is expected from you, make your answer, refusal, or deliberate silence observable now. Speech and attempted effects are distinct; the world kernel resolves consequences. Asking, inviting, persuading, threatening, or demanding completes only your own speech: never supply the other person's answer, choice, consent, belief, disclosure, or obedience.",
word_budget: 160,
}),
output_schema: None,
@ -395,7 +407,7 @@ impl PersonaProjectionEngine {
domain_guidance: &permission_guidance,
}),
output_schema: Some(schema),
source_receipt_ids: slice.source_receipt_ids,
source_receipt_ids: slice.source_receipt_ids.clone(),
temperature: Some(0.0),
max_output_tokens: Some(768),
},
@ -410,6 +422,7 @@ impl PersonaProjectionEngine {
.clone()
.ok_or_else(|| anyhow!("interpreter produced no typed proposal"))?,
)?;
validate_actor_proposals(&slice, &proposals)?;
Ok(PersonaTerminalBundle {
lived_stream: lived,
persona_output: persona.narrative,
@ -420,8 +433,16 @@ impl PersonaProjectionEngine {
}
fn actor_interpreter_guidance(slice: &PermittedActorSlice) -> String {
let response_guidance = match slice.interaction_role {
ActorInteractionRole::DirectResponseExpected => {
"This actor is an exact direct addressee. Extract an explicit spoken response, or set deliberate_silence true only when the Persona deliberately refuses or remains silent. Do not erase the Persona's response into null fields."
}
ActorInteractionRole::PresentObserver => {
"This actor is present but was not directly asked to respond. Speech or observable action remains optional and must follow only from this actor's projected choice."
}
};
format!(
"Record only private changes supported by the lived stream and typed context. World actions are attempts, not completed effects. Speech is extracted separately and is already complete. Do not emit a world action merely to make another actor answer, choose, consent, believe, disclose, feel, or obey; the other actor retains agency and any requested response remains unresolved. actor_id must be {:?}. Exact allowed state references are {:?}. Relationship update keys may only be {:?}.",
"{response_guidance} Record only private changes supported by the lived stream and typed context. World actions are attempts, not completed effects. Speech is extracted separately and is already complete. Do not emit a world action merely to make another actor answer, choose, consent, believe, disclose, feel, or obey; the other actor retains agency and any requested response remains unresolved. actor_id must be {:?}. Exact allowed state references are {:?}. Relationship update keys may only be {:?}.",
slice.actor_id,
allowed_actor_references(slice),
slice.perceived_actors.keys().collect::<Vec<_>>()
@ -455,11 +476,46 @@ fn ground_actor_lived_stream(slice: &PermittedActorSlice, projection: &str) -> S
} else {
slice.memories[recent_memory_start..].join("; ")
};
let interaction = match slice.interaction_role {
ActorInteractionRole::DirectResponseExpected => {
"You are an exact direct addressee of the current speech. A response is expected from you now. You retain the agency to answer, refuse, or remain silent, but refusal or silence must be made observable rather than disappearing from the scene."
}
ActorInteractionRole::PresentObserver => {
"You are present and perceive the current event, but you were not directly asked to respond. You may react from your own goals and pressures; you need not seize conversational focus."
}
};
format!(
"{projection}\n\nYour reliable footing in this moment is narrow. What you know as external fact: {reliable_knowledge}. What you remember experiencing or being told: {remembered_experience}. These are your attributed recollections, not omniscient proof. What is happening now: {visible_now}. People you can presently perceive: {people_now}. Everything else in your impressions is feeling, inference, uncertainty, or possibility—not a remembered or witnessed fact."
"{projection}\n\n{interaction} Your reliable footing in this moment is narrow. What you know as external fact: {reliable_knowledge}. What you remember experiencing or being told: {remembered_experience}. These are your attributed recollections, not omniscient proof. What is happening now: {visible_now}. People you can presently perceive: {people_now}. Everything else in your impressions is feeling, inference, uncertainty, or possibility—not a remembered or witnessed fact."
)
}
fn validate_actor_proposals(
slice: &PermittedActorSlice,
proposals: &PersonaProposalBundle,
) -> Result<()> {
if matches!(
slice.interaction_role,
ActorInteractionRole::DirectResponseExpected
) && proposals
.speech
.as_deref()
.is_none_or(|value| value.trim().is_empty())
&& !proposals.deliberate_silence
{
return Err(anyhow!(
"directly addressed Persona produced no observable response"
));
}
if proposals
.speech
.as_deref()
.is_some_and(|value| value.trim().is_empty() || value.chars().count() > 1_000)
{
return Err(anyhow!("Persona speech must contain 1 to 1000 characters"));
}
Ok(())
}
fn cell_projector_context(slice: &PermittedCellSlice) -> serde_json::Value {
serde_json::json!({
"cell_id": slice.cell_id,
@ -2537,6 +2593,36 @@ fn constrain_interpreter_schema(
.and_then(|value| value.as_object_mut())
.ok_or_else(|| anyhow!("Persona proposal schema has no memory additions"))?;
memories_add.insert("maxItems".into(), serde_json::json!(0));
let root = schema
.as_object_mut()
.ok_or_else(|| anyhow!("Persona proposal schema is not an object"))?;
let properties = root
.get_mut("properties")
.and_then(serde_json::Value::as_object_mut)
.ok_or_else(|| anyhow!("Persona proposal schema has no root properties"))?;
properties.insert(
"speech".into(),
serde_json::json!({"type":["string","null"],"minLength":1,"maxLength":1000}),
);
properties.insert(
"deliberate_silence".into(),
serde_json::json!({"type":"boolean"}),
);
if matches!(
slice.interaction_role,
ActorInteractionRole::DirectResponseExpected
) {
root.entry("allOf")
.or_insert_with(|| serde_json::json!([]))
.as_array_mut()
.ok_or_else(|| anyhow!("Persona proposal schema allOf is not an array"))?
.push(serde_json::json!({
"anyOf":[
{"required":["speech"],"properties":{"speech":{"type":"string","minLength":1,"maxLength":1000}}},
{"required":["deliberate_silence"],"properties":{"deliberate_silence":{"const":true}}}
]
}));
}
Ok(())
}
@ -3834,6 +3920,7 @@ mod tests {
actor_id: "npc".into(),
location_id: "room".into(),
snapshot_binding: "campaign:1".into(),
interaction_role: ActorInteractionRole::PresentObserver,
identity_experience: vec!["A tired navigator".into()],
memories: vec!["Proposed the eastern trail evacuation at dusk.".into()],
perceived_events: vec![],

View file

@ -475,18 +475,21 @@ mod tests {
at: Utc::now(),
speaker: "player".into(),
text: "I ask the question.".into(),
persona_response_actor_ids: Default::default(),
},
NarrativeTurn {
revision: 2,
at: Utc::now(),
speaker: "world".into(),
text: "raw outcome".into(),
persona_response_actor_ids: Default::default(),
},
NarrativeTurn {
revision: 3,
at: Utc::now(),
speaker: "npc".into(),
text: "I answer directly.".into(),
persona_response_actor_ids: Default::default(),
},
];
let story = story_nodes(&campaign);

View file

@ -1,11 +1,128 @@
use crate::{
domain::{ActorReaction, Campaign},
model::ModelStageReceipt,
model::{ModelPort, ModelStageReceipt, ModelStageRequest, run_validated_stage},
persistence::CampaignStore,
persona::{ExecutionPermit, PermittedActorSlice, PersonaProjectionEngine},
persona::{
ActorInteractionRole, ExecutionPermit, PermittedActorSlice, PersonaProjectionEngine,
},
};
use anyhow::{Result, anyhow};
use async_trait::async_trait;
use schemars::{JsonSchema, schema_for};
use serde::{Deserialize, Serialize};
use std::{collections::BTreeSet, sync::Arc};
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct SpeechAddressPlan {
/// Exact present, model-controlled actors from whom the utterance requests
/// an answer. Mentioned people and passive hearers are not included.
pub persona_response_actor_ids: BTreeSet<String>,
}
/// Resolve conversational address from player language against the exact
/// present-actor catalog. The output is proposal-only until WorldKernel
/// validates co-presence and commits it with the speech turn.
pub async fn resolve_speech_addresses(
model: Arc<dyn ModelPort>,
model_name: &str,
campaign: &Campaign,
speaker_actor_id: &str,
speech: &str,
) -> Result<(SpeechAddressPlan, Vec<ModelStageReceipt>)> {
let speaker = campaign
.actors
.get(speaker_actor_id)
.ok_or_else(|| anyhow!("speech actor is unknown"))?;
let candidates = campaign
.actors
.values()
.filter(|actor| {
actor.id != speaker_actor_id
&& actor.location_id == speaker.location_id
&& campaign
.agency_profiles
.get(&actor.id)
.is_none_or(|profile| profile.simulation_eligible)
})
.map(|actor| (actor.id.clone(), actor.name.clone()))
.collect::<std::collections::BTreeMap<_, _>>();
if candidates.is_empty() {
return Ok((
SpeechAddressPlan {
persona_response_actor_ids: BTreeSet::new(),
},
Vec::new(),
));
}
let allowed_ids = candidates.keys().cloned().collect::<BTreeSet<_>>();
let previous_focus = campaign
.transcript
.iter()
.rev()
.find(|turn| {
turn.speaker == speaker_actor_id && !turn.persona_response_actor_ids.is_empty()
})
.map(|turn| turn.persona_response_actor_ids.clone())
.unwrap_or_default();
let recent_public_turns = campaign
.transcript
.iter()
.rev()
.take(8)
.collect::<Vec<_>>()
.into_iter()
.rev()
.map(|turn| {
serde_json::json!({
"speaker":turn.speaker,
"text":turn.text,
"persona_response_actor_ids":turn.persona_response_actor_ids,
})
})
.collect::<Vec<_>>();
let mut schema = serde_json::to_value(schema_for!(SpeechAddressPlan))?;
schema["properties"]["persona_response_actor_ids"] = serde_json::json!({
"type":"array",
"uniqueItems":true,
"maxItems":allowed_ids.len(),
"items":{"type":"string","enum":allowed_ids},
});
let prompt = format!(
"You are Ghostlight's private scene-address resolver. Decide only which supplied present, model-controlled actors this exact speech directly asks to answer now. A person who is merely mentioned, discussed, visible, or able to overhear is not a response target. A room-wide statement need not demand an answer from everyone. Preserve ordinary conversational focus from previous_focus when pronouns or an unqualified follow-up clearly continue it. Select only exact supplied actor IDs. Empty is valid when the speaker asks nobody present to answer. Return exactly one JSON object matching the schema.\nSCHEMA:\n{}\nPRESENT ACTORS:\n{}\nPREVIOUS FOCUS:\n{}\nRECENT PUBLIC TURNS:\n{}\nSPEAKER ACTOR ID:\n{}\nEXACT SPEECH:\n{}",
serde_json::to_string(&schema)?,
serde_json::to_string(&candidates)?,
serde_json::to_string(&previous_focus)?,
serde_json::to_string(&recent_public_turns)?,
serde_json::to_string(speaker_actor_id)?,
serde_json::to_string(speech)?,
);
let output = run_validated_stage(
model.as_ref(),
&ModelStageRequest {
stage: "speech_address_resolver".into(),
model: model_name.into(),
snapshot_binding: format!("campaign:{}:revision:{}", campaign.id, campaign.revision),
lived_stream: prompt,
output_schema: Some(schema),
source_receipt_ids: campaign.branch_origin.evidence_receipt_ids.clone(),
temperature: Some(0.0),
max_output_tokens: Some(256),
},
)
.await?;
let plan: SpeechAddressPlan = serde_json::from_value(
output
.structured
.clone()
.ok_or_else(|| anyhow!("speech address resolver produced no plan"))?,
)?;
if !plan.persona_response_actor_ids.is_subset(&allowed_ids) {
return Err(anyhow!(
"speech address resolver selected an unavailable actor"
));
}
Ok((plan, vec![output.receipt]))
}
pub struct SnapshotPermit {
store: CampaignStore,
@ -65,6 +182,9 @@ pub async fn appraise_present(
campaign: &Campaign,
event_summary: &str,
) -> Result<ReactionWaveOutput> {
let response_expected_actor_ids = canonical_reaction_turn(campaign, event_summary)?
.persona_response_actor_ids
.clone();
let player = campaign
.actors
.get(&campaign.player_actor_id)
@ -98,6 +218,11 @@ pub async fn appraise_present(
let receipts = campaign.branch_origin.evidence_receipt_ids.clone();
let perceived_actors = perceived_actors.clone();
let semaphore = semaphore.clone();
let interaction_role = if response_expected_actor_ids.contains(&actor.id) {
ActorInteractionRole::DirectResponseExpected
} else {
ActorInteractionRole::PresentObserver
};
jobs.spawn(async move {
let _provider_slot = semaphore
.acquire_owned()
@ -107,6 +232,7 @@ pub async fn appraise_present(
actor_id: actor.id.clone(),
location_id: actor.location_id.clone(),
snapshot_binding: snapshot,
interaction_role,
identity_experience: vec![format!("You are {}.", actor.name)],
memories: actor.memories,
perceived_events: vec![event],
@ -140,6 +266,7 @@ pub async fn appraise_present(
reactions.push(ActorReaction {
actor_id,
speech: terminal.proposals.speech,
deliberate_silence: terminal.proposals.deliberate_silence,
private_delta: terminal.proposals.private_delta,
action_proposals: terminal.proposals.world_actions,
});
@ -150,3 +277,188 @@ pub async fn appraise_present(
receipts,
})
}
fn canonical_reaction_turn<'a>(
campaign: &'a Campaign,
event_summary: &str,
) -> Result<&'a crate::domain::NarrativeTurn> {
let event_summary = event_summary.trim();
campaign
.transcript
.iter()
.rev()
.find(|turn| {
let canonical = if turn.speaker == "world" {
turn.text.trim().to_owned()
} else {
format!("{} says: {}", turn.speaker, turn.text.trim())
};
canonical == event_summary
})
.ok_or_else(|| anyhow!("reaction stimulus does not match a committed transcript turn"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
domain::{ActorState, NarrativeTurn},
model::ModelStageRequest,
persona::{AllowAllPermit, PersonaProjectionEngine},
};
use chrono::Utc;
use std::collections::{BTreeMap, BTreeSet};
struct AddressAwareModel;
#[async_trait]
impl ModelPort for AddressAwareModel {
async fn run(&self, request: &ModelStageRequest) -> Result<String> {
Ok(match request.stage.as_str() {
"speech_address_resolver" => {
assert!(request.lived_stream.contains("Refugee One"));
assert!(request.lived_stream.contains("Refugee Two"));
assert!(request.lived_stream.contains("Relay Observer"));
serde_json::json!({
"persona_response_actor_ids":["refugee-one","refugee-two"]
})
.to_string()
}
"projector" => "The question lands in the small circle.".into(),
"persona" => {
if request.lived_stream.contains("exact direct addressee") {
"I answer plainly: that memory is mine alone.".into()
} else {
"I listen without taking over their answer.".into()
}
}
"interpreter" => {
let direct = request.lived_stream.contains("direct_response_expected");
serde_json::json!({
"private_delta":{
"memories_add":[],
"conditions_add":[],
"conditions_remove":[],
"goals_add":[],
"relationship_updates":{},
},
"speech":direct.then_some("that memory is mine alone"),
"deliberate_silence":false,
"reaction_priority":0,
"world_actions":[],
})
.to_string()
}
stage => return Err(anyhow!("unexpected stage {stage}")),
})
}
fn provider(&self) -> &'static str {
"fixture"
}
}
fn actor(id: &str, name: &str, location_id: &str) -> ActorState {
ActorState {
id: id.into(),
name: name.into(),
location_id: location_id.into(),
capabilities: BTreeSet::from(["ordinary conversation".into()]),
knowledge: BTreeSet::new(),
equipment: BTreeSet::new(),
conditions: BTreeSet::new(),
obligations: BTreeSet::new(),
relationships: BTreeMap::new(),
goals: vec![],
memories: vec![],
}
}
fn addressed_campaign() -> Campaign {
let mut campaign = crate::resolution::tests::campaign(0, 8);
let location = campaign.actors[&campaign.player_actor_id]
.location_id
.clone();
campaign.actors.insert(
"refugee-one".into(),
actor("refugee-one", "Refugee One", &location),
);
campaign.actors.insert(
"refugee-two".into(),
actor("refugee-two", "Refugee Two", &location),
);
campaign.actors.insert(
"relay-observer".into(),
actor("relay-observer", "Relay Observer", &location),
);
crate::resolution::ensure_agency_profiles(&mut campaign);
campaign
.agency_profiles
.get_mut(&campaign.player_actor_id)
.unwrap()
.simulation_eligible = false;
campaign
}
#[tokio::test]
async fn address_resolver_selects_exact_supplied_respondents() {
let campaign = addressed_campaign();
let (plan, receipts) = resolve_speech_addresses(
Arc::new(AddressAwareModel),
"flash",
&campaign,
&campaign.player_actor_id,
"Refugee One and Refugee Two: answer separately. Relay Observer, listen.",
)
.await
.unwrap();
assert_eq!(
plan.persona_response_actor_ids,
BTreeSet::from(["refugee-one".into(), "refugee-two".into()])
);
assert_eq!(receipts.len(), 1);
}
#[tokio::test]
async fn exact_addressees_receive_response_duty_while_observers_keep_agency() {
let mut campaign = addressed_campaign();
let speech = "Refugee One and Refugee Two: answer separately.";
campaign.transcript.push(NarrativeTurn {
revision: campaign.revision,
at: Utc::now(),
speaker: campaign.player_actor_id.clone(),
text: speech.into(),
persona_response_actor_ids: BTreeSet::from([
"refugee-one".into(),
"refugee-two".into(),
]),
});
let summary = format!("{} says: {speech}", campaign.player_actor_id);
let engine = PersonaProjectionEngine {
model: Arc::new(AddressAwareModel),
permit: Arc::new(AllowAllPermit),
projector_model: "flash".into(),
persona_model: "pro".into(),
interpreter_model: "flash".into(),
};
let wave = appraise_present(engine, &campaign, &summary).await.unwrap();
assert_eq!(wave.reactions.len(), 3);
for actor_id in ["refugee-one", "refugee-two"] {
assert_eq!(
wave.reactions
.iter()
.find(|reaction| reaction.actor_id == actor_id)
.and_then(|reaction| reaction.speech.as_deref()),
Some("that memory is mine alone")
);
}
assert!(
wave.reactions
.iter()
.find(|reaction| reaction.actor_id == "relay-observer")
.unwrap()
.speech
.is_none()
);
}
}

View file

@ -694,6 +694,19 @@ to their own snapshot versions. Initiative selects compatible reactions for a
later `WorldCommand`; it does not suppress perception for actors outside the
current conversational focus.
Player speech first passes through a small private address projection against
the exact co-present, simulation-eligible actor catalog. The projection may
select only supplied actor IDs and identifies only actors from whom the speech
actually requests a response; people who are merely mentioned or able to hear
remain observers. `WorldKernel` validates co-presence and simulation custody,
then commits the selected response set with the exact speech turn. Browser and
native callers cannot supply those IDs. Each actor's permitted slice carries
either `direct_response_expected` or `present_observer`, while every present
actor still appraises the event. A direct addressee must emit speech or choose
typed deliberate silence. The latter lowers to deterministic visible refusal;
it is not a free-text effect lane. The complete reaction wave still validates
and commits atomically.
The primary player event commits before optional presence casting, reaction
appraisal and initiative. A failure in those later stages is stored
as a rejected-proposal receipt and returned as an accepted committed result

View file

@ -734,6 +734,18 @@ world turns, or read historical `narration_projection.v1` rows. This removes two
calls per live turn and makes display-layer invention of names, dialogue,
injuries, participants, or actions structurally impossible.
Player speech address is also typed before it can steer a reaction. A compact
Flash projection receives only the exact co-present simulation-eligible actor
IDs and names, bounded recent public turns, prior conversational focus, and the
new utterance. It returns the exact Persona actors from whom the utterance asks
for a response. `WorldKernel` revalidates those IDs and commits them with the
speech `NarrativeTurn`; neither Eve nor the native client may submit them.
Every present actor continues to appraise the turn, but the private actor slice
now distinguishes `direct_response_expected` from `present_observer`. A direct
addressee must speak or select typed deliberate silence. The kernel lowers
silence to a deterministic visible refusal, so no free-text gesture lane can
claim an unassessed world effect.
`ghostlight-campaign-inspect` is a read-only typed-store witness. It reports the
latest strategic cover, attributed appraisals and inactions, activity outcomes,
events, news, subject continuity, and model receipt/token metadata without
@ -952,6 +964,10 @@ Pending NPC actions are revision-scoped reaction-window state. The next
reaction wave atomically replaces the set, and the kernel resolves an action
only when the current last event is the exact current-revision reaction wave.
Same-wave assessment correction remains possible; cross-turn rebasing is not.
The same reaction commit validates its response duty against the exact
committed source turn. A missing directly addressed Persona or a null response
aborts the whole wave without private deltas, transcript additions, initiative,
or revision change; observers may still choose to speak from their own goals.
Population fission now composes admitted child entities, stable child
identities, one lineage split, exact resource custody transfers, and exact

View file

@ -118,6 +118,15 @@ browser renders these projections as labeled, escaped human-readable controls,
and the player story lowers exact committed transcript turns without a
generative rewrite.
Foreground speech now carries explicit response ownership. A private bounded
address stage selects only exact co-present Persona IDs; `WorldKernel` commits
that set with the speech turn and projects direct-addressee versus observer
roles into parallel Persona appraisal. Direct addressees must speak or choose
typed deliberate silence, which the kernel lowers deterministically. This
closes the live failure where every present actor perceived a direct question
but all Interpreters returned `speech: null`, without forcing observers to seize
conversational focus or admitting a free-text reaction-effect lane.
World transition authority is now a closed semantic mutation algebra rather
than four empirical effect universes. Foreground attempts, NPC initiative,
reaction appraisal, strategic actor/institution/Gestalt outcomes, waits,