Preserve canonical actor agency in strategic cells

This commit is contained in:
Meta Vens 2026-08-23 01:49:06 +02:00
commit 1ddbd294af
9 changed files with 427 additions and 6 deletions

View file

@ -332,6 +332,7 @@ async fn main() -> anyhow::Result<()> {
+ plan.gestalt_activities.len()
+ plan.gestalt_migrations.len()
+ plan.actor_moves.len()
+ plan.actor_activities.len()
+ plan.member_activities.len();
for stage in &output.stages {
store.insert(

View file

@ -161,7 +161,7 @@ async fn main() -> anyhow::Result<()> {
"cover":cover,
"appraisals":output.wave.appraisals,
"plan":plan,
"material_action_count":plan.institution_actions.len() + plan.gestalt_actions.len() + plan.gestalt_activities.len() + plan.gestalt_migrations.len() + plan.actor_moves.len() + plan.member_activities.len() + plan.member_migrations.len(),
"material_action_count":plan.institution_actions.len() + plan.gestalt_actions.len() + plan.gestalt_activities.len() + plan.gestalt_migrations.len() + plan.actor_moves.len() + plan.actor_activities.len() + plan.member_activities.len() + plan.member_migrations.len(),
"explicit_inaction_count":explicit_inaction_count,
"model_stage_receipts":output.stages.iter().map(|stage|&stage.receipt).collect::<Vec<_>>(),
"commit":committed,

View file

@ -448,6 +448,14 @@ pub enum StrategicCellEffect {
actor_id: String,
destination_id: String,
},
ActorActivity {
actor_id: String,
activity: StrategicActivityKind,
#[serde(default)]
target_subject_ids: Vec<String>,
#[serde(default)]
location_ids: Vec<String>,
},
MemberActivity {
member_id: String,
activity: StrategicActivityKind,
@ -703,6 +711,8 @@ pub struct StrategicTickPlan {
pub gestalt_migrations: Vec<StrategicGestaltMigration>,
pub actor_moves: Vec<StrategicActorMove>,
#[serde(default)]
pub actor_activities: Vec<StrategicActorActivity>,
#[serde(default)]
pub member_migrations: Vec<StrategicMemberMigration>,
#[serde(default)]
pub member_activities: Vec<StrategicMemberActivity>,
@ -751,6 +761,16 @@ pub struct StrategicActorMove {
pub public_channels: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct StrategicActorActivity {
pub action_digest: String,
pub actor_id: String,
pub activity: StrategicActivityKind,
pub target_subject_ids: Vec<String>,
pub location_ids: Vec<String>,
pub public_channels: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct StrategicMemberMigration {
pub member_id: String,

View file

@ -1695,6 +1695,16 @@ fn apply_strategic_tick_plan(
),
);
}
for activity in &plan.actor_activities {
outcome_event_context.insert(
activity.action_digest.clone(),
(
activity.actor_id.clone(),
activity.location_ids.clone(),
activity.public_channels.clone(),
),
);
}
for activity in &plan.member_activities {
outcome_event_context.insert(
activity.action_digest.clone(),
@ -2013,6 +2023,87 @@ fn apply_strategic_tick_plan(
});
}
for action in plan.actor_activities {
if !seen_actors.insert(action.actor_id.clone()) {
return Err(KernelError::Invalid(
"actor acts twice in one strategic tick".into(),
));
}
if is_human_controlled_actor(campaign, &action.actor_id) {
return Err(KernelError::Invalid(
"strategic simulation cannot puppet a human-controlled actor".into(),
));
}
validate_public_channels(&action.public_channels)?;
let actor = campaign
.actors
.get(&action.actor_id)
.ok_or_else(|| KernelError::Invalid("strategic plan invented an actor".into()))?;
let allowed_targets =
crate::resolution::strategic_activity_targets(campaign, &action.actor_id);
let unique_targets = action.target_subject_ids.iter().collect::<BTreeSet<_>>();
let needs_target = !action.activity.allows_targetless_local_attempt();
if action.target_subject_ids.len() > 4
|| unique_targets.len() != action.target_subject_ids.len()
|| action
.target_subject_ids
.iter()
.any(|target| !allowed_targets.contains(target))
|| (needs_target && action.target_subject_ids.is_empty())
|| action.location_ids.len() != 1
|| action.location_ids[0] != actor.location_id
{
return Err(KernelError::Invalid(
"strategic actor activity exceeds exact graph or location scope".into(),
));
}
let target_names = action
.target_subject_ids
.iter()
.map(|target| agency_subject_name(campaign, target))
.collect::<Result<Vec<_>, _>>()?;
let institution_ids = action
.target_subject_ids
.iter()
.filter(|target| campaign.institutions.contains_key(*target))
.cloned()
.collect();
let mut actor_ids = vec![action.actor_id.clone()];
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 = action
.target_subject_ids
.iter()
.filter(|target| campaign.gestalts.contains_key(*target))
.cloned()
.collect::<Vec<_>>();
gestalt_ids.sort();
gestalt_ids.dedup();
events.push(Event {
id: format!("strategic:{revision}:actor-activity:{}", action.actor_id),
at,
kind: "actor_activity".into(),
summary: strategic_activity_summary(&actor.name, &action.activity, &target_names),
actor_ids,
institution_ids,
gestalt_ids,
location_ids: action.location_ids,
public_channels: action.public_channels,
});
}
let mut seen_members = BTreeSet::new();
for action in plan.member_activities {
if !seen_members.insert(action.member_id.clone()) {
@ -3077,6 +3168,11 @@ mod tests {
plan.gestalt_activities
.iter()
.map(|activity| (activity.action_digest.clone(), activity.gestalt_id.clone()))
.chain(
plan.actor_activities.iter().map(|activity| {
(activity.action_digest.clone(), activity.actor_id.clone())
}),
)
.chain(plan.member_activities.iter().map(|activity| {
(
activity.action_digest.clone(),
@ -4654,6 +4750,46 @@ mod tests {
ticks[0].model_receipt_hash,
Some(format!("sha256:{}", "b".repeat(64)))
);
let actor_activity = resolve_test_activities(crate::domain::StrategicTickPlan {
actor_activities: vec![crate::domain::StrategicActorActivity {
action_digest: test_action_digest("runner-investigates-yard"),
actor_id: "runner".into(),
activity: StrategicActivityKind::Investigate,
target_subject_ids: vec![],
location_ids: vec!["yard".into()],
public_channels: vec![],
}],
..Default::default()
});
let result = kernel
.command(WorldCommand::AdvanceStrategicTick {
expected_revision: 1,
source: TickSource::Scheduler,
plan: Some(actor_activity),
model_receipt_hash: Some(format!("sha256:{}", "c".repeat(64))),
resolution_wave: None,
})
.await
.unwrap();
let CommandResult::Committed { campaign, .. } = result else {
panic!("expected commit")
};
assert_eq!(campaign.actors["runner"].location_id, "yard");
assert_eq!(campaign.actors["player"].location_id, "room");
assert!(
campaign
.events
.iter()
.any(|event| event.kind == "actor_activity"
&& event.actor_ids.contains(&"runner".to_owned()))
);
assert!(
campaign
.events
.iter()
.any(|event| event.kind == "strategic_activity_outcome")
);
}
#[tokio::test]

View file

@ -349,6 +349,11 @@ pub fn plan_activity_digests(plan: &StrategicTickPlan) -> Vec<String> {
plan.gestalt_activities
.iter()
.map(|activity| activity.action_digest.clone())
.chain(
plan.actor_activities
.iter()
.map(|activity| activity.action_digest.clone()),
)
.chain(
plan.member_activities
.iter()
@ -395,6 +400,25 @@ pub fn validate_plan_activity_outcomes(
},
)
})
.chain(plan.actor_activities.iter().map(|activity| {
(
activity.action_digest.clone(),
CellActionProposal {
subject_id: activity.actor_id.clone(),
intent: "committed strategic activity".into(),
intended_effect: "resolve the committed strategic activity".into(),
priority: 0,
state_references: vec![],
public_channels: activity.public_channels.clone(),
effect: StrategicCellEffect::ActorActivity {
actor_id: activity.actor_id.clone(),
activity: activity.activity.clone(),
target_subject_ids: activity.target_subject_ids.clone(),
location_ids: activity.location_ids.clone(),
},
},
)
}))
.chain(plan.member_activities.iter().map(|activity| {
(
activity.action_digest.clone(),
@ -415,7 +439,11 @@ pub fn validate_plan_activity_outcomes(
)
}))
.collect::<BTreeMap<_, _>>();
if synthetic.len() != plan.gestalt_activities.len() + plan.member_activities.len() {
if synthetic.len()
!= plan.gestalt_activities.len()
+ plan.actor_activities.len()
+ plan.member_activities.len()
{
return Err(anyhow!(
"strategic activities contain duplicate action digests"
));
@ -1378,6 +1406,12 @@ fn activity_parts(
location_ids,
..
}
| StrategicCellEffect::ActorActivity {
activity,
target_subject_ids,
location_ids,
..
}
| StrategicCellEffect::MemberActivity {
activity,
target_subject_ids,

View file

@ -172,6 +172,13 @@ enum CellEffectCandidate {
ActorMove {
destination_id: String,
},
ActorActivity {
activity: crate::domain::StrategicActivityKind,
#[serde(default)]
target_subject_ids: Vec<String>,
#[serde(default)]
location_ids: Vec<String>,
},
MemberActivity {
activity: crate::domain::StrategicActivityKind,
#[serde(default)]
@ -249,6 +256,7 @@ const CELL_APPRAISAL_OUTPUT_CONTRACT: &str = r#"{
{"type":"object","required":["type","activity","target_subject_ids","location_ids"],"properties":{"type":{"const":"gestalt_activity"},"activity":{"enum":["prepare","coordinate","investigate","recruit","obstruct","trade","communicate"]},"target_subject_ids":{"type":"array","maxItems":4,"items":{"type":"string"}},"location_ids":{"type":"array","maxItems":4,"items":{"type":"string"}}}},
{"type":"object","required":["type","destination_gestalt_id"],"properties":{"type":{"const":"gestalt_migration"},"destination_gestalt_id":{"type":"string"}}},
{"type":"object","required":["type","destination_id"],"properties":{"type":{"const":"actor_move"},"destination_id":{"type":"string"}}},
{"type":"object","required":["type","activity","target_subject_ids","location_ids"],"properties":{"type":{"const":"actor_activity"},"activity":{"enum":["prepare","coordinate","investigate","recruit","obstruct","trade","communicate"]},"target_subject_ids":{"type":"array","maxItems":4,"items":{"type":"string"}},"location_ids":{"type":"array","maxItems":1,"items":{"type":"string"}}}},
{"type":"object","required":["type","activity","target_subject_ids","location_ids"],"properties":{"type":{"const":"member_activity"},"activity":{"enum":["prepare","coordinate","investigate","recruit","obstruct","trade","communicate"]},"target_subject_ids":{"type":"array","maxItems":4,"items":{"type":"string"}},"location_ids":{"type":"array","maxItems":1,"items":{"type":"string"}}}},
{"type":"object","required":["type","destination_gestalt_id"],"properties":{"type":{"const":"member_migration"},"destination_gestalt_id":{"type":"string"}}}
]}
@ -726,7 +734,7 @@ fn required_projection_subject_ids(slice: &PermittedCellSlice) -> BTreeSet<Strin
fn allowed_effect_type(kind: &crate::domain::AgencySubjectKind) -> &'static str {
match kind {
crate::domain::AgencySubjectKind::Actor => "actor_move",
crate::domain::AgencySubjectKind::Actor => "actor_move_or_actor_activity",
crate::domain::AgencySubjectKind::Institution => "institution",
crate::domain::AgencySubjectKind::Gestalt => {
"gestalt_pressure_or_gestalt_activity_or_gestalt_migration"
@ -1218,6 +1226,16 @@ fn bind_cell_appraisal(
destination_id,
}
}
CellEffectCandidate::ActorActivity {
activity,
target_subject_ids,
location_ids,
} => crate::domain::StrategicCellEffect::ActorActivity {
actor_id: candidate.subject_id.clone(),
activity,
target_subject_ids,
location_ids,
},
CellEffectCandidate::MemberActivity {
activity,
target_subject_ids,
@ -1616,6 +1634,36 @@ fn validate_constituent_effect(
));
}
}
crate::domain::StrategicCellEffect::ActorActivity {
actor_id,
activity,
target_subject_ids,
location_ids,
} => {
let unique_targets = target_subject_ids.iter().collect::<BTreeSet<_>>();
let needs_target = !activity.allows_targetless_local_attempt();
if subject.subject_kind != crate::domain::AgencySubjectKind::Actor
|| actor_id != &subject.subject_id
|| target_subject_ids.len() > 4
|| unique_targets.len() != target_subject_ids.len()
|| target_subject_ids
.iter()
.any(|target| !subject.activity_target_ids.contains(target))
|| (needs_target && target_subject_ids.is_empty())
|| location_ids.len() != 1
|| !subject.location_ids.contains(&location_ids[0])
{
return Err(anyhow!(
"actor {} proposed {:?} toward {:?} at {:?}; exact allowed targets are {:?} and exact locations are {:?}",
subject.subject_id,
activity,
target_subject_ids,
location_ids,
subject.activity_target_ids,
subject.location_ids
));
}
}
crate::domain::StrategicCellEffect::MemberMigration { .. } => {
return Err(anyhow!(
"constituent {} is not a named member and may not emit member_migration",
@ -2406,6 +2454,72 @@ mod tests {
assert!(validate_cell_appraisal(&slice, &stolen).is_err());
}
#[test]
fn canonical_actor_activity_stays_attributed_to_the_actor() {
let mut slice = fixture_cell_slice();
let subject = &mut slice.constituents[0];
subject.subject_id = "actor:liaison".into();
subject.subject_kind = AgencySubjectKind::Actor;
subject.name = "Liaison".into();
subject.collective_authority_id = None;
subject.location_ids = BTreeSet::from(["forum".into()]);
subject.permitted_state_references = BTreeSet::from(["subject:actor:liaison".into()]);
subject.activity_target_ids = BTreeSet::from(["clinic".into()]);
subject.current_posture = None;
let appraisal = bind_cell_appraisal(
&slice,
CellAppraisalProposal {
actions: vec![CellActionCandidate {
subject_id: "actor:liaison".into(),
intent: "ask the clinic for its shortage count".into(),
intended_effect: "send the request without inventing a reply".into(),
priority: 70,
state_references: vec!["subject:actor:liaison".into()],
public_channels: vec![],
effect: CellEffectCandidate::ActorActivity {
activity: crate::domain::StrategicActivityKind::Communicate,
target_subject_ids: vec!["clinic".into()],
location_ids: vec!["forum".into()],
},
}],
inactions: vec![],
},
)
.unwrap();
validate_cell_appraisal(&slice, &appraisal).unwrap();
assert!(matches!(
&appraisal.actions[0].effect,
StrategicCellEffect::ActorActivity { actor_id, .. } if actor_id == "actor:liaison"
));
let wrong_lane = bind_cell_appraisal(
&slice,
CellAppraisalProposal {
actions: vec![CellActionCandidate {
subject_id: "actor:liaison".into(),
intent: "ask the clinic for its shortage count".into(),
intended_effect: "send the request without inventing a reply".into(),
priority: 70,
state_references: vec!["subject:actor:liaison".into()],
public_channels: vec![],
effect: CellEffectCandidate::MemberActivity {
activity: crate::domain::StrategicActivityKind::Communicate,
target_subject_ids: vec!["clinic".into()],
location_ids: vec!["forum".into()],
},
}],
inactions: vec![],
},
)
.unwrap_err();
assert!(
wrong_lane
.to_string()
.contains("is not a selected member exception")
);
}
#[test]
fn repeated_institution_posture_reports_the_exact_missing_change() {
let slice = fixture_cell_slice();

View file

@ -1678,6 +1678,7 @@ pub fn select_resolution_wave(
let is_activity = matches!(
proposal.effect,
StrategicCellEffect::GestaltActivity { .. }
| StrategicCellEffect::ActorActivity { .. }
| StrategicCellEffect::MemberActivity { .. }
);
let selected_activity_proposal = is_activity.then(|| proposal.clone());
@ -1737,6 +1738,19 @@ pub fn select_resolution_wave(
destination_id,
public_channels: proposal.public_channels,
}),
StrategicCellEffect::ActorActivity {
actor_id,
activity,
target_subject_ids,
location_ids,
} => plan.actor_activities.push(StrategicActorActivity {
action_digest,
actor_id,
activity,
target_subject_ids,
location_ids,
public_channels: proposal.public_channels,
}),
StrategicCellEffect::MemberActivity {
member_id,
activity,
@ -1783,6 +1797,7 @@ pub fn select_resolution_wave(
+ plan.gestalt_activities.len()
+ plan.gestalt_migrations.len()
+ plan.actor_moves.len()
+ plan.actor_activities.len()
+ plan.member_activities.len()
+ plan.member_migrations.len()
>= consequence_limit
@ -1950,6 +1965,34 @@ fn validate_cell_proposal(
return Err(anyhow!("actor proposal exceeds spatial reach"));
}
}
StrategicCellEffect::ActorActivity {
actor_id,
activity,
target_subject_ids,
location_ids,
} => {
let actor = campaign
.actors
.get(actor_id)
.filter(|_| actor_id == &proposal.subject_id)
.ok_or_else(|| anyhow!("actor activity exceeds constituent state"))?;
let allowed_targets = strategic_activity_targets(campaign, actor_id);
let unique_targets = target_subject_ids.iter().collect::<BTreeSet<_>>();
let needs_target = !activity.allows_targetless_local_attempt();
if target_subject_ids.len() > 4
|| unique_targets.len() != target_subject_ids.len()
|| target_subject_ids
.iter()
.any(|target| !allowed_targets.contains(target))
|| (needs_target && target_subject_ids.is_empty())
|| location_ids.len() != 1
|| location_ids[0] != actor.location_id
{
return Err(anyhow!(
"actor activity exceeds exact subject, graph, or location scope"
));
}
}
StrategicCellEffect::MemberMigration { .. } => {
return Err(anyhow!(
"a population, institution, actor, or arena cannot migrate a named member"
@ -2602,6 +2645,7 @@ fn proposal_target_key(proposal: &CellActionProposal) -> String {
format!("gestalt:{}", proposal.subject_id)
}
StrategicCellEffect::ActorMove { actor_id, .. } => format!("actor:{actor_id}"),
StrategicCellEffect::ActorActivity { actor_id, .. } => format!("actor:{actor_id}"),
StrategicCellEffect::MemberActivity { member_id, .. } => {
format!("member:{member_id}")
}
@ -3111,6 +3155,59 @@ pub(crate) mod tests {
));
}
#[test]
fn canonical_actor_activity_uses_exact_actor_scope() {
let mut value = campaign(0, 1);
let mut liaison = value.actors["player"].clone();
liaison.id = "liaison".into();
liaison.name = "Liaison".into();
liaison.capabilities.insert("read ration ledgers".into());
value.actors.insert(liaison.id.clone(), liaison);
ensure_agency_profiles(&mut value);
let cell = SimulationCell {
schema: "ghostlight.simulation_cell.v1".into(),
id: "cell:liaison".into(),
mode: SimulationCellMode::Cohesive,
subject_ids: BTreeSet::from(["liaison".into()]),
merge_loss: MergeLoss::default(),
rationale: "exact actor fixture".into(),
lease_until_world_revision: 0,
lease_until_strategic_tick: 0,
detail_focus_subject_id: Some("liaison".into()),
};
let proposal = CellActionProposal {
subject_id: "liaison".into(),
intent: "inspect the room's ration ledger".into(),
intended_effect: "attempt a local investigation".into(),
priority: 50,
state_references: vec!["subject:liaison".into()],
public_channels: vec![],
effect: StrategicCellEffect::ActorActivity {
actor_id: "liaison".into(),
activity: StrategicActivityKind::Investigate,
target_subject_ids: vec![],
location_ids: vec!["center".into()],
},
};
validate_cell_proposal(&value, &cell, &proposal).unwrap();
let mut wrong_location = proposal.clone();
let StrategicCellEffect::ActorActivity { location_ids, .. } = &mut wrong_location.effect
else {
unreachable!()
};
*location_ids = vec!["somewhere-else".into()];
assert!(validate_cell_proposal(&value, &cell, &wrong_location).is_err());
let mut player_puppet = proposal;
player_puppet.subject_id = "player".into();
let StrategicCellEffect::ActorActivity { actor_id, .. } = &mut player_puppet.effect else {
unreachable!()
};
*actor_id = "player".into();
assert!(validate_cell_proposal(&value, &cell, &player_puppet).is_err());
}
#[test]
fn partitions_one_thousand_subjects_with_bounded_local_work() {
let value = campaign(1_000, 8);

View file

@ -269,9 +269,10 @@ One wave-level strategic outcome resolver owns that missing decision. It runs
after priority and incompatibility selection but before the command enters the
kernel:
1. Ghostlight content-addresses every selected `gestalt_activity` and
`member_activity` proposal. The digest binds exact source, intent, intended
effect, targets, locations, state references, channels, and typed activity.
1. Ghostlight content-addresses every selected `gestalt_activity`,
`actor_activity`, and `member_activity` proposal. The digest binds exact
source, intent, intended effect, targets, locations, state references,
channels, and typed activity.
2. A local context compiler supplies only those selected attempts and their
legal consequence handles: source capabilities and holdings, exact target
state, active incident relations, current pressures, eligible named-member
@ -509,6 +510,16 @@ the destination population cannot claim the person's voice. `member_activity`
and `member_migration` resolve under one exact member key, so only the
higher-priority compatible choice can commit in a wave.
Canonical non-player actors use `actor_activity` for preparation,
coordination, investigation, recruitment, obstruction, trade, and
communication. The action is bound to the actor's exact current location,
state references, information channels, and graph-adjacent or co-located
targets. `actor_move` and `actor_activity` share one actor action key, so a
cell cannot make the same person travel and act again in the same wave.
Human-controlled actors are inadmissible in both paths. An arena may carry the
actor's attributed proposal but cannot translate it into population speech,
member activity, or collective authority.
The Interpreter maps natural attempts narrowly: speech, offers, requests, and
notices are communication; coordination requires an actual attempt to arrange
joint work; preparation is the source's own concrete work; investigation seeks

View file

@ -391,6 +391,14 @@ around unnamed local features remains part of the source-attributed attempt
and does not create topology or assert arrival. A different supplied canonical
location or population destination still requires the exact movement path.
Canonical non-player actors have an `actor_activity` path for ordinary
preparation, coordination, investigation, recruitment, obstruction, trade,
and communication. The Interpreter binds the actor's exact ID locally;
WorldKernel validates current location, graph targets, state references,
channels, and human-control exclusion. Actor movement and actor activity share
one action key, while successful consequences remain a separately resolved,
digest-bound outcome.
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 explicitly related or exactly co-located targets, and