Allow bounded colocated gestalt interaction

This commit is contained in:
Meta Vens 2026-08-18 00:02:45 +02:00
commit bcb03e4647
8 changed files with 228 additions and 25 deletions

View file

@ -1421,6 +1421,17 @@ fn apply_strategic_tick_plan(
.filter(|target| campaign.institutions.contains_key(*target))
.cloned()
.collect();
let actor_ids = action
.target_subject_ids
.iter()
.filter(|target| {
campaign.actors.contains_key(*target)
|| target
.strip_prefix("member:")
.is_some_and(|member_id| campaign.gestalt_members.contains_key(member_id))
})
.cloned()
.collect();
let mut gestalt_ids = vec![action.gestalt_id.clone()];
gestalt_ids.extend(
action
@ -1437,7 +1448,7 @@ fn apply_strategic_tick_plan(
at,
kind: "gestalt_activity".into(),
summary: strategic_activity_summary(&gestalt.name, &action.activity, &target_names),
actor_ids: vec![],
actor_ids,
institution_ids,
gestalt_ids,
location_ids: locations,
@ -1553,6 +1564,21 @@ fn apply_strategic_tick_plan(
.filter(|target| campaign.institutions.contains_key(*target))
.cloned()
.collect();
let mut actor_ids = vec![format!("member:{}", action.member_id)];
actor_ids.extend(
action
.target_subject_ids
.iter()
.filter(|target| {
campaign.actors.contains_key(*target)
|| target.strip_prefix("member:").is_some_and(|member_id| {
campaign.gestalt_members.contains_key(member_id)
})
})
.cloned(),
);
actor_ids.sort();
actor_ids.dedup();
let mut gestalt_ids = vec![action.source_gestalt_id.clone()];
gestalt_ids.extend(
action
@ -1568,7 +1594,7 @@ fn apply_strategic_tick_plan(
at,
kind: "gestalt_member_activity".into(),
summary: strategic_activity_summary(&member.name, &action.activity, &target_names),
actor_ids: vec![format!("member:{}", action.member_id)],
actor_ids,
institution_ids,
gestalt_ids,
location_ids: action.location_ids,
@ -1654,6 +1680,13 @@ fn strategic_activity_summary(
}
fn agency_subject_name(campaign: &Campaign, subject_id: &str) -> Result<String, KernelError> {
if let Some(member_id) = subject_id.strip_prefix("member:") {
return campaign
.gestalt_members
.get(member_id)
.map(|value| value.name.clone())
.ok_or_else(|| KernelError::Invalid("strategic activity target vanished".into()));
}
campaign
.actors
.get(subject_id)
@ -2359,6 +2392,62 @@ mod tests {
assert!(events[0].institution_ids.is_empty());
}
#[test]
fn colocated_gestalt_can_address_a_dormant_member_without_absorbing_their_agency() {
let mut value = hierarchical_refugee_campaign();
value
.gestalt_members
.get_mut("mira")
.unwrap()
.last_location_id = Some("docks".into());
value.gestalt_members.get_mut("mira").unwrap().gestalt_id = "dock-neighbors".into();
let before = value.clone();
let events = apply_strategic_tick_plan(
&mut value,
StrategicTickPlan {
gestalt_activities: vec![StrategicGestaltActivity {
gestalt_id: "dock-neighbors".into(),
activity: StrategicActivityKind::Communicate,
target_subject_ids: vec!["member:mira".into()],
location_ids: vec!["docks".into()],
public_channels: vec![],
}],
..Default::default()
},
)
.unwrap();
assert_eq!(value, before);
assert_eq!(events.len(), 1);
assert_eq!(
events[0].summary,
"South dock neighbors sends a communication to Mira Venn."
);
assert_eq!(events[0].actor_ids, vec!["member:mira"]);
assert_eq!(events[0].gestalt_ids, vec!["dock-neighbors"]);
}
#[test]
fn gestalt_cannot_address_a_dormant_member_at_another_location() {
let mut value = hierarchical_refugee_campaign();
let before = value.clone();
let error = apply_strategic_tick_plan(
&mut value,
StrategicTickPlan {
gestalt_activities: vec![StrategicGestaltActivity {
gestalt_id: "dock-neighbors".into(),
activity: StrategicActivityKind::Communicate,
target_subject_ids: vec!["member:mira".into()],
location_ids: vec!["docks".into()],
public_channels: vec![],
}],
..Default::default()
},
)
.unwrap_err();
assert!(error.to_string().contains("exact graph or location scope"));
assert_eq!(value, before);
}
#[test]
fn gestalt_activity_rejects_a_nonadjacent_target_without_mutation() {
let mut value = hierarchical_refugee_campaign();

View file

@ -2245,7 +2245,7 @@ pub fn subject_state_references(campaign: &Campaign, subject_id: &str) -> Result
}
pub fn strategic_activity_targets(campaign: &Campaign, subject_id: &str) -> BTreeSet<String> {
campaign
let mut targets = campaign
.agency_relations
.values()
.filter(|relation| relation.active)
@ -2259,7 +2259,32 @@ pub fn strategic_activity_targets(campaign: &Campaign, subject_id: &str) -> BTre
}
})
.filter(|target| target != subject_id && campaign.agency_profiles.contains_key(target))
.collect()
.collect::<BTreeSet<_>>();
if let Some(source) = campaign.agency_profiles.get(subject_id) {
targets.extend(
campaign
.agency_profiles
.values()
.filter(|target| {
target.subject_id != subject_id
&& target.active_leaf
&& target.simulation_eligible
&& !source.location_ids.is_disjoint(&target.location_ids)
})
.map(|target| target.subject_id.clone()),
);
targets.extend(campaign.gestalt_members.values().filter_map(|member| {
if member.materialized_actor_id.is_some() {
return None;
}
let location = dormant_member_location(campaign, &member.id).ok()?;
source
.location_ids
.contains(&location)
.then(|| format!("member:{}", member.id))
}));
}
targets
}
pub fn member_activity_targets(campaign: &Campaign, member_id: &str) -> Result<BTreeSet<String>> {
@ -2271,6 +2296,7 @@ pub fn member_activity_targets(campaign: &Campaign, member_id: &str) -> Result<B
dormant_member_location(campaign, member_id)?;
let mut targets = strategic_activity_targets(campaign, &member.gestalt_id);
targets.insert(member.gestalt_id.clone());
targets.remove(&format!("member:{member_id}"));
Ok(targets)
}
@ -3116,8 +3142,31 @@ pub(crate) mod tests {
else {
unreachable!()
};
*target_subject_ids = vec!["player".into()];
*target_subject_ids = vec!["unseen-stranger".into()];
assert!(validate_and_resolve_wave(&value, &make_wave(invented_target)).is_err());
assert!(strategic_activity_targets(&value, "dockers").contains("member:mira"));
let address_mira = CellActionProposal {
subject_id: "dockers".into(),
intent: "offer Mira paid work unloading the next boat".into(),
intended_effect: "make the offer without deciding Mira's response".into(),
priority: 75,
state_references: vec!["subject:dockers".into()],
public_channels: vec![],
effect: StrategicCellEffect::GestaltActivity {
gestalt_id: "dockers".into(),
activity: StrategicActivityKind::Communicate,
target_subject_ids: vec!["member:mira".into()],
location_ids: vec!["center".into()],
},
};
let address_plan = validate_and_resolve_wave(&value, &make_wave(address_mira)).unwrap();
assert_eq!(address_plan.gestalt_activities.len(), 1);
assert_eq!(address_plan.gestalt_activities[0].gestalt_id, "dockers");
assert_eq!(
address_plan.gestalt_activities[0].target_subject_ids,
vec!["member:mira"]
);
}
#[test]

View file

@ -382,11 +382,26 @@ fn resolution_demand_context(campaign: &Campaign) -> (Vec<String>, serde_json::V
}
fn cell_slice(campaign: &Campaign, cell: &SimulationCell) -> Result<PermittedCellSlice> {
let constituents = cell
let mut member_exceptions = member_exceptions(campaign, cell)?;
let selected_member_ids = member_exceptions
.iter()
.map(|member| member.subject_id.clone())
.collect::<BTreeSet<_>>();
let mut constituents = cell
.subject_ids
.iter()
.map(|id| constituent_slice(campaign, id))
.collect::<Result<Vec<_>>>()?;
for subject in &mut constituents {
subject.activity_target_ids.retain(|target| {
!target.starts_with("member:") || selected_member_ids.contains(target)
});
}
for member in &mut member_exceptions {
member.activity_target_ids.retain(|target| {
!target.starts_with("member:") || selected_member_ids.contains(target)
});
}
let shared_knowledge = intersection(
constituents
.iter()
@ -399,7 +414,6 @@ fn cell_slice(campaign: &Campaign, cell: &SimulationCell) -> Result<PermittedCel
.map(|subject| &subject.capabilities)
.collect(),
);
let member_exceptions = member_exceptions(campaign, cell)?;
let perceived_events = cell_perceived_events(campaign, &constituents, &member_exceptions);
Ok(PermittedCellSlice {
cell_id: cell.id.clone(),
@ -934,6 +948,11 @@ mod tests {
campaign
.gestalt_members
.insert("other".into(), member("other", false));
for member_id in ["third", "fourth", "fifth", "sixth", "seventh"] {
campaign
.gestalt_members
.insert(member_id.into(), member(member_id, false));
}
crate::resolution::ensure_agency_profiles(&mut campaign);
campaign.agency_relations.insert(
"migration".into(),
@ -973,6 +992,31 @@ mod tests {
.activity_target_ids
.contains("refugees")
);
let selected_member_ids = slice
.member_exceptions
.iter()
.map(|member| member.subject_id.clone())
.collect::<BTreeSet<_>>();
assert!(selected_member_ids.contains("member:mira"));
assert!(slice.constituents.iter().all(|constituent| {
constituent
.activity_target_ids
.iter()
.filter(|target| target.starts_with("member:"))
.all(|target| selected_member_ids.contains(target))
}));
assert!(slice.member_exceptions.iter().all(|member| {
member
.activity_target_ids
.iter()
.filter(|target| target.starts_with("member:"))
.all(|target| selected_member_ids.contains(target))
}));
assert!(
!serde_json::to_string(&slice)
.unwrap()
.contains("member:seventh")
);
assert!(
!serde_json::to_string(&slice)
.unwrap()

View file

@ -95,8 +95,12 @@ is capped so a large population does not dump its roster into the prompt. A
member may migrate only along an explicit active `migration` relation to a
reachable destination leaf. At any active leaf, the same member may instead
emit a `member_activity` from their exact location toward their current
population or a graph-adjacent subject. Migration and ordinary activity share
one per-member strategic action slot. This lets a refugee who mattered during
population, an explicitly related subject, or another selected named person or
active subject at that exact location. Only the capped salient member
exceptions exposed to the cell may appear as named targets in its prompt;
canonical validation still checks the target's identity and current location.
Migration and ordinary activity share one per-member strategic action slot.
This lets a refugee who mattered during
a crisis choose to travel, offer work after settling, and later rematerialize
as the same person without forcing every refugee to consume an active Persona
cell or giving their agency to the destination Gestalt.
@ -275,8 +279,10 @@ prose is not a pressure.
Populations may instead propose a bounded `gestalt_activity`: prepare,
coordinate, investigate, recruit, obstruct, trade, or communicate. Its source
is an exact active gestalt leaf; its targets must be adjacent canonical agency
subjects; its locations and channels must belong to the source's permitted
slice. The effect commits only an attributed attempted-activity event. It does
subjects through an explicit active relation or exact shared location. A
dormant named person is addressable as `member:<id>` without becoming gestalt
state. Its locations and channels must belong to the source's permitted slice.
The effect commits only an attributed attempted-activity event. It does
not claim successful discovery, recruitment, persuasion, delivery, exchange,
obstruction, preparation, or target response. Event records carry exact
gestalt participant IDs so later demand and perception can attribute the

View file

@ -81,6 +81,15 @@ knowledge, and channel scope. It shares the person's resolution key with
migration and emits an attempt event without mutating the member delta or
letting a containing arena or destination Gestalt steal the choice.
The next live failure exposed a reciprocal reach gap rather than a Persona
problem: South Harbor residents could perceive settled Mira but their typed
target list contained only explicit agency relations. Strategic activity reach
now includes exact shared location as well as explicit relations. Dormant named
people remain exact `member:<id>` targets, and the scheduler filters every cell
slice to its capped salient member exceptions so a crowded location cannot dump
its roster into a prompt. Resolver and WorldKernel independently revalidate
identity, location, attribution, and attempt-only semantics.
The multiresolution body is deployed on Starfire. Typed health matched the
immutable release, DeepSeek was ready, and the disposable live strategic-cell
smoke committed three offscreen events plus three accessible news items without

View file

@ -71,16 +71,20 @@ bounded local validation failures.
Gestalt background choices use two distinct typed paths. Pressure transitions
change exact unresolved markers. `gestalt_activity` records an attributed
preparation, coordination, investigation, recruitment, obstruction, trade, or
communication attempt against graph-adjacent subjects without claiming the
outcome. The kernel derives the event text and exact gestalt participant IDs;
the arena and model prose own neither.
communication attempt against subjects connected by an explicit agency
relation or exact shared location without claiming the outcome. A selected
dormant member can be addressed by their durable ID; this does not union them
into the source population. The kernel derives the event text and exact
participant IDs; the arena and model prose own neither.
Salient dormant members have their own `member_activity` path for ordinary
local attempts. It uses the same bounded verbs, the member's exact location,
their current leaf plus graph-adjacent targets, and their own state/channel
permissions. Migration and activity conflict on one member key. A destination
population cannot inherit the person's offer, speech, or decision merely
because the cover placed them in the same arena.
their current leaf plus explicitly related or exactly co-located targets, and
their own state/channel permissions. Only the capped salient member exceptions
selected for the cell enter any prompt or can be named there. Migration and
activity conflict on one member key. A destination population cannot inherit
the person's offer, speech, or decision merely because the cover placed them
in the same arena.
Population scale uses reversible individuation:

View file

@ -168,12 +168,14 @@ stake prose alone never impersonates world mutation.
are bounded per constituent, and population pressure cannot silently become
canon knowledge. Gestalts may also emit typed preparation, coordination,
investigation, recruitment, obstruction, trade, or communication attempts
against exact graph-adjacent subjects. These commit only an attributed
attempted-activity event; success and target response require later admitted
effects. Durable dematerialized members use the same bounded activity
vocabulary with exact personal attribution; ordinary action and migration
share one per-person slot. Invalid, stale, timed-out, or malformed waves
leave world state unchanged.
against subjects with an explicit agency relation or exact shared location.
A capped salient dormant member may be addressed by durable ID without being
absorbed into a population voice. These commit only an attributed attempted-
activity event; success and target response require later admitted effects.
Durable dematerialized members use the same bounded activity vocabulary with
exact personal attribution; ordinary action and migration share one per-
person slot. Invalid, stale, timed-out, or malformed waves leave world state
unchanged.
- The scheduler and return-time catch-up invoke the same `AdvanceStrategicTick` command. Catch-up processes missed ticks before accepting the next player action.
- Live play has inference priority; background work stops launching new calls while a player turn is active.
- Offscreen events generate information-channel-aware news leads. On return, the player may receive newspapers, reports, messages, or rumors only when the character has access to them; the news layer cannot reveal omniscient state.

View file

@ -265,5 +265,5 @@ data_flow:
current_status:
summary: GhostlightDungeon production remains on the verified Starfire release. The acceptance candidate is pressuring multiresolution agency: exact Gestalt activity attempts, typed Gestalt event participation, nested lineage identity preservation, location-bounded presence projection, and automatic refugee callback rematerialization. Production state is untouched while the strict live-fire harness and receipts falsify the candidate.
next_action: Run the strict multiresolution refugee-return live fire. Require meaningful whole-setting background activity, exact rival-arena attribution, migration across unrelated nested lineage trees, and automatic presence-planner rematerialization of the same durable member. Profile stage receipts and repair only the owning context or typed effect seam.
next_action: Run the strict multiresolution refugee-return live fire after the shared-location activity-reach repair. Require meaningful whole-setting background activity, exact rival-arena attribution, migration across unrelated nested lineage trees, and automatic presence-planner rematerialization of the same durable member. Profile stage receipts and repair only the owning context or typed effect seam.
gestalt_population: Gestalt Personas own shared population baselines; persistent member deltas own named individuality; WorldKernel materialization and approval-gated fission preserve lineage and identity. Presence projection is limited to nearby active leaves and exact-location dormant members. Simulation cells are derived covers only.