From 2a8cec6b0ad9537232e1978c19912fa89ebddf08 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 5 Sep 2026 00:07:49 +0200 Subject: [PATCH] Falsify the clock, commitments, pressure, boundaries, and scale Twenty-two tests over the pass-7 surface: the tick's three stages and its re-derivation at apply and at replay, the clock capability's single admitted body, every commitment shape check as one complete sorted set, the attention order's totality and its refusal to filter, boundary derivation and the answer-must-be-satisfied rule, and the scale deficit that only admitted subjects reduce. Two defects the tests found and this commit fixes: PressureSource mixed struct and newtype variants under an internal tag, which cannot serialize a scalar newtype, so it is adjacently tagged; and Motion carried an is_empty no caller outside a test used. Co-Authored-By: Claude Opus 5 --- crates/ghostlight-dungeon/src/world/action.rs | 14 +- crates/ghostlight-dungeon/src/world/clock.rs | 6 - .../src/world/controllers.rs | 17 +- .../ghostlight-dungeon/src/world/journal.rs | 74 + crates/ghostlight-dungeon/src/world/mod.rs | 1688 +++++++++++++++++ crates/ghostlight-dungeon/src/world/patch.rs | 4 +- 6 files changed, 1783 insertions(+), 20 deletions(-) diff --git a/crates/ghostlight-dungeon/src/world/action.rs b/crates/ghostlight-dungeon/src/world/action.rs index 869ffbd..3d1335d 100644 --- a/crates/ghostlight-dungeon/src/world/action.rs +++ b/crates/ghostlight-dungeon/src/world/action.rs @@ -7,8 +7,8 @@ //! draws a band, and the draw goes through the one `digest()` owner. use super::patch::{ - self, Affordance, Audience, AudienceSpec, BoundPrecondition, Bounds, CommitmentKind, - ComponentOpKind, Cost, Precondition, Quantity, RefKind, Role, WorldPatch, + self, Affordance, Audience, AudienceSpec, BoundPrecondition, Bounds, ComponentOpKind, Cost, + Precondition, Quantity, RefKind, Role, WorldPatch, }; use super::{ AffordanceId, AuthorityGrant, AuthorityTarget, CommandId, DecisionEvent, DecisionInvocation, @@ -2682,7 +2682,15 @@ mod tests { }], speech: None, }; - let lowered = lower(&entry, 0, &invocation, &bindings).expect("the acquire slot lowers"); + let lowered = lower( + &entry, + 0, + bench.clerk, + FictionalMinutes::default(), + &invocation, + &bindings, + ) + .expect("the acquire slot lowers"); assert_eq!( lowered, vec![ComponentOp::AcquireKnowledge { diff --git a/crates/ghostlight-dungeon/src/world/clock.rs b/crates/ghostlight-dungeon/src/world/clock.rs index af9d9e0..6f8e023 100644 --- a/crates/ghostlight-dungeon/src/world/clock.rs +++ b/crates/ghostlight-dungeon/src/world/clock.rs @@ -65,12 +65,6 @@ pub(crate) struct Motion { pub(crate) pressed: Vec, } -impl Motion { - pub(crate) fn is_empty(&self) -> bool { - self.fulfilled.is_empty() && self.pressed.is_empty() - } -} - #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub(crate) struct RoutineFulfilled { diff --git a/crates/ghostlight-dungeon/src/world/controllers.rs b/crates/ghostlight-dungeon/src/world/controllers.rs index 8d7dfa1..54c3eaf 100644 --- a/crates/ghostlight-dungeon/src/world/controllers.rs +++ b/crates/ghostlight-dungeon/src/world/controllers.rs @@ -3844,13 +3844,12 @@ mod tests { assert!(decode_controller_work(&row).is_err()); } - /// The schema bump to `v5` refuses a prior store rather than migrating it - /// silently: `open` walks every row and demands both the row type and the - /// schema id match the current constants, so a `controller_work.v4` row - /// left over from before the bump is an open-time error, not a quietly - /// dropped or reinterpreted checkpoint. + /// The schema bump refuses a prior store rather than migrating it silently: + /// `open` walks every row and demands both the row type and the schema id + /// match the current constants, so a row left over from before the bump is + /// an open-time error, not a quietly dropped or reinterpreted checkpoint. #[test] - fn a_controller_work_v4_row_is_refused_at_open() { + fn a_controller_work_row_from_a_prior_schema_is_refused_at_open() { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("controller-work.cc"); let command_id = CommandId::new(); @@ -3866,15 +3865,15 @@ mod tests { let mut store = OwnedRedbMessagePackBackingStore::new(&path).unwrap(); let row = CultCacheEnvelope { key: store_key(command_id).unwrap(), - r#type: "controller_work.v4".into(), + r#type: "controller_work.v5".into(), payload: rmp_serde::to_vec_named(&work).unwrap(), stored_at: Utc::now().to_rfc3339(), - schema_id: Some("ghostlight.controller_work.v4".into()), + schema_id: Some("ghostlight.controller_work.v5".into()), }; store.push(&row).unwrap(); } let Err(error) = CultCacheControllerWorkStore::open(&path) else { - panic!("a v4 row was accepted by the v5 store"); + panic!("a v5 row was accepted by the v6 store"); }; assert!(matches!(error, ControllerWorkStoreError::Fault { .. })); } diff --git a/crates/ghostlight-dungeon/src/world/journal.rs b/crates/ghostlight-dungeon/src/world/journal.rs index 6f26a9d..76f1ea2 100644 --- a/crates/ghostlight-dungeon/src/world/journal.rs +++ b/crates/ghostlight-dungeon/src/world/journal.rs @@ -1177,6 +1177,80 @@ mod tests { )); } + /// A store written at the schema this pass replaced is refused outright. + /// There is no migration adapter, and the refusal is the behaviour. + #[test] + fn a_store_from_the_knowledge_schema_is_refused() { + let row = CultCacheEnvelope { + key: "state".into(), + r#type: "world_state.knowledge.v1".into(), + payload: Vec::new(), + stored_at: Utc::now().to_rfc3339(), + schema_id: Some("ghostlight.world_state.knowledge.v1".into()), + }; + let error = recover(vec![row], None).unwrap_err(); + let JournalError::Corrupt(message) = error else { + panic!("expected a corrupt store"); + }; + assert!( + message.contains("unadmitted row type"), + "unexpected refusal: {message}" + ); + } + + /// The tick is re-derived at replay by the same function that produced it, + /// so a committed motion rewritten after the fact no longer reduces from + /// its command. The clock chain refuses a rewritten reading the same way. + #[test] + fn a_forged_motion_does_not_replay() { + use crate::world::tests::{activate, auth_principal, command, owner}; + use crate::world::{ + AuthenticatedCaller, CallerId, CommandBody, FictionalMinutes, SystemCapability, + TickMinutes, + }; + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("world.cc"); + let authenticated = auth_principal(owner()); + let (mut kernel, _) = WorldKernel::create( + &path, + crate::world::tests::creation(CommandId::new(), "Chronicle"), + &authenticated, + ) + .unwrap(); + let active = activate(&mut kernel); + let command_id = CommandId::new(); + let caller = CallerId::System(SystemCapability::Clock); + kernel + .submit( + command( + &active, + command_id, + caller.clone(), + CommandBody::AdvanceTime { + minutes: TickMinutes::new(30).unwrap(), + }, + ), + &AuthenticatedCaller::fixture(caller), + ) + .unwrap(); + + let mut forged_head = kernel.state.clone(); + let mut forged_commits = kernel.journal.commits.clone(); + let forged = forged_commits.get_mut(&command_id).unwrap(); + let WorldEffect::TimeAdvanced { to, .. } = &mut forged.effect else { + panic!("expected a time-advanced effect"); + }; + *to = FictionalMinutes(31); + forged.digest = commit_digest(forged).unwrap(); + forged_head.last_commit_digest = Some(forged.digest.clone()); + + assert!(matches!( + verify_history(&forged_head, &forged_commits), + Err(JournalError::Corrupt(_)) + )); + } + /// Replay re-derives the operation half too: a committed relocation rewritten /// to move a different subject no longer reduces from its command. #[test] diff --git a/crates/ghostlight-dungeon/src/world/mod.rs b/crates/ghostlight-dungeon/src/world/mod.rs index 9b6fb78..4a44ec2 100644 --- a/crates/ghostlight-dungeon/src/world/mod.rs +++ b/crates/ghostlight-dungeon/src/world/mod.rs @@ -9583,3 +9583,1691 @@ mod soul_knowledge_tests { } } } + +/// The clock, commitments, pressure, ordered attention, boundaries, and scale. +#[cfg(test)] +mod clock_tests { + use super::patch::{PreconditionRef, PressureSourceRef, WorldScaleIntentRef}; + use super::tests::{ + activate, affordance_named, auth_principal, command, creation, operations, opportunity_for, + owner, player, reject_owner, submit_owner, + }; + use super::*; + use std::path::Path; + + /// The canonical IDs of the clock fixture: a hall containing a yard, a + /// dead end reached by exactly one route, three subjects, a resource, and + /// one commitment of each kind. + pub(super) struct Clockwork { + yard: EntityId, + dead_end: EntityId, + gate: EdgeId, + reeve: SubjectId, + farmer: SubjectId, + treasury: SubjectId, + routine: CommitmentKey, + obligation: CommitmentKey, + goal: CommitmentKey, + deliver: AffordanceId, + threaten: AffordanceId, + } + + const ROUTINE_DUE: u64 = 60; + const LATE_DUE: u64 = 100; + + fn minutes(value: u32) -> TickMinutes { + TickMinutes::new(value).expect("a valid span") + } + + fn clock_kernel(path: &Path, title: &str) -> (WorldKernel, Clockwork, WorldSnapshot) { + let mut kernel = WorldKernel::create( + path.join("world.cc"), + creation(CommandId::new(), title), + &auth_principal(owner()), + ) + .expect("a created world") + .0; + let (clockwork, active) = clock_world(&mut kernel); + (kernel, clockwork, active) + } + + fn clock_world(kernel: &mut WorldKernel) -> (Clockwork, WorldSnapshot) { + let before = kernel.snapshot().unwrap(); + let speak = super::tests::speak_entry(kernel); + let person = |handle: &str, label: &str, kind: SubjectKind, place: &str| { + Declaration::Subject(SubjectDeclaration { + handle: DraftHandle::new(handle), + label: label.into(), + kind, + controller: NewController::NarrativePersona, + affordances: BTreeSet::from([ + speak.clone(), + Ref::Draft(DraftHandle::new("deliver")), + Ref::Draft(DraftHandle::new("threaten")), + ]), + position: Some(Ref::Draft(DraftHandle::new(place))), + }) + }; + let subject = |handle: &str| Ref::Draft(DraftHandle::new(handle)); + submit_owner( + kernel, + &before, + CommandBody::AdmitPatch { + answers: None, + patch: WorldPatch { + evidence: vec![EvidenceRef::new("vault:harvest")], + declarations: vec![ + Declaration::Entity(EntityDeclaration { + handle: DraftHandle::new("hall"), + label: "The Long Hall".into(), + kind: EntityKind::Place, + container: None, + }), + Declaration::Entity(EntityDeclaration { + handle: DraftHandle::new("yard"), + label: "The Cavity Yard".into(), + kind: EntityKind::Place, + container: Some(Ref::Draft(DraftHandle::new("hall"))), + }), + // Nothing inside it, nobody in it, and one route out: + // the world has not grown into it yet. + Declaration::Entity(EntityDeclaration { + handle: DraftHandle::new("dead-end"), + label: "The Unwalked Road".into(), + kind: EntityKind::Place, + container: None, + }), + Declaration::Entity(EntityDeclaration { + handle: DraftHandle::new("grain"), + label: "Winter Grain".into(), + kind: EntityKind::Resource, + container: None, + }), + Declaration::Route(RouteDeclaration { + handle: DraftHandle::new("stair"), + label: "The Yard Stair".into(), + from: Ref::Draft(DraftHandle::new("yard")), + to: Ref::Draft(DraftHandle::new("hall")), + access: AccessKind::Public, + cost: Cost(1), + }), + Declaration::Route(RouteDeclaration { + handle: DraftHandle::new("back"), + label: "The Hall Steps".into(), + from: Ref::Draft(DraftHandle::new("hall")), + to: Ref::Draft(DraftHandle::new("yard")), + access: AccessKind::Public, + cost: Cost(1), + }), + Declaration::Route(RouteDeclaration { + handle: DraftHandle::new("gate"), + label: "The Field Gate".into(), + from: Ref::Draft(DraftHandle::new("hall")), + to: Ref::Draft(DraftHandle::new("dead-end")), + access: AccessKind::Public, + cost: Cost(1), + }), + // An affordance whose legitimacy is a promise rather + // than a jurisdiction, and one that presses directly. + Declaration::Affordance(AffordanceDeclaration { + handle: DraftHandle::new("deliver"), + kind: AffordanceKindName("deliver".into()), + roles: vec![RoleSpec { + role: Role("creditor".into()), + kind: RefKind::Subject(None), + }], + preconditions: vec![Precondition::Committed { + to: Role("creditor".into()), + kind: CommitmentKind::Obligation, + }], + effect_slots: vec![EffectSlot { + op_kind: ComponentOpKind::CreateCommitment { + kind: CommitmentKind::Obligation, + horizon: minutes(1440), + period: None, + }, + roles: vec![Role("actor".into()), Role("creditor".into())], + bounds: Bounds::None, + }], + outcome_bands: vec![OutcomeBand { + weight: 1, + effects: vec![0], + }], + carries_speech: false, + }), + Declaration::Affordance(AffordanceDeclaration { + handle: DraftHandle::new("threaten"), + kind: AffordanceKindName("threaten".into()), + roles: vec![RoleSpec { + role: Role("target".into()), + kind: RefKind::Subject(None), + }], + preconditions: Vec::new(), + effect_slots: vec![EffectSlot { + op_kind: ComponentOpKind::AdvancePressure { + by: PressureMagnitude(3), + }, + roles: vec![Role("target".into())], + bounds: Bounds::None, + }], + outcome_bands: vec![OutcomeBand { + weight: 1, + effects: vec![0], + }], + carries_speech: false, + }), + person("reeve", "The Yard Reeve", SubjectKind::Person, "yard"), + person("farmer", "The Yard Farmer", SubjectKind::Person, "yard"), + person( + "treasury", + "The Hall Treasury", + SubjectKind::Institution, + "hall", + ), + ], + operations: vec![ + ComponentOp::Admit { + holder: subject("farmer"), + resource: Ref::Draft(DraftHandle::new("grain")), + qty: Quantity(4), + evidence: EvidenceRef::new("vault:harvest"), + }, + // 0: the routine, whose check is where its subject stands. + ComponentOp::CreateCommitment { + subject: subject("farmer"), + counterparty: None, + kind: CommitmentKind::Routine, + due: FictionalMinutes(ROUTINE_DUE), + period: Some(minutes(10)), + checks: vec![PreconditionRef::Present { + at: Ref::Draft(DraftHandle::new("yard")), + }], + }, + ComponentOp::CreateCommitment { + subject: subject("farmer"), + counterparty: Some(subject("treasury")), + kind: CommitmentKind::Obligation, + due: FictionalMinutes(LATE_DUE), + period: None, + checks: Vec::new(), + }, + ComponentOp::CreateCommitment { + subject: subject("reeve"), + counterparty: None, + kind: CommitmentKind::Goal, + due: FictionalMinutes(LATE_DUE), + period: None, + checks: Vec::new(), + }, + ComponentOp::Bind { + subject: subject("reeve"), + target: DependencyRef::Route(Ref::Draft(DraftHandle::new("gate"))), + }, + ], + }, + }, + ); + let active = activate(kernel); + let place = |label: &str| { + active + .places + .iter() + .find(|place| place.label == label) + .expect("the declared place") + .id + }; + let who = |label: &str| { + active + .subjects + .iter() + .find(|subject| subject.label == label) + .expect("the declared subject") + .id + }; + let reeve = who("The Yard Reeve"); + let farmer = who("The Yard Farmer"); + let key_of = |subject: SubjectId, kind: CommitmentKind| { + *kernel + .state + .commitments + .get(&subject) + .expect("the subject holds commitments") + .iter() + .find(|(_, commitment)| commitment.kind == kind) + .expect("the declared commitment") + .0 + }; + let gate = active + .routes + .iter() + .find(|route| route.label == "The Field Gate") + .expect("the declared route") + .id; + ( + Clockwork { + yard: place("The Cavity Yard"), + dead_end: place("The Unwalked Road"), + gate, + reeve, + farmer, + treasury: who("The Hall Treasury"), + routine: key_of(farmer, CommitmentKind::Routine), + obligation: key_of(farmer, CommitmentKind::Obligation), + goal: key_of(reeve, CommitmentKind::Goal), + deliver: affordance_named(&active, "deliver"), + threaten: affordance_named(&active, "threaten"), + }, + active, + ) + } + + fn clock_caller() -> CallerId { + CallerId::System(SystemCapability::Clock) + } + + fn tick(kernel: &mut WorldKernel, span: u32) -> Result { + let snapshot = kernel.snapshot().unwrap(); + kernel.submit( + command( + &snapshot, + CommandId::new(), + clock_caller(), + CommandBody::AdvanceTime { + minutes: minutes(span), + }, + ), + &AuthenticatedCaller::fixture(clock_caller()), + ) + } + + fn exercise( + kernel: &mut WorldKernel, + actor: SubjectId, + affordance: AffordanceId, + bindings: Vec, + ) -> Result { + let snapshot = kernel.snapshot().unwrap(); + let opportunity = opportunity_for(&snapshot, actor); + let caller = CallerId::Controller(opportunity.controller_id); + kernel.submit( + command( + &snapshot, + CommandId::new(), + caller.clone(), + CommandBody::ExerciseDecision { + opportunity, + invocation: DecisionInvocation { + affordance, + bindings, + proposed: vec![ProposedEffect { + slot: 0, + magnitude: Magnitude::None, + }], + speech: None, + }, + }, + ), + &AuthenticatedCaller::fixture(caller), + ) + } + + fn binding(role: &str, target: Target) -> RoleBinding { + RoleBinding { + role: Role(role.into()), + target, + } + } + + fn pressure(kernel: &WorldKernel, target: SubjectId, source: PressureSource) -> u32 { + kernel + .state + .pressures + .get(&target) + .and_then(|held| held.get(&source)) + .map_or(0, |magnitude| magnitude.0) + } + + fn due(kernel: &WorldKernel, subject: SubjectId, key: CommitmentKey) -> FictionalMinutes { + kernel + .state + .commitments + .get(&subject) + .and_then(|held| held.get(&key)) + .expect("the live commitment") + .due + } + + /// Verification 12, both halves: a defaulted commitment advances pressure on + /// its subject, and that subject still derives exactly one opportunity. + #[test] + fn a_defaulted_commitment_advances_pressure_and_the_subject_derives_an_opportunity() { + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, clockwork, _) = clock_kernel(directory.path(), "Default"); + let sourced = PressureSource::Commitment { + subject: clockwork.farmer, + key: clockwork.obligation, + }; + + tick(&mut kernel, 120).expect("the tick commits"); + assert_eq!(pressure(&kernel, clockwork.farmer, sourced), 1); + assert_eq!( + kernel.state.pressures[&clockwork.farmer].len(), + 1, + "only the past-due obligation pressed the farmer" + ); + let snapshot = kernel.snapshot().unwrap(); + assert_eq!( + snapshot + .opportunities + .iter() + .filter(|opportunity| opportunity.scope.subject_id == clockwork.farmer) + .count(), + 1 + ); + + tick(&mut kernel, 1).expect("the second tick commits"); + assert_eq!(pressure(&kernel, clockwork.farmer, sourced), 2); + } + + /// Verification 19: a due routine fulfils with no inference and no pressure, + /// and a blocked one neither rolls nor presses. + #[test] + fn a_due_routine_auto_fulfils_and_a_blocked_one_does_not() { + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, clockwork, active) = clock_kernel(directory.path(), "Routine"); + let holdings = kernel.state.holdings.clone(); + let positions = kernel.state.positions.clone(); + let edges = kernel.state.edges.clone(); + + tick(&mut kernel, ROUTINE_DUE as u32).expect("the tick commits"); + assert_eq!( + due(&kernel, clockwork.farmer, clockwork.routine), + FictionalMinutes(ROUTINE_DUE + 10) + ); + assert!( + kernel.state.pressures.is_empty(), + "a routine raises nothing" + ); + assert_eq!(kernel.state.holdings, holdings); + assert_eq!(kernel.state.positions, positions); + assert_eq!(kernel.state.edges, edges); + + // Out of the yard: the check fails, so the routine neither rolls nor + // presses, and it retries on the next tick. + let route = |label: &str| { + active + .routes + .iter() + .find(|route| route.label == label) + .expect("the declared route") + .id + }; + let snapshot = kernel.snapshot().unwrap(); + submit_owner( + &mut kernel, + &snapshot, + operations(vec![ComponentOp::Relocate { + subject: Ref::Existing(clockwork.farmer), + via: Ref::Existing(route("The Yard Stair")), + }]), + ); + tick(&mut kernel, 10).expect("the tick commits"); + assert_eq!( + due(&kernel, clockwork.farmer, clockwork.routine), + FictionalMinutes(ROUTINE_DUE + 10) + ); + assert!( + kernel.state.pressures.is_empty(), + "a blocked routine neither rolls nor presses" + ); + + let snapshot = kernel.snapshot().unwrap(); + submit_owner( + &mut kernel, + &snapshot, + operations(vec![ComponentOp::Relocate { + subject: Ref::Existing(clockwork.farmer), + via: Ref::Existing(route("The Hall Steps")), + }]), + ); + tick(&mut kernel, 1).expect("the tick commits"); + assert_eq!( + due(&kernel, clockwork.farmer, clockwork.routine), + FictionalMinutes(ROUTINE_DUE + 20) + ); + } + + /// A tick with nothing due is still a commit: the clock moved, which is a + /// canonical change. + #[test] + fn a_tick_with_nothing_due_moves_only_the_clock() { + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, _, _) = clock_kernel(directory.path(), "Quiet"); + let before = kernel.state.clone(); + + tick(&mut kernel, 1).expect("the tick commits"); + assert_eq!(kernel.state.now, FictionalMinutes(1)); + assert_eq!(kernel.state.revision, before.revision + 1); + assert!(kernel.state.pressures.is_empty()); + assert_eq!(kernel.state.commitments, before.commitments); + assert_eq!(kernel.state.holdings, before.holdings); + assert_eq!(kernel.state.positions, before.positions); + assert_eq!(kernel.state.events, before.events); + + tick(&mut kernel, 2).expect("the tick commits"); + assert_eq!(kernel.state.now, FictionalMinutes(3)); + } + + /// A closed route in one place becomes political pressure in another without + /// anyone deciding that it should. The tick never reduces; only an operation + /// does. + #[test] + fn an_unavailable_dependency_presses_its_depender() { + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, clockwork, _) = clock_kernel(directory.path(), "Dependency"); + let sourced = PressureSource::Dependency(DependencyTarget::Route(clockwork.gate)); + + tick(&mut kernel, 1).expect("the tick commits"); + assert_eq!(pressure(&kernel, clockwork.reeve, sourced), 0); + + let snapshot = kernel.snapshot().unwrap(); + submit_owner( + &mut kernel, + &snapshot, + operations(vec![ComponentOp::CloseRoute { + route: Ref::Existing(clockwork.gate), + }]), + ); + tick(&mut kernel, 1).expect("the tick commits"); + assert_eq!(pressure(&kernel, clockwork.reeve, sourced), 1); + + // Repair stops the pressing but never unwrites what accrued. + let snapshot = kernel.snapshot().unwrap(); + submit_owner( + &mut kernel, + &snapshot, + operations(vec![ComponentOp::OpenRoute { + route: Ref::Existing(clockwork.gate), + }]), + ); + tick(&mut kernel, 1).expect("the tick commits"); + assert_eq!(pressure(&kernel, clockwork.reeve, sourced), 1); + + let snapshot = kernel.snapshot().unwrap(); + submit_owner( + &mut kernel, + &snapshot, + operations(vec![ComponentOp::ResolvePressure { + source: PressureSourceRef::Dependency(DependencyRef::Route(Ref::Existing( + clockwork.gate, + ))), + target: Ref::Existing(clockwork.reeve), + }]), + ); + assert!( + !kernel.state.pressures.contains_key(&clockwork.reeve), + "an emptied target removes its key rather than storing an empty map" + ); + } + + /// The clock moves through exactly one command body. + #[test] + fn time_advances_only_through_advance_time() { + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, clockwork, _) = clock_kernel(directory.path(), "OnlyTick"); + tick(&mut kernel, 5).expect("the tick commits"); + let now = kernel.state.now; + + exercise( + &mut kernel, + clockwork.reeve, + clockwork.threaten, + vec![binding("target", Target::Subject(clockwork.farmer))], + ) + .expect("the threat commits"); + assert_eq!(kernel.state.now, now); + + let snapshot = kernel.snapshot().unwrap(); + let opportunity = opportunity_for(&snapshot, clockwork.farmer); + let caller = CallerId::Controller(opportunity.controller_id); + kernel + .submit( + command( + &snapshot, + CommandId::new(), + caller.clone(), + CommandBody::DeclineDecision { opportunity }, + ), + &AuthenticatedCaller::fixture(caller), + ) + .expect("the decline commits"); + assert_eq!(kernel.state.now, now); + + let snapshot = kernel.snapshot().unwrap(); + submit_owner( + &mut kernel, + &snapshot, + operations(vec![ComponentOp::CloseRoute { + route: Ref::Existing(clockwork.gate), + }]), + ); + assert_eq!(kernel.state.now, now); + } + + /// The clock capability is admitted for one body and nothing else. + #[test] + fn the_clock_capability_can_do_nothing_else() { + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, clockwork, _) = clock_kernel(directory.path(), "Capability"); + let before = kernel.state.clone(); + let snapshot = kernel.snapshot().unwrap(); + let opportunity = opportunity_for(&snapshot, clockwork.reeve); + + for body in [ + CommandBody::ApproveDraft, + CommandBody::ActivateWorld, + CommandBody::DeclineDecision { + opportunity: opportunity.clone(), + }, + CommandBody::ExerciseDecision { + opportunity, + invocation: DecisionInvocation { + affordance: clockwork.threaten, + bindings: vec![binding("target", Target::Subject(clockwork.farmer))], + proposed: vec![ProposedEffect { + slot: 0, + magnitude: Magnitude::None, + }], + speech: None, + }, + }, + operations(vec![ComponentOp::CloseRoute { + route: Ref::Existing(clockwork.gate), + }]), + ] { + let error = kernel + .submit( + command(&snapshot, CommandId::new(), clock_caller(), body), + &AuthenticatedCaller::fixture(clock_caller()), + ) + .unwrap_err(); + assert!(matches!(error, KernelError::Unauthorized), "{error:?}"); + } + assert_eq!(kernel.state, before); + tick(&mut kernel, 1).expect("the same caller may tick"); + } + + /// Who may tick, when, and how far. + #[test] + fn advance_time_negatives() { + let directory = tempfile::tempdir().unwrap(); + let mut kernel = WorldKernel::create( + directory.path().join("world.cc"), + creation(CommandId::new(), "Negatives"), + &auth_principal(owner()), + ) + .expect("a created world") + .0; + + // There is no time in Draft. + let draft = kernel.snapshot().unwrap(); + let error = kernel + .submit( + command( + &draft, + CommandId::new(), + clock_caller(), + CommandBody::AdvanceTime { + minutes: minutes(1), + }, + ), + &AuthenticatedCaller::fixture(clock_caller()), + ) + .unwrap_err(); + assert!(matches!( + error, + KernelError::WrongPhase { + expected: WorldPhase::Active, + actual: WorldPhase::Draft, + } + )); + + let active = activate(&mut kernel); + // A span is constructor-checked, so a malformed one never reaches a + // command at all. + assert!(TickMinutes::new(0).is_none()); + assert!(TickMinutes::new(super::patch::MAX_ROUTE_COST + 1).is_none()); + + for caller in [ + CallerId::Principal(player()), + CallerId::Controller(active.opportunities[0].controller_id), + ] { + let error = kernel + .submit( + command( + &active, + CommandId::new(), + caller.clone(), + CommandBody::AdvanceTime { + minutes: minutes(1), + }, + ), + &AuthenticatedCaller::fixture(caller), + ) + .unwrap_err(); + assert!(matches!(error, KernelError::Unauthorized), "{error:?}"); + } + + // The owner may tick from Eve. + submit_owner( + &mut kernel, + &active, + CommandBody::AdvanceTime { + minutes: minutes(7), + }, + ); + assert_eq!(kernel.state.now, FictionalMinutes(7)); + + // And a span that would overflow the clock is refused rather than + // wrapping. + kernel.state.now = FictionalMinutes(u64::MAX); + let snapshot = kernel.snapshot().unwrap(); + let error = kernel + .submit( + command( + &snapshot, + CommandId::new(), + clock_caller(), + CommandBody::AdvanceTime { + minutes: minutes(1), + }, + ), + &AuthenticatedCaller::fixture(clock_caller()), + ) + .unwrap_err(); + assert!(matches!(error, KernelError::Serialization(_)), "{error:?}"); + } + + /// The whole motion is re-derived at apply, so a forged fulfilment, target, + /// magnitude, or clock reading is one comparison. + #[test] + fn a_forged_motion_does_not_apply() { + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, clockwork, _) = clock_kernel(directory.path(), "Forged"); + let to = FictionalMinutes(120); + let honest = clock::derive_motion(&kernel.state, to); + assert!(!honest.fulfilled.is_empty() || !honest.pressed.is_empty()); + + let forgeries = [ + // A forged clock reading. + WorldEffect::TimeAdvanced { + minutes: minutes(120), + to: FictionalMinutes(121), + motion: honest.clone(), + }, + // A fulfilment nothing derived. + WorldEffect::TimeAdvanced { + minutes: minutes(120), + to, + motion: Motion { + fulfilled: vec![clock::RoutineFulfilled { + subject: clockwork.farmer, + key: clockwork.routine, + next_due: FictionalMinutes(9_999), + }], + pressed: honest.pressed.clone(), + }, + }, + // A forged magnitude. + WorldEffect::TimeAdvanced { + minutes: minutes(120), + to, + motion: Motion { + fulfilled: honest.fulfilled.clone(), + pressed: honest + .pressed + .iter() + .map(|written| clock::PressureWritten { + magnitude: PressureMagnitude(written.magnitude.0 + 9), + ..written.clone() + }) + .collect(), + }, + }, + // A pressure row for an untouched subject. + WorldEffect::TimeAdvanced { + minutes: minutes(120), + to, + motion: Motion { + fulfilled: honest.fulfilled.clone(), + pressed: honest + .pressed + .iter() + .cloned() + .chain(std::iter::once(clock::PressureWritten { + target: clockwork.treasury, + source: PressureSource::Subject(clockwork.reeve), + magnitude: PressureMagnitude(5), + })) + .collect(), + }, + }, + ]; + for forged in forgeries { + let mut candidate = kernel.state.clone(); + let error = apply_effect(&mut candidate, CommandId::issue(), &clock_caller(), &forged) + .unwrap_err(); + assert!(matches!(error, KernelError::Invariant(_)), "{error:?}"); + assert_eq!(candidate, kernel.state); + } + + // The honest one applies. + let mut candidate = kernel.state.clone(); + apply_effect( + &mut candidate, + CommandId::issue(), + &clock_caller(), + &WorldEffect::TimeAdvanced { + minutes: minutes(120), + to, + motion: honest, + }, + ) + .expect("the honest motion applies"); + assert_eq!(candidate.now, to); + } + + /// Replay across a tick is exact, and every envelope is idempotent. + #[test] + fn restart_replay_across_a_tick_is_exact() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("world.cc"); + let (mut kernel, clockwork, _) = clock_kernel(directory.path(), "Replay"); + let world_id = kernel.state.world_id; + + exercise( + &mut kernel, + clockwork.reeve, + clockwork.threaten, + vec![binding("target", Target::Subject(clockwork.farmer))], + ) + .expect("the threat commits"); + tick(&mut kernel, 60).expect("the routine tick commits"); + tick(&mut kernel, 60).expect("the obligation tick commits"); + let snapshot = kernel.snapshot().unwrap(); + let repeat = command( + &snapshot, + CommandId::new(), + CallerId::Principal(owner()), + operations(vec![ComponentOp::DischargeCommitment { + subject: Ref::Existing(clockwork.farmer), + key: clockwork.obligation, + }]), + ); + kernel + .submit(repeat.clone(), &auth_principal(owner())) + .expect("the discharge commits"); + // Discharge removes every pressure row it sourced. + assert_eq!( + pressure( + &kernel, + clockwork.farmer, + PressureSource::Commitment { + subject: clockwork.farmer, + key: clockwork.obligation, + } + ), + 0 + ); + + let before = kernel.state.clone(); + let before_snapshot = kernel.snapshot().unwrap(); + drop(kernel); + let mut replayed = WorldKernel::open(&path, world_id).expect("the store replays"); + assert_eq!(replayed.state, before); + assert_eq!(replayed.snapshot().unwrap(), before_snapshot); + assert!(matches!( + replayed.submit(repeat, &auth_principal(owner())), + Ok(SubmitReceipt::AlreadyApplied(_)) + )); + } + + /// The order is total, deterministic, and never a filter. + #[test] + fn the_attention_order_is_total_and_deterministic() { + let directory = tempfile::tempdir().unwrap(); + let (kernel, clockwork, active) = clock_kernel(directory.path(), "Order"); + let derived = derive_opportunities(&kernel.state).unwrap(); + + let with = |pressures: BTreeMap, + stamps: BTreeMap| + -> Vec { + let mut state = kernel.state.clone(); + state.now = FictionalMinutes(1_000); + state.pressures = pressures + .into_iter() + .map(|(subject, magnitude)| { + ( + subject, + BTreeMap::from([( + PressureSource::Subject(subject), + PressureMagnitude(magnitude), + )]), + ) + }) + .collect(); + state.last_opportunity_at = stamps + .into_iter() + .map(|(subject, stamp)| (subject, FictionalMinutes(stamp))) + .collect(); + order_opportunities(&state, derived.clone()) + .into_iter() + .map(|opportunity| opportunity.scope.subject_id) + .collect() + }; + + // Pressure dominates. + let ordered = with( + BTreeMap::from([(clockwork.farmer, 5), (clockwork.reeve, 3)]), + BTreeMap::new(), + ); + assert_eq!(ordered[0], clockwork.farmer); + assert_eq!(ordered[1], clockwork.reeve); + + // Equal pressure orders by debt, and a subject never attended outranks + // every subject that has. + let stamped: BTreeMap = active + .subjects + .iter() + .map(|subject| (subject.id, 900)) + .collect(); + let mut earlier = stamped.clone(); + earlier.insert(clockwork.treasury, 100); + let ordered = with(BTreeMap::new(), earlier); + assert_eq!(ordered[0], clockwork.treasury); + + // Never a filter: the same length and the same set, whatever the terms. + for pressures in [ + BTreeMap::new(), + BTreeMap::from([(clockwork.reeve, 1)]), + BTreeMap::from([(clockwork.farmer, 9), (clockwork.treasury, 9)]), + ] { + let ordered = with(pressures, stamped.clone()); + assert_eq!(ordered.len(), derived.len()); + assert_eq!( + ordered.iter().copied().collect::>(), + derived + .iter() + .map(|opportunity| opportunity.scope.subject_id) + .collect::>() + ); + } + } + + /// The debt term rotates: with no pressure anywhere, every subject reaches + /// the head within one pass, and standing pressure holds the head until it + /// is resolved. + #[test] + fn every_subject_reaches_the_head_within_bounded_ticks() { + let directory = tempfile::tempdir().unwrap(); + let (kernel, clockwork, _) = clock_kernel(directory.path(), "Rotation"); + let derived = derive_opportunities(&kernel.state).unwrap(); + let count = derived.len(); + + let mut state = kernel.state.clone(); + let mut seen = BTreeSet::new(); + for step in 0..count { + state.now = FictionalMinutes(u64::try_from(step).unwrap() + 1); + let head = order_opportunities(&state, derived.clone())[0] + .scope + .subject_id; + seen.insert(head); + state.last_opportunity_at.insert(head, state.now); + } + assert_eq!(seen.len(), count, "every subject reached the head"); + + // Standing pressure holds the head; the others still rotate among + // themselves once it is resolved. + state.pressures.insert( + clockwork.farmer, + BTreeMap::from([( + PressureSource::Subject(clockwork.reeve), + PressureMagnitude(4), + )]), + ); + for step in 0..count { + state.now = FictionalMinutes(1_000 + u64::try_from(step).unwrap()); + let head = order_opportunities(&state, derived.clone())[0] + .scope + .subject_id; + assert_eq!(head, clockwork.farmer); + state.last_opportunity_at.insert(head, state.now); + } + state.pressures.remove(&clockwork.farmer); + assert_ne!( + order_opportunities(&state, derived.clone())[0] + .scope + .subject_id, + clockwork.farmer + ); + } + + /// Verification 14, first half: a dead end is derived, answered once, and + /// then no longer derived. Nothing clears one but the predicate failing. + #[test] + fn an_unelaborated_destination_is_derived_and_answered_exactly_once() { + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, clockwork, _) = clock_kernel(directory.path(), "DeadEnd"); + let boundaries = derive_boundaries(&kernel.state).unwrap(); + let destinations: Vec<_> = boundaries + .iter() + .filter(|boundary| matches!(boundary, CausalBoundary::UnelaboratedDestination { .. })) + .collect(); + assert_eq!(destinations.len(), 1); + let answered = destinations[0].clone(); + assert!(matches!( + answered, + CausalBoundary::UnelaboratedDestination { route, place, .. } + if route == clockwork.gate && place == clockwork.dead_end + )); + + let snapshot = kernel.snapshot().unwrap(); + assert!(snapshot.boundaries.contains(&answered)); + submit_owner( + &mut kernel, + &snapshot, + CommandBody::AdmitPatch { + answers: Some(PatchAnswer::Boundary(answered.clone())), + patch: WorldPatch { + declarations: vec![Declaration::Entity(EntityDeclaration { + handle: DraftHandle::new("shed"), + label: "The Roadside Shed".into(), + kind: EntityKind::Place, + container: Some(Ref::Existing(clockwork.dead_end)), + })], + operations: Vec::new(), + evidence: Vec::new(), + }, + }, + ); + assert!( + !derive_boundaries(&kernel.state) + .unwrap() + .contains(&answered) + ); + + // Answering it again names a boundary the kernel no longer derives. + let snapshot = kernel.snapshot().unwrap(); + let error = kernel + .submit( + command( + &snapshot, + CommandId::new(), + CallerId::Principal(owner()), + CommandBody::AdmitPatch { + answers: Some(PatchAnswer::Boundary(answered)), + patch: WorldPatch { + declarations: vec![Declaration::Entity(EntityDeclaration { + handle: DraftHandle::new("byre"), + label: "The Roadside Byre".into(), + kind: EntityKind::Place, + container: Some(Ref::Existing(clockwork.dead_end)), + })], + operations: Vec::new(), + evidence: Vec::new(), + }, + }, + ), + &auth_principal(owner()), + ) + .unwrap_err(); + assert!(matches!(error, KernelError::AnswerNotDerived), "{error:?}"); + } + + /// A promise the counterparty can neither command nor litigate is a missing + /// structure, and either repair clears it. A goal never derives one. + #[test] + fn a_commitment_with_no_authority_or_redress_derives_missing_structure() { + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, clockwork, _) = clock_kernel(directory.path(), "Structure"); + let missing = CausalBoundary::MissingStructure { + subject: clockwork.farmer, + key: clockwork.obligation, + scope: BoundaryDigest(String::new()), + }; + let derives = |kernel: &WorldKernel| { + derive_boundaries(&kernel.state) + .unwrap() + .into_iter() + .any(|boundary| match (&boundary, &missing) { + ( + CausalBoundary::MissingStructure { subject, key, .. }, + CausalBoundary::MissingStructure { + subject: expected_subject, + key: expected_key, + .. + }, + ) => subject == expected_subject && key == expected_key, + _ => false, + }) + }; + assert!(derives(&kernel)); + // A goal is a promise to oneself and needs no forum. + assert!(!derive_boundaries(&kernel.state).unwrap().iter().any( + |boundary| matches!(boundary, CausalBoundary::MissingStructure { key, .. } if *key == clockwork.goal) + )); + + // Jurisdiction over the promisor's ground clears it. + let snapshot = kernel.snapshot().unwrap(); + let grant = ComponentOp::GrantAuthority { + holder: Ref::Existing(clockwork.treasury), + grant: AuthorityGrantRef { + kind: AuthorityKindName("command".into()), + over: AuthorityTargetRef::PlaceSubtree(Ref::Existing(clockwork.yard)), + }, + }; + submit_owner(&mut kernel, &snapshot, operations(vec![grant.clone()])); + assert!(!derives(&kernel)); + + // Revoke it, and a forum whose standing covers the counterparty clears + // it the other way. + let snapshot = kernel.snapshot().unwrap(); + submit_owner( + &mut kernel, + &snapshot, + operations(vec![ComponentOp::RevokeAuthority { + holder: Ref::Existing(clockwork.treasury), + grant: AuthorityGrantRef { + kind: AuthorityKindName("command".into()), + over: AuthorityTargetRef::PlaceSubtree(Ref::Existing(clockwork.yard)), + }, + }]), + ); + assert!(derives(&kernel)); + let snapshot = kernel.snapshot().unwrap(); + submit_owner( + &mut kernel, + &snapshot, + operations(vec![ComponentOp::OpenForum { + grievance: GrievanceKindName("debt".into()), + forum: Ref::Existing(clockwork.reeve), + standing: AuthorityTargetRef::Subject(Ref::Existing(clockwork.treasury)), + }]), + ); + assert!(!derives(&kernel)); + } + + /// Verification 14, second half: an answer must be derived, and the commit + /// must satisfy what it answered. + #[test] + fn an_answer_must_be_derived_and_must_be_satisfied() { + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, clockwork, _) = clock_kernel(directory.path(), "Answers"); + let before = kernel.state.clone(); + let declaring = |answers: Option, handle: &str| CommandBody::AdmitPatch { + answers, + patch: WorldPatch { + declarations: vec![Declaration::Entity(EntityDeclaration { + handle: DraftHandle::new(handle), + label: "A Later Room".into(), + kind: EntityKind::Place, + container: None, + })], + operations: Vec::new(), + evidence: Vec::new(), + }, + }; + let refuse = |kernel: &mut WorldKernel, body: CommandBody| { + let snapshot = kernel.snapshot().unwrap(); + kernel + .submit( + command( + &snapshot, + CommandId::new(), + CallerId::Principal(owner()), + body, + ), + &auth_principal(owner()), + ) + .unwrap_err() + }; + + // Declaring in Active without answering. + assert!(matches!( + refuse(&mut kernel, declaring(None, "unanswered")), + KernelError::AnswerRequired + )); + // The two declared-but-never-derived variants. + for boundary in [ + CausalBoundary::PolityInCausalRange { + subject: clockwork.reeve, + scope: BoundaryDigest(String::new()), + }, + CausalBoundary::IndividuationRequired { + population: clockwork.reeve, + scope: BoundaryDigest(String::new()), + }, + ] { + assert!(matches!( + refuse( + &mut kernel, + declaring(Some(PatchAnswer::Boundary(boundary)), "underived") + ), + KernelError::AnswerNotDerived + )); + } + // A jurisdiction with no deficit. + assert!(matches!( + refuse( + &mut kernel, + declaring( + Some(PatchAnswer::Deficit(JurisdictionKey::Unplaced)), + "no-deficit" + ) + ), + KernelError::AnswerNotDerived + )); + // A derived boundary answered by a patch that leaves the predicate + // holding. + let destination = derive_boundaries(&kernel.state) + .unwrap() + .into_iter() + .find(|boundary| matches!(boundary, CausalBoundary::UnelaboratedDestination { .. })) + .expect("the dead end"); + assert!(matches!( + refuse( + &mut kernel, + declaring(Some(PatchAnswer::Boundary(destination)), "elsewhere") + ), + KernelError::AnswerNotSatisfied + )); + assert_eq!(kernel.state, before); + + // Draft answers nothing. + let directory = tempfile::tempdir().unwrap(); + let mut draft_kernel = WorldKernel::create( + directory.path().join("world.cc"), + creation(CommandId::new(), "Draft"), + &auth_principal(owner()), + ) + .expect("a created world") + .0; + assert!(matches!( + refuse( + &mut draft_kernel, + declaring( + Some(PatchAnswer::Deficit(JurisdictionKey::Unplaced)), + "in-draft" + ) + ), + KernelError::AnswerNotDerived + )); + } + + fn creation_with_intent( + id: CommandId, + title: &str, + intent: WorldScaleIntentRef, + ) -> CreateWorld { + let mut creation = creation(id, title); + creation.scale_intent = intent; + creation + } + + fn intent(root: &str, persons: u32, permille: u32) -> WorldScaleIntentRef { + WorldScaleIntentRef { + targets: BTreeMap::from([(SubjectKind::Person, persons)]), + jurisdictions: BTreeMap::from([(DraftHandle::new(root), permille)]), + } + } + + /// Verification 22, first half: only a qualified subject reduces the + /// deficit, and `Goal` carries the whole discrimination. + #[test] + fn a_subject_counts_toward_the_deficit_only_when_qualified() { + let directory = tempfile::tempdir().unwrap(); + let mut kernel = WorldKernel::create( + directory.path().join("world.cc"), + creation_with_intent( + CommandId::new(), + "Scale", + intent(super::tests::COMMONS, 3, 1000), + ), + &auth_principal(owner()), + ) + .expect("a created world") + .0; + let commons = kernel + .state + .scale_intent + .jurisdictions + .keys() + .copied() + .next() + .expect("the declared root"); + let active = activate(&mut kernel); + let counted = active + .subjects + .iter() + .find(|subject| subject.kind == SubjectKind::Person) + .expect("the fixture world has a person") + .id; + + let deficit = |kernel: &WorldKernel, jurisdiction: JurisdictionKey| { + derive_scale_deficit(&kernel.state) + .unwrap() + .into_iter() + .find(|row| row.jurisdiction == jurisdiction && row.kind == SubjectKind::Person) + }; + let row = deficit(&kernel, JurisdictionKey::PlaceSubtree(commons)) + .expect("a target of three persons"); + assert_eq!((row.target, row.qualified, row.deficit), (3, 0, 3)); + + // A controller and a grant are not enough; a `Goal` is what counts. + let snapshot = kernel.snapshot().unwrap(); + submit_owner( + &mut kernel, + &snapshot, + operations(vec![ComponentOp::CreateCommitment { + subject: Ref::Existing(counted), + counterparty: None, + kind: CommitmentKind::Goal, + due: FictionalMinutes(500), + period: None, + checks: Vec::new(), + }]), + ); + let goal = *kernel.state.commitments[&counted] + .keys() + .next() + .expect("the goal"); + let row = deficit(&kernel, JurisdictionKey::PlaceSubtree(commons)).unwrap(); + assert_eq!((row.target, row.qualified, row.deficit), (3, 1, 2)); + + // Discharging it raises the deficit again. + let snapshot = kernel.snapshot().unwrap(); + submit_owner( + &mut kernel, + &snapshot, + operations(vec![ComponentOp::DischargeCommitment { + subject: Ref::Existing(counted), + key: goal, + }]), + ); + let row = deficit(&kernel, JurisdictionKey::PlaceSubtree(commons)).unwrap(); + assert_eq!((row.target, row.qualified, row.deficit), (3, 0, 3)); + } + + /// Weights distribute the target and never raise it, and a root must be a + /// declared place. + #[test] + fn scale_weights_distribute_and_never_raise() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("world.cc"); + let over_whole = WorldScaleIntentRef { + targets: BTreeMap::from([(SubjectKind::Person, 10)]), + jurisdictions: BTreeMap::from([ + (DraftHandle::new(super::tests::COMMONS), 700), + (DraftHandle::new("player"), 400), + ]), + }; + let Err(error) = WorldKernel::create( + &path, + creation_with_intent(CommandId::new(), "TooMuch", over_whole), + &auth_principal(owner()), + ) else { + panic!("a genesis intent over the whole was admitted"); + }; + let KernelError::PatchRejected(mismatches) = error else { + panic!("expected a rejected genesis patch"); + }; + assert!(mismatches.contains(&Mismatch::ScaleWeightsExceedWhole)); + // `player` is a subject handle, not a place. + assert!(mismatches.contains(&Mismatch::UnknownJurisdictionRoot { + handle: DraftHandle::new("player"), + })); + + // Half the whole distributes half the target, rounded down. + let mut kernel = WorldKernel::create( + &path, + creation_with_intent( + CommandId::new(), + "Half", + intent(super::tests::COMMONS, 5, 500), + ), + &auth_principal(owner()), + ) + .expect("a created world") + .0; + activate(&mut kernel); + let rows = derive_scale_deficit(&kernel.state).unwrap(); + let commons_target: u32 = rows + .iter() + .filter(|row| matches!(row.jurisdiction, JurisdictionKey::PlaceSubtree(_))) + .map(|row| row.target) + .sum(); + assert_eq!(commons_target, 2); + assert!(commons_target <= 5); + } + + /// A rejected patch mutates nothing, so the deficit it would have reduced + /// stays visible. + #[test] + fn a_rejected_patch_leaves_the_deficit_unchanged_and_visible() { + let directory = tempfile::tempdir().unwrap(); + let mut kernel = WorldKernel::create( + directory.path().join("world.cc"), + creation_with_intent( + CommandId::new(), + "Rejected", + intent(super::tests::COMMONS, 3, 1000), + ), + &auth_principal(owner()), + ) + .expect("a created world") + .0; + let active = activate(&mut kernel); + let before = derive_scale_deficit(&kernel.state).unwrap(); + let subjects: Vec = active + .subjects + .iter() + .filter(|subject| subject.kind == SubjectKind::Person) + .map(|subject| subject.id) + .collect(); + let goal = |subject: SubjectId, due: u64| ComponentOp::CreateCommitment { + subject: Ref::Existing(subject), + counterparty: None, + kind: CommitmentKind::Goal, + due: FictionalMinutes(due), + period: None, + checks: Vec::new(), + }; + + // One of the two goals is born past due. + assert_eq!( + reject_owner( + &mut kernel, + &active, + operations(vec![goal(subjects[0], 500), goal(subjects[1], 0)]), + ), + vec![Mismatch::CommitmentDueInThePast { operation: 1 }] + ); + assert_eq!(derive_scale_deficit(&kernel.state).unwrap(), before); + + let snapshot = kernel.snapshot().unwrap(); + submit_owner( + &mut kernel, + &snapshot, + operations(vec![goal(subjects[0], 500), goal(subjects[1], 600)]), + ); + let after = derive_scale_deficit(&kernel.state).unwrap(); + let total = |rows: &[ScaleDeficitRow]| -> u32 { rows.iter().map(|row| row.deficit).sum() }; + assert_eq!(total(&after) + 2, total(&before)); + } + + /// Every commitment shape check, in one complete sorted set. + #[test] + fn commitment_declaration_negatives() { + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, clockwork, active) = clock_kernel(directory.path(), "Shapes"); + let before = kernel.state.clone(); + let create = |kind: CommitmentKind, + counterparty: Option, + due: u64, + period: Option, + checks: Vec| { + ComponentOp::CreateCommitment { + subject: Ref::Existing(clockwork.farmer), + counterparty: counterparty.map(Ref::Existing), + kind, + due: FictionalMinutes(due), + period, + checks, + } + }; + + assert_eq!( + reject_owner( + &mut kernel, + &active, + operations(vec![ + // 0: a routine with no period. + create(CommitmentKind::Routine, None, 500, None, Vec::new()), + // 1: a goal with a period. + create( + CommitmentKind::Goal, + None, + 500, + Some(minutes(10)), + Vec::new() + ), + // 2: checks on a non-routine. + create( + CommitmentKind::Obligation, + Some(clockwork.treasury), + 500, + None, + vec![PreconditionRef::Present { + at: Ref::Existing(clockwork.yard), + }], + ), + // 3: born past due. + create( + CommitmentKind::Obligation, + Some(clockwork.treasury), + 0, + None, + Vec::new() + ), + // 4: a promise to oneself with a counterparty. + create( + CommitmentKind::Obligation, + Some(clockwork.farmer), + 500, + None, + Vec::new() + ), + // 5: a goal with a counterparty. + create( + CommitmentKind::Goal, + Some(clockwork.treasury), + 500, + None, + Vec::new() + ), + // 6: a discharge of a key nobody holds. + ComponentOp::DischargeCommitment { + subject: Ref::Existing(clockwork.farmer), + key: CommitmentKey { + command: CommandId::new(), + index: 0, + }, + }, + ]), + ), + { + let mut expected = vec![ + Mismatch::CommitmentPeriodMismatch { operation: 0 }, + Mismatch::CommitmentPeriodMismatch { operation: 1 }, + Mismatch::ChecksOnNonRoutine { operation: 2 }, + Mismatch::CommitmentDueInThePast { operation: 3 }, + Mismatch::SelfCommitment { operation: 4 }, + Mismatch::GoalWithCounterparty { operation: 5 }, + Mismatch::UnknownCommitment { operation: 6 }, + ]; + expected.sort(); + expected + } + ); + assert_eq!(kernel.state, before); + + // Repairing exactly those commits. + submit_owner( + &mut kernel, + &active, + operations(vec![ + create( + CommitmentKind::Routine, + None, + 500, + Some(minutes(10)), + Vec::new(), + ), + create(CommitmentKind::Goal, None, 500, None, Vec::new()), + create( + CommitmentKind::Obligation, + Some(clockwork.treasury), + 500, + None, + Vec::new(), + ), + ComponentOp::DischargeCommitment { + subject: Ref::Existing(clockwork.farmer), + key: clockwork.routine, + }, + ]), + ); + assert!( + !kernel.state.commitments[&clockwork.farmer].contains_key(&clockwork.routine), + "the discharge removed the routine" + ); + } + + /// `Committed` reads the actor's own commitments, and a discharge mid-flight + /// rejects the promisor's bound proposal while the counterparty's own + /// invocation fails at admission. + #[test] + fn committed_reads_the_actors_own_commitments() { + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, clockwork, _) = clock_kernel(directory.path(), "Committed"); + + // The reeve promised the treasury nothing. + let error = exercise( + &mut kernel, + clockwork.reeve, + clockwork.deliver, + vec![binding("creditor", Target::Subject(clockwork.treasury))], + ) + .unwrap_err(); + assert!(matches!( + error, + KernelError::ActionRejected(ref mismatches) + if *mismatches == vec![ActionMismatch::NotCommitted { precondition: 0 }] + )); + + // The farmer did, so the entry commits and mints a second obligation + // whose due date the kernel computed. + let before = kernel.state.commitments[&clockwork.farmer].len(); + exercise( + &mut kernel, + clockwork.farmer, + clockwork.deliver, + vec![binding("creditor", Target::Subject(clockwork.treasury))], + ) + .expect("the delivery commits"); + assert_eq!( + kernel.state.commitments[&clockwork.farmer].len(), + before + 1 + ); + + // A proposal bound before a discharge is rejected as a scope change for + // the promisor: `Committed` reads commitments, so the digest binds them. + let snapshot = kernel.snapshot().unwrap(); + let stale = opportunity_for(&snapshot, clockwork.farmer); + submit_owner( + &mut kernel, + &snapshot, + operations(vec![ComponentOp::DischargeCommitment { + subject: Ref::Existing(clockwork.farmer), + key: clockwork.obligation, + }]), + ); + let caller = CallerId::Controller(stale.controller_id); + let after = kernel.snapshot().unwrap(); + let error = kernel + .submit( + command( + &after, + CommandId::new(), + caller.clone(), + CommandBody::ExerciseDecision { + opportunity: stale, + invocation: DecisionInvocation { + affordance: clockwork.deliver, + bindings: vec![binding( + "creditor", + Target::Subject(clockwork.treasury), + )], + proposed: vec![ProposedEffect { + slot: 0, + magnitude: Magnitude::None, + }], + speech: None, + }, + }, + ), + &AuthenticatedCaller::fixture(caller), + ) + .unwrap_err(); + assert!( + matches!(error, KernelError::ScopeChanged { .. }), + "{error:?}" + ); + } + + /// A tick that only advances pressure moves nobody's scope digest; a tick + /// that fulfils a routine moves exactly that subject's. + #[test] + fn a_pressure_tick_moves_no_scope_digest_and_a_routine_tick_moves_one() { + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, clockwork, _) = clock_kernel(directory.path(), "Digests"); + let digests = |kernel: &WorldKernel| -> BTreeMap { + derive_opportunities(&kernel.state) + .unwrap() + .into_iter() + .map(|opportunity| (opportunity.scope.subject_id, opportunity.scope_digest)) + .collect() + }; + + // Past the obligation and the goal, but the routine is blocked out of + // the yard first so only pressure moves. + let snapshot = kernel.snapshot().unwrap(); + submit_owner( + &mut kernel, + &snapshot, + operations(vec![ComponentOp::DischargeCommitment { + subject: Ref::Existing(clockwork.farmer), + key: clockwork.routine, + }]), + ); + let before = digests(&kernel); + tick(&mut kernel, 200).expect("the tick commits"); + assert!(!kernel.state.pressures.is_empty(), "the tick pressed"); + assert_eq!(digests(&kernel), before, "pressure binds no proposal"); + + // A routine fulfilment moves exactly its own subject's digest. + let snapshot = kernel.snapshot().unwrap(); + submit_owner( + &mut kernel, + &snapshot, + operations(vec![ComponentOp::CreateCommitment { + subject: Ref::Existing(clockwork.reeve), + counterparty: None, + kind: CommitmentKind::Routine, + due: FictionalMinutes(500), + period: Some(minutes(60)), + checks: Vec::new(), + }]), + ); + let before = digests(&kernel); + tick(&mut kernel, 400).expect("the tick commits"); + let after = digests(&kernel); + for (subject, digest) in &before { + if *subject == clockwork.reeve { + assert_ne!(after[subject], *digest, "the fulfilling subject rebinds"); + } else { + assert_eq!(after[subject], *digest, "everyone else still commits"); + } + } + } +} diff --git a/crates/ghostlight-dungeon/src/world/patch.rs b/crates/ghostlight-dungeon/src/world/patch.rs index a258d9c..7855a65 100644 --- a/crates/ghostlight-dungeon/src/world/patch.rs +++ b/crates/ghostlight-dungeon/src/world/patch.rs @@ -477,7 +477,7 @@ pub(crate) struct PressureMagnitude(pub(crate) u32); /// *on* a subject and the typed view shows pressure on self. Source-major has no /// reader. #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -#[serde(tag = "from", rename_all = "snake_case")] +#[serde(tag = "from", content = "of", rename_all = "snake_case")] pub(crate) enum PressureSource { /// A past-due commitment. Carries the promisor and the structural key. Commitment { @@ -494,7 +494,7 @@ pub(crate) enum PressureSource { /// The proposal-time twin of [`PressureSource`]. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -#[serde(tag = "from", rename_all = "snake_case")] +#[serde(tag = "from", content = "of", rename_all = "snake_case")] pub(crate) enum PressureSourceRef { Commitment { subject: Ref,