Route region expansion through mutation algebra
This commit is contained in:
parent
cebd4de372
commit
bee96763e6
6 changed files with 941 additions and 45 deletions
|
|
@ -274,6 +274,7 @@ struct CompiledAgencyRelation {
|
|||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
|
||||
struct CompiledExpansionSeed {
|
||||
origin_routes: BTreeMap<String, crate::domain::Route>,
|
||||
locations: Vec<Location>,
|
||||
facts: Vec<WorldFact>,
|
||||
gaps: Vec<String>,
|
||||
|
|
@ -1117,7 +1118,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. At least one new location must route back to origin id {} with a positive travel time. Do not rewrite existing geography. 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 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 globally unique. 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,
|
||||
|
|
@ -1141,6 +1142,7 @@ 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(),
|
||||
facts: seed.facts.clone(),
|
||||
};
|
||||
|
|
@ -2425,7 +2427,10 @@ pub fn validate_region_expansion(
|
|||
return Err(anyhow!("destination expansion origin is unknown"));
|
||||
}
|
||||
let new_ids: BTreeSet<_> = expansion.locations.iter().map(|x| x.id.as_str()).collect();
|
||||
if expansion.locations.is_empty() || new_ids.len() != expansion.locations.len() {
|
||||
if expansion.locations.is_empty()
|
||||
|| new_ids.len() != expansion.locations.len()
|
||||
|| new_ids.iter().any(|id| id.trim().is_empty())
|
||||
{
|
||||
return Err(anyhow!("destination expansion has no unique locations"));
|
||||
}
|
||||
if new_ids
|
||||
|
|
@ -2437,10 +2442,76 @@ pub fn validate_region_expansion(
|
|||
));
|
||||
}
|
||||
let known = |id: &str| campaign.locations.contains_key(id) || new_ids.contains(id);
|
||||
let new_locations = expansion
|
||||
.locations
|
||||
.iter()
|
||||
.map(|location| (location.id.as_str(), location))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let existing_route_ids = campaign
|
||||
.locations
|
||||
.values()
|
||||
.flat_map(|location| location.routes.keys().map(String::as_str))
|
||||
.collect::<BTreeSet<_>>();
|
||||
let mut new_route_ids = BTreeSet::new();
|
||||
if expansion.origin_routes.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"destination expansion has no explicit route from the origin"
|
||||
));
|
||||
}
|
||||
for (route_id, route) in &expansion.origin_routes {
|
||||
if route_id.trim().is_empty()
|
||||
|| existing_route_ids.contains(route_id.as_str())
|
||||
|| !new_route_ids.insert(route_id.as_str())
|
||||
|| route.travel_minutes == 0
|
||||
|| route.distance.trim().is_empty()
|
||||
|| !new_ids.contains(route.destination_id.as_str())
|
||||
{
|
||||
return Err(anyhow!("destination expansion has an invalid origin route"));
|
||||
}
|
||||
let destination = new_locations
|
||||
.get(route.destination_id.as_str())
|
||||
.expect("origin route destination was admitted above");
|
||||
if !destination.routes.values().any(|reverse| {
|
||||
reverse.destination_id == expansion.origin_location_id
|
||||
&& reverse.travel_minutes == route.travel_minutes
|
||||
}) {
|
||||
return Err(anyhow!(
|
||||
"destination expansion origin route lacks an exact reciprocal return route"
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut attached = false;
|
||||
for location in &expansion.locations {
|
||||
for route in location.routes.values() {
|
||||
if route.travel_minutes == 0 || !known(&route.destination_id) {
|
||||
if location.name.trim().is_empty()
|
||||
|| location
|
||||
.persistent_features
|
||||
.iter()
|
||||
.any(|feature| feature.trim().is_empty())
|
||||
|| location
|
||||
.persistent_features
|
||||
.iter()
|
||||
.collect::<BTreeSet<_>>()
|
||||
.len()
|
||||
!= location.persistent_features.len()
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"destination expansion has a malformed place profile"
|
||||
));
|
||||
}
|
||||
if let Some(container_id) = &location.container_id
|
||||
&& (!known(container_id) || container_id == &location.id)
|
||||
{
|
||||
return Err(anyhow!("destination expansion has an invalid container"));
|
||||
}
|
||||
for (route_id, route) in &location.routes {
|
||||
if route_id.trim().is_empty()
|
||||
|| existing_route_ids.contains(route_id.as_str())
|
||||
|| !new_route_ids.insert(route_id.as_str())
|
||||
|| route.travel_minutes == 0
|
||||
|| route.distance.trim().is_empty()
|
||||
|| !known(&route.destination_id)
|
||||
|| route.destination_id == location.id
|
||||
{
|
||||
return Err(anyhow!("destination expansion has a dangling route"));
|
||||
}
|
||||
if route.destination_id == expansion.origin_location_id {
|
||||
|
|
@ -2451,6 +2522,26 @@ pub fn validate_region_expansion(
|
|||
if !attached {
|
||||
return Err(anyhow!("destination expansion is not attached to origin"));
|
||||
}
|
||||
for location in &expansion.locations {
|
||||
let mut seen = BTreeSet::from([location.id.as_str()]);
|
||||
let mut cursor = location.container_id.as_deref();
|
||||
while let Some(container_id) = cursor {
|
||||
if !seen.insert(container_id) {
|
||||
return Err(anyhow!(
|
||||
"destination expansion containment contains a cycle"
|
||||
));
|
||||
}
|
||||
cursor = new_locations
|
||||
.get(container_id)
|
||||
.and_then(|container| container.container_id.as_deref())
|
||||
.or_else(|| {
|
||||
campaign
|
||||
.locations
|
||||
.get(container_id)
|
||||
.and_then(|container| container.container_id.as_deref())
|
||||
});
|
||||
}
|
||||
}
|
||||
let existing_fact_ids = campaign.facts.keys().collect::<BTreeSet<_>>();
|
||||
let mut new_fact_ids = BTreeSet::new();
|
||||
let mut fact_statements = campaign
|
||||
|
|
@ -2464,6 +2555,10 @@ pub fn validate_region_expansion(
|
|||
|| !new_fact_ids.insert(fact.id.clone())
|
||||
|| fact.statement.trim().is_empty()
|
||||
|| !fact_statements.insert(fact.statement.clone())
|
||||
|| fact
|
||||
.evidence_receipt_ids
|
||||
.iter()
|
||||
.any(|receipt_id| receipt_id.trim().is_empty())
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"destination expansion facts must have new IDs and non-empty unique statements"
|
||||
|
|
@ -4925,4 +5020,67 @@ mod tests {
|
|||
assert_eq!(world_receipts[0].validation_result, "semantic_invalid");
|
||||
assert_eq!(world_receipts[1].validation_result, "valid");
|
||||
}
|
||||
|
||||
fn valid_region_expansion() -> crate::domain::RegionExpansion {
|
||||
crate::domain::RegionExpansion {
|
||||
origin_location_id: "center".into(),
|
||||
origin_routes: BTreeMap::from([(
|
||||
"route:center-annex".into(),
|
||||
crate::domain::Route {
|
||||
destination_id: "annex".into(),
|
||||
distance: "near".into(),
|
||||
travel_minutes: 10,
|
||||
},
|
||||
)]),
|
||||
locations: vec![Location {
|
||||
id: "annex".into(),
|
||||
name: "Annex".into(),
|
||||
container_id: None,
|
||||
routes: BTreeMap::from([(
|
||||
"route:annex-center".into(),
|
||||
crate::domain::Route {
|
||||
destination_id: "center".into(),
|
||||
distance: "near".into(),
|
||||
travel_minutes: 10,
|
||||
},
|
||||
)]),
|
||||
persistent_features: vec!["sealed gate".into()],
|
||||
}],
|
||||
facts: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_expansion_requires_an_approved_round_trip() {
|
||||
let campaign = crate::resolution::tests::campaign(0, 1);
|
||||
let mut expansion = valid_region_expansion();
|
||||
validate_region_expansion(&campaign, &expansion).unwrap();
|
||||
expansion.origin_routes.clear();
|
||||
assert!(
|
||||
validate_region_expansion(&campaign, &expansion)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("explicit route from the origin")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_expansion_rejects_containment_cycles() {
|
||||
let campaign = crate::resolution::tests::campaign(0, 1);
|
||||
let mut expansion = valid_region_expansion();
|
||||
expansion.locations[0].container_id = Some("service-ring".into());
|
||||
expansion.locations.push(Location {
|
||||
id: "service-ring".into(),
|
||||
name: "Service Ring".into(),
|
||||
container_id: Some("annex".into()),
|
||||
routes: BTreeMap::new(),
|
||||
persistent_features: vec!["fixed conduits".into()],
|
||||
});
|
||||
assert!(
|
||||
validate_region_expansion(&campaign, &expansion)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("containment contains a cycle")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -830,7 +830,7 @@ pub struct CanonCandidate {
|
|||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FactScope {
|
||||
CanonBaseline,
|
||||
|
|
@ -1027,6 +1027,12 @@ fn default_true() -> bool {
|
|||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
|
||||
pub struct RegionExpansion {
|
||||
pub origin_location_id: String,
|
||||
/// Exact outbound routes added to the already-materialized origin. Keeping
|
||||
/// these separate from `locations` makes the existing-place mutation
|
||||
/// explicit in the approval preview instead of deriving a hidden reverse
|
||||
/// route after commit.
|
||||
#[serde(default)]
|
||||
pub origin_routes: BTreeMap<String, Route>,
|
||||
pub locations: Vec<Location>,
|
||||
pub facts: Vec<WorldFact>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1088,14 +1088,39 @@ fn execute(
|
|||
model_stage_receipts,
|
||||
} => {
|
||||
require_revision(&campaign, expected_revision)?;
|
||||
let supplied_evidence = evidence_receipts
|
||||
.iter()
|
||||
.map(|receipt| receipt.id.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
if expansion.facts.iter().any(|fact| {
|
||||
fact.evidence_receipt_ids
|
||||
.iter()
|
||||
.any(|id| !supplied_evidence.contains(id.as_str()))
|
||||
}) || canon_candidates.iter().any(|candidate| {
|
||||
candidate
|
||||
.evidence_receipt_ids
|
||||
.iter()
|
||||
.any(|id| !supplied_evidence.contains(id.as_str()))
|
||||
}) {
|
||||
return Err(KernelError::Invalid(
|
||||
"region expansion evidence receipts were not supplied".into(),
|
||||
));
|
||||
}
|
||||
crate::compiler::validate_region_expansion(&campaign, &expansion)
|
||||
.map_err(|error| KernelError::Invalid(error.to_string()))?;
|
||||
for location in expansion.locations {
|
||||
campaign.locations.insert(location.id.clone(), location);
|
||||
}
|
||||
for fact in expansion.facts {
|
||||
campaign.facts.insert(fact.id.clone(), fact);
|
||||
}
|
||||
let transition = crate::legacy_transition::lower_region_expansion(
|
||||
&campaign,
|
||||
&expansion,
|
||||
Utc::now() + Duration::minutes(5),
|
||||
)
|
||||
.map_err(|error| KernelError::Invalid(error.to_string()))?;
|
||||
let mutation_receipt = crate::legacy_transition::apply_lowered_region_expansion(
|
||||
&mut campaign,
|
||||
&expansion,
|
||||
&transition,
|
||||
Utc::now(),
|
||||
)
|
||||
.map_err(|error| KernelError::Invalid(error.to_string()))?;
|
||||
for candidate in &canon_candidates {
|
||||
campaign
|
||||
.canon_candidates
|
||||
|
|
@ -1109,7 +1134,7 @@ fn execute(
|
|||
evidence_receipts,
|
||||
canon_candidates,
|
||||
model_stage_receipts,
|
||||
None,
|
||||
Some((transition, mutation_receipt)),
|
||||
)
|
||||
}
|
||||
WorldCommand::MaterializeGestaltMember {
|
||||
|
|
@ -3314,6 +3339,8 @@ mod tests {
|
|||
memberships: BTreeMap::new(),
|
||||
population_lineages: BTreeMap::new(),
|
||||
identities: BTreeMap::new(),
|
||||
place_profiles: BTreeMap::new(),
|
||||
propositions: BTreeMap::new(),
|
||||
topology: BTreeMap::new(),
|
||||
};
|
||||
let initial_world_time = state.world_time;
|
||||
|
|
@ -3334,6 +3361,7 @@ mod tests {
|
|||
maximum: 30,
|
||||
},
|
||||
)]),
|
||||
exact_mutation: None,
|
||||
maximum_uses: 1,
|
||||
};
|
||||
let mut authority = MutationAuthorityEnvelope {
|
||||
|
|
@ -5219,8 +5247,22 @@ mod tests {
|
|||
expected_revision: 0,
|
||||
expansion: RegionExpansion {
|
||||
origin_location_id: "room".into(),
|
||||
origin_routes: BTreeMap::from([(
|
||||
"to-annex".into(),
|
||||
Route {
|
||||
destination_id: "annex".into(),
|
||||
distance: "near".into(),
|
||||
travel_minutes: 10,
|
||||
},
|
||||
)]),
|
||||
locations: vec![location],
|
||||
facts: vec![],
|
||||
facts: vec![WorldFact {
|
||||
id: "annex-gate".into(),
|
||||
statement: "The annex gate is maintained from the inner booth.".into(),
|
||||
scope: FactScope::BranchLocal,
|
||||
evidence_receipt_ids: vec![evidence.id.clone()],
|
||||
discoverable_at_location_ids: BTreeSet::from(["annex".into()]),
|
||||
}],
|
||||
},
|
||||
evidence_receipts: vec![evidence],
|
||||
canon_candidates: vec![candidate],
|
||||
|
|
@ -5232,9 +5274,35 @@ mod tests {
|
|||
panic!("expected commit")
|
||||
};
|
||||
assert!(campaign.locations.contains_key("annex"));
|
||||
assert_eq!(
|
||||
campaign.locations["room"].routes["to-annex"].destination_id,
|
||||
"annex"
|
||||
);
|
||||
assert_eq!(campaign.revision, 1);
|
||||
assert_eq!(
|
||||
campaign.facts["annex-gate"].discoverable_at_location_ids,
|
||||
BTreeSet::from(["annex".into()])
|
||||
);
|
||||
assert_eq!(store.keys("vault_evidence_receipt.v1").unwrap().len(), 1);
|
||||
assert_eq!(store.keys("canon_candidate.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 batches = store
|
||||
.load_all::<crate::transition::WorldMutationBatch>("world_mutation_batch.v1")
|
||||
.unwrap();
|
||||
assert_eq!(batches[0].1.mutations.len(), 4);
|
||||
assert_eq!(
|
||||
batches[0]
|
||||
.1
|
||||
.mutations
|
||||
.iter()
|
||||
.filter(|mutation| matches!(
|
||||
&mutation.mutation,
|
||||
crate::transition::WorldMutation::AdmitEntity { .. }
|
||||
))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
let (_, manifest): (_, VaultManifest) = store
|
||||
.load("vault_manifest.v1", &seed.id.to_string())
|
||||
.unwrap()
|
||||
|
|
|
|||
|
|
@ -170,6 +170,7 @@ pub fn lower_fission(
|
|||
WorldComponentKind::PopulationLineage,
|
||||
]),
|
||||
initial_place: Some(place_subject(&child.home_location_id)),
|
||||
initial_profile: None,
|
||||
admission_receipt_id: source_receipt_id.clone(),
|
||||
});
|
||||
mutations.push(WorldMutation::ChangeIdentity {
|
||||
|
|
@ -246,6 +247,196 @@ pub fn apply_lowered_fission(
|
|||
Ok(application.receipt)
|
||||
}
|
||||
|
||||
pub fn lower_region_expansion(
|
||||
campaign: &Campaign,
|
||||
expansion: &crate::domain::RegionExpansion,
|
||||
expires_at: DateTime<Utc>,
|
||||
) -> Result<LoweredLegacyTransition> {
|
||||
crate::compiler::validate_region_expansion(campaign, expansion)?;
|
||||
let expansion_digest = digest_serializable(expansion)?;
|
||||
let source_receipt_id = format!(
|
||||
"region-expansion:{}",
|
||||
expansion_digest.trim_start_matches("sha256:")
|
||||
);
|
||||
let mut mutations = Vec::new();
|
||||
for location in &expansion.locations {
|
||||
mutations.push(WorldMutation::AdmitEntity {
|
||||
subject: place_subject(&location.id),
|
||||
initial_components: BTreeSet::from([
|
||||
WorldComponentKind::PlaceProfile,
|
||||
WorldComponentKind::Topology,
|
||||
]),
|
||||
initial_place: None,
|
||||
initial_profile: Some(AdmittedEntityProfile::Place {
|
||||
name: location.name.clone(),
|
||||
container: location.container_id.as_deref().map(place_subject),
|
||||
persistent_features: location.persistent_features.iter().cloned().collect(),
|
||||
}),
|
||||
admission_receipt_id: source_receipt_id.clone(),
|
||||
});
|
||||
}
|
||||
for fact in &expansion.facts {
|
||||
mutations.push(WorldMutation::AdmitEntity {
|
||||
subject: proposition_subject(&fact.id),
|
||||
initial_components: BTreeSet::from([
|
||||
WorldComponentKind::Knowledge,
|
||||
WorldComponentKind::PropositionContent,
|
||||
]),
|
||||
initial_place: None,
|
||||
initial_profile: Some(AdmittedEntityProfile::Proposition {
|
||||
statement: fact.statement.clone(),
|
||||
scope: fact.scope.clone(),
|
||||
evidence_receipt_ids: fact.evidence_receipt_ids.iter().cloned().collect(),
|
||||
discoverable_at_places: fact
|
||||
.discoverable_at_location_ids
|
||||
.iter()
|
||||
.map(|id| place_subject(id))
|
||||
.collect(),
|
||||
}),
|
||||
admission_receipt_id: source_receipt_id.clone(),
|
||||
});
|
||||
}
|
||||
let origin = place_subject(&expansion.origin_location_id);
|
||||
for (route_id, route) in &expansion.origin_routes {
|
||||
mutations.push(WorldMutation::ChangeTopology {
|
||||
operation: TopologyMutationOperation::Add,
|
||||
edge_id: route_id.clone(),
|
||||
from_place: origin.clone(),
|
||||
to_place: place_subject(&route.destination_id),
|
||||
distance: Some(route.distance.clone()),
|
||||
travel_minutes: Some(i64::from(route.travel_minutes)),
|
||||
});
|
||||
}
|
||||
for location in &expansion.locations {
|
||||
for (route_id, route) in &location.routes {
|
||||
mutations.push(WorldMutation::ChangeTopology {
|
||||
operation: TopologyMutationOperation::Add,
|
||||
edge_id: route_id.clone(),
|
||||
from_place: place_subject(&location.id),
|
||||
to_place: place_subject(&route.destination_id),
|
||||
distance: Some(route.distance.clone()),
|
||||
travel_minutes: Some(i64::from(route.travel_minutes)),
|
||||
});
|
||||
}
|
||||
}
|
||||
lower_exact_mutations(
|
||||
campaign,
|
||||
mutations,
|
||||
MutationProcedure::CompilerAdmission,
|
||||
None,
|
||||
MutationOutcomeBinding::Deterministic,
|
||||
None,
|
||||
"Admit only the approved place profiles and proposition contents, then add the exact approved route edges without rewriting existing geography.",
|
||||
&source_receipt_id,
|
||||
Some(digest_serializable(&(
|
||||
"compile_destination",
|
||||
&expansion.origin_location_id,
|
||||
))?),
|
||||
Some(expansion_digest),
|
||||
expires_at,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn apply_lowered_region_expansion(
|
||||
campaign: &mut Campaign,
|
||||
expansion: &crate::domain::RegionExpansion,
|
||||
transition: &LoweredLegacyTransition,
|
||||
now: DateTime<Utc>,
|
||||
) -> Result<WorldMutationReceipt> {
|
||||
crate::compiler::validate_region_expansion(campaign, expansion)?;
|
||||
let snapshot = component_snapshot(campaign)?;
|
||||
let application =
|
||||
apply_component_world_batch(&snapshot, &transition.authority, &transition.batch, now)?;
|
||||
project_accepted_region_expansion(campaign, expansion, &application.state)?;
|
||||
Ok(application.receipt)
|
||||
}
|
||||
|
||||
fn project_accepted_region_expansion(
|
||||
campaign: &mut Campaign,
|
||||
expansion: &crate::domain::RegionExpansion,
|
||||
next: &ComponentWorldState,
|
||||
) -> Result<()> {
|
||||
for requested in &expansion.locations {
|
||||
let subject = place_subject(&requested.id);
|
||||
let profile = next
|
||||
.place_profiles
|
||||
.get(&subject)
|
||||
.ok_or_else(|| anyhow!("accepted expansion lost a place profile"))?;
|
||||
let routes = next
|
||||
.topology
|
||||
.values()
|
||||
.filter(|edge| edge.from_place == subject && edge.open)
|
||||
.map(|edge| {
|
||||
Ok((
|
||||
edge.id.clone(),
|
||||
crate::domain::Route {
|
||||
destination_id: edge.to_place.id.clone(),
|
||||
distance: edge.distance.clone(),
|
||||
travel_minutes: u32::try_from(edge.travel_minutes).map_err(|_| {
|
||||
anyhow!("accepted expansion travel time exceeds aggregate storage")
|
||||
})?,
|
||||
},
|
||||
))
|
||||
})
|
||||
.collect::<Result<BTreeMap<_, _>>>()?;
|
||||
campaign.locations.insert(
|
||||
subject.id.clone(),
|
||||
crate::domain::Location {
|
||||
id: subject.id,
|
||||
name: profile.name.clone(),
|
||||
container_id: profile
|
||||
.container
|
||||
.as_ref()
|
||||
.map(|container| container.id.clone()),
|
||||
routes,
|
||||
persistent_features: profile.persistent_features.iter().cloned().collect(),
|
||||
},
|
||||
);
|
||||
}
|
||||
let origin = campaign
|
||||
.locations
|
||||
.get_mut(&expansion.origin_location_id)
|
||||
.ok_or_else(|| anyhow!("accepted expansion origin vanished"))?;
|
||||
for route_id in expansion.origin_routes.keys() {
|
||||
let edge = next
|
||||
.topology
|
||||
.get(route_id)
|
||||
.filter(|edge| edge.from_place.id == expansion.origin_location_id && edge.open)
|
||||
.ok_or_else(|| anyhow!("accepted expansion lost an origin route"))?;
|
||||
origin.routes.insert(
|
||||
route_id.clone(),
|
||||
crate::domain::Route {
|
||||
destination_id: edge.to_place.id.clone(),
|
||||
distance: edge.distance.clone(),
|
||||
travel_minutes: u32::try_from(edge.travel_minutes)
|
||||
.map_err(|_| anyhow!("accepted origin route exceeds aggregate storage"))?,
|
||||
},
|
||||
);
|
||||
}
|
||||
for requested in &expansion.facts {
|
||||
let subject = proposition_subject(&requested.id);
|
||||
let value = next
|
||||
.propositions
|
||||
.get(&subject)
|
||||
.ok_or_else(|| anyhow!("accepted expansion lost proposition content"))?;
|
||||
campaign.facts.insert(
|
||||
subject.id.clone(),
|
||||
crate::domain::WorldFact {
|
||||
id: subject.id,
|
||||
statement: value.statement.clone(),
|
||||
scope: value.scope.clone(),
|
||||
evidence_receipt_ids: value.evidence_receipt_ids.iter().cloned().collect(),
|
||||
discoverable_at_location_ids: value
|
||||
.discoverable_at_places
|
||||
.iter()
|
||||
.map(|place| place.id.clone())
|
||||
.collect(),
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn project_accepted_fission(
|
||||
campaign: &mut Campaign,
|
||||
preview: &GestaltFissionPreview,
|
||||
|
|
@ -1055,6 +1246,8 @@ fn component_snapshot(campaign: &Campaign) -> Result<ComponentWorldState> {
|
|||
memberships: BTreeMap::new(),
|
||||
population_lineages: BTreeMap::new(),
|
||||
identities: BTreeMap::new(),
|
||||
place_profiles: BTreeMap::new(),
|
||||
propositions: BTreeMap::new(),
|
||||
topology: BTreeMap::new(),
|
||||
};
|
||||
admit(
|
||||
|
|
@ -1067,9 +1260,23 @@ fn component_snapshot(campaign: &Campaign) -> Result<ComponentWorldState> {
|
|||
admit(
|
||||
&mut state,
|
||||
place_subject(&location.id),
|
||||
BTreeSet::from([WorldComponentKind::Topology]),
|
||||
BTreeSet::from([
|
||||
WorldComponentKind::PlaceProfile,
|
||||
WorldComponentKind::Topology,
|
||||
]),
|
||||
version,
|
||||
);
|
||||
let place = place_subject(&location.id);
|
||||
state.place_profiles.insert(
|
||||
place.clone(),
|
||||
PlaceComponentState {
|
||||
place,
|
||||
name: location.name.clone(),
|
||||
container: location.container_id.as_deref().map(place_subject),
|
||||
persistent_features: location.persistent_features.iter().cloned().collect(),
|
||||
version,
|
||||
},
|
||||
);
|
||||
}
|
||||
for actor in campaign.actors.values() {
|
||||
let subject = actor_subject(&actor.id);
|
||||
|
|
@ -1439,12 +1646,31 @@ fn component_snapshot(campaign: &Campaign) -> Result<ComponentWorldState> {
|
|||
);
|
||||
}
|
||||
for fact in campaign.facts.values() {
|
||||
let proposition = proposition_subject(&fact.id);
|
||||
admit(
|
||||
&mut state,
|
||||
proposition_subject(&fact.id),
|
||||
BTreeSet::from([WorldComponentKind::Knowledge]),
|
||||
proposition.clone(),
|
||||
BTreeSet::from([
|
||||
WorldComponentKind::Knowledge,
|
||||
WorldComponentKind::PropositionContent,
|
||||
]),
|
||||
version,
|
||||
);
|
||||
state.propositions.insert(
|
||||
proposition.clone(),
|
||||
PropositionComponentState {
|
||||
proposition,
|
||||
statement: fact.statement.clone(),
|
||||
scope: fact.scope.clone(),
|
||||
evidence_receipt_ids: fact.evidence_receipt_ids.iter().cloned().collect(),
|
||||
discoverable_at_places: fact
|
||||
.discoverable_at_location_ids
|
||||
.iter()
|
||||
.map(|id| place_subject(id))
|
||||
.collect(),
|
||||
version,
|
||||
},
|
||||
);
|
||||
}
|
||||
for clock in campaign.clocks.values() {
|
||||
let pressure = pressure_subject(&clock.id);
|
||||
|
|
@ -1475,6 +1701,7 @@ fn component_snapshot(campaign: &Campaign) -> Result<ComponentWorldState> {
|
|||
id: route_id.clone(),
|
||||
from_place: place_subject(&location.id),
|
||||
to_place: place_subject(&route.destination_id),
|
||||
distance: route.distance.clone(),
|
||||
travel_minutes: i64::from(route.travel_minutes),
|
||||
open: true,
|
||||
version,
|
||||
|
|
@ -1492,15 +1719,7 @@ fn component_snapshot(campaign: &Campaign) -> Result<ComponentWorldState> {
|
|||
{
|
||||
proposition_subject(&fact.id)
|
||||
} else {
|
||||
let proposition =
|
||||
proposition_subject(&format!("legacy-knowledge:{}", short_digest(statement)));
|
||||
admit(
|
||||
&mut state,
|
||||
proposition.clone(),
|
||||
BTreeSet::from([WorldComponentKind::Knowledge]),
|
||||
version,
|
||||
);
|
||||
proposition
|
||||
ensure_proposition(&mut state, campaign, statement, version)
|
||||
};
|
||||
state.knowledge.insert(
|
||||
KnowledgeKey {
|
||||
|
|
@ -2211,9 +2430,23 @@ fn ensure_proposition(
|
|||
admit(
|
||||
state,
|
||||
proposition.clone(),
|
||||
BTreeSet::from([WorldComponentKind::Knowledge]),
|
||||
BTreeSet::from([
|
||||
WorldComponentKind::Knowledge,
|
||||
WorldComponentKind::PropositionContent,
|
||||
]),
|
||||
version,
|
||||
);
|
||||
state
|
||||
.propositions
|
||||
.entry(proposition.clone())
|
||||
.or_insert_with(|| PropositionComponentState {
|
||||
proposition: proposition.clone(),
|
||||
statement: statement.into(),
|
||||
scope: crate::domain::FactScope::ProvisionalLocal,
|
||||
evidence_receipt_ids: BTreeSet::new(),
|
||||
discoverable_at_places: BTreeSet::new(),
|
||||
version,
|
||||
});
|
||||
proposition
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4853,7 +4853,9 @@ fn world_compile_preview_projection(preview: &WorldCompilePreview) -> serde_json
|
|||
fn region_expansion_preview_projection(preview: &RegionExpansionPreview) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"origin_location_id":preview.expansion.origin_location_id,
|
||||
"origin_routes":preview.expansion.origin_routes,
|
||||
"locations":preview.expansion.locations,
|
||||
"facts":preview.expansion.facts,
|
||||
"gaps":preview.gaps,
|
||||
"requires_approval":preview.requires_approval,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use crate::domain::{OutcomeBand, StrategicOutcomeBand};
|
||||
use crate::domain::{FactScope, OutcomeBand, StrategicOutcomeBand};
|
||||
use anyhow::{Result, anyhow};
|
||||
use chrono::{DateTime, Utc};
|
||||
use schemars::{JsonSchema, schema_for};
|
||||
|
|
@ -50,6 +50,8 @@ pub enum WorldComponentKind {
|
|||
Posture,
|
||||
PopulationMembership,
|
||||
PopulationLineage,
|
||||
PlaceProfile,
|
||||
PropositionContent,
|
||||
Topology,
|
||||
Lifecycle,
|
||||
WorldTime,
|
||||
|
|
@ -242,6 +244,7 @@ pub enum MutationStringRole {
|
|||
IdentityHandleId,
|
||||
IdentityHandleValue,
|
||||
TopologyEdgeId,
|
||||
Distance,
|
||||
AdmissionReceiptId,
|
||||
}
|
||||
|
||||
|
|
@ -290,6 +293,8 @@ pub struct MutationPermit {
|
|||
pub string_constraints: BTreeMap<MutationStringRole, StringConstraint>,
|
||||
#[serde(default)]
|
||||
pub integer_bounds: BTreeMap<MutationIntegerRole, IntegerBounds>,
|
||||
#[serde(default)]
|
||||
pub exact_mutation: Option<WorldMutation>,
|
||||
#[schemars(range(min = 1))]
|
||||
pub maximum_uses: u16,
|
||||
}
|
||||
|
|
@ -429,6 +434,27 @@ pub enum TopologyMutationOperation {
|
|||
Retire,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum AdmittedEntityProfile {
|
||||
Place {
|
||||
#[schemars(length(min = 1, max = 240))]
|
||||
name: String,
|
||||
container: Option<SubjectRef>,
|
||||
#[serde(default)]
|
||||
persistent_features: BTreeSet<String>,
|
||||
},
|
||||
Proposition {
|
||||
#[schemars(length(min = 1, max = 4000))]
|
||||
statement: String,
|
||||
scope: FactScope,
|
||||
#[serde(default)]
|
||||
evidence_receipt_ids: BTreeSet<String>,
|
||||
#[serde(default)]
|
||||
discoverable_at_places: BTreeSet<SubjectRef>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum WorldMutation {
|
||||
|
|
@ -544,6 +570,8 @@ pub enum WorldMutation {
|
|||
edge_id: String,
|
||||
from_place: SubjectRef,
|
||||
to_place: SubjectRef,
|
||||
#[serde(default)]
|
||||
distance: Option<String>,
|
||||
travel_minutes: Option<i64>,
|
||||
},
|
||||
AdmitEntity {
|
||||
|
|
@ -551,6 +579,8 @@ pub enum WorldMutation {
|
|||
initial_components: BTreeSet<WorldComponentKind>,
|
||||
#[serde(default)]
|
||||
initial_place: Option<SubjectRef>,
|
||||
#[serde(default)]
|
||||
initial_profile: Option<AdmittedEntityProfile>,
|
||||
admission_receipt_id: String,
|
||||
},
|
||||
RetireEntity {
|
||||
|
|
@ -747,11 +777,35 @@ pub struct PopulationLineageState {
|
|||
pub version: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
pub struct PlaceComponentState {
|
||||
pub place: SubjectRef,
|
||||
pub name: String,
|
||||
pub container: Option<SubjectRef>,
|
||||
#[serde(default)]
|
||||
pub persistent_features: BTreeSet<String>,
|
||||
pub version: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
pub struct PropositionComponentState {
|
||||
pub proposition: SubjectRef,
|
||||
pub statement: String,
|
||||
pub scope: FactScope,
|
||||
#[serde(default)]
|
||||
pub evidence_receipt_ids: BTreeSet<String>,
|
||||
#[serde(default)]
|
||||
pub discoverable_at_places: BTreeSet<SubjectRef>,
|
||||
pub version: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
pub struct TopologyComponentState {
|
||||
pub id: String,
|
||||
pub from_place: SubjectRef,
|
||||
pub to_place: SubjectRef,
|
||||
#[serde(default = "default_route_distance")]
|
||||
pub distance: String,
|
||||
pub travel_minutes: i64,
|
||||
pub open: bool,
|
||||
pub version: u64,
|
||||
|
|
@ -779,9 +833,17 @@ pub struct ComponentWorldState {
|
|||
pub memberships: BTreeMap<MembershipKey, PopulationMembershipState>,
|
||||
pub population_lineages: BTreeMap<String, PopulationLineageState>,
|
||||
pub identities: BTreeMap<String, IdentityHandleState>,
|
||||
#[serde(default)]
|
||||
pub place_profiles: BTreeMap<SubjectRef, PlaceComponentState>,
|
||||
#[serde(default)]
|
||||
pub propositions: BTreeMap<SubjectRef, PropositionComponentState>,
|
||||
pub topology: BTreeMap<String, TopologyComponentState>,
|
||||
}
|
||||
|
||||
fn default_route_distance() -> String {
|
||||
"unspecified".into()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct MutationApplication {
|
||||
pub state: ComponentWorldState,
|
||||
|
|
@ -1237,6 +1299,7 @@ fn apply_component_mutation(
|
|||
edge_id,
|
||||
from_place,
|
||||
to_place,
|
||||
distance,
|
||||
travel_minutes,
|
||||
} => apply_topology_mutation(
|
||||
state,
|
||||
|
|
@ -1244,12 +1307,14 @@ fn apply_component_mutation(
|
|||
edge_id,
|
||||
from_place,
|
||||
to_place,
|
||||
distance.as_deref(),
|
||||
*travel_minutes,
|
||||
)?,
|
||||
AdmitEntity {
|
||||
subject,
|
||||
initial_components,
|
||||
initial_place,
|
||||
initial_profile,
|
||||
..
|
||||
} => {
|
||||
if subject.kind == SubjectKind::Resource {
|
||||
|
|
@ -1273,6 +1338,44 @@ fn apply_component_mutation(
|
|||
require_kind(place, SubjectKind::Place)?;
|
||||
state.occupancy.insert(subject.clone(), place.clone());
|
||||
}
|
||||
let version = next_component_version(state);
|
||||
match initial_profile {
|
||||
Some(AdmittedEntityProfile::Place {
|
||||
name,
|
||||
container,
|
||||
persistent_features,
|
||||
}) => {
|
||||
state.place_profiles.insert(
|
||||
subject.clone(),
|
||||
PlaceComponentState {
|
||||
place: subject.clone(),
|
||||
name: name.clone(),
|
||||
container: container.clone(),
|
||||
persistent_features: persistent_features.clone(),
|
||||
version,
|
||||
},
|
||||
);
|
||||
}
|
||||
Some(AdmittedEntityProfile::Proposition {
|
||||
statement,
|
||||
scope,
|
||||
evidence_receipt_ids,
|
||||
discoverable_at_places,
|
||||
}) => {
|
||||
state.propositions.insert(
|
||||
subject.clone(),
|
||||
PropositionComponentState {
|
||||
proposition: subject.clone(),
|
||||
statement: statement.clone(),
|
||||
scope: scope.clone(),
|
||||
evidence_receipt_ids: evidence_receipt_ids.clone(),
|
||||
discoverable_at_places: discoverable_at_places.clone(),
|
||||
version,
|
||||
},
|
||||
);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
RetireEntity { subject, .. } => retire_entity(state, subject)?,
|
||||
AdvanceWorldTime { campaign, minutes } => {
|
||||
|
|
@ -2315,6 +2418,7 @@ fn apply_topology_mutation(
|
|||
edge_id: &str,
|
||||
from_place: &SubjectRef,
|
||||
to_place: &SubjectRef,
|
||||
distance: Option<&str>,
|
||||
travel_minutes: Option<i64>,
|
||||
) -> Result<()> {
|
||||
active_subject(state, from_place)?;
|
||||
|
|
@ -2333,12 +2437,16 @@ fn apply_topology_mutation(
|
|||
let travel_minutes = travel_minutes
|
||||
.filter(|minutes| *minutes > 0)
|
||||
.ok_or_else(|| anyhow!("topology edge requires positive travel time"))?;
|
||||
let distance = distance
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| anyhow!("topology edge requires a distance description"))?;
|
||||
state.topology.insert(
|
||||
edge_id.into(),
|
||||
TopologyComponentState {
|
||||
id: edge_id.into(),
|
||||
from_place: from_place.clone(),
|
||||
to_place: to_place.clone(),
|
||||
distance: distance.into(),
|
||||
travel_minutes,
|
||||
open: true,
|
||||
version,
|
||||
|
|
@ -2356,6 +2464,10 @@ fn apply_topology_mutation(
|
|||
value.travel_minutes = travel_minutes
|
||||
.filter(|minutes| *minutes > 0)
|
||||
.ok_or_else(|| anyhow!("topology alteration requires positive travel time"))?;
|
||||
value.distance = distance
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| anyhow!("topology alteration requires a distance description"))?
|
||||
.into();
|
||||
value.version = version;
|
||||
}
|
||||
TopologyMutationOperation::Open | TopologyMutationOperation::Close => {
|
||||
|
|
@ -2390,6 +2502,7 @@ fn apply_topology_mutation(
|
|||
fn retire_entity(state: &mut ComponentWorldState, subject: &SubjectRef) -> Result<()> {
|
||||
active_subject(state, subject)?;
|
||||
if state.occupancy.contains_key(subject)
|
||||
|| state.occupancy.values().any(|place| place == subject)
|
||||
|| state.custody.contains_key(subject)
|
||||
|| state.custody.values().any(|owner| owner == subject)
|
||||
|| state.memberships.iter().any(|(key, value)| {
|
||||
|
|
@ -2406,6 +2519,18 @@ fn retire_entity(state: &mut ComponentWorldState, subject: &SubjectRef) -> Resul
|
|||
|| state.commitments.iter().any(|(key, commitment)| {
|
||||
&key.subject == subject || commitment.counterparty.as_ref() == Some(subject)
|
||||
})
|
||||
|| state
|
||||
.knowledge
|
||||
.keys()
|
||||
.any(|key| &key.knower == subject || &key.proposition == subject)
|
||||
|| state
|
||||
.place_profiles
|
||||
.values()
|
||||
.any(|profile| profile.container.as_ref() == Some(subject))
|
||||
|| state
|
||||
.propositions
|
||||
.values()
|
||||
.any(|proposition| proposition.discoverable_at_places.contains(subject))
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"entity retirement would leave active component references"
|
||||
|
|
@ -2418,6 +2543,8 @@ fn retire_entity(state: &mut ComponentWorldState, subject: &SubjectRef) -> Resul
|
|||
.expect("active subject was resolved");
|
||||
record.lifecycle = LifecycleStatus::Retired;
|
||||
record.version = version;
|
||||
state.place_profiles.remove(subject);
|
||||
state.propositions.remove(subject);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -2530,12 +2657,93 @@ pub fn validate_component_world(state: &ComponentWorldState) -> Result<()> {
|
|||
active_subject(state, observer)?;
|
||||
}
|
||||
}
|
||||
for (place, profile) in &state.place_profiles {
|
||||
let subject = active_subject(state, place)?;
|
||||
require_kind(place, SubjectKind::Place)?;
|
||||
if &profile.place != place
|
||||
|| profile.name.trim().is_empty()
|
||||
|| !subject
|
||||
.admitted_components
|
||||
.contains(&WorldComponentKind::PlaceProfile)
|
||||
|| profile
|
||||
.persistent_features
|
||||
.iter()
|
||||
.any(|feature| feature.trim().is_empty())
|
||||
{
|
||||
return Err(anyhow!("place profile is malformed"));
|
||||
}
|
||||
if let Some(container) = &profile.container {
|
||||
active_subject(state, container)?;
|
||||
require_kind(container, SubjectKind::Place)?;
|
||||
if container == place {
|
||||
return Err(anyhow!("place cannot contain itself"));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (subject, record) in &state.subjects {
|
||||
if matches!(
|
||||
record.lifecycle,
|
||||
LifecycleStatus::Admitted | LifecycleStatus::Active
|
||||
) && subject.kind == SubjectKind::Place
|
||||
&& !state.place_profiles.contains_key(subject)
|
||||
{
|
||||
return Err(anyhow!("active place lacks its canonical profile"));
|
||||
}
|
||||
}
|
||||
for start in state.place_profiles.keys() {
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut cursor = Some(start);
|
||||
while let Some(place) = cursor {
|
||||
if !seen.insert(place.clone()) {
|
||||
return Err(anyhow!("place containment contains a cycle"));
|
||||
}
|
||||
cursor = state
|
||||
.place_profiles
|
||||
.get(place)
|
||||
.and_then(|profile| profile.container.as_ref());
|
||||
}
|
||||
}
|
||||
let mut proposition_statements = BTreeSet::new();
|
||||
for (proposition, value) in &state.propositions {
|
||||
let subject = active_subject(state, proposition)?;
|
||||
require_kind(proposition, SubjectKind::Proposition)?;
|
||||
if &value.proposition != proposition
|
||||
|| value.statement.trim().is_empty()
|
||||
|| !proposition_statements.insert(value.statement.clone())
|
||||
|| !subject
|
||||
.admitted_components
|
||||
.contains(&WorldComponentKind::PropositionContent)
|
||||
|| value
|
||||
.evidence_receipt_ids
|
||||
.iter()
|
||||
.any(|receipt_id| receipt_id.trim().is_empty())
|
||||
{
|
||||
return Err(anyhow!("proposition content is malformed"));
|
||||
}
|
||||
for place in &value.discoverable_at_places {
|
||||
active_subject(state, place)?;
|
||||
require_kind(place, SubjectKind::Place)?;
|
||||
}
|
||||
}
|
||||
for (subject, record) in &state.subjects {
|
||||
if matches!(
|
||||
record.lifecycle,
|
||||
LifecycleStatus::Admitted | LifecycleStatus::Active
|
||||
) && subject.kind == SubjectKind::Proposition
|
||||
&& !state.propositions.contains_key(subject)
|
||||
{
|
||||
return Err(anyhow!("active proposition lacks canonical content"));
|
||||
}
|
||||
}
|
||||
for edge in state.topology.values() {
|
||||
active_subject(state, &edge.from_place)?;
|
||||
active_subject(state, &edge.to_place)?;
|
||||
require_kind(&edge.from_place, SubjectKind::Place)?;
|
||||
require_kind(&edge.to_place, SubjectKind::Place)?;
|
||||
if edge.travel_minutes <= 0 || edge.from_place == edge.to_place {
|
||||
if edge.distance.trim().is_empty()
|
||||
|| edge.travel_minutes <= 0
|
||||
|| edge.from_place == edge.to_place
|
||||
{
|
||||
return Err(anyhow!("topology edge is malformed"));
|
||||
}
|
||||
}
|
||||
|
|
@ -2646,14 +2854,24 @@ fn mutation_component_refs(
|
|||
AdmitEntity {
|
||||
subject,
|
||||
initial_place,
|
||||
initial_profile,
|
||||
..
|
||||
} => std::iter::once((subject.clone(), Component::Lifecycle, None))
|
||||
.chain(
|
||||
initial_place
|
||||
.iter()
|
||||
.map(|_| (subject.clone(), Component::Occupancy, None)),
|
||||
)
|
||||
.collect(),
|
||||
} => {
|
||||
let mut refs = vec![(subject.clone(), Component::Lifecycle, None)];
|
||||
if initial_place.is_some() {
|
||||
refs.push((subject.clone(), Component::Occupancy, None));
|
||||
}
|
||||
match initial_profile {
|
||||
Some(AdmittedEntityProfile::Place { .. }) => {
|
||||
refs.push((subject.clone(), Component::PlaceProfile, None));
|
||||
}
|
||||
Some(AdmittedEntityProfile::Proposition { .. }) => {
|
||||
refs.push((subject.clone(), Component::PropositionContent, None));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
refs
|
||||
}
|
||||
RetireEntity { subject, .. } => vec![(subject.clone(), Component::Lifecycle, None)],
|
||||
AdvanceWorldTime { campaign, .. } => vec![(campaign.clone(), Component::WorldTime, None)],
|
||||
}
|
||||
|
|
@ -2746,6 +2964,20 @@ fn permit_schema_branch(
|
|||
permit: &MutationPermit,
|
||||
variants: &[serde_json::Value],
|
||||
) -> Result<serde_json::Value> {
|
||||
if let Some(exact) = &permit.exact_mutation {
|
||||
if exact.operation() != permit.operation {
|
||||
return Err(anyhow!("exact mutation permit operation is inconsistent"));
|
||||
}
|
||||
return Ok(serde_json::json!({
|
||||
"type":"object",
|
||||
"additionalProperties":false,
|
||||
"required":["permit_id","mutation"],
|
||||
"properties":{
|
||||
"permit_id":{"const":permit.id},
|
||||
"mutation":{"const":exact}
|
||||
}
|
||||
}));
|
||||
}
|
||||
let (mutation_type, operation) = mutation_schema_tags(permit.operation);
|
||||
let mut mutation = variants
|
||||
.iter()
|
||||
|
|
@ -3044,9 +3276,14 @@ fn string_schema_fields(
|
|||
(IdentityHandleId, "handle_id", Required),
|
||||
(IdentityHandleValue, "handle_value", Optional),
|
||||
],
|
||||
TopologyAdd | TopologyAlter | TopologyOpen | TopologyClose | TopologyRetire => {
|
||||
vec![(TopologyEdgeId, "edge_id", Required)]
|
||||
}
|
||||
TopologyAdd | TopologyAlter => vec![
|
||||
(TopologyEdgeId, "edge_id", Required),
|
||||
(Distance, "distance", Required),
|
||||
],
|
||||
TopologyOpen | TopologyClose | TopologyRetire => vec![
|
||||
(TopologyEdgeId, "edge_id", Required),
|
||||
(Distance, "distance", Optional),
|
||||
],
|
||||
AdmitEntity => vec![(AdmissionReceiptId, "admission_receipt_id", Required)],
|
||||
RetireEntity => vec![(RetirementReason, "reason", Required)],
|
||||
TransferCustody | KnowledgeAcquire | KnowledgeCommunicate | KnowledgeConceal
|
||||
|
|
@ -3162,11 +3399,23 @@ fn mutation_schema_tags(operation: WorldMutationOperation) -> (&'static str, Opt
|
|||
}
|
||||
|
||||
fn validate_permitted_mutation(permit: &MutationPermit, mutation: &WorldMutation) -> Result<()> {
|
||||
if permit.operation == WorldMutationOperation::AdmitEntity && permit.exact_mutation.is_none() {
|
||||
return Err(anyhow!(
|
||||
"entity admission requires an exact typed mutation permit"
|
||||
));
|
||||
}
|
||||
if mutation.operation() != permit.operation {
|
||||
return Err(anyhow!(
|
||||
"world mutation operation does not match its permit"
|
||||
));
|
||||
}
|
||||
if let Some(exact) = &permit.exact_mutation
|
||||
&& exact != mutation
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"world mutation differs from the exact semantic mutation admitted by its permit"
|
||||
));
|
||||
}
|
||||
let roles = mutation.subject_roles();
|
||||
for binding in &permit.subject_bindings {
|
||||
let actual = roles.get(&binding.role).cloned().unwrap_or_default();
|
||||
|
|
@ -3560,8 +3809,13 @@ impl WorldMutation {
|
|||
values.push((MutationStringRole::IdentityHandleValue, value.clone()));
|
||||
}
|
||||
}
|
||||
ChangeTopology { edge_id, .. } => {
|
||||
values.push((MutationStringRole::TopologyEdgeId, edge_id.clone()))
|
||||
ChangeTopology {
|
||||
edge_id, distance, ..
|
||||
} => {
|
||||
values.push((MutationStringRole::TopologyEdgeId, edge_id.clone()));
|
||||
if let Some(value) = distance {
|
||||
values.push((MutationStringRole::Distance, value.clone()));
|
||||
}
|
||||
}
|
||||
AdmitEntity {
|
||||
admission_receipt_id,
|
||||
|
|
@ -3823,6 +4077,7 @@ impl WorldMutation {
|
|||
ChangeTopology {
|
||||
operation,
|
||||
edge_id,
|
||||
distance,
|
||||
travel_minutes,
|
||||
..
|
||||
} => {
|
||||
|
|
@ -3830,18 +4085,31 @@ impl WorldMutation {
|
|||
if matches!(
|
||||
operation,
|
||||
TopologyMutationOperation::Add | TopologyMutationOperation::Alter
|
||||
) && travel_minutes.is_none()
|
||||
) && (travel_minutes.is_none()
|
||||
|| distance
|
||||
.as_deref()
|
||||
.is_none_or(|value| value.trim().is_empty()))
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"topology creation or alteration requires travel time"
|
||||
"topology creation or alteration requires distance and travel time"
|
||||
));
|
||||
}
|
||||
if !matches!(
|
||||
operation,
|
||||
TopologyMutationOperation::Add | TopologyMutationOperation::Alter
|
||||
) && distance.is_some()
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"topology open, close, or retire cannot rewrite distance"
|
||||
));
|
||||
}
|
||||
}
|
||||
AdmitEntity {
|
||||
subject,
|
||||
initial_components,
|
||||
initial_place,
|
||||
initial_profile,
|
||||
admission_receipt_id,
|
||||
..
|
||||
} => {
|
||||
if initial_components.is_empty() {
|
||||
return Err(anyhow!("entity admission requires initial components"));
|
||||
|
|
@ -3856,6 +4124,88 @@ impl WorldMutation {
|
|||
if let Some(place) = initial_place {
|
||||
require_kind(place, SubjectKind::Place)?;
|
||||
}
|
||||
match (subject.kind.clone(), initial_profile) {
|
||||
(
|
||||
SubjectKind::Place,
|
||||
Some(AdmittedEntityProfile::Place {
|
||||
name,
|
||||
container,
|
||||
persistent_features,
|
||||
}),
|
||||
) => {
|
||||
if !initial_components.contains(&WorldComponentKind::PlaceProfile)
|
||||
|| !initial_components.contains(&WorldComponentKind::Topology)
|
||||
|| initial_place.is_some()
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"place admission requires place-profile and topology components without occupancy"
|
||||
));
|
||||
}
|
||||
nonempty("place name", name)?;
|
||||
if let Some(container) = container {
|
||||
require_kind(container, SubjectKind::Place)?;
|
||||
nonempty("place container id", &container.id)?;
|
||||
if container == subject {
|
||||
return Err(anyhow!("place cannot contain itself"));
|
||||
}
|
||||
}
|
||||
if persistent_features
|
||||
.iter()
|
||||
.any(|feature| feature.trim().is_empty())
|
||||
{
|
||||
return Err(anyhow!("place profile contains an empty feature"));
|
||||
}
|
||||
}
|
||||
(
|
||||
SubjectKind::Proposition,
|
||||
Some(AdmittedEntityProfile::Proposition {
|
||||
statement,
|
||||
evidence_receipt_ids,
|
||||
discoverable_at_places,
|
||||
..
|
||||
}),
|
||||
) => {
|
||||
if !initial_components.contains(&WorldComponentKind::PropositionContent)
|
||||
|| !initial_components.contains(&WorldComponentKind::Knowledge)
|
||||
|| initial_place.is_some()
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"proposition admission requires proposition-content and knowledge components without occupancy"
|
||||
));
|
||||
}
|
||||
nonempty("proposition statement", statement)?;
|
||||
for receipt_id in evidence_receipt_ids {
|
||||
nonempty("proposition evidence receipt id", receipt_id)?;
|
||||
}
|
||||
for place in discoverable_at_places {
|
||||
require_kind(place, SubjectKind::Place)?;
|
||||
nonempty("proposition discovery place id", &place.id)?;
|
||||
}
|
||||
}
|
||||
(SubjectKind::Place, _) => {
|
||||
return Err(anyhow!("place admission requires an exact place profile"));
|
||||
}
|
||||
(SubjectKind::Proposition, _) => {
|
||||
return Err(anyhow!(
|
||||
"proposition admission requires exact proposition content"
|
||||
));
|
||||
}
|
||||
(_, Some(_)) => {
|
||||
return Err(anyhow!(
|
||||
"entity admission profile does not match the subject kind"
|
||||
));
|
||||
}
|
||||
(_, None)
|
||||
if initial_components.contains(&WorldComponentKind::PlaceProfile)
|
||||
|| initial_components
|
||||
.contains(&WorldComponentKind::PropositionContent) =>
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"non-place/proposition admission cannot claim definition components"
|
||||
));
|
||||
}
|
||||
(_, None) => {}
|
||||
}
|
||||
nonempty("admission receipt id", admission_receipt_id)?;
|
||||
}
|
||||
RetireEntity { reason, .. } => nonempty("retirement reason", reason)?,
|
||||
|
|
@ -3918,6 +4268,7 @@ pub fn exact_mutation_permit(
|
|||
subject_bindings,
|
||||
string_constraints,
|
||||
integer_bounds,
|
||||
exact_mutation: Some(mutation.clone()),
|
||||
maximum_uses: 1,
|
||||
})
|
||||
}
|
||||
|
|
@ -3968,6 +4319,7 @@ mod tests {
|
|||
exact(&["route:camp-east"]),
|
||||
)]),
|
||||
integer_bounds: BTreeMap::new(),
|
||||
exact_mutation: None,
|
||||
maximum_uses: 1,
|
||||
}
|
||||
}
|
||||
|
|
@ -4059,6 +4411,36 @@ mod tests {
|
|||
assert!(validate_permitted_mutation(&permit, &rewritten).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_admission_permit_binds_the_complete_place_profile() {
|
||||
let mutation = WorldMutation::AdmitEntity {
|
||||
subject: subject(SubjectKind::Place, "place:annex"),
|
||||
initial_components: BTreeSet::from([
|
||||
WorldComponentKind::PlaceProfile,
|
||||
WorldComponentKind::Topology,
|
||||
]),
|
||||
initial_place: None,
|
||||
initial_profile: Some(AdmittedEntityProfile::Place {
|
||||
name: "Annex".into(),
|
||||
container: Some(subject(SubjectKind::Place, "place:camp")),
|
||||
persistent_features: BTreeSet::from(["sealed gate".into()]),
|
||||
}),
|
||||
admission_receipt_id: "region-expansion:test".into(),
|
||||
};
|
||||
let permit = exact_mutation_permit("permit:annex", &mutation).unwrap();
|
||||
validate_permitted_mutation(&permit, &mutation).unwrap();
|
||||
let mut rewritten = mutation;
|
||||
let WorldMutation::AdmitEntity {
|
||||
initial_profile: Some(AdmittedEntityProfile::Place { name, .. }),
|
||||
..
|
||||
} = &mut rewritten
|
||||
else {
|
||||
unreachable!();
|
||||
};
|
||||
*name = "Conveniently Adjacent Dream Annex".into();
|
||||
assert!(validate_permitted_mutation(&permit, &rewritten).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_or_partially_invalid_batch_has_no_structural_admission() {
|
||||
let envelope = envelope();
|
||||
|
|
@ -4139,6 +4521,7 @@ mod tests {
|
|||
(MutationStringRole::IdentityHandleValue, exact(&["Sable"])),
|
||||
]),
|
||||
integer_bounds: BTreeMap::new(),
|
||||
exact_mutation: None,
|
||||
maximum_uses: 1,
|
||||
};
|
||||
let mutation = WorldMutation::ChangeIdentity {
|
||||
|
|
@ -4182,13 +4565,24 @@ mod tests {
|
|||
medicine.clone(),
|
||||
proposition.clone(),
|
||||
] {
|
||||
let admitted_components = match &value.kind {
|
||||
SubjectKind::Place => BTreeSet::from([
|
||||
WorldComponentKind::PlaceProfile,
|
||||
WorldComponentKind::Topology,
|
||||
]),
|
||||
SubjectKind::Proposition => BTreeSet::from([
|
||||
WorldComponentKind::Knowledge,
|
||||
WorldComponentKind::PropositionContent,
|
||||
]),
|
||||
_ => BTreeSet::new(),
|
||||
};
|
||||
subjects.insert(
|
||||
value.clone(),
|
||||
TypedSubject {
|
||||
schema: "ghostlight.typed_subject.v1".into(),
|
||||
subject: value,
|
||||
lifecycle: LifecycleStatus::Active,
|
||||
admitted_components: BTreeSet::new(),
|
||||
admitted_components,
|
||||
version: 4,
|
||||
},
|
||||
);
|
||||
|
|
@ -4227,7 +4621,7 @@ mod tests {
|
|||
knowledge: BTreeMap::from([(
|
||||
KnowledgeKey {
|
||||
knower: sable,
|
||||
proposition,
|
||||
proposition: proposition.clone(),
|
||||
},
|
||||
KnowledgeComponentState {
|
||||
status: "known".into(),
|
||||
|
|
@ -4251,12 +4645,46 @@ mod tests {
|
|||
)]),
|
||||
population_lineages: BTreeMap::new(),
|
||||
identities: BTreeMap::new(),
|
||||
place_profiles: BTreeMap::from([
|
||||
(
|
||||
camp.clone(),
|
||||
PlaceComponentState {
|
||||
place: camp.clone(),
|
||||
name: "Camp".into(),
|
||||
container: None,
|
||||
persistent_features: BTreeSet::new(),
|
||||
version: 4,
|
||||
},
|
||||
),
|
||||
(
|
||||
subject(SubjectKind::Place, "place:trail"),
|
||||
PlaceComponentState {
|
||||
place: subject(SubjectKind::Place, "place:trail"),
|
||||
name: "Trail".into(),
|
||||
container: None,
|
||||
persistent_features: BTreeSet::new(),
|
||||
version: 4,
|
||||
},
|
||||
),
|
||||
]),
|
||||
propositions: BTreeMap::from([(
|
||||
proposition.clone(),
|
||||
PropositionComponentState {
|
||||
proposition,
|
||||
statement: "The eastern trail exists.".into(),
|
||||
scope: FactScope::CanonBaseline,
|
||||
evidence_receipt_ids: BTreeSet::new(),
|
||||
discoverable_at_places: BTreeSet::from([camp.clone()]),
|
||||
version: 4,
|
||||
},
|
||||
)]),
|
||||
topology: BTreeMap::from([(
|
||||
"route:camp-trail".into(),
|
||||
TopologyComponentState {
|
||||
id: "route:camp-trail".into(),
|
||||
from_place: camp,
|
||||
to_place: subject(SubjectKind::Place, "place:trail"),
|
||||
distance: "near".into(),
|
||||
travel_minutes: 30,
|
||||
open: true,
|
||||
version: 4,
|
||||
|
|
@ -4284,6 +4712,7 @@ mod tests {
|
|||
.collect(),
|
||||
string_constraints: strings.into_iter().collect(),
|
||||
integer_bounds: integers.into_iter().collect(),
|
||||
exact_mutation: None,
|
||||
maximum_uses: 1,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue