Preserve actor continuity in strategic cells

This commit is contained in:
Meta Vens 2026-08-23 06:58:04 +02:00
commit 122aa1fb3e
6 changed files with 94 additions and 13 deletions

View file

@ -96,7 +96,14 @@ fn main() -> Result<()> {
for actor in campaign.actors.values() {
subjects.insert(
actor.id.clone(),
json!({ "kind": "actor", "name": actor.name }),
json!({
"kind": "actor",
"name": actor.name,
"goals": actor.goals,
"obligations": actor.obligations,
"relationships": actor.relationships,
"memories": actor.memories,
}),
);
}
for institution in campaign.institutions.values() {
@ -108,13 +115,26 @@ fn main() -> Result<()> {
for gestalt in campaign.gestalts.values() {
subjects.insert(
gestalt.id.clone(),
json!({ "kind": "gestalt", "name": gestalt.name }),
json!({
"kind": "gestalt",
"name": gestalt.name,
"goals": gestalt.goals,
"pressures": gestalt.pressures,
}),
);
}
for member in campaign.gestalt_members.values() {
subjects.insert(
member.id.clone(),
json!({ "kind": "gestalt_member", "name": member.name, "gestaltId": member.gestalt_id }),
json!({
"kind": "gestalt_member",
"name": member.name,
"gestaltId": member.gestalt_id,
"goals": member.goals,
"obligations": member.obligations,
"relationships": member.relationships,
"memories": member.memories,
}),
);
}

View file

@ -598,7 +598,7 @@ impl WorldCompiler {
.map(|brief| brief.subject_id.clone())
.collect::<BTreeSet<_>>();
let agency_prompt = format!(
"MULTIRESOLUTION AGENCY SKELETON\nCompile only this exact, already validated subject roster:\n{}\n\nReturn exactly one agency profile for every supplied subject and no other subject. Copy every subject_id, subject_kind, and location_ids exactly. Every profile must contain exactly the six facet axes geography, ideology, authority, economy_role, species_body, and information. Derive facets only from the supplied roster fields; use an explicit unknown value when they do not support a sharper claim. information_channels are routes through which the subject can publish or receive reports (for example a courier wire, bulletin, newspaper, radio net, or word of mouth), never facts the subject knows; do not copy knowledge_or_posture values into information_channels. collective_authority_id must be null or one supplied subject ID; it denotes real shared authority, never mere alliance or proximity. Relations may use only supplied subject IDs and strength must be an integer from 1 through 100. Cross-faction relations never imply shared speech, knowledge, or authority. Preserve geographic, ideological, institutional, economic, biological, and information boundaries that predict different behavior under pressure.",
"MULTIRESOLUTION AGENCY SKELETON\nCompile only this exact, already validated subject roster:\n{}\n\nReturn exactly one agency profile for every supplied subject and no other subject. Copy every subject_id, subject_kind, and location_ids exactly. Every profile must contain exactly the six facet axes geography, ideology, authority, economy_role, species_body, and information. Derive facets only from the supplied roster fields; use an explicit unknown value when they do not support a sharper facet claim. information_channels are concrete routes through which the subject can publish or receive reports (for example a courier wire, bulletin, newspaper, radio net, or word of mouth), never facts the subject knows; do not copy knowledge_or_posture values into information_channels, and use an empty set rather than an unknown placeholder when no route is supported. collective_authority_id must be null or one supplied subject ID; it denotes real shared authority, never mere alliance or proximity. Relations may use only supplied subject IDs and strength must be an integer from 1 through 100. Cross-faction relations never imply shared speech, knowledge, or authority. Preserve geographic, ideological, institutional, economic, biological, and information boundaries that predict different behavior under pressure.",
serde_json::to_string(&subject_briefs)?
);
let agency_schema = serde_json::to_value(schema_for!(CompiledAgencySkeleton))?;
@ -3041,17 +3041,24 @@ fn apply_compiled_agency_skeleton(
.unwrap_or_default(),
AgencySubjectKind::Institution => Vec::new(),
};
let invalid_information_channels = input
.information_channels
.iter()
.filter(|channel| !crate::resolution::information_channel_is_concrete(channel))
.cloned()
.collect::<Vec<_>>();
if profile.subject_kind != input.subject_kind
|| input_axes != axes
|| input.location_ids != profile.location_ids
|| !unknown_locations.is_empty()
|| !authority_known
|| !knowledge_channel_overlap.is_empty()
|| !invalid_information_channels.is_empty()
{
let missing_axes = axes.difference(&input_axes).cloned().collect::<Vec<_>>();
let unexpected_axes = input_axes.difference(&axes).cloned().collect::<Vec<_>>();
return Err(anyhow!(
"agency profile {} malformed: expected_kind={:?}; supplied_kind={:?}; expected_location_ids={:?}; supplied_location_ids={:?}; missing_axes={missing_axes:?}; unexpected_axes={unexpected_axes:?}; unknown_locations={unknown_locations:?}; unknown_collective_authority={:?}; knowledge_channel_overlap={knowledge_channel_overlap:?}",
"agency profile {} malformed: expected_kind={:?}; supplied_kind={:?}; expected_location_ids={:?}; supplied_location_ids={:?}; missing_axes={missing_axes:?}; unexpected_axes={unexpected_axes:?}; unknown_locations={unknown_locations:?}; unknown_collective_authority={:?}; knowledge_channel_overlap={knowledge_channel_overlap:?}; invalid_information_channels={invalid_information_channels:?}",
input.subject_id,
profile.subject_kind,
input.subject_kind,
@ -3297,7 +3304,7 @@ mod tests {
subject_id: "player".into(),
subject_kind: AgencySubjectKind::Actor,
collective_authority_id: None,
facets,
facets: facets.clone(),
location_ids: BTreeSet::from(["center".into()]),
information_channels: BTreeSet::from(["convoy vulnerabilities".into()]),
}],
@ -3305,6 +3312,23 @@ mod tests {
)
.unwrap_err();
assert!(error.to_string().contains("knowledge_channel_overlap"));
campaign.actors.get_mut("player").unwrap().knowledge.clear();
let error = apply_compiled_agency_skeleton(
&mut campaign,
&BTreeSet::from(["player".into()]),
vec![CompiledAgencyProfile {
subject_id: "player".into(),
subject_kind: AgencySubjectKind::Actor,
collective_authority_id: None,
facets,
location_ids: BTreeSet::from(["center".into()]),
information_channels: BTreeSet::from(["unknown".into()]),
}],
vec![],
)
.unwrap_err();
assert!(error.to_string().contains("invalid_information_channels"));
}
#[test]

View file

@ -2705,7 +2705,7 @@ fn validate_public_channels(channels: &[String]) -> Result<(), KernelError> {
if channels.len() > 8
|| channels
.iter()
.any(|c| c.trim().is_empty() || c.len() > 160)
.any(|channel| !crate::resolution::information_channel_is_concrete(channel))
{
return Err(KernelError::Invalid(
"strategic action has invalid information channels".into(),

View file

@ -69,6 +69,8 @@ pub struct CellConstituentSlice {
pub migration_destinations: BTreeMap<String, String>,
pub activity_target_ids: BTreeSet<String>,
pub goals: Vec<String>,
pub relationships: BTreeMap<String, String>,
pub memories: Vec<String>,
pub current_posture: Option<String>,
pub pressures: Vec<String>,
}
@ -450,6 +452,8 @@ fn cell_projector_context(slice: &PermittedCellSlice) -> serde_json::Value {
"migration_destinations": subject.migration_destinations,
"activity_target_ids": subject.activity_target_ids,
"goals": subject.goals,
"relationships": subject.relationships,
"memories": subject.memories,
"already_committed_posture": subject.current_posture,
"pressures": subject.pressures,
})).collect::<Vec<_>>(),
@ -1993,6 +1997,8 @@ mod tests {
migration_destinations: BTreeMap::new(),
activity_target_ids: BTreeSet::new(),
goals: vec!["publish a position".into()],
relationships: BTreeMap::new(),
memories: vec![],
current_posture: Some("weighing whether to publish a position".into()),
pressures: vec!["the vote is near".into()],
}],

View file

@ -8,6 +8,11 @@ pub const MIN_ACTIVE_CELL_BUDGET: u8 = 1;
pub const MAX_ACTIVE_CELL_BUDGET: u8 = 128;
pub const MAX_PROVIDER_PARALLELISM: u8 = 32;
pub(crate) fn information_channel_is_concrete(channel: &str) -> bool {
let channel = channel.trim();
!channel.is_empty() && channel.len() <= 160 && !channel.eq_ignore_ascii_case("unknown")
}
#[derive(Clone, Debug)]
struct Candidate {
left: String,
@ -82,16 +87,17 @@ pub fn ensure_agency_profiles(campaign: &mut Campaign) {
for actor in campaign.actors.values() {
if let Some(profile) = campaign.agency_profiles.get_mut(&actor.id) {
profile.location_ids = BTreeSet::from([actor.location_id.clone()]);
profile
.information_channels
.retain(|channel| !actor.knowledge.contains(channel));
profile.information_channels.retain(|channel| {
information_channel_is_concrete(channel) && !actor.knowledge.contains(channel)
});
}
}
for gestalt in campaign.gestalts.values() {
if let Some(profile) = campaign.agency_profiles.get_mut(&gestalt.id) {
profile
.information_channels
.retain(|channel| !gestalt.shared_knowledge.contains(channel));
profile.information_channels.retain(|channel| {
information_channel_is_concrete(channel)
&& !gestalt.shared_knowledge.contains(channel)
});
}
}
let live: BTreeSet<_> = campaign
@ -2952,6 +2958,7 @@ pub(crate) mod tests {
.information_channels = BTreeSet::from([
"private convoy vulnerability".into(),
"licensed courier wire".into(),
"unknown".into(),
]);
ensure_agency_profiles(&mut value);
assert_eq!(

View file

@ -730,6 +730,8 @@ fn constituent_slice(campaign: &Campaign, id: &str) -> Result<CellConstituentSli
migration_destinations: BTreeMap::new(),
activity_target_ids: crate::resolution::strategic_activity_targets(campaign, id),
goals: vec![],
relationships: BTreeMap::new(),
memories: vec![],
current_posture: None,
pressures: vec![],
};
@ -741,6 +743,8 @@ fn constituent_slice(campaign: &Campaign, id: &str) -> Result<CellConstituentSli
value.capabilities = actor.capabilities.clone();
value.resources = actor.equipment.clone();
value.goals = actor.goals.clone();
value.relationships = actor.relationships.clone();
value.memories = actor.memories.clone();
value.pressures = actor
.conditions
.iter()
@ -983,6 +987,26 @@ mod tests {
);
}
#[test]
fn canonical_actor_cell_slice_preserves_memory_and_relationship_context() {
let mut campaign = crate::resolution::tests::campaign(0, 1);
let expected_goals = vec!["keep the twelve patients together".into()];
let expected_memories = vec!["Promised Ash to stay with the twelve.".into()];
let expected_relationships =
BTreeMap::from([("ash".into(), "trusted with a responsibility".into())]);
{
let actor = campaign.actors.get_mut("player").unwrap();
actor.goals = expected_goals.clone();
actor.memories = expected_memories.clone();
actor.relationships = expected_relationships.clone();
}
let slice = constituent_slice(&campaign, "player").unwrap();
assert_eq!(slice.goals, expected_goals);
assert_eq!(slice.memories, expected_memories);
assert_eq!(slice.relationships, expected_relationships);
}
#[test]
fn cell_slice_projects_only_actionable_salient_member_exceptions() {
use crate::domain::*;