diff --git a/crates/ghostlight/src/controllers.rs b/crates/ghostlight/src/controllers.rs index 266eb92..21a3094 100644 --- a/crates/ghostlight/src/controllers.rs +++ b/crates/ghostlight/src/controllers.rs @@ -4852,9 +4852,15 @@ impl SelectedDecision { }; // The recency marker: strictly after the subject's own last // exercised decision, so a subject that never acted - // (`last_acted_at` is `None`) sees every minted row as new, + // (`last_acted_at` is `None`) sees every row it holds as new, // and the subject's own acting revision is not itself new. - let new = entry.minted_at > self.subject.last_acted_at; + // `acquired_at` is written by every knowledge writer (PA.f44), + // unlike `minted_at`, which a patch, a seed, or a ruling never + // sets, so this marker sees rows those writers land too. + let new = match self.subject.last_acted_at { + None => true, + Some(acted_at) => entry.acquired_at > acted_at, + }; json!({ "how": how, "by": by, @@ -7003,6 +7009,7 @@ mod tests { via: None, }, minted_at: Some(40), + acquired_at: 40, }, // Mara also saw Iris do something. The row is Iris's own // sentence, marked as seen and never as said. @@ -7013,6 +7020,7 @@ mod tests { confidence: Confidence::Believed, source: KnowledgeSource::Seen { by: speaker_id }, minted_at: Some(41), + acquired_at: 41, }, ], commitments: Vec::new(), @@ -7042,6 +7050,7 @@ mod tests { confidence: Confidence::Certain, source: KnowledgeSource::Witnessed, minted_at: None, + acquired_at: 0, }], commitments: Vec::new(), pressures: Vec::new(), @@ -7616,6 +7625,7 @@ mod tests { confidence: Confidence::Believed, source: KnowledgeSource::Witnessed, minted_at: Some(1), + acquired_at: 1, }, KnowledgeSnapshot { fact: EntityId::issue(), @@ -7624,6 +7634,7 @@ mod tests { confidence: Confidence::Believed, source: KnowledgeSource::Witnessed, minted_at: Some(2), + acquired_at: 2, }, KnowledgeSnapshot { fact: EntityId::issue(), @@ -7632,6 +7643,7 @@ mod tests { confidence: Confidence::Believed, source: KnowledgeSource::Witnessed, minted_at: Some(3), + acquired_at: 3, }, ]; let boundary_selected = SelectedDecision { @@ -7732,6 +7744,242 @@ mod tests { } } + /// PA.f44: a row `AcquireKnowledge` lands — a patch, not a minting speech + /// or display act — after the subject's own last act is new. Before + /// PA.f44 this row carried no `minted_at` at all, so the old marker + /// (`minted_at > last_acted_at`, `None` never beats `Some`) could never + /// flag it, whatever revision it actually landed on. + #[test] + fn a_row_acquired_by_a_patch_after_the_last_act_is_new() { + use crate::tests::{ + FLOOD_STATEMENT, auth_principal, command, operations, opportunity_for, owner, + speech_world, submit_owner, + }; + use crate::{ + AuthenticatedCaller, AuthoredSource, DecisionInvocation, Ref, Role, RoleBinding, + Statement, SubmitReceipt, Target, WorldKernel, + }; + + let directory = tempfile::tempdir().unwrap(); + let mut kernel = WorldKernel::create( + directory.path().join("world.cc"), + crate::tests::creation(CommandId::new(), "PatchAfterAct"), + &auth_principal(owner()), + ) + .expect("a created world") + .0; + let (speech, active) = speech_world(&mut kernel); + + // The listener acts once, so it carries a `last_acted_at`. + let opportunity = opportunity_for(&active, speech.listener); + let caller = CallerId::Controller(opportunity.controller_id); + let receipt = kernel + .submit( + command( + &active, + CommandId::new(), + caller.clone(), + CommandBody::ExerciseDecision { + opportunity, + invocation: DecisionInvocation { + affordance: speech.whisper, + bindings: vec![RoleBinding { + role: Role("target".into()), + target: Target::Subject(speech.speaker), + }], + proposed: Vec::new(), + speech: Some(Statement::new("I have something to say.").unwrap()), + display: None, + }, + }, + ), + &AuthenticatedCaller::fixture(caller), + ) + .expect("the whisper commits"); + assert!(matches!(receipt, SubmitReceipt::Applied(_))); + let after_act = kernel.snapshot().unwrap(); + + // A patch, after the act, lands a row the listener never spoke or + // saw: the sole scenario PA.f44 exists for. + submit_owner( + &mut kernel, + &after_act, + operations(vec![crate::patch::ComponentOp::AcquireKnowledge { + subject: Ref::Existing(speech.listener), + fact: Ref::Existing(speech.flood), + source: AuthoredSource::Witnessed, + confidence: Confidence::Believed, + }]), + ); + let after_patch = kernel.snapshot().unwrap(); + + let subject = after_patch + .subjects + .iter() + .find(|subject| subject.id == speech.listener) + .unwrap() + .clone(); + assert!(subject.last_acted_at.is_some()); + let row = subject + .knowledge + .iter() + .find(|row| row.fact == speech.flood) + .expect("the patch landed the row"); + assert_eq!(row.minted_at, None, "a patch mints no event"); + let opportunity = opportunity_for(&after_patch, speech.listener); + let granted: Vec = after_patch + .affordances + .iter() + .filter(|entry| subject.affordances.contains(&entry.id)) + .cloned() + .collect(); + let selected = SelectedDecision { + snapshot: after_patch, + subject, + opportunity, + granted, + }; + let rendered = selected + .projector_knowledge() + .into_iter() + .find(|row| row["text"] == Value::String(FLOOD_STATEMENT.into())) + .expect("the flood row is rendered"); + assert_eq!(rendered["new"], Value::Bool(true)); + } + + /// PA.f44's other boundary: a row `AcquireKnowledge` lands *before* the + /// subject's own last act is not new, exactly as a row minted by an act + /// before it is not. + #[test] + fn a_row_acquired_by_a_patch_before_the_last_act_is_not_new() { + use crate::tests::{ + FLOOD_STATEMENT, auth_principal, command, operations, opportunity_for, owner, + speech_world, submit_owner, + }; + use crate::{ + AuthenticatedCaller, AuthoredSource, DecisionInvocation, Ref, Role, RoleBinding, + Statement, SubmitReceipt, Target, WorldKernel, + }; + + let directory = tempfile::tempdir().unwrap(); + let mut kernel = WorldKernel::create( + directory.path().join("world.cc"), + crate::tests::creation(CommandId::new(), "PatchBeforeAct"), + &auth_principal(owner()), + ) + .expect("a created world") + .0; + let (speech, active) = speech_world(&mut kernel); + + // The patch lands first, before the listener has ever acted. + submit_owner( + &mut kernel, + &active, + operations(vec![crate::patch::ComponentOp::AcquireKnowledge { + subject: Ref::Existing(speech.listener), + fact: Ref::Existing(speech.flood), + source: AuthoredSource::Witnessed, + confidence: Confidence::Believed, + }]), + ); + let after_patch = kernel.snapshot().unwrap(); + + // Now the listener acts, so its `last_acted_at` moves past the patch. + let opportunity = opportunity_for(&after_patch, speech.listener); + let caller = CallerId::Controller(opportunity.controller_id); + let receipt = kernel + .submit( + command( + &after_patch, + CommandId::new(), + caller.clone(), + CommandBody::ExerciseDecision { + opportunity, + invocation: DecisionInvocation { + affordance: speech.whisper, + bindings: vec![RoleBinding { + role: Role("target".into()), + target: Target::Subject(speech.speaker), + }], + proposed: Vec::new(), + speech: Some(Statement::new("I have something to say.").unwrap()), + display: None, + }, + }, + ), + &AuthenticatedCaller::fixture(caller), + ) + .expect("the whisper commits"); + assert!(matches!(receipt, SubmitReceipt::Applied(_))); + let after_act = kernel.snapshot().unwrap(); + + let subject = after_act + .subjects + .iter() + .find(|subject| subject.id == speech.listener) + .unwrap() + .clone(); + assert!(subject.last_acted_at.is_some()); + let opportunity = opportunity_for(&after_act, speech.listener); + let granted: Vec = after_act + .affordances + .iter() + .filter(|entry| subject.affordances.contains(&entry.id)) + .cloned() + .collect(); + let selected = SelectedDecision { + snapshot: after_act, + subject, + opportunity, + granted, + }; + let rendered = selected + .projector_knowledge() + .into_iter() + .find(|row| row["text"] == Value::String(FLOOD_STATEMENT.into())) + .expect("the flood row is rendered"); + assert_eq!(rendered["new"], Value::Bool(false)); + } + + /// PA.f44: `acquired_at` is real committed state, not a snapshot-only + /// derivation, so it must survive a journal reopen exactly as + /// `minted_at`'s source events do. + #[test] + fn the_acquisition_revision_survives_replay() { + use crate::tests::{auth_principal, operations, owner, speech_world}; + use crate::{AuthoredSource, Ref, WorldKernel}; + + let directory = tempfile::tempdir().unwrap(); + let mut kernel = WorldKernel::create( + directory.path().join("world.cc"), + crate::tests::creation(CommandId::new(), "AcquisitionReplay"), + &auth_principal(owner()), + ) + .expect("a created world") + .0; + let (speech, active) = speech_world(&mut kernel); + crate::tests::submit_owner( + &mut kernel, + &active, + operations(vec![crate::patch::ComponentOp::AcquireKnowledge { + subject: Ref::Existing(speech.listener), + fact: Ref::Existing(speech.flood), + source: AuthoredSource::Witnessed, + confidence: Confidence::Believed, + }]), + ); + let acquired_at = kernel.state.knowledge[&speech.listener][&speech.flood].acquired_at; + let world_id = kernel.state.world_id; + drop(kernel); + + let reopened = + WorldKernel::open(directory.path().join("world.cc"), world_id).expect("it replays"); + assert_eq!( + reopened.state.knowledge[&speech.listener][&speech.flood].acquired_at, + acquired_at + ); + } + /// Verification: `turn` refuses any mode but `NarrativePersona`, and /// `narrate` refuses any mode but `Human` — before either ever reaches the /// mailbox. @@ -7936,9 +8184,14 @@ mod tests { /// PA-Q2's default plus Cut 6: the stimulus is exactly Cut 0's capture /// (session scratchpad `play-cut0/base-stimulus.txt`: one `Told` row, one /// `Seen` row, one held fact with no author) with exactly one `"new"` - /// field added per row. This subject never acted, so both minted rows are - /// new; the third row was never minted by an event at all (no - /// `minted_at`), so it carries no arrival time to compare and is not new. + /// field added per row. This subject never acted (`last_acted_at` is + /// `None`), so every row it holds is new (PA.f44) — including the third, + /// which carries no `minted_at` at all (no minting event ever asserted + /// it) but still carries `acquired_at`, the field every knowledge writer + /// sets. Before PA.f44 that row read `"new": false`: a row with no + /// `minted_at` could never be new, which is exactly the bug — a fact + /// landed by a patch, a seed, or a ruling would never surface to a + /// Persona as new. #[test] fn the_stimulus_equals_cut_zeros_capture_plus_the_marker() { let subject_id = SubjectId::issue(); @@ -7979,6 +8232,7 @@ mod tests { via: None, }, minted_at: Some(1), + acquired_at: 1, }, KnowledgeSnapshot { fact: EntityId::issue(), @@ -7987,6 +8241,7 @@ mod tests { confidence: Confidence::Believed, source: KnowledgeSource::Seen { by: speaker_id }, minted_at: Some(2), + acquired_at: 2, }, KnowledgeSnapshot { fact: EntityId::issue(), @@ -7995,6 +8250,9 @@ mod tests { confidence: Confidence::Certain, source: KnowledgeSource::Witnessed, minted_at: None, + // No minting event ever asserted this row (declaration-time + // seeding, PA.f44) but it still carries `acquired_at`. + acquired_at: 0, }, ], commitments: Vec::new(), @@ -8073,7 +8331,7 @@ mod tests { "by": null, "certainty": "certain", "how": "known", - "new": false, + "new": true, "text": "The rain gate sticks in the cold." } ])) diff --git a/crates/ghostlight/src/lib.rs b/crates/ghostlight/src/lib.rs index f54c82b..1c108ab 100644 --- a/crates/ghostlight/src/lib.rs +++ b/crates/ghostlight/src/lib.rs @@ -1074,6 +1074,10 @@ pub(crate) struct KnowledgeSnapshot { /// The revision of the act that minted this claim, spoken or displayed, /// when some committed event asserted it. pub(crate) minted_at: Option, + /// The revision at which this subject acquired this row, from `Knowledge` + /// directly: always present, unlike `minted_at`, so the recency marker can + /// see a row a patch, a seed, or a ruling landed with no minting event. + pub(crate) acquired_at: u64, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -2718,23 +2722,38 @@ fn apply_operation( "knowledge operation names no live subject, or evidences a claim".into(), )); } - let entry = Knowledge { - confidence: *confidence, - source: match source { - AuthoredSource::Witnessed => KnowledgeSource::Witnessed, - AuthoredSource::Evidenced => KnowledgeSource::Evidenced, - }, + let source = match source { + AuthoredSource::Witnessed => KnowledgeSource::Witnessed, + AuthoredSource::Evidenced => KnowledgeSource::Evidenced, }; + // Compared on `confidence` and `source` alone: `acquired_at` is + // this write's own revision, not part of what the author asserts, + // so a re-assertion identical in everything the author controls + // still refuses as a no-op even though it would land a later + // `acquired_at`. if state .knowledge .get(subject) .and_then(|held| held.get(fact)) - .is_some_and(|held| *held == entry) + .is_some_and(|held| held.confidence == *confidence && held.source == source) { return Err(KernelError::Invariant( "knowledge operation changes nothing".into(), )); } + // Matches `DecisionEvent.revision` and the `minted_at` scan built + // from it (`action::exercise`, `snapshot`): both name the revision + // this commit resolves to, one past `state.revision` here, which + // still holds the *previous* revision until `submit` bumps it. + let acquired_at = state + .revision + .checked_add(1) + .ok_or_else(|| KernelError::Serialization("world revision overflow".into()))?; + let entry = Knowledge { + confidence: *confidence, + source, + acquired_at, + }; state .knowledge .entry(*subject) @@ -2773,6 +2792,12 @@ fn apply_operation( // An empty fan-out is legal: the delta of a telling is a property of // the world — an empty room, a silenced channel — not a defect in // the proposal. Speaking alone commits the claim and lands nothing. + // `acquired_at` matches `minted_at`: one past `state.revision`, the + // revision this commit resolves to (PA.f44). + let acquired_at = state + .revision + .checked_add(1) + .ok_or_else(|| KernelError::Serialization("world revision overflow".into()))?; for listener in fan_out(state, *speaker, *fact, to) { state.knowledge.entry(listener).or_default().insert( *fact, @@ -2782,6 +2807,7 @@ fn apply_operation( by: *speaker, via: to.channel(), }, + acquired_at, }, ); } @@ -2804,12 +2830,17 @@ fn apply_operation( "an actor displays from no place".into(), )); } + let acquired_at = state + .revision + .checked_add(1) + .ok_or_else(|| KernelError::Serialization("world revision overflow".into()))?; for viewer in fan_out(state, *actor, *fact, &room) { state.knowledge.entry(viewer).or_default().insert( *fact, Knowledge { confidence: Confidence::Believed, source: KnowledgeSource::Seen { by: *actor }, + acquired_at, }, ); } @@ -2840,12 +2871,17 @@ fn apply_operation( "a witness reaches nobody who does not already hold the fact".into(), )); } + let acquired_at = state + .revision + .checked_add(1) + .ok_or_else(|| KernelError::Serialization("world revision overflow".into()))?; for witness in recipients { state.knowledge.entry(witness).or_default().insert( *fact, Knowledge { confidence: *confidence, source: KnowledgeSource::Witnessed, + acquired_at, }, ); } @@ -3845,6 +3881,7 @@ fn snapshot(state: &WorldState) -> Result { confidence: held.confidence, source: held.source, minted_at: minted_at.get(fact).copied(), + acquired_at: held.acquired_at, }) }) .collect::, KernelError>>()?; @@ -9752,6 +9789,7 @@ mod knowledge_tests { by: speech.speaker, via: None, }, + acquired_at: active.revision + 1, }) ); // The yard is inside the hall, but a voice fills a room and not the @@ -9922,6 +9960,7 @@ mod knowledge_tests { Some(Knowledge { confidence: Confidence::Believed, source: KnowledgeSource::Seen { by: speech.speaker }, + acquired_at: active.revision + 1, }) ); for blind in [speech.bystander, speech.stranger, speech.speaker] { @@ -10260,6 +10299,7 @@ mod knowledge_tests { by: speech.speaker, via: Some(speech.horn), }, + acquired_at: active.revision + 1, }) ); } @@ -10779,6 +10819,7 @@ mod soul_knowledge_tests { by: speech.speaker, via: Some(speech.horn), }, + acquired_at: active.revision + 1, }) ); // The controller gains no knowledge of its own claim: it is outside @@ -11051,6 +11092,7 @@ mod soul_knowledge_tests { by: speech.speaker, via: None, }, + acquired_at: active.revision + 1, }) ); } @@ -11504,10 +11546,11 @@ mod witness_tests { .copied() } - fn seen(confidence: Confidence) -> Option { + fn seen(confidence: Confidence, acquired_at: u64) -> Option { Some(Knowledge { confidence, source: KnowledgeSource::Witnessed, + acquired_at, }) } @@ -11528,11 +11571,11 @@ mod witness_tests { ); assert_eq!( knows(&kernel, world.villager, world.asteroid), - seen(Confidence::Believed) + seen(Confidence::Believed, active.revision + 1) ); assert_eq!( knows(&kernel, world.neighbour, world.asteroid), - seen(Confidence::Believed) + seen(Confidence::Believed, active.revision + 1) ); assert_eq!(knows(&kernel, world.coaster, world.asteroid), None); assert_eq!(knows(&kernel, world.drifter, world.asteroid), None); @@ -11555,7 +11598,7 @@ mod witness_tests { ] { assert_eq!( knows(&kernel, subject, world.moon), - seen(Confidence::Certain) + seen(Confidence::Certain, after.revision + 1) ); } @@ -11586,8 +11629,10 @@ mod witness_tests { by: world.coaster, via: None, }, + acquired_at: 0, }; let mut state = kernel.state.clone(); + let state_revision = state.revision; state .knowledge .entry(world.villager) @@ -11609,6 +11654,7 @@ mod witness_tests { Knowledge { confidence: Confidence::Certain, source: KnowledgeSource::Witnessed, + acquired_at: state_revision + 1, } ); } @@ -11738,7 +11784,7 @@ mod witness_tests { ); assert_eq!( knows(&kernel, world.coaster, world.asteroid), - seen(Confidence::Certain) + seen(Confidence::Certain, seeded.revision + 1) ); // The inverse: the coast's only stander leaves before the witness, so @@ -11831,6 +11877,7 @@ mod witness_tests { site: Site::Operation(0) }] ); + let before_admit = kernel.state.revision; let admitted = submit(&mut kernel, world.village); assert!( matches!(admitted, Ok(SubmitReceipt::Applied(_))), @@ -11838,7 +11885,7 @@ mod witness_tests { ); assert_eq!( knows(&kernel, world.villager, world.asteroid), - seen(Confidence::Believed) + seen(Confidence::Believed, before_admit + 1) ); } @@ -12080,11 +12127,11 @@ mod witness_tests { ); assert_eq!( knows(&kernel, world.villager, world.asteroid), - seen(Confidence::Believed) + seen(Confidence::Believed, active.revision + 1) ); assert_eq!( knows(&kernel, world.neighbour, world.asteroid), - seen(Confidence::Believed) + seen(Confidence::Believed, active.revision + 1) ); assert_eq!(knows(&kernel, world.coaster, world.asteroid), None); @@ -12130,7 +12177,7 @@ mod witness_tests { assert_eq!(reopened.state, committed); assert_eq!( knows(&reopened, world.villager, world.asteroid), - seen(Confidence::Certain) + seen(Confidence::Certain, active.revision + 1) ); } @@ -12152,6 +12199,7 @@ mod witness_tests { Knowledge { confidence: Confidence::Doubted, source: KnowledgeSource::Witnessed, + acquired_at: 0, }, ); for root in [world.hemisphere, world.region, world.village, world.coast] { @@ -12227,6 +12275,7 @@ mod witness_tests { // reach it. Presence is read from `positions` alone: there is no second // notion of being somewhere for a placeless subject to fall back on. let mut adrift = kernel.state.clone(); + let adrift_revision = adrift.revision; adrift.positions.remove(&world.drifter); for root in [ world.hemisphere, @@ -12263,6 +12312,7 @@ mod witness_tests { Knowledge { confidence: Confidence::Certain, source: KnowledgeSource::Witnessed, + acquired_at: adrift_revision + 1, } ); } @@ -12364,6 +12414,7 @@ mod witness_tests { // Declared and placed under the witnessed place: the resolver's // candidate map carries the draft position, and the apply pass derives // the same subject from live state. + let before_arrival = kernel.state.revision; let receipt = arrival( &mut kernel, "arrival", @@ -12383,17 +12434,17 @@ mod witness_tests { .0; assert_eq!( knows(&kernel, arrived, world.asteroid), - seen(Confidence::Certain) + seen(Confidence::Certain, before_arrival + 1) ); - // The standers who already held it kept their own rows, so the landing - // was exactly the draft subject. + // The standers who already held it kept their own rows, from the + // earlier witness, and this landing did not touch them. assert_eq!( knows(&kernel, world.villager, world.asteroid), - seen(Confidence::Believed) + seen(Confidence::Believed, active.revision + 1) ); assert_eq!( knows(&kernel, world.neighbour, world.asteroid), - seen(Confidence::Believed) + seen(Confidence::Believed, active.revision + 1) ); } @@ -12445,12 +12496,12 @@ mod witness_tests { assert!(matches!(receipt, SubmitReceipt::Applied(_)), "{receipt:?}"); assert_eq!( knows(&kernel, world.villager, world.asteroid), - seen(Confidence::Certain), + seen(Confidence::Certain, seeded.revision + 1), "the forgotten subject was not re-reached" ); assert_eq!( knows(&kernel, world.neighbour, world.asteroid), - seen(Confidence::Believed), + seen(Confidence::Believed, active.revision + 1), "a holder the patch did not touch was overwritten" ); } diff --git a/crates/ghostlight/src/patch.rs b/crates/ghostlight/src/patch.rs index 16d07ac..8ab8417 100644 --- a/crates/ghostlight/src/patch.rs +++ b/crates/ghostlight/src/patch.rs @@ -417,6 +417,12 @@ pub(crate) enum KnowledgeSource { pub(crate) struct Knowledge { pub(crate) confidence: Confidence, pub(crate) source: KnowledgeSource, + /// The revision at which this subject acquired this row: witnessed, + /// told, shown, or seeded by an author. Written by every writer of this + /// map (PA.f44), so the Projector's recency marker can tell a row minted + /// with no `minted_at` — a patch, a seed, a ruled fact — from one the + /// subject already held before its last act. + pub(crate) acquired_at: u64, } /// The source an author may write. `Told` is unrepresentable here: only