From 18e528388d9f57cc968d7c5d72f7e97ce8607e04 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 25 Aug 2026 05:03:00 +0200 Subject: [PATCH] Remove dynamic maps from strict model outputs --- crates/ghostlight-dungeon/src/compiler.rs | 218 +++++++++++++++++---- crates/ghostlight-dungeon/src/scheduler.rs | 47 ++++- 2 files changed, 222 insertions(+), 43 deletions(-) diff --git a/crates/ghostlight-dungeon/src/compiler.rs b/crates/ghostlight-dungeon/src/compiler.rs index d0aeceb..d9cae84 100644 --- a/crates/ghostlight-dungeon/src/compiler.rs +++ b/crates/ghostlight-dungeon/src/compiler.rs @@ -269,27 +269,7 @@ impl CompiledActorState { impl CompiledLocation { fn into_location(self) -> Result { - let mut routes = BTreeMap::new(); - for route in self.routes { - if route.route_id.trim().is_empty() - || routes - .insert( - route.route_id.clone(), - Route { - destination_id: route.destination_id, - distance: route.distance, - travel_minutes: route.travel_minutes, - }, - ) - .is_some() - { - return Err(anyhow!( - "compiled location {} has an empty or duplicate route ID {:?}", - self.id, - route.route_id - )); - } - } + let routes = compiled_route_map(self.routes, &self.id)?; Ok(Location { id: self.id, name: self.name, @@ -300,6 +280,33 @@ impl CompiledLocation { } } +fn compiled_route_map( + compiled_routes: Vec, + origin_id: &str, +) -> Result> { + let mut routes = BTreeMap::new(); + for route in compiled_routes { + if route.route_id.trim().is_empty() + || routes + .insert( + route.route_id.clone(), + Route { + destination_id: route.destination_id, + distance: route.distance, + travel_minutes: route.travel_minutes, + }, + ) + .is_some() + { + return Err(anyhow!( + "compiled location {origin_id} has an empty or duplicate route ID {:?}", + route.route_id + )); + } + } + Ok(routes) +} + impl CompiledGestaltMemberDelta { fn into_member(self) -> Result { Ok(GestaltMemberDelta { @@ -452,11 +459,47 @@ struct CompiledAgencyProfile { subject_id: String, subject_kind: AgencySubjectKind, collective_authority_id: Option, - facets: BTreeMap>, + facets: CompiledAgencyFacets, location_ids: BTreeSet, information_channels: BTreeSet, } +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] +struct CompiledAgencyFacets { + geography: BTreeSet, + ideology: BTreeSet, + authority: BTreeSet, + economy_role: BTreeSet, + species_body: BTreeSet, + information: BTreeSet, +} + +impl CompiledAgencyFacets { + fn into_map(self) -> BTreeMap> { + BTreeMap::from([ + (AgencyAxis::Geography, self.geography), + (AgencyAxis::Ideology, self.ideology), + (AgencyAxis::Authority, self.authority), + (AgencyAxis::EconomyRole, self.economy_role), + (AgencyAxis::SpeciesBody, self.species_body), + (AgencyAxis::Information, self.information), + ]) + } +} + +impl From>> for CompiledAgencyFacets { + fn from(mut facets: BTreeMap>) -> Self { + Self { + geography: facets.remove(&AgencyAxis::Geography).unwrap_or_default(), + ideology: facets.remove(&AgencyAxis::Ideology).unwrap_or_default(), + authority: facets.remove(&AgencyAxis::Authority).unwrap_or_default(), + economy_role: facets.remove(&AgencyAxis::EconomyRole).unwrap_or_default(), + species_body: facets.remove(&AgencyAxis::SpeciesBody).unwrap_or_default(), + information: facets.remove(&AgencyAxis::Information).unwrap_or_default(), + } + } +} + #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] struct CompiledAgencyRelation { id: String, @@ -469,8 +512,8 @@ struct CompiledAgencyRelation { #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] struct CompiledExpansionSeed { - origin_routes: BTreeMap, - locations: Vec, + origin_routes: Vec, + locations: Vec, facts: Vec, gaps: Vec, } @@ -478,13 +521,49 @@ struct CompiledExpansionSeed { #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] struct CompiledFissionSeed { children: Vec, - child_partition_values: BTreeMap, + child_partition_values: Vec, #[serde(default)] - member_child_assignments: BTreeMap, - resource_child_assignments: BTreeMap, + member_child_assignments: Vec, + resource_child_assignments: Vec, gaps: Vec, } +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] +struct CompiledChildPartitionValue { + child_id: String, + value: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] +struct CompiledMemberChildAssignment { + member_id: String, + child_id: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] +struct CompiledResourceChildAssignment { + resource_id: String, + child_id: String, +} + +fn compiled_assignment_map( + label: &str, + assignments: impl IntoIterator, +) -> Result> { + let mut mapped = BTreeMap::new(); + for (subject_id, child_id) in assignments { + if subject_id.trim().is_empty() + || child_id.trim().is_empty() + || mapped.insert(subject_id.clone(), child_id).is_some() + { + return Err(anyhow!( + "compiled {label} require unique non-empty subject IDs and child IDs; rejected subject {subject_id:?}" + )); + } + } + Ok(mapped) +} + pub struct WorldCompiler { vault: Arc, model: Arc, @@ -1173,7 +1252,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'. 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{}", + "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. Emit one child_partition_values record per child with child_id and value. Exact scarce resources are not inheritable traits: assign every parent resource to exactly one child through a resource_id/child_id record 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, using a member_id/child_id record; 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), ); @@ -1195,8 +1274,28 @@ impl WorldCompiler { .await?; stages.push(stage); let compiled: CompiledFissionSeed = serde_json::from_value(value)?; - let residual_child_id = compiled - .child_partition_values + let child_partition_values = compiled_assignment_map( + "child partition values", + compiled + .child_partition_values + .iter() + .map(|entry| (entry.child_id.clone(), entry.value.clone())), + )?; + let member_child_assignments = compiled_assignment_map( + "member child assignments", + compiled + .member_child_assignments + .iter() + .map(|entry| (entry.member_id.clone(), entry.child_id.clone())), + )?; + let resource_child_assignments = compiled_assignment_map( + "resource child assignments", + compiled + .resource_child_assignments + .iter() + .map(|entry| (entry.resource_id.clone(), entry.child_id.clone())), + )?; + let residual_child_id = child_partition_values .iter() .find(|(_, value)| value.trim().eq_ignore_ascii_case("other/unknown")) .map(|(id, _)| id.clone()); @@ -1243,10 +1342,10 @@ impl WorldCompiler { parent_gestalt_id: parent.id.clone(), partition_axis: request.partition_axis.clone(), children: compiled.children, - child_partition_values: compiled.child_partition_values, + 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, + member_child_assignments, + resource_child_assignments, evidence_receipt_ids, gaps, canon_candidates, @@ -1318,7 +1417,7 @@ impl WorldCompiler { .await?; let snapshot = format!("campaign:{}:revision:{}", campaign.id, campaign.revision); let base_prompt = format!( - "Compile only the requested bounded destination region. Every new location id must be new. Return explicit origin_routes owned by origin id {} into the new region, and give every such destination a reciprocal route back to the origin with the same positive travel time. Route IDs are stable keys local to their exact origin location; the same local key may exist under another origin without naming the same route. Do not rewrite existing geography. Every place has a non-empty name, valid container, and concrete persistent features. Any locally observable clue must already exist as a fact and list exact discoverable_at_location_ids from the combined existing and new topology; later action assessment can reveal facts but cannot invent them. CAMPAIGN LOCATIONS:\n{}\nREQUEST:\n{}\nEVIDENCE:\n{}", + "Compile only the requested bounded destination region. Every new location id must be new. Return explicit origin_routes records owned by origin id {} into the new region, and give every such destination a reciprocal route record back to the origin with the same positive travel time. Every route record needs a stable route_id local to its exact origin, an exact destination_id, a distance, and positive travel_minutes; the same local route_id may exist under another origin without naming the same route. Do not rewrite existing geography. Every place has a non-empty name, valid container, and concrete persistent features. Any locally observable clue must already exist as a fact and list exact discoverable_at_location_ids from the combined existing and new topology; later action assessment can reveal facts but cannot invent them. CAMPAIGN LOCATIONS:\n{}\nREQUEST:\n{}\nEVIDENCE:\n{}", origin_location_id, serde_json::to_string(&campaign.locations)?, destination_request, @@ -1342,8 +1441,13 @@ impl WorldCompiler { let seed: CompiledExpansionSeed = serde_json::from_value(output.0)?; let expansion = crate::domain::RegionExpansion { origin_location_id: origin_location_id.into(), - origin_routes: seed.origin_routes.clone(), - locations: seed.locations.clone(), + origin_routes: compiled_route_map(seed.origin_routes.clone(), origin_location_id)?, + locations: seed + .locations + .clone() + .into_iter() + .map(CompiledLocation::into_location) + .collect::>>()?, facts: seed.facts.clone(), }; match validate_region_expansion(campaign, &expansion) { @@ -3359,7 +3463,8 @@ fn apply_compiled_agency_skeleton( .agency_profiles .get_mut(&input.subject_id) .ok_or_else(|| anyhow!("agency profile references an unknown subject"))?; - let input_axes: BTreeSet<_> = input.facets.keys().cloned().collect(); + let input_facets = input.facets.into_map(); + let input_axes: BTreeSet<_> = input_facets.keys().cloned().collect(); let unknown_locations = input .location_ids .iter() @@ -3418,7 +3523,7 @@ fn apply_compiled_agency_skeleton( )); } profile.collective_authority_id = input.collective_authority_id; - profile.facets = input.facets; + profile.facets = input_facets; profile.location_ids = input.location_ids; profile.information_channels = input.information_channels; profile.evidence_receipt_ids = campaign.branch_origin.evidence_receipt_ids.clone(); @@ -3691,7 +3796,7 @@ mod tests { use super::*; #[test] - fn world_compiler_responses_schema_preserves_routes_and_relationships() { + fn compiler_responses_schemas_preserve_dynamic_semantics_as_records() { let mut schema = serde_json::to_value(schema_for!(CompiledSeed)).unwrap(); crate::model_connector::project_strict_responses_schema(&mut schema).unwrap(); @@ -3710,6 +3815,37 @@ mod tests { let serialized = serde_json::to_string(&schema).unwrap(); assert!(serialized.contains("\"route_id\"")); assert!(serialized.contains("\"subject_id\"")); + + let mut agency = serde_json::to_value(schema_for!(CompiledAgencySkeleton)).unwrap(); + crate::model_connector::project_strict_responses_schema(&mut agency).unwrap(); + assert_eq!( + agency["$defs"]["CompiledAgencyFacets"]["properties"]["geography"]["type"], + "array" + ); + assert_eq!( + agency["$defs"]["CompiledAgencyFacets"]["properties"]["information"]["type"], + "array" + ); + + let mut expansion = serde_json::to_value(schema_for!(CompiledExpansionSeed)).unwrap(); + crate::model_connector::project_strict_responses_schema(&mut expansion).unwrap(); + assert_eq!(expansion["properties"]["origin_routes"]["type"], "array"); + assert_eq!(expansion["properties"]["locations"]["type"], "array"); + + let mut fission = serde_json::to_value(schema_for!(CompiledFissionSeed)).unwrap(); + crate::model_connector::project_strict_responses_schema(&mut fission).unwrap(); + assert_eq!( + fission["properties"]["child_partition_values"]["type"], + "array" + ); + assert_eq!( + fission["properties"]["member_child_assignments"]["type"], + "array" + ); + assert_eq!( + fission["properties"]["resource_child_assignments"]["type"], + "array" + ); } #[test] @@ -3797,7 +3933,7 @@ mod tests { subject_id: "player".into(), subject_kind: AgencySubjectKind::Actor, collective_authority_id: None, - facets: facets.clone(), + facets: facets.clone().into(), location_ids: BTreeSet::from(["center".into()]), information_channels: BTreeSet::from(["convoy vulnerabilities".into()]), }], @@ -3814,7 +3950,7 @@ mod tests { subject_id: "player".into(), subject_kind: AgencySubjectKind::Actor, collective_authority_id: None, - facets, + facets: facets.into(), location_ids: BTreeSet::from(["center".into()]), information_channels: BTreeSet::from(["unknown".into()]), }], diff --git a/crates/ghostlight-dungeon/src/scheduler.rs b/crates/ghostlight-dungeon/src/scheduler.rs index 0e962fa..479c3c1 100644 --- a/crates/ghostlight-dungeon/src/scheduler.rs +++ b/crates/ghostlight-dungeon/src/scheduler.rs @@ -26,11 +26,34 @@ use std::sync::Arc; #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] struct DemandProjection { - axis_weights: BTreeMap, + axis_weights: DemandAxisWeights, focal_subject_ids: BTreeSet, rationale: String, } +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +struct DemandAxisWeights { + geography: f32, + ideology: f32, + authority: f32, + economy_role: f32, + species_body: f32, + information: f32, +} + +impl DemandAxisWeights { + fn into_map(self) -> BTreeMap { + BTreeMap::from([ + (AgencyAxis::Geography, self.geography), + (AgencyAxis::Ideology, self.ideology), + (AgencyAxis::Authority, self.authority), + (AgencyAxis::EconomyRole, self.economy_role), + (AgencyAxis::SpeciesBody, self.species_body), + (AgencyAxis::Information, self.information), + ]) + } +} + #[derive(Clone, Debug, Default, Serialize)] struct RelationDemandSummary { edges: u64, @@ -297,7 +320,7 @@ async fn project_resolution_demand( campaign_id: campaign.id, world_revision: campaign.revision, resolution_epoch: campaign.resolution_policy.resolution_epoch, - axis_weights: projection.axis_weights, + axis_weights: projection.axis_weights.into_map(), focal_subject_ids: projection.focal_subject_ids, horizon_minutes: campaign.tick_hours.saturating_mul(60), rationale: projection.rationale, @@ -949,6 +972,26 @@ mod tests { malformed_cell: bool, } + #[test] + fn demand_schema_preserves_all_six_weights_for_strict_providers() { + let mut schema = serde_json::to_value(schema_for!(DemandProjection)).unwrap(); + crate::model_connector::project_strict_responses_schema(&mut schema).unwrap(); + let properties = schema["$defs"]["DemandAxisWeights"]["properties"] + .as_object() + .unwrap(); + assert_eq!( + properties.keys().cloned().collect::>(), + BTreeSet::from([ + "authority".into(), + "economy_role".into(), + "geography".into(), + "ideology".into(), + "information".into(), + "species_body".into(), + ]) + ); + } + #[test] fn clock_projection_distinguishes_remaining_time_from_reached_consequence() { let mut clock = crate::domain::WorldClock {