From 686dccff7d986e2a18b0bd98adf3530c4378b7eb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 24 Aug 2026 06:04:14 +0200 Subject: [PATCH] Bind gestalt individuation to direct player speech --- crates/ghostlight-dungeon/src/gestalt.rs | 70 ++++++++++++- crates/ghostlight-dungeon/src/kernel.rs | 105 ++++++++++++++++++++ docs/architecture/ghostlight-dungeon-mvp.md | 8 ++ notes/fresh-workspace-handoff.md | 7 ++ notes/ghostlight-current-system-map.md | 8 ++ 5 files changed, 195 insertions(+), 3 deletions(-) diff --git a/crates/ghostlight-dungeon/src/gestalt.rs b/crates/ghostlight-dungeon/src/gestalt.rs index 827e6d0..35e18b2 100644 --- a/crates/ghostlight-dungeon/src/gestalt.rs +++ b/crates/ghostlight-dungeon/src/gestalt.rs @@ -83,6 +83,10 @@ impl GestaltPresencePlanner { .values() .filter_map(|member| member.materialized_actor_id.clone()) .collect::>(); + let admitted_individuation_stimulus = automatic_individuation_stimulus(campaign); + let individuation_admitted = admitted_individuation_stimulus + .as_deref() + .is_some_and(|stimulus| stimulus == event_summary); let candidates = serde_json::json!({ "player_location_id": player_location, "nearby_active_leaf_gestalts": nearby_gestalts, @@ -96,9 +100,15 @@ impl GestaltPresencePlanner { &nearby_dormant_member_ids, &materialized_actor_ids, player_location, + individuation_admitted, )?; + let individuation_instruction = if individuation_admitted { + "The immediately committed event is direct player speech. If that speech makes one anonymous population member individually relevant and no supplied member fits, you may individuate exactly one durable member delta from the gestalt baseline. Use a new stable lowercase id, version 0, the exact gestalt id/version, no materialized actor id, and record only personal departures from the shared baseline." + } else { + "The immediately committed event does not admit a new canonical person. The individuations array must be empty. Cast only supplied existing members." + }; let base_prompt = format!( - "Cast reversible Persona population presence for the next scene after this event. The purpose is to make causal continuity visible without crowding the scene or inventing coincidence. Promote an existing member when their exact durable history makes them individually relevant. When a nearby dormant member has a reciprocal player relationship and an unresolved callback signal such as an obligation, memory, or goal, promote the single strongest earned callback unless the event makes their presence implausible or dramatically harmful. An ordinary shared-location event is enough opportunity for an earned callback; do not require the player to ask for that person. Prefer an existing person over anonymous individuation whenever their exact delta supports the scene. Return no promotion when there is no earned callback or the current event conflicts with it. If the event makes an anonymous population member individually relevant and no supplied member fits, individuate exactly one durable member delta from the gestalt baseline; use a new stable lowercase id, version 0, the exact gestalt id/version, no materialized actor id, and record only personal departures from the shared baseline. Demote a materialized member when they are no longer scene-relevant. Never place a promoted or individuated member outside the player location. Aggregate deltas must remain empty; population learning requires separate review. Emit the exact JSON schema.\nSCHEMA:\n{}\nCANDIDATES:\n{}\nEVENT:\n{}", + "Cast reversible Persona population presence for the next scene after this event. The purpose is to make causal continuity visible without crowding the scene or inventing coincidence. Promote an existing member when their exact durable history makes them individually relevant. When a nearby dormant member has a reciprocal player relationship and an unresolved callback signal such as an obligation, memory, or goal, promote the single strongest earned callback unless the event makes their presence implausible or dramatically harmful. An ordinary shared-location event is enough opportunity for an earned callback; do not require the player to ask for that person. Prefer an existing person over anonymous individuation whenever their exact delta supports the scene. Return no promotion when there is no earned callback or the current event conflicts with it. {individuation_instruction} Demote a materialized member when they are no longer scene-relevant. Never place a promoted or individuated member outside the player location. Aggregate deltas must remain empty; population learning requires separate review. Emit the exact JSON schema.\nSCHEMA:\n{}\nCANDIDATES:\n{}\nEVENT:\n{}", serde_json::to_string_pretty(&schema)?, candidates, event_summary @@ -127,7 +137,7 @@ impl GestaltPresencePlanner { .ok_or_else(|| anyhow!("presence planner produced no plan")) .and_then(|value| serde_json::from_value(value).map_err(Into::into)) .and_then(|plan| { - validate_plan(campaign, &plan, player_location)?; + validate_plan(campaign, &plan, player_location, event_summary)?; Ok(plan) }); match candidate { @@ -165,6 +175,7 @@ fn constrain_presence_schema( dormant_member_ids: &BTreeSet, materialized_actor_ids: &BTreeSet, player_location: &str, + individuation_admitted: bool, ) -> Result<()> { constrain_candidate_array( schema, @@ -187,6 +198,13 @@ fn constrain_presence_schema( "gestalt_id", nearby_gestalt_ids, )?; + schema["properties"]["individuations"]["maxItems"] = serde_json::json!( + if individuation_admitted && !nearby_gestalt_ids.is_empty() { + 1 + } else { + 0 + } + ); for definition in ["GestaltPromotion", "GestaltIndividuation"] { if !nearby_gestalt_ids.is_empty() { schema["$defs"][definition]["properties"]["gestalt_id"] = @@ -227,7 +245,16 @@ fn validate_plan( campaign: &Campaign, plan: &GestaltPresencePlan, player_location: &str, + event_summary: &str, ) -> Result<()> { + let individuation_admitted = automatic_individuation_stimulus(campaign) + .as_deref() + .is_some_and(|stimulus| stimulus == event_summary); + if plan.individuations.len() > usize::from(individuation_admitted) { + return Err(anyhow!( + "automatic individuation requires the exact immediately committed player speech and admits at most one person" + )); + } let mut members = BTreeSet::new(); for individuation in &plan.individuations { let member = &individuation.member; @@ -309,11 +336,17 @@ fn validate_plan( Ok(()) } +pub(crate) fn automatic_individuation_stimulus(campaign: &Campaign) -> Option { + let turn = campaign.transcript.last()?; + (turn.revision == campaign.revision && turn.speaker == campaign.player_actor_id) + .then(|| format!("{} says: {}", turn.speaker, turn.text.trim())) +} + #[cfg(test)] mod tests { use super::*; use crate::{ - domain::{GestaltMemberDelta, GestaltPersonaState, Location}, + domain::{GestaltMemberDelta, GestaltPersonaState, Location, NarrativeTurn}, model::ModelStageRequest, }; use async_trait::async_trait; @@ -398,6 +431,7 @@ mod tests { &BTreeSet::from(["mira".into()]), &BTreeSet::from(["member:already-here".into()]), "center", + true, ) .unwrap(); @@ -428,6 +462,7 @@ mod tests { &BTreeSet::new(), &BTreeSet::new(), "center", + false, ) .unwrap(); @@ -437,6 +472,35 @@ mod tests { jsonschema::validator_for(&schema).unwrap(); } + #[test] + fn only_exact_immediately_committed_player_speech_admits_individuation() { + let mut campaign = crate::resolution::tests::campaign(0, 8); + campaign.revision = 7; + campaign.transcript.push(NarrativeTurn { + revision: 7, + at: chrono::Utc::now(), + speaker: campaign.player_actor_id.clone(), + text: "I ask the unnamed porter for their name.".into(), + }); + let exact = format!( + "{} says: I ask the unnamed porter for their name.", + campaign.player_actor_id + ); + assert_eq!( + automatic_individuation_stimulus(&campaign).as_deref(), + Some(exact.as_str()) + ); + + campaign.transcript.push(NarrativeTurn { + revision: 8, + at: chrono::Utc::now(), + speaker: "world".into(), + text: "They agree to help.".into(), + }); + campaign.revision = 8; + assert!(automatic_individuation_stimulus(&campaign).is_none()); + } + #[tokio::test] async fn presence_planner_corrects_one_semantic_failure_against_the_same_snapshot() { let mut campaign = crate::resolution::tests::campaign(0, 8); diff --git a/crates/ghostlight-dungeon/src/kernel.rs b/crates/ghostlight-dungeon/src/kernel.rs index 82cd19e..86d805b 100644 --- a/crates/ghostlight-dungeon/src/kernel.rs +++ b/crates/ghostlight-dungeon/src/kernel.rs @@ -1210,6 +1210,20 @@ fn execute( if reason.trim().is_empty() { return Err(KernelError::Invalid("presence plan has no reason".into())); } + if !plan.individuations.is_empty() + && crate::gestalt::automatic_individuation_stimulus(&campaign).as_deref() + != Some(reason.as_str()) + { + return Err(KernelError::Invalid( + "automatic individuation is admitted only by the exact immediately committed player speech" + .into(), + )); + } + if plan.individuations.len() > 1 { + return Err(KernelError::Invalid( + "one player speech can admit at most one first-relevance person".into(), + )); + } let player_location = &campaign.actors[&campaign.player_actor_id].location_id; if plan .individuations @@ -3347,6 +3361,97 @@ mod tests { } } + #[tokio::test] + async fn resolved_outcome_cannot_admit_an_unrequested_gestalt_member() { + 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.transcript.push(NarrativeTurn { + revision: 0, + at: Utc::now(), + speaker: "world".into(), + text: "They agree to help design the relay.".into(), + }); + seed.gestalts.insert( + "refugees".into(), + GestaltPersonaState { + schema: "ghostlight.gestalt_persona_state.v1".into(), + id: "refugees".into(), + name: "Refugees".into(), + version: 0, + home_location_id: "room".into(), + shared_capabilities: BTreeSet::new(), + shared_knowledge: BTreeSet::new(), + resources: BTreeSet::new(), + goals: vec![], + pressures: vec![], + }, + ); + crate::resolution::ensure_agency_profiles(&mut seed); + let campaign_id = seed.id; + kernel + .command(WorldCommand::CreateCampaign { + campaign: seed, + evidence_receipts: vec![], + model_stage_receipts: vec![], + }) + .await + .unwrap(); + + let member = GestaltMemberDelta { + schema: "ghostlight.gestalt_member_delta.v1".into(), + id: "relay-volunteer".into(), + gestalt_id: "refugees".into(), + version: 0, + name: "Relay Volunteer".into(), + capability_additions: BTreeSet::new(), + capability_removals: BTreeSet::new(), + knowledge_additions: BTreeSet::new(), + knowledge_removals: BTreeSet::new(), + equipment: BTreeSet::new(), + conditions: BTreeSet::new(), + obligations: BTreeSet::new(), + relationships: BTreeMap::new(), + goals: vec!["help with the relay".into()], + memories: vec!["agreed to help".into()], + last_location_id: Some("room".into()), + materialized_actor_id: None, + last_relevant_revision: 0, + relevance_lease_until_revision: 0, + }; + let error = kernel + .command(WorldCommand::ReconcileGestaltPresence { + expected_revision: 0, + reason: "They agree to help design the relay.".into(), + plan: GestaltPresencePlan { + individuations: vec![GestaltIndividuation { + gestalt_id: "refugees".into(), + expected_gestalt_version: 0, + member, + location_id: "room".into(), + }], + promotions: vec![], + demotions: vec![], + }, + }) + .await + .unwrap_err(); + assert!( + error + .to_string() + .contains("immediately committed player speech") + ); + let persisted = store + .load::("campaign.v1", &campaign_id.to_string()) + .unwrap() + .unwrap() + .1; + assert_eq!(persisted.revision, 0); + assert!(persisted.gestalt_members.is_empty()); + assert_eq!(persisted.transcript.len(), 1); + } + #[test] fn composite_selected_action_commits_atomically_and_rebuilds_derived_lanes() { let mut value = campaign(); diff --git a/docs/architecture/ghostlight-dungeon-mvp.md b/docs/architecture/ghostlight-dungeon-mvp.md index f2206d8..70f7feb 100644 --- a/docs/architecture/ghostlight-dungeon-mvp.md +++ b/docs/architecture/ghostlight-dungeon-mvp.md @@ -295,6 +295,14 @@ cannot write aggregate gestalt learning. A malformed, conflicting, stale, or partially invalid plan changes nothing. The compiler may seed likely members, but it is not required to predict every future person at campaign creation. +First-relevance identity admission is bound to the exact immediately committed +player-speech turn. Existing dormant members may be recast after any relevant +committed foreground event, but an attempt result, narration, event summary, or +plural outcome phrase cannot authorize a new canonical person. One admitted +speech may introduce at most one member delta; the kernel rederives that +permission from the canonical transcript rather than trusting the planner or +its freeform reason. + ### The ownership decision Ghostlight owns the reusable Persona projection machinery: diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index c379a3f..d710556 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -204,6 +204,13 @@ messages. agency-profile/cover projection outside the reducer. The nested regression moves one granary through two successive fissions without duplication while preserving John's exact identity and delta. +- Automatic gestalt presence may recast exact dormant members after a relevant + foreground event. It may admit one new first-relevance member only when the + reason exactly matches the immediately committed player-speech turn. + Resolved-attempt stakes, narration, and event summaries cannot mint people; + both the model schema and `WorldKernel` close that lane. This cut followed a + live relay attempt that correctly targeted two refugees but whose plural + outcome prose caused a third `Relay Volunteer` to be created. - Live refresh pressure reproduced and removed two contract faults: reuse of a private-command idempotency key across separately sealed attempts, and validation of a refresh receipt as if it carried the initial-login account diff --git a/notes/ghostlight-current-system-map.md b/notes/ghostlight-current-system-map.md index 23edbd8..4e526c3 100644 --- a/notes/ghostlight-current-system-map.md +++ b/notes/ghostlight-current-system-map.md @@ -621,6 +621,14 @@ nearby dormant member. The planner owns reversible scene casting and may surface an earned callback without a player prompt; it cannot move a remote person or commit the cast. Failed live casts retain a private presence-preflight artifact. +Automatic presence has two different authorities. Recasting an existing exact +member is reversible and may follow any relevant foreground event. Admitting a +first-relevance member is canonical entity creation and is available only when +the planner reason exactly matches the immediately committed player-speech +turn. The generated schema closes the individuation lane for resolved attempts, +and `WorldKernel` independently rejects any such plan. Outcome prose and +narration are not entity-admission inputs. + Institution `already_committed_posture` is projected as durable state, distinct from unresolved pressure and fresh choice. Maintaining it is inaction. A model may propose only a materially different posture; repeated posture remains a