Route population fission through mutation algebra

This commit is contained in:
Meta Vens 2026-08-23 15:06:54 +02:00
commit c7463c0506
10 changed files with 706 additions and 94 deletions

View file

@ -285,6 +285,7 @@ struct CompiledFissionSeed {
child_partition_values: BTreeMap<String, String>,
#[serde(default)]
member_child_assignments: BTreeMap<String, String>,
resource_child_assignments: BTreeMap<String, String>,
gaps: Vec<String>,
}
@ -971,7 +972,7 @@ impl WorldCompiler {
.retrieve_all(&queries, &campaign.branch_origin.canon_cutoff, 12)
.await?;
let base_prompt = format!(
"Refine one canonical leaf gestalt along exactly the requested facet. Produce one child per requested value plus one mandatory child whose value is exactly 'other/unknown'. Children inherit the parent baseline and contain only justified refinements; every child starts at version 0 and uses an existing campaign location. Do not erase or rewrite member deltas. Assign a member only when evidence or durable existing delta supports the cut; unassigned members will remain in other/unknown. List every material lore gap. This is an approval preview, not a commit. SUBJECT:\n{}\nEVIDENCE:\n{}",
"Refine one canonical leaf gestalt along exactly the requested facet. Produce one child per requested value plus one mandatory child whose value is exactly 'other/unknown'. Every child starts at version 0 and uses an existing campaign location. Each child must copy the parent's shared capabilities, shared knowledge, goals, and pressures exactly; the partition facet belongs to the agency profile and does not silently rewrite the population baseline. Exact scarce resources are not inheritable traits: assign every parent resource to exactly one child in resource_child_assignments, and make each child's resources set equal exactly the resources assigned to that child. Do not create, duplicate, omit, or rename a resource. Do not erase or rewrite member deltas. Assign a member only when evidence or durable existing delta supports the cut; unassigned members will remain in other/unknown. List every material lore gap. This is an approval preview, not a commit. SUBJECT:\n{}\nEVIDENCE:\n{}",
serde_json::to_string(&subject)?,
evidence_text(&receipts),
);
@ -1044,6 +1045,7 @@ impl WorldCompiler {
child_partition_values: compiled.child_partition_values,
residual_child_id: residual_child_id.unwrap_or_default(),
member_child_assignments: compiled.member_child_assignments,
resource_child_assignments: compiled.resource_child_assignments,
evidence_receipt_ids,
gaps,
canon_candidates,

View file

@ -615,6 +615,11 @@ pub struct GestaltFissionPreview {
pub residual_child_id: String,
#[serde(default)]
pub member_child_assignments: BTreeMap<String, String>,
/// Exact scarce-resource custody after the split. Every resource owned by
/// the parent appears once and names one child; it is never inherited by
/// every child as population baseline.
#[serde(default)]
pub resource_child_assignments: BTreeMap<String, String>,
pub evidence_receipt_ids: Vec<String>,
pub gaps: Vec<String>,
#[serde(default)]

View file

@ -870,8 +870,19 @@ fn execute(
"fission preview evidence receipts were not supplied".into(),
));
}
crate::resolution::apply_fission(&mut campaign, &preview)
.map_err(|error| KernelError::Invalid(error.to_string()))?;
let transition = crate::legacy_transition::lower_fission(
&campaign,
&preview,
Utc::now() + Duration::minutes(5),
)
.map_err(|error| KernelError::Invalid(error.to_string()))?;
let mutation_receipt = crate::legacy_transition::apply_lowered_fission(
&mut campaign,
&preview,
&transition,
Utc::now(),
)
.map_err(|error| KernelError::Invalid(error.to_string()))?;
for candidate in &preview.canon_candidates {
campaign
.canon_candidates
@ -885,6 +896,7 @@ fn execute(
evidence_receipts,
preview.canon_candidates,
model_stage_receipts,
Some((transition, mutation_receipt)),
)
}
WorldCommand::AdvanceStrategicTick {
@ -1097,6 +1109,7 @@ fn execute(
evidence_receipts,
canon_candidates,
model_stage_receipts,
None,
)
}
WorldCommand::MaterializeGestaltMember {
@ -3098,17 +3111,32 @@ fn commit_with_records(
evidence: Vec<VaultEvidenceReceipt>,
candidates: Vec<CanonCandidate>,
model_receipts: Vec<crate::model::ModelStageReceipt>,
mutation: Option<(
crate::legacy_transition::LoweredLegacyTransition,
crate::transition::WorldMutationReceipt,
)>,
) -> Result<CommandResult, KernelError> {
crate::resolution::ensure_agency_profiles(&mut campaign);
let previous_revision = campaign.revision;
campaign.revision += 1;
if let Some((_, mutation_receipt)) = &mutation
&& (mutation_receipt.previous_world_revision != previous_revision
|| mutation_receipt.world_revision != campaign.revision)
{
return Err(KernelError::Invalid(
"mutation receipt does not bind the recorded world commit".into(),
));
}
let receipt = WorldCommitReceipt {
schema: "ghostlight.world_commit_receipt.v1".into(),
campaign_id: campaign.id,
previous_revision,
revision: campaign.revision,
command_kind: kind.into(),
committed_at: Utc::now(),
committed_at: mutation
.as_ref()
.map(|(_, receipt)| receipt.committed_at)
.unwrap_or_else(Utc::now),
roll: None,
};
store
@ -3120,6 +3148,9 @@ fn commit_with_records(
&evidence,
&candidates,
&model_receipts,
mutation
.as_ref()
.map(|(transition, receipt)| (&transition.authority, &transition.batch, receipt)),
)
.map_err(persist)?;
Ok(CommandResult::Committed { campaign, receipt })
@ -3513,6 +3544,103 @@ mod tests {
value
}
#[tokio::test]
async fn approved_fission_commits_one_typed_lineage_batch_and_no_parallel_writer() {
let dir = tempfile::tempdir().unwrap();
let store = CampaignStore::open(dir.path().join("campaign.cc")).unwrap();
let seed = hierarchical_refugee_campaign();
let parent = seed.gestalts["refugees-east"].clone();
let child = |id: &str, name: &str| GestaltPersonaState {
schema: "ghostlight.gestalt_persona_state.v1".into(),
id: id.into(),
name: name.into(),
version: 0,
home_location_id: parent.home_location_id.clone(),
shared_capabilities: parent.shared_capabilities.clone(),
shared_knowledge: parent.shared_knowledge.clone(),
resources: BTreeSet::new(),
goals: parent.goals.clone(),
pressures: parent.pressures.clone(),
};
let preview = GestaltFissionPreview {
schema: "ghostlight.gestalt_fission_preview.v1".into(),
campaign_id: seed.id,
expected_world_revision: seed.revision,
parent_gestalt_id: parent.id.clone(),
partition_axis: AgencyAxis::Ideology,
children: vec![
child("refugees-returning", "Refugees planning to return"),
child("refugees-other", "Other eastern transit refugees"),
],
child_partition_values: BTreeMap::from([
("refugees-returning".into(), "returning".into()),
("refugees-other".into(), "other/unknown".into()),
]),
residual_child_id: "refugees-other".into(),
member_child_assignments: BTreeMap::from([(
"mira".into(),
"refugees-returning".into(),
)]),
resource_child_assignments: BTreeMap::new(),
evidence_receipt_ids: vec![],
gaps: vec![],
canon_candidates: vec![],
requires_approval: true,
};
let kernel = WorldKernel::start(store.clone());
kernel
.command(WorldCommand::CreateCampaign {
campaign: seed.clone(),
evidence_receipts: vec![],
model_stage_receipts: vec![],
})
.await
.unwrap();
let result = kernel
.command(WorldCommand::FissionGestalt {
expected_revision: seed.revision,
preview,
evidence_receipts: vec![],
model_stage_receipts: vec![],
})
.await
.unwrap();
let CommandResult::Committed { campaign, receipt } = result else {
panic!("fission did not commit")
};
assert_eq!(receipt.command_kind, "fission_gestalt");
assert_eq!(campaign.revision, seed.revision + 1);
assert_eq!(campaign.gestalt_members["mira"].id, "mira");
assert_eq!(
campaign.gestalt_members["mira"].gestalt_id,
"refugees-returning"
);
assert!(!campaign.agency_profiles["refugees-east"].active_leaf);
assert!(campaign.agency_profiles["refugees-returning"].active_leaf);
assert_eq!(
store.keys("mutation_authority_envelope.v1").unwrap().len(),
1
);
assert_eq!(store.keys("world_mutation_batch.v1").unwrap().len(), 1);
assert_eq!(store.keys("world_mutation_receipt.v1").unwrap().len(), 1);
let batch = store
.load_all::<crate::transition::WorldMutationBatch>("world_mutation_batch.v1")
.unwrap()
.pop()
.unwrap();
let operations = batch
.mutations
.iter()
.map(|mutation| mutation.mutation.operation())
.collect::<BTreeSet<_>>();
assert!(operations.contains(&crate::transition::WorldMutationOperation::AdmitEntity));
assert!(operations.contains(&crate::transition::WorldMutationOperation::PopulationSplit));
assert!(
operations.contains(&crate::transition::WorldMutationOperation::PopulationTransfer)
);
assert!(!operations.contains(&crate::transition::WorldMutationOperation::ResourceCreate));
}
#[test]
fn member_migration_rebases_across_unrelated_hierarchy_without_changing_the_person() {
let mut value = hierarchical_refugee_campaign();

View file

@ -8,8 +8,9 @@
use crate::{
domain::{
ActorReaction, Campaign, OutcomeBand, StrategicActivityOutcome, StrategicOutcomeEffect,
StrategicTickPlan, WorldEffectDelta,
ActorReaction, Campaign, GestaltFissionPreview, GestaltLineage, GestaltPersonaState,
OutcomeBand, StrategicActivityOutcome, StrategicOutcomeEffect, StrategicTickPlan,
WorldEffectDelta,
},
transition::*,
};
@ -142,6 +143,253 @@ pub fn lower_group_travel(
)
}
pub fn lower_fission(
campaign: &Campaign,
preview: &GestaltFissionPreview,
expires_at: DateTime<Utc>,
) -> Result<LoweredLegacyTransition> {
crate::resolution::validate_fission(campaign, preview)?;
let preview_digest = digest_serializable(preview)?;
let source_receipt_id = format!(
"fission-preview:{}",
preview_digest.trim_start_matches("sha256:")
);
let parent = population_subject(&preview.parent_gestalt_id);
let mut mutations = Vec::new();
for child in &preview.children {
let subject = population_subject(&child.id);
mutations.push(WorldMutation::AdmitEntity {
subject: subject.clone(),
initial_components: BTreeSet::from([
WorldComponentKind::Identity,
WorldComponentKind::Occupancy,
WorldComponentKind::Capability,
WorldComponentKind::Knowledge,
WorldComponentKind::Commitment,
WorldComponentKind::Pressure,
WorldComponentKind::PopulationLineage,
]),
initial_place: Some(place_subject(&child.home_location_id)),
admission_receipt_id: source_receipt_id.clone(),
});
mutations.push(WorldMutation::ChangeIdentity {
subject,
operation: IdentityMutationOperation::Adopt,
handle_id: format!("identity:canonical:{}", child.id),
handle_value: Some(child.name.clone()),
audience: Vec::new(),
});
}
mutations.push(WorldMutation::ChangePopulationLineage {
operation: PopulationLineageOperation::Split,
parent_populations: vec![parent.clone()],
child_populations: preview
.children
.iter()
.map(|child| population_subject(&child.id))
.collect(),
remainder_population: Some(population_subject(&preview.residual_child_id)),
});
for (resource, child_id) in &preview.resource_child_assignments {
mutations.push(WorldMutation::TransferCustody {
resource: resource_subject(campaign, resource),
from_custodian: parent.clone(),
to_custodian: population_subject(child_id),
});
}
for member in campaign
.gestalt_members
.values()
.filter(|member| member.gestalt_id == preview.parent_gestalt_id)
{
let destination = preview
.member_child_assignments
.get(&member.id)
.unwrap_or(&preview.residual_child_id);
mutations.push(WorldMutation::ChangePopulationMembership {
actor: actor_subject(&format!("member:{}", member.id)),
operation: PopulationMembershipOperation::Transfer,
source_population: Some(parent.clone()),
destination_population: Some(population_subject(destination)),
});
}
lower_exact_mutations(
campaign,
mutations,
MutationProcedure::CompilerAdmission,
None,
MutationOutcomeBinding::Deterministic,
Some(campaign.resolution_policy.resolution_epoch),
"Admit the approved child populations, preserve their inherited baseline, partition each exact resource once, transfer each named member once, and record one population lineage split.",
&source_receipt_id,
Some(digest_serializable(&(
"fission",
&preview.parent_gestalt_id,
&preview.partition_axis,
))?),
Some(preview_digest),
expires_at,
)
}
pub fn apply_lowered_fission(
campaign: &mut Campaign,
preview: &GestaltFissionPreview,
transition: &LoweredLegacyTransition,
now: DateTime<Utc>,
) -> Result<WorldMutationReceipt> {
crate::resolution::validate_fission(campaign, preview)?;
let snapshot = component_snapshot(campaign)?;
let application =
apply_component_world_batch(&snapshot, &transition.authority, &transition.batch, now)?;
project_accepted_fission(campaign, preview, &application.state)?;
Ok(application.receipt)
}
fn project_accepted_fission(
campaign: &mut Campaign,
preview: &GestaltFissionPreview,
next: &ComponentWorldState,
) -> Result<()> {
let parent = campaign
.gestalts
.get(&preview.parent_gestalt_id)
.cloned()
.ok_or_else(|| anyhow!("accepted fission parent vanished"))?;
let inherited_relations = campaign
.agency_relations
.values()
.filter(|relation| {
relation.active
&& (relation.from_subject_id == preview.parent_gestalt_id
|| relation.to_subject_id == preview.parent_gestalt_id)
})
.cloned()
.collect::<Vec<_>>();
for approved in &preview.children {
let subject = population_subject(&approved.id);
let place = next
.occupancy
.get(&subject)
.filter(|place| place.kind == SubjectKind::Place)
.ok_or_else(|| anyhow!("accepted fission child lacks exact occupancy"))?;
let identity = next
.identities
.get(&format!("identity:canonical:{}", approved.id))
.filter(|identity| identity.subject == subject && identity.active)
.ok_or_else(|| anyhow!("accepted fission child lacks exact identity"))?;
campaign.gestalts.insert(
approved.id.clone(),
GestaltPersonaState {
schema: "ghostlight.gestalt_persona_state.v1".into(),
id: approved.id.clone(),
name: identity.value.clone(),
version: 0,
home_location_id: place.id.clone(),
shared_capabilities: parent.shared_capabilities.clone(),
shared_knowledge: parent.shared_knowledge.clone(),
resources: BTreeSet::new(),
goals: parent.goals.clone(),
pressures: parent.pressures.clone(),
},
);
}
let member_ids = campaign
.gestalt_members
.values()
.filter(|member| member.gestalt_id == preview.parent_gestalt_id)
.map(|member| member.id.clone())
.collect::<Vec<_>>();
for member_id in member_ids {
let expected_destination = preview
.member_child_assignments
.get(&member_id)
.unwrap_or(&preview.residual_child_id);
let actor = actor_subject(&format!("member:{member_id}"));
let active_destinations = next
.memberships
.iter()
.filter(|(key, value)| key.actor == actor && value.active)
.map(|(key, _)| key.population.id.as_str())
.collect::<Vec<_>>();
if active_destinations != [expected_destination.as_str()] {
return Err(anyhow!(
"accepted fission member does not have one exact destination"
));
}
project_member_population_transfer(
campaign,
&member_id,
&preview.parent_gestalt_id,
expected_destination,
)?;
let member = campaign
.gestalt_members
.get_mut(&member_id)
.expect("member was projected");
member.version = member.version.saturating_add(1);
}
project_all_resources(campaign, next)?;
for child in &preview.children {
if campaign.gestalts[&child.id].resources != child.resources {
return Err(anyhow!(
"accepted fission resource custody does not match the approved partition"
));
}
}
campaign
.gestalts
.get_mut(&preview.parent_gestalt_id)
.expect("parent existence checked")
.version = parent.version.saturating_add(1);
for relation in inherited_relations {
for child in &preview.children {
let id = format!("{}:fission:{}", relation.id, child.id);
let component = next
.relationships
.get(&id)
.ok_or_else(|| anyhow!("accepted fission lost inherited agency relation"))?;
let mut inherited = relation.clone();
inherited.id = id.clone();
if inherited.from_subject_id == preview.parent_gestalt_id {
inherited.from_subject_id = child.id.clone();
}
if inherited.to_subject_id == preview.parent_gestalt_id {
inherited.to_subject_id = child.id.clone();
}
if component.source.id != inherited.from_subject_id
|| component.target.id != inherited.to_subject_id
|| component.strength != Some(i64::from(inherited.strength))
{
return Err(anyhow!("accepted fission agency relation was rewritten"));
}
campaign.agency_relations.insert(id, inherited);
}
}
campaign.gestalt_lineages.insert(
preview.parent_gestalt_id.clone(),
GestaltLineage {
schema: "ghostlight.gestalt_lineage.v1".into(),
parent_gestalt_id: preview.parent_gestalt_id.clone(),
child_gestalt_ids: preview
.children
.iter()
.map(|child| child.id.clone())
.collect(),
partition_axis: preview.partition_axis.clone(),
partition_values: preview.child_partition_values.clone(),
residual_child_id: preview.residual_child_id.clone(),
source_revision: campaign.revision,
},
);
crate::resolution::project_fission_resolution(campaign, preview)?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn lower_exact_mutations(
campaign: &Campaign,

View file

@ -646,15 +646,20 @@ impl CampaignStore {
Ok(next_campaign_row)
}
pub fn append_world_commit<T: Serialize, R: Serialize>(
pub fn append_world_commit<T: Serialize>(
&self,
expected: &CultCacheEnvelope,
next: &T,
receipt_key: &str,
receipt: &R,
receipt: &WorldCommitReceipt,
evidence: &[VaultEvidenceReceipt],
candidates: &[crate::domain::CanonCandidate],
model_receipts: &[crate::model::ModelStageReceipt],
mutation: Option<(
&MutationAuthorityEnvelope,
&WorldMutationBatch,
&WorldMutationReceipt,
)>,
) -> Result<CultCacheEnvelope> {
let next_row = envelope(
&expected.r#type,
@ -704,6 +709,9 @@ impl CampaignStore {
item,
)?);
}
if let Some(mutation) = mutation {
append_mutation_proof(&mut rows, receipt, mutation)?;
}
let mut expected_rows = vec![expected.clone()];
if let Some((row, _)) = existing_manifest {
expected_rows.push(row);

View file

@ -2694,13 +2694,13 @@ fn is_sha256(value: &str) -> bool {
.is_some_and(|hex| hex.len() == 64 && hex.bytes().all(|byte| byte.is_ascii_hexdigit()))
}
pub fn apply_fission(campaign: &mut Campaign, preview: &GestaltFissionPreview) -> Result<()> {
validate_fission(campaign, preview)?;
let parent = campaign
.gestalts
.get_mut(&preview.parent_gestalt_id)
.ok_or_else(|| anyhow!("fission parent vanished"))?;
parent.version = parent.version.saturating_add(1);
/// Project the resolution-only consequences of an already accepted population
/// split. Canonical populations, memberships, lineage, and resource custody
/// must already have been committed by the world mutation reducer.
pub(crate) fn project_fission_resolution(
campaign: &mut Campaign,
preview: &GestaltFissionPreview,
) -> Result<()> {
let parent_profile = campaign
.agency_profiles
.get_mut(&preview.parent_gestalt_id)
@ -2732,59 +2732,6 @@ pub fn apply_fission(campaign: &mut Campaign, preview: &GestaltFissionPreview) -
}
campaign.agency_profiles.insert(child.id.clone(), profile);
}
for member in campaign
.gestalt_members
.values_mut()
.filter(|member| member.gestalt_id == preview.parent_gestalt_id)
{
member.gestalt_id = preview
.member_child_assignments
.get(&member.id)
.cloned()
.unwrap_or_else(|| preview.residual_child_id.clone());
member.version = member.version.saturating_add(1);
}
let inherited_relations: Vec<_> = campaign
.agency_relations
.values()
.filter(|relation| {
relation.active
&& (relation.from_subject_id == preview.parent_gestalt_id
|| relation.to_subject_id == preview.parent_gestalt_id)
})
.cloned()
.collect();
for relation in inherited_relations {
for child in &preview.children {
let mut inherited_relation = relation.clone();
inherited_relation.id = format!("{}:fission:{}", relation.id, child.id);
if inherited_relation.from_subject_id == preview.parent_gestalt_id {
inherited_relation.from_subject_id = child.id.clone();
}
if inherited_relation.to_subject_id == preview.parent_gestalt_id {
inherited_relation.to_subject_id = child.id.clone();
}
campaign
.agency_relations
.insert(inherited_relation.id.clone(), inherited_relation);
}
}
campaign.gestalt_lineages.insert(
preview.parent_gestalt_id.clone(),
GestaltLineage {
schema: "ghostlight.gestalt_lineage.v1".into(),
parent_gestalt_id: preview.parent_gestalt_id.clone(),
child_gestalt_ids: preview
.children
.iter()
.map(|child| child.id.clone())
.collect(),
partition_axis: preview.partition_axis.clone(),
partition_values: preview.child_partition_values.clone(),
residual_child_id: preview.residual_child_id.clone(),
source_revision: campaign.revision,
},
);
campaign.resolution_cover = None;
campaign.resolution_policy.resolution_epoch = campaign
.resolution_policy
@ -2812,6 +2759,26 @@ pub fn validate_fission(campaign: &Campaign, preview: &GestaltFissionPreview) ->
.child_partition_values
.get(&preview.residual_child_id)
.map(|value| value.trim().to_ascii_lowercase());
let assigned_resources = preview
.resource_child_assignments
.keys()
.cloned()
.collect::<BTreeSet<_>>();
let child_resources = preview
.children
.iter()
.flat_map(|child| {
child
.resources
.iter()
.cloned()
.map(move |resource| (resource, child.id.clone()))
})
.collect::<Vec<_>>();
let unique_child_resources = child_resources
.iter()
.map(|(resource, _)| resource.clone())
.collect::<BTreeSet<_>>();
if preview.campaign_id != campaign.id
|| preview.expected_world_revision != campaign.revision
|| !preview.requires_approval
@ -2828,6 +2795,10 @@ pub fn validate_fission(campaign: &Campaign, preview: &GestaltFissionPreview) ->
|| !campaign.locations.contains_key(&child.home_location_id)
|| (preview.partition_axis != AgencyAxis::Geography
&& child.home_location_id != parent.home_location_id)
|| child.shared_capabilities != parent.shared_capabilities
|| child.shared_knowledge != parent.shared_knowledge
|| child.goals != parent.goals
|| child.pressures != parent.pressures
|| !preview.child_partition_values.contains_key(&child.id)
})
|| preview
@ -2840,6 +2811,22 @@ pub fn validate_fission(campaign: &Campaign, preview: &GestaltFissionPreview) ->
.is_none_or(|value| value.gestalt_id != parent.id)
|| !child_ids.contains(child.as_str())
})
|| campaign
.gestalt_members
.values()
.any(|member| member.gestalt_id == parent.id && member.materialized_actor_id.is_some())
|| assigned_resources != parent.resources
|| unique_child_resources != parent.resources
|| child_resources.len() != unique_child_resources.len()
|| preview
.resource_child_assignments
.iter()
.any(|(resource, child)| {
!child_ids.contains(child.as_str())
|| !preview.children.iter().any(|candidate| {
candidate.id == *child && candidate.resources.contains(resource)
})
})
|| parent_profile.subject_kind != AgencySubjectKind::Gestalt
{
return Err(anyhow!("gestalt fission preview is stale or malformed"));
@ -3777,36 +3764,58 @@ pub(crate) mod tests {
home_location_id: "center".into(),
shared_capabilities: BTreeSet::from(["farm".into()]),
shared_knowledge: BTreeSet::from(["local roads".into()]),
resources: BTreeSet::from(["granary".into()]),
resources: BTreeSet::new(),
goals: vec!["survive winter".into()],
pressures: vec![],
};
let mut other = child("other", "Other villagers");
other.resources.insert("granary".into());
let preview = GestaltFissionPreview {
schema: "ghostlight.gestalt_fission_preview.v1".into(),
campaign_id: value.id,
expected_world_revision: value.revision,
parent_gestalt_id: "villagers".into(),
partition_axis: AgencyAxis::Ideology,
children: vec![
child("traditionalists", "Traditionalists"),
child("other", "Other villagers"),
],
children: vec![child("traditionalists", "Traditionalists"), other],
child_partition_values: BTreeMap::from([
("traditionalists".into(), "traditionalist".into()),
("other".into(), "other/unknown".into()),
]),
residual_child_id: "other".into(),
member_child_assignments: BTreeMap::new(),
resource_child_assignments: BTreeMap::from([("granary".into(), "other".into())]),
evidence_receipt_ids: vec![],
gaps: vec![],
canon_candidates: vec![],
requires_approval: true,
};
apply_fission(&mut value, &preview).unwrap();
let mut duplicated = preview.clone();
duplicated.children[0].resources.insert("granary".into());
assert!(validate_fission(&value, &duplicated).is_err());
let transition = crate::legacy_transition::lower_fission(
&value,
&preview,
Utc::now() + chrono::Duration::minutes(5),
)
.unwrap();
crate::legacy_transition::apply_lowered_fission(
&mut value,
&preview,
&transition,
Utc::now(),
)
.unwrap();
value.revision += 1;
assert!(value.gestalts.contains_key("villagers"));
assert!(!value.agency_profiles["villagers"].active_leaf);
assert!(value.agency_profiles["traditionalists"].active_leaf);
assert!(value.agency_profiles["other"].active_leaf);
assert!(value.gestalts["villagers"].resources.is_empty());
assert!(value.gestalts["traditionalists"].resources.is_empty());
assert_eq!(
value.gestalts["other"].resources,
BTreeSet::from(["granary".into()])
);
assert_eq!(value.gestalt_members["john"].gestalt_id, "other");
assert_eq!(value.gestalt_members["john"].version, 4);
assert!(
@ -3819,6 +3828,8 @@ pub(crate) mod tests {
"other"
);
let mut nested_residual = child("other-unknown", "Other unclassified villagers");
nested_residual.resources.insert("granary".into());
let nested = GestaltFissionPreview {
schema: "ghostlight.gestalt_fission_preview.v1".into(),
campaign_id: value.id,
@ -3827,7 +3838,7 @@ pub(crate) mod tests {
partition_axis: AgencyAxis::EconomyRole,
children: vec![
child("other-smiths", "Smiths among the other villagers"),
child("other-unknown", "Other unclassified villagers"),
nested_residual,
],
child_partition_values: BTreeMap::from([
("other-smiths".into(), "smith".into()),
@ -3835,14 +3846,36 @@ pub(crate) mod tests {
]),
residual_child_id: "other-unknown".into(),
member_child_assignments: BTreeMap::from([("john".into(), "other-smiths".into())]),
resource_child_assignments: BTreeMap::from([(
"granary".into(),
"other-unknown".into(),
)]),
evidence_receipt_ids: vec![],
gaps: vec![],
canon_candidates: vec![],
requires_approval: true,
};
apply_fission(&mut value, &nested).unwrap();
let transition = crate::legacy_transition::lower_fission(
&value,
&nested,
Utc::now() + chrono::Duration::minutes(5),
)
.unwrap();
crate::legacy_transition::apply_lowered_fission(
&mut value,
&nested,
&transition,
Utc::now(),
)
.unwrap();
assert!(!value.agency_profiles["other"].active_leaf);
assert!(value.agency_profiles["other-smiths"].active_leaf);
assert!(value.gestalts["other"].resources.is_empty());
assert!(value.gestalts["other-smiths"].resources.is_empty());
assert_eq!(
value.gestalts["other-unknown"].resources,
BTreeSet::from(["granary".into()])
);
assert_eq!(value.gestalt_members["john"].gestalt_id, "other-smiths");
assert_eq!(value.gestalt_members["john"].version, 5);
assert_eq!(

View file

@ -216,6 +216,7 @@ pub enum MutationSubjectRole {
TopologyOrigin,
TopologyDestination,
Entity,
InitialPlace,
}
#[derive(
@ -548,6 +549,8 @@ pub enum WorldMutation {
AdmitEntity {
subject: SubjectRef,
initial_components: BTreeSet<WorldComponentKind>,
#[serde(default)]
initial_place: Option<SubjectRef>,
admission_receipt_id: String,
},
RetireEntity {
@ -1246,6 +1249,7 @@ fn apply_component_mutation(
AdmitEntity {
subject,
initial_components,
initial_place,
..
} => {
if subject.kind == SubjectKind::Resource {
@ -1264,6 +1268,11 @@ fn apply_component_mutation(
version: next_component_version(state),
},
);
if let Some(place) = initial_place {
active_subject(state, place)?;
require_kind(place, SubjectKind::Place)?;
state.occupancy.insert(subject.clone(), place.clone());
}
}
RetireEntity { subject, .. } => retire_entity(state, subject)?,
AdvanceWorldTime { campaign, minutes } => {
@ -2086,6 +2095,7 @@ fn apply_lineage_mutation(
if !child_set.contains(remainder) {
return Err(anyhow!("population split remainder is not a child"));
}
inherit_population_baseline(state, &parents[0], children)?;
}
PopulationLineageOperation::Merge => {
if parents.len() < 2 || children.len() != 1 || remainder.is_some() {
@ -2112,6 +2122,122 @@ fn apply_lineage_mutation(
Ok(())
}
fn inherit_population_baseline(
state: &mut ComponentWorldState,
parent: &SubjectRef,
children: &[SubjectRef],
) -> Result<()> {
let version = next_component_version(state);
let capabilities = state
.capabilities
.iter()
.filter(|(key, _)| key.subject == *parent)
.map(|(key, value)| (key.entry_id.clone(), value.clone()))
.collect::<Vec<_>>();
let commitments = state
.commitments
.iter()
.filter(|(key, _)| key.subject == *parent)
.map(|(key, value)| (key.entry_id.clone(), value.clone()))
.collect::<Vec<_>>();
let knowledge = state
.knowledge
.iter()
.filter(|(key, _)| key.knower == *parent)
.map(|(key, value)| (key.proposition.clone(), value.clone()))
.collect::<Vec<_>>();
let pressures = state
.pressures
.iter()
.filter(|(_, value)| value.owner == *parent)
.map(|(subject, value)| (subject.clone(), value.clone()))
.collect::<Vec<_>>();
let relationships = state
.relationships
.iter()
.filter(|(_, value)| value.source == *parent || value.target == *parent)
.map(|(id, value)| (id.clone(), value.clone()))
.collect::<Vec<_>>();
for child in children {
for (entry_id, mut value) in capabilities.clone() {
value.version = version;
state.capabilities.insert(
SubjectEntryKey {
subject: child.clone(),
entry_id,
},
value,
);
}
for (entry_id, mut value) in commitments.clone() {
value.version = version;
state.commitments.insert(
SubjectEntryKey {
subject: child.clone(),
entry_id,
},
value,
);
}
for (proposition, mut value) in knowledge.clone() {
value.version = version;
state.knowledge.insert(
KnowledgeKey {
knower: child.clone(),
proposition,
},
value,
);
}
for (_source_pressure, mut value) in pressures.clone() {
let pressure = SubjectRef {
kind: SubjectKind::Pressure,
id: format!("gestalt-pressure:{}:{}", child.id, short_hash(&value.label)),
};
if state.subjects.contains_key(&pressure) || state.pressures.contains_key(&pressure) {
return Err(anyhow!("population split pressure identity collides"));
}
state.subjects.insert(
pressure.clone(),
TypedSubject {
schema: "ghostlight.typed_subject.v1".into(),
subject: pressure.clone(),
lifecycle: LifecycleStatus::Admitted,
admitted_components: BTreeSet::from([WorldComponentKind::Pressure]),
version,
},
);
value.pressure = pressure.clone();
value.owner = child.clone();
value.version = version;
state.pressures.insert(pressure, value);
}
for (source_id, mut value) in relationships.clone() {
let id = format!("{source_id}:fission:{}", child.id);
if state.relationships.contains_key(&id) {
return Err(anyhow!("population split relationship identity collides"));
}
if value.source == *parent {
value.source = child.clone();
}
if value.target == *parent {
value.target = child.clone();
}
value.version = version;
state.relationships.insert(id, value);
}
}
Ok(())
}
fn short_hash(value: &str) -> String {
format!("{:x}", Sha256::digest(value.as_bytes()))
.chars()
.take(16)
.collect()
}
fn apply_identity_mutation(
state: &mut ComponentWorldState,
subject: &SubjectRef,
@ -2517,9 +2643,18 @@ fn mutation_component_refs(
Component::Topology,
Some(edge_id.clone()),
)],
AdmitEntity { subject, .. } | RetireEntity { subject, .. } => {
vec![(subject.clone(), Component::Lifecycle, None)]
}
AdmitEntity {
subject,
initial_place,
..
} => std::iter::once((subject.clone(), Component::Lifecycle, None))
.chain(
initial_place
.iter()
.map(|_| (subject.clone(), Component::Occupancy, None)),
)
.collect(),
RetireEntity { subject, .. } => vec![(subject.clone(), Component::Lifecycle, None)],
AdvanceWorldTime { campaign, .. } => vec![(campaign.clone(), Component::WorldTime, None)],
}
}
@ -2842,7 +2977,11 @@ fn subject_schema_fields(
(TopologyOrigin, "from_place", Required),
(TopologyDestination, "to_place", Required),
],
AdmitEntity | RetireEntity => vec![(Entity, "subject", Required)],
AdmitEntity => vec![
(Entity, "subject", Required),
(InitialPlace, "initial_place", Optional),
],
RetireEntity => vec![(Entity, "subject", Required)],
AdvanceWorldTime => vec![(Subject, "campaign", Required)],
}
}
@ -3310,9 +3449,17 @@ impl WorldMutation {
add(MutationSubjectRole::TopologyOrigin, from_place);
add(MutationSubjectRole::TopologyDestination, to_place);
}
AdmitEntity { subject, .. } | RetireEntity { subject, .. } => {
add(MutationSubjectRole::Entity, subject)
AdmitEntity {
subject,
initial_place,
..
} => {
add(MutationSubjectRole::Entity, subject);
if let Some(place) = initial_place {
add(MutationSubjectRole::InitialPlace, place);
}
}
RetireEntity { subject, .. } => add(MutationSubjectRole::Entity, subject),
AdvanceWorldTime { campaign, .. } => add(MutationSubjectRole::Subject, campaign),
}
roles
@ -3692,12 +3839,23 @@ impl WorldMutation {
}
AdmitEntity {
initial_components,
initial_place,
admission_receipt_id,
..
} => {
if initial_components.is_empty() {
return Err(anyhow!("entity admission requires initial components"));
}
if initial_components.contains(&WorldComponentKind::Occupancy)
!= initial_place.is_some()
{
return Err(anyhow!(
"entity admission occupancy requires one exact initial place"
));
}
if let Some(place) = initial_place {
require_kind(place, SubjectKind::Place)?;
}
nonempty("admission receipt id", admission_receipt_id)?;
}
RetireEntity { reason, .. } => nonempty("retirement reason", reason)?,

View file

@ -47,11 +47,20 @@ leaves. A material split uses approval-gated `FissionGestalt`:
2. every requested enumerated facet gets a child and one `other/unknown`
remainder is mandatory;
3. children inherit the parent baseline and own only later deltas;
4. member deltas are assigned to one child without rewriting identity;
5. the parent remains as inactive lineage rather than being destroyed;
6. the kernel validates the preview, evidence, versions, assignments, and
4. every exact scarce resource is assigned to one child rather than copied as
an inheritable trait;
5. member deltas are assigned to one child without rewriting identity;
6. the parent remains as inactive lineage rather than being destroyed;
7. the kernel validates the preview, evidence, versions, assignments, and
residual child before one atomic commit.
The commit is a canonical `WorldMutationBatch`: child admission establishes
identity and occupancy, the lineage mutation inherits non-scarce baseline
components, custody transfers partition resources, and membership transfers
move exact named people. Agency profiles, inherited partition facets, and the
discarded cover are resolution projections of that accepted transition; they
cannot create a second result.
Reversible simulation aggregation never invokes fission and never deletes or
reverses this lineage.

View file

@ -1,7 +1,9 @@
# Ghostlight World Transition Algebra
Status: adopted architecture; production migration is incomplete until the
legacy effect writers named below are deleted.
Status: adopted architecture; foreground, reaction, strategic, time, travel,
and approval-gated fission writes use the mutation reducer. Region expansion,
initial compiler publication, and aggregate-storage removal remain migration
work.
## Objective
@ -97,9 +99,12 @@ The migration is not complete while any of these can decide canonical state:
- model output, browser payloads, narration, news generation, retrieval, or a
derived simulation cell.
Administrative world creation, compiler publication, and approval-gated
population fission must ultimately lower to the same batch primitive. They may
possess broader authority envelopes, but not another state-writing mechanism.
Administrative world creation, compiler publication, and region expansion must
ultimately lower to the same batch primitive. They may possess broader
authority envelopes, but not another state-writing mechanism. Approval-gated
population fission already lowers to entity admission, lineage, custody, and
membership mutations; the fission projector owns only the derived resolution
profiles and cover epoch.
### Shared paths
@ -217,8 +222,12 @@ Complex effects are batches, not new verbs. Giving half a medicine lot to a
clinic composes resource split, custody transfer, and perhaps an obligation.
Evacuating a named refugee composes relocation, population-membership transfer,
and accessible knowledge. Population fission composes entity admission,
lineage, and exact member transfers while preserving an `other/unknown`
remainder.
lineage, exact member transfers, and one custody transfer per scarce resource
while preserving an `other/unknown` remainder. Capabilities, shared knowledge,
goals, and active pressures inherit through the lineage reducer. Resources do
not: every parent resource must be assigned to exactly one child in the
approved preview, so aggregation cannot mint a granary for every ideological
subgroup.
## Means, intended effect, and committed mutation
@ -385,7 +394,8 @@ Migration order:
5. Normalize named Gestalt members into stable actor subjects and resource,
membership, identity, and occupancy components. Remove materialized actor as
a second identity owner.
6. Lower travel, waits, fission, expansion, and compiler publication.
6. Lower travel, waits, fission, expansion, and compiler publication. Travel,
waits, and fission are complete; expansion and initial publication remain.
7. Migrate persisted campaign rows transactionally, quarantine ambiguous legacy
resources or identities, and make the old campaign fields projection-only.
8. Remove legacy schemas from model boundaries. Retain schema readers solely
@ -478,4 +488,3 @@ The algebra cut is complete only when:
- the agency corpus meets its coverage and stability gates;
- adversarial play through the typed Eve surface still proves the homepage
promises after migration.

View file

@ -110,6 +110,18 @@ responses are spoiler-safe projections rather than serialized campaigns. The
browser renders these projections as labeled, escaped human-readable controls,
and narration receives only the latest causal turn rather than a route dump.
World transition authority is now a closed semantic mutation algebra rather
than four empirical effect universes. Foreground attempts, NPC initiative,
reaction appraisal, strategic actor/institution/Gestalt outcomes, waits,
unanimous group travel, and approval-gated population fission lower through one
revision-bound batch reducer and persist its authority, batch, and receipt with
the aggregate campaign projection. Fission inherits non-scarce population
baseline through typed lineage while partitioning every exact resource and
named member once; the resolution organ only changes profiles and cover epoch.
Region expansion, initial compiler publication, removal of legacy model effect
schemas, and the 300-case reviewed agency corpus remain the explicit migration
frontier. See `docs/architecture/ghostlight-transition-algebra.md`.
Live local evidence now covers grounded VoidBot compilation, a four-actor wave,
impossible and receipted d20 actions, grounded narration, strategic cells, and a
24-faction budget-4 wave. The first scale baseline used 37,327 prompt tokens and