diff --git a/crates/ghostlight-dungeon/src/compiler.rs b/crates/ghostlight-dungeon/src/compiler.rs index 863b205..f406a42 100644 --- a/crates/ghostlight-dungeon/src/compiler.rs +++ b/crates/ghostlight-dungeon/src/compiler.rs @@ -285,6 +285,7 @@ struct CompiledFissionSeed { child_partition_values: BTreeMap, #[serde(default)] member_child_assignments: BTreeMap, + resource_child_assignments: BTreeMap, gaps: Vec, } @@ -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, diff --git a/crates/ghostlight-dungeon/src/domain.rs b/crates/ghostlight-dungeon/src/domain.rs index 7d4c941..91ba757 100644 --- a/crates/ghostlight-dungeon/src/domain.rs +++ b/crates/ghostlight-dungeon/src/domain.rs @@ -615,6 +615,11 @@ pub struct GestaltFissionPreview { pub residual_child_id: String, #[serde(default)] pub member_child_assignments: BTreeMap, + /// 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, pub evidence_receipt_ids: Vec, pub gaps: Vec, #[serde(default)] diff --git a/crates/ghostlight-dungeon/src/kernel.rs b/crates/ghostlight-dungeon/src/kernel.rs index 585efaa..04e4472 100644 --- a/crates/ghostlight-dungeon/src/kernel.rs +++ b/crates/ghostlight-dungeon/src/kernel.rs @@ -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, candidates: Vec, model_receipts: Vec, + mutation: Option<( + crate::legacy_transition::LoweredLegacyTransition, + crate::transition::WorldMutationReceipt, + )>, ) -> Result { 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::("world_mutation_batch.v1") + .unwrap() + .pop() + .unwrap(); + let operations = batch + .mutations + .iter() + .map(|mutation| mutation.mutation.operation()) + .collect::>(); + 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(); diff --git a/crates/ghostlight-dungeon/src/legacy_transition.rs b/crates/ghostlight-dungeon/src/legacy_transition.rs index 30633d8..e0006ce 100644 --- a/crates/ghostlight-dungeon/src/legacy_transition.rs +++ b/crates/ghostlight-dungeon/src/legacy_transition.rs @@ -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, +) -> Result { + 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, +) -> Result { + 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::>(); + + 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::>(); + 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::>(); + 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, diff --git a/crates/ghostlight-dungeon/src/persistence.rs b/crates/ghostlight-dungeon/src/persistence.rs index 7effbdf..d747fba 100644 --- a/crates/ghostlight-dungeon/src/persistence.rs +++ b/crates/ghostlight-dungeon/src/persistence.rs @@ -646,15 +646,20 @@ impl CampaignStore { Ok(next_campaign_row) } - pub fn append_world_commit( + pub fn append_world_commit( &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 { 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); diff --git a/crates/ghostlight-dungeon/src/resolution.rs b/crates/ghostlight-dungeon/src/resolution.rs index bc90d63..d306edb 100644 --- a/crates/ghostlight-dungeon/src/resolution.rs +++ b/crates/ghostlight-dungeon/src/resolution.rs @@ -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::>(); + let child_resources = preview + .children + .iter() + .flat_map(|child| { + child + .resources + .iter() + .cloned() + .map(move |resource| (resource, child.id.clone())) + }) + .collect::>(); + let unique_child_resources = child_resources + .iter() + .map(|(resource, _)| resource.clone()) + .collect::>(); 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!( diff --git a/crates/ghostlight-dungeon/src/transition.rs b/crates/ghostlight-dungeon/src/transition.rs index 5097c4b..158aa5a 100644 --- a/crates/ghostlight-dungeon/src/transition.rs +++ b/crates/ghostlight-dungeon/src/transition.rs @@ -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, + #[serde(default)] + initial_place: Option, 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::>(); + let commitments = state + .commitments + .iter() + .filter(|(key, _)| key.subject == *parent) + .map(|(key, value)| (key.entry_id.clone(), value.clone())) + .collect::>(); + let knowledge = state + .knowledge + .iter() + .filter(|(key, _)| key.knower == *parent) + .map(|(key, value)| (key.proposition.clone(), value.clone())) + .collect::>(); + let pressures = state + .pressures + .iter() + .filter(|(_, value)| value.owner == *parent) + .map(|(subject, value)| (subject.clone(), value.clone())) + .collect::>(); + let relationships = state + .relationships + .iter() + .filter(|(_, value)| value.source == *parent || value.target == *parent) + .map(|(id, value)| (id.clone(), value.clone())) + .collect::>(); + + 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)?, diff --git a/docs/architecture/ghostlight-multiresolution-agency.md b/docs/architecture/ghostlight-multiresolution-agency.md index d8538b4..b32ae08 100644 --- a/docs/architecture/ghostlight-multiresolution-agency.md +++ b/docs/architecture/ghostlight-multiresolution-agency.md @@ -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. diff --git a/docs/architecture/ghostlight-transition-algebra.md b/docs/architecture/ghostlight-transition-algebra.md index a13694b..c2131dc 100644 --- a/docs/architecture/ghostlight-transition-algebra.md +++ b/docs/architecture/ghostlight-transition-algebra.md @@ -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. - diff --git a/notes/ghostlight-implementation-plan.md b/notes/ghostlight-implementation-plan.md index 9817bee..227226f 100644 --- a/notes/ghostlight-implementation-plan.md +++ b/notes/ghostlight-implementation-plan.md @@ -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