Separate agency knowledge from news channels

This commit is contained in:
Meta Vens 2026-08-23 06:47:26 +02:00
commit a830fe6699
3 changed files with 115 additions and 16 deletions

View file

@ -1,8 +1,7 @@
use anyhow::{Context, Result};
use ghostlight_dungeon::{
domain::{
Campaign, CellAppraisal, ResolutionCover, StrategicActivityOutcome,
StrategicTickReceipt,
Campaign, CellAppraisal, ResolutionCover, StrategicActivityOutcome, StrategicTickReceipt,
},
model::ModelStageReceipt,
persistence::CampaignStore,
@ -34,9 +33,7 @@ fn main() -> Result<()> {
(Some(revision), Some(epoch)) => store
.load_all::<ResolutionCover>("resolution_cover.v1")?
.into_iter()
.find(|cover| {
cover.world_revision == revision && cover.resolution_epoch == epoch
}),
.find(|cover| cover.world_revision == revision && cover.resolution_epoch == epoch),
_ => None,
};
let appraisals = match (snapshot_revision, resolution_epoch) {
@ -55,10 +52,8 @@ fn main() -> Result<()> {
let mut outcomes = Vec::new();
for key in store.keys("strategic_activity_outcome.v1")? {
if key.starts_with(&prefix)
&& let Some((_, outcome)) = store.load::<StrategicActivityOutcome>(
"strategic_activity_outcome.v1",
&key,
)?
&& let Some((_, outcome)) = store
.load::<StrategicActivityOutcome>("strategic_activity_outcome.v1", &key)?
{
outcomes.push(outcome);
}
@ -99,7 +94,10 @@ fn main() -> Result<()> {
let mut subjects = BTreeMap::new();
for actor in campaign.actors.values() {
subjects.insert(actor.id.clone(), json!({ "kind": "actor", "name": actor.name }));
subjects.insert(
actor.id.clone(),
json!({ "kind": "actor", "name": actor.name }),
);
}
for institution in campaign.institutions.values() {
subjects.insert(

View file

@ -598,7 +598,7 @@ impl WorldCompiler {
.map(|brief| brief.subject_id.clone())
.collect::<BTreeSet<_>>();
let agency_prompt = format!(
"MULTIRESOLUTION AGENCY SKELETON\nCompile only this exact, already validated subject roster:\n{}\n\nReturn exactly one agency profile for every supplied subject and no other subject. Copy every subject_id, subject_kind, and location_ids exactly. Every profile must contain exactly the six facet axes geography, ideology, authority, economy_role, species_body, and information. Derive facets only from the supplied roster fields; use an explicit unknown value when they do not support a sharper claim. collective_authority_id must be null or one supplied subject ID; it denotes real shared authority, never mere alliance or proximity. Relations may use only supplied subject IDs and strength must be an integer from 1 through 100. Cross-faction relations never imply shared speech, knowledge, or authority. Preserve geographic, ideological, institutional, economic, biological, and information boundaries that predict different behavior under pressure.",
"MULTIRESOLUTION AGENCY SKELETON\nCompile only this exact, already validated subject roster:\n{}\n\nReturn exactly one agency profile for every supplied subject and no other subject. Copy every subject_id, subject_kind, and location_ids exactly. Every profile must contain exactly the six facet axes geography, ideology, authority, economy_role, species_body, and information. Derive facets only from the supplied roster fields; use an explicit unknown value when they do not support a sharper claim. information_channels are routes through which the subject can publish or receive reports (for example a courier wire, bulletin, newspaper, radio net, or word of mouth), never facts the subject knows; do not copy knowledge_or_posture values into information_channels. collective_authority_id must be null or one supplied subject ID; it denotes real shared authority, never mere alliance or proximity. Relations may use only supplied subject IDs and strength must be an integer from 1 through 100. Cross-faction relations never imply shared speech, knowledge, or authority. Preserve geographic, ideological, institutional, economic, biological, and information boundaries that predict different behavior under pressure.",
serde_json::to_string(&subject_briefs)?
);
let agency_schema = serde_json::to_value(schema_for!(CompiledAgencySkeleton))?;
@ -3016,16 +3016,42 @@ fn apply_compiled_agency_skeleton(
.filter(|id| !campaign.locations.contains_key(*id))
.cloned()
.collect::<Vec<_>>();
let knowledge_channel_overlap = match input.subject_kind {
AgencySubjectKind::Actor => campaign
.actors
.get(&input.subject_id)
.map(|actor| {
input
.information_channels
.intersection(&actor.knowledge)
.cloned()
.collect::<Vec<_>>()
})
.unwrap_or_default(),
AgencySubjectKind::Gestalt => campaign
.gestalts
.get(&input.subject_id)
.map(|gestalt| {
input
.information_channels
.intersection(&gestalt.shared_knowledge)
.cloned()
.collect::<Vec<_>>()
})
.unwrap_or_default(),
AgencySubjectKind::Institution => Vec::new(),
};
if profile.subject_kind != input.subject_kind
|| input_axes != axes
|| input.location_ids != profile.location_ids
|| !unknown_locations.is_empty()
|| !authority_known
|| !knowledge_channel_overlap.is_empty()
{
let missing_axes = axes.difference(&input_axes).cloned().collect::<Vec<_>>();
let unexpected_axes = input_axes.difference(&axes).cloned().collect::<Vec<_>>();
return Err(anyhow!(
"agency profile {} malformed: expected_kind={:?}; supplied_kind={:?}; expected_location_ids={:?}; supplied_location_ids={:?}; missing_axes={missing_axes:?}; unexpected_axes={unexpected_axes:?}; unknown_locations={unknown_locations:?}; unknown_collective_authority={:?}",
"agency profile {} malformed: expected_kind={:?}; supplied_kind={:?}; expected_location_ids={:?}; supplied_location_ids={:?}; missing_axes={missing_axes:?}; unexpected_axes={unexpected_axes:?}; unknown_locations={unknown_locations:?}; unknown_collective_authority={:?}; knowledge_channel_overlap={knowledge_channel_overlap:?}",
input.subject_id,
profile.subject_kind,
input.subject_kind,
@ -3244,6 +3270,43 @@ fn validate_opening_playability(campaign: &Campaign) -> Result<()> {
mod tests {
use super::*;
#[test]
fn agency_compiler_cannot_disguise_private_knowledge_as_a_channel() {
let mut campaign = crate::resolution::tests::campaign(0, 1);
campaign
.actors
.get_mut("player")
.unwrap()
.knowledge
.insert("convoy vulnerabilities".into());
let facets = BTreeMap::from([
(AgencyAxis::Geography, BTreeSet::from(["center".into()])),
(AgencyAxis::Ideology, BTreeSet::from(["unknown".into()])),
(AgencyAxis::Authority, BTreeSet::from(["unknown".into()])),
(AgencyAxis::EconomyRole, BTreeSet::from(["unknown".into()])),
(AgencyAxis::SpeciesBody, BTreeSet::from(["unknown".into()])),
(
AgencyAxis::Information,
BTreeSet::from(["convoy vulnerabilities".into()]),
),
]);
let error = apply_compiled_agency_skeleton(
&mut campaign,
&BTreeSet::from(["player".into()]),
vec![CompiledAgencyProfile {
subject_id: "player".into(),
subject_kind: AgencySubjectKind::Actor,
collective_authority_id: None,
facets,
location_ids: BTreeSet::from(["center".into()]),
information_channels: BTreeSet::from(["convoy vulnerabilities".into()]),
}],
vec![],
)
.unwrap_err();
assert!(error.to_string().contains("knowledge_channel_overlap"));
}
#[test]
fn user_text_admission_rejects_empty_oversized_and_binary_control_input() {
assert!(validate_user_text("field", "", 8).is_err());
@ -3661,7 +3724,7 @@ mod tests {
}).to_string()
}
"agency_compile" => serde_json::json!({
"agency_profiles":[{"subject_id":"yard-workers","subject_kind":"gestalt","collective_authority_id":"yard-workers","facets":{"geography":["yard"],"ideology":["mutual aid"],"authority":["yard-workers"],"economy_role":["maintenance"],"species_body":["human"],"information":["yard routines"]},"location_ids":["yard"],"information_channels":["yard routines"]}],
"agency_profiles":[{"subject_id":"yard-workers","subject_kind":"gestalt","collective_authority_id":"yard-workers","facets":{"geography":["yard"],"ideology":["mutual aid"],"authority":["yard-workers"],"economy_role":["maintenance"],"species_body":["human"],"information":["yard routines"]},"location_ids":["yard"],"information_channels":["yard bulletin"]}],
"agency_relations":[]
}).to_string(),
_ => return Err(anyhow!("unexpected stage")),

View file

@ -84,7 +84,14 @@ pub fn ensure_agency_profiles(campaign: &mut Campaign) {
profile.location_ids = BTreeSet::from([actor.location_id.clone()]);
profile
.information_channels
.extend(actor.knowledge.iter().cloned());
.retain(|channel| !actor.knowledge.contains(channel));
}
}
for gestalt in campaign.gestalts.values() {
if let Some(profile) = campaign.agency_profiles.get_mut(&gestalt.id) {
profile
.information_channels
.retain(|channel| !gestalt.shared_knowledge.contains(channel));
}
}
let live: BTreeSet<_> = campaign
@ -143,7 +150,6 @@ fn profile_for_actor(actor: &ActorState, evidence: &[String]) -> AgencyProfile {
AgencyAxis::Information,
actor.knowledge.iter().cloned().collect(),
);
profile.information_channels = actor.knowledge.iter().cloned().collect();
profile
}
@ -185,7 +191,6 @@ fn profile_for_gestalt(value: &GestaltPersonaState, evidence: &[String]) -> Agen
AgencyAxis::Information,
value.shared_knowledge.iter().cloned().collect(),
);
profile.information_channels = value.shared_knowledge.iter().cloned().collect();
profile
}
@ -2922,6 +2927,39 @@ pub(crate) mod tests {
value
}
#[test]
fn profile_maintenance_never_promotes_knowledge_into_public_channels() {
let mut value = campaign(1, 1);
value
.actors
.get_mut("player")
.unwrap()
.knowledge
.insert("private convoy vulnerability".into());
value.agency_profiles.remove("player");
ensure_agency_profiles(&mut value);
assert!(
value.agency_profiles["player"]
.information_channels
.is_empty()
);
value
.agency_profiles
.get_mut("player")
.unwrap()
.information_channels = BTreeSet::from([
"private convoy vulnerability".into(),
"licensed courier wire".into(),
]);
ensure_agency_profiles(&mut value);
assert_eq!(
value.agency_profiles["player"].information_channels,
BTreeSet::from(["licensed courier wire".into()])
);
}
#[test]
fn aetheria_scale_cover_is_complete_unique_and_budgeted() {
for budget in [1, 4, 8, 32] {