Include cohesive Gestalts in foreground reaction waves
This commit is contained in:
parent
1eba8d8238
commit
06df9a903a
7 changed files with 476 additions and 42 deletions
|
|
@ -77,6 +77,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
expected_revision: 0,
|
||||
event_summary: event_summary.clone(),
|
||||
reactions: wave.reactions.clone(),
|
||||
gestalt_reactions: wave.gestalt_reactions.clone(),
|
||||
})
|
||||
.await?;
|
||||
let total_seconds = started.elapsed().as_secs_f64();
|
||||
|
|
|
|||
|
|
@ -684,6 +684,18 @@ pub struct ActorReaction {
|
|||
pub action_proposals: Vec<WorldActionProposal>,
|
||||
}
|
||||
|
||||
/// A foreground appraisal owned by one cohesive population subject. Unlike an
|
||||
/// arena cell, a Gestalt has genuine collective authority and may speak in the
|
||||
/// plural. Its foreground reaction cannot mutate actor-private state or smuggle
|
||||
/// an unassessed strategic action through dialogue.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
|
||||
pub struct GestaltReaction {
|
||||
pub gestalt_id: String,
|
||||
pub speech: Option<String>,
|
||||
#[serde(default)]
|
||||
pub deliberate_silence: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
|
||||
pub struct InstitutionState {
|
||||
pub id: String,
|
||||
|
|
@ -881,10 +893,10 @@ pub struct NarrativeTurn {
|
|||
pub speaker: String,
|
||||
pub text: String,
|
||||
/// Exact model-controlled Persona subject IDs whom this committed speech
|
||||
/// asks to respond. A nearby folded member keeps their stable future actor
|
||||
/// ID here while the kernel materializes that same person for appraisal.
|
||||
/// Other present actors still perceive and appraise the turn, but they are
|
||||
/// not response-bound. Human-controlled actors never appear here.
|
||||
/// asks to respond. The legacy field name includes actors, folded named
|
||||
/// members, and cohesive Gestalts. Other present subjects still perceive
|
||||
/// and appraise the turn but are not response-bound. Human-controlled
|
||||
/// actors never appear here.
|
||||
#[serde(default)]
|
||||
pub persona_response_actor_ids: BTreeSet<String>,
|
||||
}
|
||||
|
|
@ -1047,6 +1059,8 @@ pub enum WorldCommand {
|
|||
expected_revision: u64,
|
||||
event_summary: String,
|
||||
reactions: Vec<ActorReaction>,
|
||||
#[serde(default)]
|
||||
gestalt_reactions: Vec<GestaltReaction>,
|
||||
},
|
||||
ResolveNpcAction {
|
||||
expected_revision: u64,
|
||||
|
|
|
|||
|
|
@ -232,7 +232,7 @@ pub fn required_addressed_promotions(campaign: &Campaign) -> Result<GestaltPrese
|
|||
};
|
||||
let mut promotions = Vec::new();
|
||||
for subject_id in &turn.persona_response_actor_ids {
|
||||
if campaign.actors.contains_key(subject_id) {
|
||||
if campaign.actors.contains_key(subject_id) || campaign.gestalts.contains_key(subject_id) {
|
||||
continue;
|
||||
}
|
||||
let member_id = crate::resolution::dormant_member_id_for_subject(campaign, subject_id)
|
||||
|
|
|
|||
|
|
@ -391,7 +391,17 @@ fn execute(
|
|||
crate::resolution::dormant_member_location(&campaign, member_id).ok()
|
||||
})
|
||||
.is_some_and(|location| location == speaker.location_id);
|
||||
if response_actor_id == &actor_id || !(eligible_present || eligible_folded) {
|
||||
let eligible_gestalt = campaign.gestalts.get(response_actor_id).is_some_and(|_| {
|
||||
crate::resolution::validate_active_gestalt_presence_location(
|
||||
&campaign,
|
||||
response_actor_id,
|
||||
&speaker.location_id,
|
||||
)
|
||||
.is_ok()
|
||||
});
|
||||
if response_actor_id == &actor_id
|
||||
|| !(eligible_present || eligible_folded || eligible_gestalt)
|
||||
{
|
||||
return Err(KernelError::Invalid(
|
||||
"speech response actor is not an eligible present Persona".into(),
|
||||
));
|
||||
|
|
@ -1305,6 +1315,7 @@ fn execute(
|
|||
expected_revision,
|
||||
event_summary,
|
||||
reactions,
|
||||
gestalt_reactions,
|
||||
} => {
|
||||
require_revision(&campaign, expected_revision)?;
|
||||
let witnessed_turn = canonical_witnessed_turn(&campaign, &event_summary)?;
|
||||
|
|
@ -1353,29 +1364,58 @@ fn execute(
|
|||
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
|
||||
{
|
||||
let mut seen_gestalts = BTreeSet::new();
|
||||
for reaction in &gestalt_reactions {
|
||||
if !seen_gestalts.insert(reaction.gestalt_id.clone()) {
|
||||
return Err(KernelError::Invalid(
|
||||
"directly addressed Persona produced no observable response".into(),
|
||||
"Gestalt reacted twice in one wave".into(),
|
||||
));
|
||||
}
|
||||
if seen.contains(&reaction.gestalt_id) {
|
||||
return Err(KernelError::Invalid(
|
||||
"reaction subject appeared as both actor and Gestalt".into(),
|
||||
));
|
||||
}
|
||||
crate::resolution::validate_active_gestalt_presence_location(
|
||||
&campaign,
|
||||
&reaction.gestalt_id,
|
||||
&player_location,
|
||||
)
|
||||
.map_err(|_| KernelError::Invalid("reaction Gestalt is not present".into()))?;
|
||||
if let Some(speech) = &reaction.speech {
|
||||
validate_bounded_text("Gestalt reaction speech", speech, 1_000)?;
|
||||
}
|
||||
}
|
||||
for response_actor_id in &response_expected_actor_ids {
|
||||
let actor_reaction = reactions
|
||||
.iter()
|
||||
.find(|reaction| &reaction.actor_id == response_actor_id);
|
||||
let gestalt_reaction = gestalt_reactions
|
||||
.iter()
|
||||
.find(|reaction| &reaction.gestalt_id == response_actor_id);
|
||||
let observable = actor_reaction.is_some_and(|reaction| {
|
||||
reaction
|
||||
.speech
|
||||
.as_deref()
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
|| reaction.deliberate_silence
|
||||
}) || gestalt_reaction.is_some_and(|reaction| {
|
||||
reaction
|
||||
.speech
|
||||
.as_deref()
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
|| reaction.deliberate_silence
|
||||
});
|
||||
if !observable {
|
||||
return Err(KernelError::Invalid(
|
||||
"directly addressed Persona is absent or produced no observable response"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let source_receipt_id = format!(
|
||||
"reaction-input:{}",
|
||||
crate::legacy_transition::digest_serializable(&reactions)
|
||||
crate::legacy_transition::digest_serializable(&(&reactions, &gestalt_reactions))
|
||||
.map_err(|error| KernelError::Invalid(error.to_string()))?
|
||||
);
|
||||
let transition = crate::legacy_transition::lower_reaction_wave(
|
||||
|
|
@ -1416,6 +1456,26 @@ fn execute(
|
|||
.pending_world_proposals
|
||||
.extend(reaction.action_proposals);
|
||||
}
|
||||
for reaction in gestalt_reactions {
|
||||
if let Some(speech) = reaction.speech {
|
||||
campaign.transcript.push(NarrativeTurn {
|
||||
revision: campaign.revision + 1,
|
||||
at: Utc::now(),
|
||||
speaker: reaction.gestalt_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.gestalt_id,
|
||||
text: "deliberately does not answer.".into(),
|
||||
persona_response_actor_ids: BTreeSet::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
refresh_materialized_member_relevance(&mut campaign, seen.iter().map(String::as_str));
|
||||
campaign.events.push(Event {
|
||||
id: format!("reaction-wave:{}", campaign.revision + 1),
|
||||
|
|
@ -1424,7 +1484,7 @@ fn execute(
|
|||
summary: event_summary,
|
||||
actor_ids: seen.into_iter().collect(),
|
||||
institution_ids: vec![],
|
||||
gestalt_ids: vec![],
|
||||
gestalt_ids: seen_gestalts.into_iter().collect(),
|
||||
location_ids: vec![player_location],
|
||||
public_channels: vec![],
|
||||
});
|
||||
|
|
@ -6161,6 +6221,7 @@ mod tests {
|
|||
expected_revision: 0,
|
||||
event_summary: "player says: Anna, answer me.".into(),
|
||||
reactions: vec![silent],
|
||||
gestalt_reactions: vec![],
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
|
@ -6186,6 +6247,7 @@ mod tests {
|
|||
private_delta: ActorStateDelta::default(),
|
||||
action_proposals: vec![],
|
||||
}],
|
||||
gestalt_reactions: vec![],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
|
@ -6199,6 +6261,81 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn directly_addressed_local_gestalt_reacts_as_its_exact_collective_subject() {
|
||||
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.gestalts.insert(
|
||||
"households".into(),
|
||||
GestaltPersonaState {
|
||||
schema: "ghostlight.gestalt_persona_state.v1".into(),
|
||||
id: "households".into(),
|
||||
name: "Settlement households".into(),
|
||||
version: 0,
|
||||
home_location_id: "room".into(),
|
||||
shared_capabilities: BTreeSet::from(["collective refusal".into()]),
|
||||
shared_knowledge: BTreeSet::from(["the slate omits five names".into()]),
|
||||
resources: BTreeSet::new(),
|
||||
goals: vec!["keep every household traceable".into()],
|
||||
pressures: vec!["the convoy slate is closing".into()],
|
||||
},
|
||||
);
|
||||
seed.transcript.push(NarrativeTurn {
|
||||
revision: 0,
|
||||
at: seed.world_time,
|
||||
speaker: "player".into(),
|
||||
text: "Households, will you refuse the slate?".into(),
|
||||
persona_response_actor_ids: BTreeSet::from(["households".into()]),
|
||||
});
|
||||
kernel
|
||||
.command(WorldCommand::CreateCampaign {
|
||||
campaign: seed.clone(),
|
||||
evidence_receipts: vec![],
|
||||
model_stage_receipts: vec![],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let missing = kernel
|
||||
.command(WorldCommand::ResolveReactionWave {
|
||||
expected_revision: 0,
|
||||
event_summary: "player says: Households, will you refuse the slate?".into(),
|
||||
reactions: vec![],
|
||||
gestalt_reactions: vec![],
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(missing.to_string().contains("no observable response"));
|
||||
|
||||
let committed = kernel
|
||||
.command(WorldCommand::ResolveReactionWave {
|
||||
expected_revision: 0,
|
||||
event_summary: "player says: Households, will you refuse the slate?".into(),
|
||||
reactions: vec![],
|
||||
gestalt_reactions: vec![GestaltReaction {
|
||||
gestalt_id: "households".into(),
|
||||
speech: Some(
|
||||
"We will refuse a slate that leaves our names untraceable.".into(),
|
||||
),
|
||||
deliberate_silence: false,
|
||||
}],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let CommandResult::Committed { campaign, .. } = committed else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(campaign.revision, 1);
|
||||
assert_eq!(campaign.transcript.last().unwrap().speaker, "households");
|
||||
assert_eq!(
|
||||
campaign.events.last().unwrap().gestalt_ids,
|
||||
vec!["households"]
|
||||
);
|
||||
assert_eq!(campaign.gestalts["households"], seed.gestalts["households"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_reaction_rejects_the_entire_wave_without_mutation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
@ -6262,6 +6399,7 @@ mod tests {
|
|||
expected_revision: 0,
|
||||
event_summary: "the player acts".into(),
|
||||
reactions,
|
||||
gestalt_reactions: vec![],
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
|
|
@ -6345,6 +6483,7 @@ mod tests {
|
|||
},
|
||||
action_proposals: vec![],
|
||||
}],
|
||||
gestalt_reactions: vec![],
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
|
|
@ -6364,6 +6503,7 @@ mod tests {
|
|||
},
|
||||
action_proposals: vec![],
|
||||
}],
|
||||
gestalt_reactions: vec![],
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
|
|
@ -6383,6 +6523,7 @@ mod tests {
|
|||
},
|
||||
action_proposals: vec![],
|
||||
}],
|
||||
gestalt_reactions: vec![],
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
|
|
@ -6446,6 +6587,7 @@ mod tests {
|
|||
private_delta: ActorStateDelta::default(),
|
||||
action_proposals: vec![],
|
||||
}],
|
||||
gestalt_reactions: vec![],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
|
@ -6532,6 +6674,7 @@ mod tests {
|
|||
action_proposals: vec![bert.clone()],
|
||||
},
|
||||
],
|
||||
gestalt_reactions: vec![],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
|
@ -6681,6 +6824,7 @@ mod tests {
|
|||
private_delta: ActorStateDelta::default(),
|
||||
action_proposals: vec![proposal.clone()],
|
||||
}],
|
||||
gestalt_reactions: vec![],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
|
@ -6731,6 +6875,7 @@ mod tests {
|
|||
private_delta: ActorStateDelta::default(),
|
||||
action_proposals: vec![],
|
||||
}],
|
||||
gestalt_reactions: vec![],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
|
|
|||
|
|
@ -5077,7 +5077,9 @@ async fn command(
|
|||
}
|
||||
}
|
||||
}
|
||||
if reaction_campaign.actors.len() > 1 {
|
||||
if reaction_campaign.actors.len() > 1
|
||||
|| !reaction_campaign.gestalts.is_empty()
|
||||
{
|
||||
let engine = PersonaProjectionEngine {
|
||||
model: model.clone(),
|
||||
permit: Arc::new(SnapshotPermit::new(
|
||||
|
|
@ -5105,6 +5107,7 @@ async fn command(
|
|||
expected_revision: reaction_campaign.revision,
|
||||
event_summary: summary,
|
||||
reactions: wave.reactions,
|
||||
gestalt_reactions: wave.gestalt_reactions,
|
||||
})
|
||||
.await
|
||||
{
|
||||
|
|
|
|||
|
|
@ -19,10 +19,18 @@ pub enum ActorInteractionRole {
|
|||
PresentObserver,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PersonaSubjectKind {
|
||||
IndividualActor,
|
||||
CohesiveGestalt,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct PermittedActorSlice {
|
||||
pub actor_id: String,
|
||||
pub location_id: String,
|
||||
pub subject_kind: PersonaSubjectKind,
|
||||
pub snapshot_binding: String,
|
||||
pub interaction_role: ActorInteractionRole,
|
||||
pub identity_experience: Vec<String>,
|
||||
|
|
@ -470,8 +478,16 @@ fn actor_interpreter_guidance(slice: &PermittedActorSlice) -> String {
|
|||
"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."
|
||||
}
|
||||
};
|
||||
let ownership_guidance = match slice.subject_kind {
|
||||
PersonaSubjectKind::IndividualActor => {
|
||||
"Record only private changes supported by the lived stream and typed context. identity_adoption is null unless the Persona explicitly adopts or presents one public self-identifier in its own speech; when set, copy the exact spoken handle and nothing else. World actions are attempts, not completed effects."
|
||||
}
|
||||
PersonaSubjectKind::CohesiveGestalt => {
|
||||
"This is a cohesive population appraisal. Extract only collective speech or deliberate silence. Every actor-private delta must remain empty, identity_adoption must be null, and world_actions must be empty; strategic population action uses the cell-resolution path, not foreground dialogue."
|
||||
}
|
||||
};
|
||||
format!(
|
||||
"{response_guidance} Record only private changes supported by the lived stream and typed context. identity_adoption is null unless the Persona explicitly adopts or presents one public self-identifier in its own speech; when set, copy the exact spoken handle and nothing else. 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} {ownership_guidance} 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<_>>()
|
||||
|
|
@ -532,8 +548,16 @@ fn ground_actor_lived_stream(slice: &PermittedActorSlice, projection: &str) -> S
|
|||
.join(", ")
|
||||
)
|
||||
};
|
||||
let subject_grounding = match slice.subject_kind {
|
||||
PersonaSubjectKind::IndividualActor => {
|
||||
"You are one situated person. Speak and choose only for yourself."
|
||||
}
|
||||
PersonaSubjectKind::CohesiveGestalt => {
|
||||
"You are a cohesive population Persona. You may speak plurally only from genuinely shared state. Do not invent unanimity about exceptions, speak for named individuals, or claim authority outside the supplied collective."
|
||||
}
|
||||
};
|
||||
format!(
|
||||
"{projection}\n\nYour active self-identity: {identity}. In your social world, {established_peer_identities}. {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."
|
||||
"{projection}\n\n{subject_grounding} Your active self-identity: {identity}. In your social world, {established_peer_identities}. {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."
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -541,6 +565,14 @@ fn validate_actor_proposals(
|
|||
slice: &PermittedActorSlice,
|
||||
proposals: &PersonaProposalBundle,
|
||||
) -> Result<()> {
|
||||
if matches!(slice.subject_kind, PersonaSubjectKind::CohesiveGestalt)
|
||||
&& (proposals.private_delta != crate::domain::ActorStateDelta::default()
|
||||
|| !proposals.world_actions.is_empty())
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"cohesive foreground Gestalt appraisal may emit only speech or deliberate silence"
|
||||
));
|
||||
}
|
||||
if matches!(
|
||||
slice.interaction_role,
|
||||
ActorInteractionRole::DirectResponseExpected
|
||||
|
|
@ -2816,6 +2848,27 @@ fn constrain_interpreter_schema(
|
|||
.ok_or_else(|| anyhow!("Persona proposal schema has no identity adoption"))?;
|
||||
*identity_adoption =
|
||||
serde_json::json!({"type":["string","null"],"minLength":1,"maxLength":160});
|
||||
if matches!(slice.subject_kind, PersonaSubjectKind::CohesiveGestalt) {
|
||||
for field in [
|
||||
"memories_add",
|
||||
"conditions_add",
|
||||
"conditions_remove",
|
||||
"goals_add",
|
||||
] {
|
||||
let value = schema
|
||||
.pointer_mut(&format!("/$defs/ActorStateDelta/properties/{field}"))
|
||||
.ok_or_else(|| anyhow!("Persona proposal schema has no {field}"))?;
|
||||
*value = serde_json::json!({"type":"array","maxItems":0,"items":{"type":"string"}});
|
||||
}
|
||||
*schema
|
||||
.pointer_mut("/$defs/ActorStateDelta/properties/relationship_updates")
|
||||
.ok_or_else(|| anyhow!("Persona proposal schema has no relationship updates"))? =
|
||||
serde_json::json!({"type":"object","maxProperties":0,"additionalProperties":false});
|
||||
*schema
|
||||
.pointer_mut("/$defs/ActorStateDelta/properties/identity_adoption")
|
||||
.ok_or_else(|| anyhow!("Persona proposal schema has no identity adoption"))? =
|
||||
serde_json::json!({"type":"null"});
|
||||
}
|
||||
let root = schema
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| anyhow!("Persona proposal schema is not an object"))?;
|
||||
|
|
@ -2831,6 +2884,12 @@ fn constrain_interpreter_schema(
|
|||
"deliberate_silence".into(),
|
||||
serde_json::json!({"type":"boolean"}),
|
||||
);
|
||||
if matches!(slice.subject_kind, PersonaSubjectKind::CohesiveGestalt) {
|
||||
properties.insert(
|
||||
"world_actions".into(),
|
||||
serde_json::json!({"type":"array","maxItems":0,"items":{"$ref":"#/$defs/WorldActionProposal"}}),
|
||||
);
|
||||
}
|
||||
root.entry("allOf")
|
||||
.or_insert_with(|| serde_json::json!([]))
|
||||
.as_array_mut()
|
||||
|
|
@ -4215,6 +4274,7 @@ mod tests {
|
|||
let slice = PermittedActorSlice {
|
||||
actor_id: "npc".into(),
|
||||
location_id: "room".into(),
|
||||
subject_kind: PersonaSubjectKind::IndividualActor,
|
||||
snapshot_binding: "campaign:1".into(),
|
||||
interaction_role: ActorInteractionRole::PresentObserver,
|
||||
identity_experience: vec!["A tired navigator".into()],
|
||||
|
|
@ -4251,6 +4311,7 @@ mod tests {
|
|||
let mut slice = PermittedActorSlice {
|
||||
actor_id: "npc".into(),
|
||||
location_id: "room".into(),
|
||||
subject_kind: PersonaSubjectKind::IndividualActor,
|
||||
snapshot_binding: "campaign:1".into(),
|
||||
interaction_role: ActorInteractionRole::DirectResponseExpected,
|
||||
identity_experience: vec!["You are an unnamed patient.".into()],
|
||||
|
|
@ -4298,6 +4359,7 @@ mod tests {
|
|||
let slice = PermittedActorSlice {
|
||||
actor_id: "npc".into(),
|
||||
location_id: "room".into(),
|
||||
subject_kind: PersonaSubjectKind::IndividualActor,
|
||||
snapshot_binding: "campaign:1".into(),
|
||||
interaction_role: ActorInteractionRole::PresentObserver,
|
||||
identity_experience: vec!["You are an unnamed patient.".into()],
|
||||
|
|
@ -4392,6 +4454,7 @@ mod tests {
|
|||
.execute(PermittedActorSlice {
|
||||
actor_id: "npc".into(),
|
||||
location_id: "room".into(),
|
||||
subject_kind: PersonaSubjectKind::IndividualActor,
|
||||
snapshot_binding: "campaign:1:revision:4".into(),
|
||||
interaction_role: ActorInteractionRole::DirectResponseExpected,
|
||||
identity_experience: vec!["You are an unnamed patient.".into()],
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
use crate::{
|
||||
domain::{ActorReaction, Campaign},
|
||||
domain::{ActorReaction, Campaign, GestaltReaction},
|
||||
model::{ModelPort, ModelStageReceipt, ModelStageRequest, run_validated_stage},
|
||||
persistence::CampaignStore,
|
||||
persona::{
|
||||
ActorInteractionRole, ExecutionPermit, PermittedActorSlice, PersonaProjectionEngine,
|
||||
PersonaSubjectKind,
|
||||
},
|
||||
};
|
||||
use anyhow::{Result, anyhow};
|
||||
|
|
@ -14,13 +15,13 @@ 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.
|
||||
/// Exact present, model-controlled Persona subjects from whom the utterance
|
||||
/// requests an answer. Mentioned subjects and passive hearers are omitted.
|
||||
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
|
||||
/// present-subject 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>,
|
||||
|
|
@ -69,6 +70,21 @@ pub async fn resolve_speech_addresses(
|
|||
}),
|
||||
))
|
||||
}));
|
||||
candidates.extend(campaign.gestalts.values().filter_map(|gestalt| {
|
||||
crate::resolution::validate_active_gestalt_presence_location(
|
||||
campaign,
|
||||
&gestalt.id,
|
||||
&speaker.location_id,
|
||||
)
|
||||
.ok()?;
|
||||
Some((
|
||||
gestalt.id.clone(),
|
||||
serde_json::json!({
|
||||
"public_name": gestalt.name,
|
||||
"presence": "cohesive_population",
|
||||
}),
|
||||
))
|
||||
}));
|
||||
if candidates.is_empty() {
|
||||
return Ok((
|
||||
SpeechAddressPlan {
|
||||
|
|
@ -111,7 +127,7 @@ pub async fn resolve_speech_addresses(
|
|||
"items":{"type":"string","enum":allowed_ids},
|
||||
});
|
||||
let prompt = format!(
|
||||
"You are Ghostlight's private scene-address resolver. Decide only which supplied present Persona subjects this exact speech directly asks to answer now. A nearby_folded_person is a persistent known individual currently represented through their population; selecting their exact member ID lets Ghostlight materialize that same person before appraisal. 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 IDs. Empty is valid when the speaker asks nobody present to answer. Return exactly one JSON object matching the schema.\nSCHEMA:\n{}\nPRESENT PERSONA SUBJECTS:\n{}\nPREVIOUS FOCUS:\n{}\nRECENT PUBLIC TURNS:\n{}\nSPEAKER ACTOR ID:\n{}\nEXACT SPEECH:\n{}",
|
||||
"You are Ghostlight's private scene-address resolver. Decide only which supplied present Persona subjects this exact speech directly asks to answer now. A nearby_folded_person is a persistent known individual currently represented through their population; selecting their exact member ID lets Ghostlight materialize that same person before appraisal. A cohesive_population is a genuine collective subject and may be directly asked for a plural response. A subject 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 IDs. Empty is valid when the speaker asks nobody present to answer. Return exactly one JSON object matching the schema.\nSCHEMA:\n{}\nPRESENT PERSONA SUBJECTS:\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)?,
|
||||
|
|
@ -197,9 +213,15 @@ impl ExecutionPermit for SnapshotPermit {
|
|||
|
||||
pub struct ReactionWaveOutput {
|
||||
pub reactions: Vec<ActorReaction>,
|
||||
pub gestalt_reactions: Vec<GestaltReaction>,
|
||||
pub receipts: Vec<ModelStageReceipt>,
|
||||
}
|
||||
|
||||
enum PresentReactionTerminal {
|
||||
Actor(String, crate::persona::PersonaTerminalBundle),
|
||||
Gestalt(String, crate::persona::PersonaTerminalBundle),
|
||||
}
|
||||
|
||||
pub async fn appraise_present(
|
||||
engine: PersonaProjectionEngine,
|
||||
campaign: &Campaign,
|
||||
|
|
@ -224,12 +246,30 @@ pub async fn appraise_present(
|
|||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
let perceived_actors = campaign
|
||||
let mut perceived_actors = campaign
|
||||
.actors
|
||||
.values()
|
||||
.filter(|actor| actor.location_id == player.location_id)
|
||||
.map(|actor| (actor.id.clone(), actor.name.clone()))
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
let local_gestalts = campaign
|
||||
.gestalts
|
||||
.values()
|
||||
.filter(|gestalt| {
|
||||
crate::resolution::validate_active_gestalt_presence_location(
|
||||
campaign,
|
||||
&gestalt.id,
|
||||
&player.location_id,
|
||||
)
|
||||
.is_ok()
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
perceived_actors.extend(
|
||||
local_gestalts
|
||||
.iter()
|
||||
.map(|gestalt| (gestalt.id.clone(), gestalt.name.clone())),
|
||||
);
|
||||
let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(usize::from(
|
||||
campaign.resolution_policy.provider_parallelism.max(1),
|
||||
)));
|
||||
|
|
@ -255,6 +295,7 @@ pub async fn appraise_present(
|
|||
let slice = PermittedActorSlice {
|
||||
actor_id: actor.id.clone(),
|
||||
location_id: actor.location_id.clone(),
|
||||
subject_kind: PersonaSubjectKind::IndividualActor,
|
||||
snapshot_binding: snapshot,
|
||||
interaction_role,
|
||||
identity_experience: vec![format!("You are {}.", actor.name)],
|
||||
|
|
@ -279,26 +320,82 @@ pub async fn appraise_present(
|
|||
source_receipt_ids: receipts,
|
||||
};
|
||||
let terminal = engine.execute(slice).await?;
|
||||
Ok::<_, anyhow::Error>((actor.id, terminal))
|
||||
Ok::<_, anyhow::Error>(PresentReactionTerminal::Actor(actor.id, terminal))
|
||||
});
|
||||
}
|
||||
for gestalt in local_gestalts {
|
||||
let engine = engine.clone();
|
||||
let snapshot = format!("campaign:{}:revision:{}", campaign.id, campaign.revision);
|
||||
let event = event_summary.to_owned();
|
||||
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(&gestalt.id) {
|
||||
ActorInteractionRole::DirectResponseExpected
|
||||
} else {
|
||||
ActorInteractionRole::PresentObserver
|
||||
};
|
||||
jobs.spawn(async move {
|
||||
let _provider_slot = semaphore
|
||||
.acquire_owned()
|
||||
.await
|
||||
.map_err(|_| anyhow!("provider concurrency gate closed"))?;
|
||||
let slice = PermittedActorSlice {
|
||||
actor_id: gestalt.id.clone(),
|
||||
location_id: gestalt.home_location_id.clone(),
|
||||
subject_kind: PersonaSubjectKind::CohesiveGestalt,
|
||||
snapshot_binding: snapshot,
|
||||
interaction_role,
|
||||
identity_experience: vec![format!(
|
||||
"You are {}, a cohesive population represented through genuinely shared state.",
|
||||
gestalt.name
|
||||
)],
|
||||
reserved_public_identities: perceived_actors.values().cloned().collect(),
|
||||
memories: vec![],
|
||||
perceived_events: vec![event],
|
||||
perceived_actors,
|
||||
relationships: vec![],
|
||||
goals: gestalt.goals,
|
||||
knowledge: gestalt.shared_knowledge.into_iter().collect(),
|
||||
capabilities: gestalt.shared_capabilities.into_iter().collect(),
|
||||
pressures: gestalt.pressures,
|
||||
affordances: gestalt.resources.into_iter().collect(),
|
||||
source_receipt_ids: receipts,
|
||||
};
|
||||
let terminal = engine.execute(slice).await?;
|
||||
Ok::<_, anyhow::Error>(PresentReactionTerminal::Gestalt(gestalt.id, terminal))
|
||||
});
|
||||
}
|
||||
let mut reactions = Vec::new();
|
||||
let mut gestalt_reactions = Vec::new();
|
||||
let mut receipts = Vec::new();
|
||||
while let Some(result) = jobs.join_next().await {
|
||||
let (actor_id, terminal) =
|
||||
result.map_err(|e| anyhow!("Persona appraisal task failed: {e}"))??;
|
||||
receipts.extend(terminal.stage_receipts);
|
||||
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,
|
||||
});
|
||||
match result.map_err(|e| anyhow!("Persona appraisal task failed: {e}"))?? {
|
||||
PresentReactionTerminal::Actor(actor_id, terminal) => {
|
||||
receipts.extend(terminal.stage_receipts);
|
||||
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,
|
||||
});
|
||||
}
|
||||
PresentReactionTerminal::Gestalt(gestalt_id, terminal) => {
|
||||
receipts.extend(terminal.stage_receipts);
|
||||
gestalt_reactions.push(GestaltReaction {
|
||||
gestalt_id,
|
||||
speech: terminal.proposals.speech,
|
||||
deliberate_silence: terminal.proposals.deliberate_silence,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
reactions.sort_by(|a, b| a.actor_id.cmp(&b.actor_id));
|
||||
gestalt_reactions.sort_by(|a, b| a.gestalt_id.cmp(&b.gestalt_id));
|
||||
Ok(ReactionWaveOutput {
|
||||
reactions,
|
||||
gestalt_reactions,
|
||||
receipts,
|
||||
})
|
||||
}
|
||||
|
|
@ -364,6 +461,7 @@ mod tests {
|
|||
|
||||
struct AddressAwareModel;
|
||||
struct DormantAddressModel;
|
||||
struct CollectiveAddressModel;
|
||||
|
||||
#[async_trait]
|
||||
impl ModelPort for AddressAwareModel {
|
||||
|
|
@ -429,6 +527,46 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ModelPort for CollectiveAddressModel {
|
||||
async fn run(&self, request: &ModelStageRequest) -> Result<String> {
|
||||
Ok(match request.stage.as_str() {
|
||||
"speech_address_resolver" => {
|
||||
assert!(request.lived_stream.contains("cohesive_population"));
|
||||
assert!(request.lived_stream.contains("Settlement households"));
|
||||
serde_json::json!({
|
||||
"persona_response_actor_ids":["settlement-households"]
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
"projector" => {
|
||||
"The households hear a direct request for their shared answer.".into()
|
||||
}
|
||||
"persona" => "We will not accept a slate that makes our names disappear.".into(),
|
||||
"interpreter" => serde_json::json!({
|
||||
"private_delta":{
|
||||
"memories_add":[],
|
||||
"identity_adoption":null,
|
||||
"conditions_add":[],
|
||||
"conditions_remove":[],
|
||||
"goals_add":[],
|
||||
"relationship_updates":{},
|
||||
},
|
||||
"speech":"We will not accept a slate that makes our names disappear.",
|
||||
"deliberate_silence":false,
|
||||
"reaction_priority":5,
|
||||
"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(),
|
||||
|
|
@ -646,4 +784,74 @@ mod tests {
|
|||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_cohesive_gestalt_is_addressable_and_appraises_in_the_foreground_wave() {
|
||||
let mut campaign = crate::resolution::tests::campaign(0, 8);
|
||||
let location = campaign.actors[&campaign.player_actor_id]
|
||||
.location_id
|
||||
.clone();
|
||||
campaign.gestalts.insert(
|
||||
"settlement-households".into(),
|
||||
GestaltPersonaState {
|
||||
schema: "ghostlight.gestalt_persona_state.v1".into(),
|
||||
id: "settlement-households".into(),
|
||||
name: "Settlement households".into(),
|
||||
version: 0,
|
||||
home_location_id: location,
|
||||
shared_capabilities: BTreeSet::from(["collective refusal".into()]),
|
||||
shared_knowledge: BTreeSet::from(["five names lack a traceable path".into()]),
|
||||
resources: BTreeSet::new(),
|
||||
goals: vec!["keep every household legible".into()],
|
||||
pressures: vec!["the slate is closing".into()],
|
||||
},
|
||||
);
|
||||
crate::resolution::ensure_agency_profiles(&mut campaign);
|
||||
campaign
|
||||
.agency_profiles
|
||||
.get_mut(&campaign.player_actor_id)
|
||||
.unwrap()
|
||||
.simulation_eligible = false;
|
||||
let speech = "Settlement households, will you refuse this slate?";
|
||||
let (plan, _) = resolve_speech_addresses(
|
||||
Arc::new(CollectiveAddressModel),
|
||||
"flash",
|
||||
&campaign,
|
||||
&campaign.player_actor_id,
|
||||
speech,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
plan.persona_response_actor_ids,
|
||||
BTreeSet::from(["settlement-households".into()])
|
||||
);
|
||||
campaign.transcript.push(NarrativeTurn {
|
||||
revision: campaign.revision,
|
||||
at: Utc::now(),
|
||||
speaker: campaign.player_actor_id.clone(),
|
||||
text: speech.into(),
|
||||
persona_response_actor_ids: plan.persona_response_actor_ids,
|
||||
});
|
||||
let summary = format!("{} says: {speech}", campaign.player_actor_id);
|
||||
let wave = appraise_present(
|
||||
PersonaProjectionEngine {
|
||||
model: Arc::new(CollectiveAddressModel),
|
||||
permit: Arc::new(AllowAllPermit),
|
||||
projector_model: "flash".into(),
|
||||
persona_model: "pro".into(),
|
||||
interpreter_model: "flash".into(),
|
||||
},
|
||||
&campaign,
|
||||
&summary,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(wave.reactions.is_empty());
|
||||
assert_eq!(wave.gestalt_reactions.len(), 1);
|
||||
assert_eq!(
|
||||
wave.gestalt_reactions[0].speech.as_deref(),
|
||||
Some("We will not accept a slate that makes our names disappear.")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue