Witness: one speakerless event over a place subtree
An event a region saw cost one AcquireKnowledge per witness against a 128-operation patch cap, so a hemisphere did not fit in a patch. `Witness` lands `Witnessed` knowledge of one fact on everyone standing anywhere under one place. The operation stores the place, never the recipients: the subtree is re-derived at apply from live positions through `under_place`, and holders are dropped through `unheld`, so a forged effect cannot assert a landing the world does not have and a repeat cannot reset a knower's credence. It names no speaker, so `Told` stays unrepresentable outside `Communicate`. A witnessed event is only its reception, so a witness that reaches nobody is refused — at resolve over the candidate graph, which now models the fan-out including this patch's own relocations, and at apply over committed state. Confinement is by the named place, which is not weaker than confining by the recipients: a consumer and an uncovered jurisdiction refuse every witness through the closure that already refuses every place. Both store schemas move to consumer.v2 and prior stores are refused at open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
307d8a5520
commit
ecdeb9214a
5 changed files with 1137 additions and 30 deletions
|
|
@ -950,6 +950,11 @@ fn lower(
|
|||
confidence: *confidence,
|
||||
}
|
||||
}
|
||||
ComponentOpKind::Witness { confidence } => patch::ComponentOp::Witness {
|
||||
fact: entity(0)?,
|
||||
place: entity(1)?,
|
||||
confidence: *confidence,
|
||||
},
|
||||
ComponentOpKind::Forget => patch::ComponentOp::Forget {
|
||||
subject: subject(0)?,
|
||||
fact: entity(1)?,
|
||||
|
|
@ -2642,12 +2647,12 @@ mod tests {
|
|||
/// fan-out (see the comment beside its construction in `patch.rs`), and
|
||||
/// that omission is safe only because no `ComponentOpKind` an affordance
|
||||
/// may declare can lower to a knowledge write carrying
|
||||
/// `KnowledgeSource::Told`. `AcquireKnowledge` is the only
|
||||
/// `ComponentOpKind` that touches knowledge at all, and `lower` fixes its
|
||||
/// source to `AuthoredSource::Witnessed` here, at the one call site that
|
||||
/// turns a declared slot into a `ComponentOp` — a type that has no `Told`
|
||||
/// variant to begin with. This test pins that lowering so a future author
|
||||
/// cannot widen it without this assertion naming the change.
|
||||
/// `KnowledgeSource::Told`. Two kinds touch knowledge: `AcquireKnowledge`,
|
||||
/// whose source `lower` fixes to `AuthoredSource::Witnessed` — a type with
|
||||
/// no `Told` variant to begin with — and `Witness`, which carries no source
|
||||
/// field and no speaker field at all. This test pins both lowerings, at the
|
||||
/// call sites that turn a declared slot into a `ComponentOp`, so a future
|
||||
/// author cannot widen either without this assertion naming the change.
|
||||
#[test]
|
||||
fn no_component_op_kind_lowers_to_a_told_knowledge_write() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
|
|
@ -2701,5 +2706,45 @@ mod tests {
|
|||
}],
|
||||
"AcquireKnowledge must lower with a Witnessed source, never a forged teller"
|
||||
);
|
||||
|
||||
let witnessing = Affordance {
|
||||
kind: AffordanceKindName("beacon".into()),
|
||||
roles: Vec::new(),
|
||||
preconditions: Vec::new(),
|
||||
effect_slots: vec![EffectSlot {
|
||||
op_kind: ComponentOpKind::Witness {
|
||||
confidence: Confidence::Believed,
|
||||
},
|
||||
roles: vec![Role("fact".into()), Role("place".into())],
|
||||
bounds: Bounds::None,
|
||||
}],
|
||||
outcome_bands: vec![OutcomeBand {
|
||||
weight: 1,
|
||||
effects: vec![0],
|
||||
}],
|
||||
carries_speech: false,
|
||||
};
|
||||
let places = BTreeMap::from([
|
||||
(Role("fact".into()), Target::Entity(bench.tithe)),
|
||||
(Role("place".into()), Target::Entity(bench.yard)),
|
||||
]);
|
||||
let lowered = lower(
|
||||
&witnessing,
|
||||
0,
|
||||
bench.clerk,
|
||||
FictionalMinutes::default(),
|
||||
&invocation,
|
||||
&places,
|
||||
)
|
||||
.expect("the witness slot lowers");
|
||||
assert_eq!(
|
||||
lowered,
|
||||
vec![ComponentOp::Witness {
|
||||
fact: PatchRef::Existing(bench.tithe),
|
||||
place: PatchRef::Existing(bench.yard),
|
||||
confidence: Confidence::Believed,
|
||||
}],
|
||||
"Witness must lower with no speaker field to forge"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2228,7 +2228,7 @@ mod tests {
|
|||
assert!(super::super::journal::verify_state_shape(&previous).is_err());
|
||||
assert_eq!(
|
||||
super::super::STATE_SCHEMA,
|
||||
"ghostlight.world_state.consumer.v1"
|
||||
"ghostlight.world_state.consumer.v2"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1218,6 +1218,34 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// A `Witness` reaches the store through two rows — a commit carries the
|
||||
/// admitted operations and the state row carries the event's effects — so
|
||||
/// both schemas bumped and both prior versions are refused at open. There
|
||||
/// is no migration adapter and no dual read.
|
||||
#[test]
|
||||
fn a_store_written_before_the_witness_schema_is_refused() {
|
||||
for (row_type, schema) in [
|
||||
(STATE_ROW, "ghostlight.world_state.consumer.v1"),
|
||||
(COMMIT_ROW, "ghostlight.world_commit.consumer.v1"),
|
||||
] {
|
||||
let row = CultCacheEnvelope {
|
||||
key: "state".into(),
|
||||
r#type: row_type.into(),
|
||||
payload: Vec::new(),
|
||||
stored_at: Utc::now().to_rfc3339(),
|
||||
schema_id: Some(schema.into()),
|
||||
};
|
||||
let error = recover(vec![row], None).unwrap_err();
|
||||
let JournalError::Corrupt(message) = error else {
|
||||
panic!("expected a corrupt store for {schema}");
|
||||
};
|
||||
assert!(
|
||||
message.contains("has the wrong schema"),
|
||||
"unexpected refusal for {schema}: {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.
|
||||
|
|
|
|||
|
|
@ -76,8 +76,8 @@ use std::path::Path;
|
|||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) const STATE_SCHEMA: &str = "ghostlight.world_state.consumer.v1";
|
||||
pub(crate) const COMMIT_SCHEMA: &str = "ghostlight.world_commit.consumer.v1";
|
||||
pub(crate) const STATE_SCHEMA: &str = "ghostlight.world_state.consumer.v2";
|
||||
pub(crate) const COMMIT_SCHEMA: &str = "ghostlight.world_commit.consumer.v2";
|
||||
|
||||
/// Compatibility tag derived from [`STATE_SCHEMA`]: the trailing
|
||||
/// `<family>-<version>` pair (e.g. `foundation-v1`). Callers that publish a
|
||||
|
|
@ -2553,6 +2553,42 @@ fn apply_operation(
|
|||
);
|
||||
}
|
||||
}
|
||||
ResolvedOp::Witness {
|
||||
fact,
|
||||
place,
|
||||
confidence,
|
||||
} => {
|
||||
if !state.facts.contains_key(fact)
|
||||
|| !state
|
||||
.entities
|
||||
.get(place)
|
||||
.is_some_and(|record| record.kind == EntityKind::Place)
|
||||
{
|
||||
return Err(KernelError::Invariant(
|
||||
"a witness names no live fact or place".into(),
|
||||
));
|
||||
}
|
||||
// Recipients are derived here and never stored: everyone standing
|
||||
// under the place, less everyone who already holds the fact. A
|
||||
// witness that reaches nobody is refused, because a witnessed event
|
||||
// is only its reception — `resolve_patch` refuses the same case over
|
||||
// the candidate graph, so the two layers cannot disagree.
|
||||
let recipients = unheld(state, *fact, under_place(state, *place));
|
||||
if recipients.is_empty() {
|
||||
return Err(KernelError::Invariant(
|
||||
"a witness reaches nobody who does not already hold the fact".into(),
|
||||
));
|
||||
}
|
||||
for witness in recipients {
|
||||
state.knowledge.entry(witness).or_default().insert(
|
||||
*fact,
|
||||
Knowledge {
|
||||
confidence: *confidence,
|
||||
source: KnowledgeSource::Witnessed,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
ResolvedOp::SetReach { channel, reach } => {
|
||||
let current = state
|
||||
.channels
|
||||
|
|
@ -4387,6 +4423,13 @@ fn operation_ground(
|
|||
(vec![*subject], Vec::new(), Vec::new())
|
||||
}
|
||||
ResolvedOp::Communicate { speaker, .. } => (vec![*speaker], Vec::new(), Vec::new()),
|
||||
// The place is the whole ground. Confining by it is not weaker than
|
||||
// confining by the recipients: `covers_place` is transitive, so every
|
||||
// subject the fan-out selects stands under the place, and a place
|
||||
// admitted under a root puts all of them under that root. Naming the
|
||||
// recipients here would be a second statement of the same rule,
|
||||
// computed from state this function does not read.
|
||||
ResolvedOp::Witness { place, .. } => (Vec::new(), places(vec![*place]), Vec::new()),
|
||||
ResolvedOp::SetReach { channel, reach } => {
|
||||
let mut subjects = Vec::new();
|
||||
let mut named = Vec::new();
|
||||
|
|
@ -10479,6 +10522,848 @@ mod soul_knowledge_tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// One witnessed event over a place subtree: who it reaches, who it leaves
|
||||
/// alone, where it is refused, and what it costs an in-flight binding.
|
||||
#[cfg(test)]
|
||||
mod witness_tests {
|
||||
use super::patch::Site;
|
||||
use super::tests::{
|
||||
activate, auth_principal, command, creation, operations, opportunity_for, owner,
|
||||
reject_owner, submit_owner,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
/// Nesting, because "under a place" is the whole question. A hemisphere
|
||||
/// holds a region and a far coast; the region holds a village and one
|
||||
/// unwalked hollow; subjects stand in the village, on the coast, and in the
|
||||
/// hemisphere under neither. The hollow is empty so an elaborator has a
|
||||
/// boundary to answer and a witness has a place that reaches nobody.
|
||||
struct Nesting {
|
||||
hemisphere: EntityId,
|
||||
region: EntityId,
|
||||
village: EntityId,
|
||||
coast: EntityId,
|
||||
hollow: EntityId,
|
||||
villager: SubjectId,
|
||||
neighbour: SubjectId,
|
||||
coaster: SubjectId,
|
||||
drifter: SubjectId,
|
||||
asteroid: EntityId,
|
||||
moon: EntityId,
|
||||
ferry: EdgeId,
|
||||
}
|
||||
|
||||
fn place(handle: &str, label: &str, container: Option<&str>) -> Declaration {
|
||||
Declaration::Entity(EntityDeclaration {
|
||||
handle: DraftHandle::new(handle),
|
||||
label: label.into(),
|
||||
kind: EntityKind::Place,
|
||||
container: container.map(|value| Ref::Draft(DraftHandle::new(value))),
|
||||
})
|
||||
}
|
||||
|
||||
fn stander(handle: &str, label: &str, place: &str, speak: &Ref<AffordanceId>) -> Declaration {
|
||||
Declaration::Subject(SubjectDeclaration {
|
||||
handle: DraftHandle::new(handle),
|
||||
label: label.into(),
|
||||
kind: SubjectKind::Person,
|
||||
controller: NewController::NarrativePersona,
|
||||
affordances: BTreeSet::from([speak.clone(), Ref::Draft(DraftHandle::new("beacon"))]),
|
||||
position: Some(Ref::Draft(DraftHandle::new(place))),
|
||||
})
|
||||
}
|
||||
|
||||
fn sky_fact(handle: &str, label: &str, statement: &str, evidence: &str) -> Declaration {
|
||||
Declaration::Fact(FactDeclaration {
|
||||
handle: DraftHandle::new(handle),
|
||||
label: label.into(),
|
||||
statement: Statement::new(statement).unwrap(),
|
||||
standing: FactStandingRef::Canonical {
|
||||
evidence: EvidenceRef::new(evidence),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn witness(fact: EntityId, place: EntityId, confidence: Confidence) -> ComponentOp {
|
||||
ComponentOp::Witness {
|
||||
fact: Ref::Existing(fact),
|
||||
place: Ref::Existing(place),
|
||||
confidence,
|
||||
}
|
||||
}
|
||||
|
||||
fn nesting_kernel(path: &Path, title: &str) -> (WorldKernel, Nesting, WorldSnapshot) {
|
||||
let mut kernel = WorldKernel::create(
|
||||
path.join("world.cc"),
|
||||
creation(CommandId::new(), title),
|
||||
&auth_principal(owner()),
|
||||
)
|
||||
.expect("a created world")
|
||||
.0;
|
||||
let before = kernel.snapshot().unwrap();
|
||||
let speak = super::tests::speak_entry(&kernel);
|
||||
submit_owner(
|
||||
&mut kernel,
|
||||
&before,
|
||||
CommandBody::AdmitPatch {
|
||||
answers: None,
|
||||
patch: WorldPatch {
|
||||
declarations: vec![
|
||||
place("hemisphere", "The Lit Hemisphere", None),
|
||||
place("region", "The Rhythm Region", Some("hemisphere")),
|
||||
place("village", "The Anchor Village", Some("region")),
|
||||
place("hollow", "The Unwalked Hollow", Some("region")),
|
||||
place("coast", "The Far Coast", Some("hemisphere")),
|
||||
Declaration::Route(RouteDeclaration {
|
||||
handle: DraftHandle::new("ferry"),
|
||||
label: "The Coast Ferry".into(),
|
||||
from: Ref::Draft(DraftHandle::new("coast")),
|
||||
to: Ref::Draft(DraftHandle::new("village")),
|
||||
access: AccessKind::Public,
|
||||
cost: Cost(1),
|
||||
}),
|
||||
Declaration::Route(RouteDeclaration {
|
||||
handle: DraftHandle::new("path"),
|
||||
label: "The Hollow Path".into(),
|
||||
from: Ref::Draft(DraftHandle::new("village")),
|
||||
to: Ref::Draft(DraftHandle::new("hollow")),
|
||||
access: AccessKind::Public,
|
||||
cost: Cost(1),
|
||||
}),
|
||||
// A world-authored verb with no speaker in it: the
|
||||
// proposer chooses which declared place the entry
|
||||
// binds and nothing else.
|
||||
Declaration::Affordance(AffordanceDeclaration {
|
||||
handle: DraftHandle::new("beacon"),
|
||||
kind: AffordanceKindName("beacon".into()),
|
||||
roles: vec![
|
||||
RoleSpec {
|
||||
role: Role("fact".into()),
|
||||
kind: RefKind::Entity(EntityKind::Fact),
|
||||
},
|
||||
RoleSpec {
|
||||
role: Role("place".into()),
|
||||
kind: RefKind::Entity(EntityKind::Place),
|
||||
},
|
||||
],
|
||||
preconditions: Vec::new(),
|
||||
effect_slots: vec![EffectSlot {
|
||||
op_kind: ComponentOpKind::Witness {
|
||||
confidence: Confidence::Believed,
|
||||
},
|
||||
roles: vec![Role("fact".into()), Role("place".into())],
|
||||
bounds: Bounds::None,
|
||||
}],
|
||||
outcome_bands: vec![OutcomeBand {
|
||||
weight: 1,
|
||||
effects: vec![0],
|
||||
}],
|
||||
carries_speech: false,
|
||||
}),
|
||||
stander("villager", "The Anchor Villager", "village", &speak),
|
||||
stander("neighbour", "The Anchor Neighbour", "village", &speak),
|
||||
stander("coaster", "The Coast Watcher", "coast", &speak),
|
||||
stander("drifter", "The Open Drifter", "hemisphere", &speak),
|
||||
sky_fact(
|
||||
"asteroid",
|
||||
"the asteroid over the region",
|
||||
"An asteroid crossed the sky above the region.",
|
||||
"vault:asteroid",
|
||||
),
|
||||
sky_fact(
|
||||
"moon",
|
||||
"the second moon",
|
||||
"A second moon rose over the hemisphere.",
|
||||
"vault:moon",
|
||||
),
|
||||
],
|
||||
operations: Vec::new(),
|
||||
evidence: vec![
|
||||
EvidenceRef::new("vault:asteroid"),
|
||||
EvidenceRef::new("vault:moon"),
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
let entity = |label: &str| {
|
||||
*kernel
|
||||
.state
|
||||
.entities
|
||||
.iter()
|
||||
.find(|(_, record)| record.label == label)
|
||||
.expect("a declared entity")
|
||||
.0
|
||||
};
|
||||
let subject = |label: &str| {
|
||||
*kernel
|
||||
.state
|
||||
.subjects
|
||||
.iter()
|
||||
.find(|(_, record)| record.label == label)
|
||||
.expect("a declared subject")
|
||||
.0
|
||||
};
|
||||
let nesting = Nesting {
|
||||
hemisphere: entity("The Lit Hemisphere"),
|
||||
region: entity("The Rhythm Region"),
|
||||
village: entity("The Anchor Village"),
|
||||
coast: entity("The Far Coast"),
|
||||
hollow: entity("The Unwalked Hollow"),
|
||||
villager: subject("The Anchor Villager"),
|
||||
neighbour: subject("The Anchor Neighbour"),
|
||||
coaster: subject("The Coast Watcher"),
|
||||
drifter: subject("The Open Drifter"),
|
||||
asteroid: entity("the asteroid over the region"),
|
||||
moon: entity("the second moon"),
|
||||
ferry: *kernel
|
||||
.state
|
||||
.edges
|
||||
.iter()
|
||||
.find(|(_, record)| record.label() == "The Coast Ferry")
|
||||
.expect("a declared route")
|
||||
.0,
|
||||
};
|
||||
let active = activate(&mut kernel);
|
||||
(kernel, nesting, active)
|
||||
}
|
||||
|
||||
fn knows(kernel: &WorldKernel, subject: SubjectId, fact: EntityId) -> Option<Knowledge> {
|
||||
kernel
|
||||
.state
|
||||
.knowledge
|
||||
.get(&subject)
|
||||
.and_then(|held| held.get(&fact))
|
||||
.copied()
|
||||
}
|
||||
|
||||
fn seen(confidence: Confidence) -> Option<Knowledge> {
|
||||
Some(Knowledge {
|
||||
confidence,
|
||||
source: KnowledgeSource::Witnessed,
|
||||
})
|
||||
}
|
||||
|
||||
/// The subtree is the whole reach: everyone under the named place, nobody
|
||||
/// beside it, and a wider place reaches the wider set.
|
||||
#[test]
|
||||
fn a_witness_reaches_every_subject_under_the_place_and_no_other() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let (mut kernel, world, active) = nesting_kernel(directory.path(), "Reach");
|
||||
submit_owner(
|
||||
&mut kernel,
|
||||
&active,
|
||||
operations(vec![witness(
|
||||
world.asteroid,
|
||||
world.region,
|
||||
Confidence::Believed,
|
||||
)]),
|
||||
);
|
||||
assert_eq!(
|
||||
knows(&kernel, world.villager, world.asteroid),
|
||||
seen(Confidence::Believed)
|
||||
);
|
||||
assert_eq!(
|
||||
knows(&kernel, world.neighbour, world.asteroid),
|
||||
seen(Confidence::Believed)
|
||||
);
|
||||
assert_eq!(knows(&kernel, world.coaster, world.asteroid), None);
|
||||
assert_eq!(knows(&kernel, world.drifter, world.asteroid), None);
|
||||
|
||||
let after = kernel.snapshot().unwrap();
|
||||
submit_owner(
|
||||
&mut kernel,
|
||||
&after,
|
||||
operations(vec![witness(
|
||||
world.moon,
|
||||
world.hemisphere,
|
||||
Confidence::Certain,
|
||||
)]),
|
||||
);
|
||||
for subject in [
|
||||
world.villager,
|
||||
world.neighbour,
|
||||
world.coaster,
|
||||
world.drifter,
|
||||
] {
|
||||
assert_eq!(
|
||||
knows(&kernel, subject, world.moon),
|
||||
seen(Confidence::Certain)
|
||||
);
|
||||
}
|
||||
|
||||
// No witness names a teller, so nothing in the world was told.
|
||||
assert!(
|
||||
kernel
|
||||
.state
|
||||
.knowledge
|
||||
.values()
|
||||
.flat_map(|held| held.values())
|
||||
.all(|entry| !matches!(entry.source, KnowledgeSource::Told { .. })),
|
||||
"a witness wrote a told row"
|
||||
);
|
||||
}
|
||||
|
||||
/// The knower owns its credence. A holder is dropped from the landing
|
||||
/// whatever it holds and however it came by it, so a witness cannot reset a
|
||||
/// mind by repeating itself.
|
||||
#[test]
|
||||
fn a_witness_never_overwrites_a_holder() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let (kernel, world, _) = nesting_kernel(directory.path(), "Holder");
|
||||
// `Told` has exactly one writer, so the holder's row is planted at the
|
||||
// layer that can hold one and the witness is applied beside it.
|
||||
let held = Knowledge {
|
||||
confidence: Confidence::Doubted,
|
||||
source: KnowledgeSource::Told {
|
||||
by: world.coaster,
|
||||
via: None,
|
||||
},
|
||||
};
|
||||
let mut state = kernel.state.clone();
|
||||
state
|
||||
.knowledge
|
||||
.entry(world.villager)
|
||||
.or_default()
|
||||
.insert(world.asteroid, held);
|
||||
apply_operation(
|
||||
&mut state,
|
||||
&ResolvedOp::Witness {
|
||||
fact: world.asteroid,
|
||||
place: world.region,
|
||||
confidence: Confidence::Certain,
|
||||
},
|
||||
&[],
|
||||
)
|
||||
.expect("the witness lands");
|
||||
assert_eq!(state.knowledge[&world.villager][&world.asteroid], held);
|
||||
assert_eq!(
|
||||
state.knowledge[&world.neighbour][&world.asteroid],
|
||||
Knowledge {
|
||||
confidence: Confidence::Certain,
|
||||
source: KnowledgeSource::Witnessed,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// A witnessed event is only its reception, so a landing that reaches
|
||||
/// nobody is not an event the world recorded. Both layers refuse it, and
|
||||
/// they refuse the same two cases: an empty subtree, and a subtree whose
|
||||
/// every stander already holds the fact.
|
||||
#[test]
|
||||
fn a_witness_over_nobody_is_refused_at_resolve_and_at_apply() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let (mut kernel, world, active) = nesting_kernel(directory.path(), "Nobody");
|
||||
let forged = |place: EntityId, state: &WorldState| {
|
||||
let mut state = state.clone();
|
||||
apply_operation(
|
||||
&mut state,
|
||||
&ResolvedOp::Witness {
|
||||
fact: world.asteroid,
|
||||
place,
|
||||
confidence: Confidence::Certain,
|
||||
},
|
||||
&[],
|
||||
)
|
||||
.unwrap_err()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
reject_owner(
|
||||
&mut kernel,
|
||||
&active,
|
||||
operations(vec![witness(
|
||||
world.asteroid,
|
||||
world.hollow,
|
||||
Confidence::Certain
|
||||
)]),
|
||||
),
|
||||
vec![Mismatch::NoOperationEffect { operation: 0 }]
|
||||
);
|
||||
assert!(matches!(
|
||||
forged(world.hollow, &kernel.state),
|
||||
KernelError::Invariant(message)
|
||||
if message == "a witness reaches nobody who does not already hold the fact"
|
||||
));
|
||||
|
||||
// Every stander already holds it, which is the same refusal.
|
||||
submit_owner(
|
||||
&mut kernel,
|
||||
&active,
|
||||
operations(vec![witness(
|
||||
world.asteroid,
|
||||
world.village,
|
||||
Confidence::Certain,
|
||||
)]),
|
||||
);
|
||||
let after = kernel.snapshot().unwrap();
|
||||
assert_eq!(
|
||||
reject_owner(
|
||||
&mut kernel,
|
||||
&after,
|
||||
operations(vec![witness(
|
||||
world.asteroid,
|
||||
world.village,
|
||||
Confidence::Certain
|
||||
)]),
|
||||
),
|
||||
vec![Mismatch::NoOperationEffect { operation: 0 }]
|
||||
);
|
||||
assert!(matches!(
|
||||
forged(world.village, &kernel.state),
|
||||
KernelError::Invariant(message)
|
||||
if message == "a witness reaches nobody who does not already hold the fact"
|
||||
));
|
||||
|
||||
// A forged effect naming a place that is not one, or no live fact, is
|
||||
// refused by the arm's own kind check before any of that.
|
||||
let mut state = kernel.state.clone();
|
||||
assert!(matches!(
|
||||
apply_operation(
|
||||
&mut state,
|
||||
&ResolvedOp::Witness {
|
||||
fact: world.asteroid,
|
||||
place: world.asteroid,
|
||||
confidence: Confidence::Certain,
|
||||
},
|
||||
&[],
|
||||
)
|
||||
.unwrap_err(),
|
||||
KernelError::Invariant(message) if message == "a witness names no live fact or place"
|
||||
));
|
||||
}
|
||||
|
||||
/// The candidate map models the fan-out, so a subject this patch moved into
|
||||
/// the subtree is a recipient and a subject it moved out is not. The resolve
|
||||
/// layer and the apply layer agree on both.
|
||||
#[test]
|
||||
fn a_mid_patch_relocation_into_the_subtree_counts() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let (mut kernel, world, active) = nesting_kernel(directory.path(), "Relocation");
|
||||
// The village's own standers already hold it, so the relocated coaster
|
||||
// is the only candidate recipient left.
|
||||
submit_owner(
|
||||
&mut kernel,
|
||||
&active,
|
||||
operations(vec![witness(
|
||||
world.asteroid,
|
||||
world.village,
|
||||
Confidence::Believed,
|
||||
)]),
|
||||
);
|
||||
let seeded = kernel.snapshot().unwrap();
|
||||
submit_owner(
|
||||
&mut kernel,
|
||||
&seeded,
|
||||
operations(vec![
|
||||
ComponentOp::Relocate {
|
||||
subject: Ref::Existing(world.coaster),
|
||||
via: Ref::Existing(world.ferry),
|
||||
},
|
||||
witness(world.asteroid, world.village, Confidence::Certain),
|
||||
]),
|
||||
);
|
||||
assert_eq!(
|
||||
kernel.state.positions[&world.coaster],
|
||||
Position {
|
||||
place: world.village
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
knows(&kernel, world.coaster, world.asteroid),
|
||||
seen(Confidence::Certain)
|
||||
);
|
||||
|
||||
// The inverse: the coast's only stander leaves before the witness, so
|
||||
// the coast reaches nobody. Both layers say so.
|
||||
let moved = kernel.snapshot().unwrap();
|
||||
assert_eq!(
|
||||
reject_owner(
|
||||
&mut kernel,
|
||||
&moved,
|
||||
operations(vec![witness(world.moon, world.coast, Confidence::Certain)]),
|
||||
),
|
||||
vec![Mismatch::NoOperationEffect { operation: 0 }]
|
||||
);
|
||||
let mut state = kernel.state.clone();
|
||||
assert!(matches!(
|
||||
apply_operations(
|
||||
&mut state,
|
||||
&[ResolvedOp::Witness {
|
||||
fact: world.moon,
|
||||
place: world.coast,
|
||||
confidence: Confidence::Certain,
|
||||
}],
|
||||
&[],
|
||||
)
|
||||
.unwrap_err(),
|
||||
KernelError::Invariant(message)
|
||||
if message == "a witness reaches nobody who does not already hold the fact"
|
||||
));
|
||||
}
|
||||
|
||||
/// The place is the whole ground, and it is not a weaker confinement than
|
||||
/// the recipients: everyone the fan-out selects stands under it. An
|
||||
/// elaborator witnesses inside its root, and nowhere outside or above it.
|
||||
#[test]
|
||||
fn an_elaborator_may_witness_inside_its_root_and_not_outside_or_above() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let (mut kernel, world, _) = nesting_kernel(directory.path(), "Jurisdiction");
|
||||
let answer = derive_boundaries(&kernel.state)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|boundary| {
|
||||
matches!(boundary, CausalBoundary::UnelaboratedDestination { place, .. }
|
||||
if *place == world.hollow)
|
||||
})
|
||||
.expect("the hollow is an unelaborated destination");
|
||||
let submit = |kernel: &mut WorldKernel, place: EntityId| {
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
let caller = CallerId::System(SystemCapability::Elaborator {
|
||||
jurisdiction: JurisdictionKey::PlaceSubtree(world.region),
|
||||
});
|
||||
kernel.submit(
|
||||
command(
|
||||
&snapshot,
|
||||
CommandId::new(),
|
||||
caller.clone(),
|
||||
CommandBody::AdmitPatch {
|
||||
answers: Some(PatchAnswer::Boundary(answer.clone())),
|
||||
patch: WorldPatch {
|
||||
// The boundary the elaborator answers: the hollow
|
||||
// grows a room. The witness rides beside it, and
|
||||
// only the witness is confined by its place.
|
||||
declarations: vec![Declaration::Entity(EntityDeclaration {
|
||||
handle: DraftHandle::new("shed"),
|
||||
label: "The Hollow Shed".into(),
|
||||
kind: EntityKind::Place,
|
||||
container: Some(Ref::Existing(world.hollow)),
|
||||
})],
|
||||
operations: vec![witness(world.asteroid, place, Confidence::Believed)],
|
||||
evidence: Vec::new(),
|
||||
},
|
||||
},
|
||||
),
|
||||
&AuthenticatedCaller::fixture(caller),
|
||||
)
|
||||
};
|
||||
let outside = |result: Result<SubmitReceipt, KernelError>| match result {
|
||||
Err(KernelError::PatchRejected(set)) => set,
|
||||
other => panic!("expected a confined refusal, got {other:?}"),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
outside(submit(&mut kernel, world.coast)),
|
||||
vec![Mismatch::OutsideJurisdiction {
|
||||
site: Site::Operation(0)
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
outside(submit(&mut kernel, world.hemisphere)),
|
||||
vec![Mismatch::OutsideJurisdiction {
|
||||
site: Site::Operation(0)
|
||||
}]
|
||||
);
|
||||
let admitted = submit(&mut kernel, world.village);
|
||||
assert!(
|
||||
matches!(admitted, Ok(SubmitReceipt::Applied(_))),
|
||||
"{admitted:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
knows(&kernel, world.villager, world.asteroid),
|
||||
seen(Confidence::Believed)
|
||||
);
|
||||
}
|
||||
|
||||
/// A consumer names no ground, and a jurisdiction over placeless subjects
|
||||
/// names no place. Both refuse every witness through the same closure, with
|
||||
/// no rule in `confine_to_ground` naming the operation.
|
||||
#[test]
|
||||
fn a_consumer_cannot_witness() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let (kernel, world, _) = nesting_kernel(directory.path(), "Consumer");
|
||||
let resolved = patch::resolve_patch(
|
||||
&kernel.state,
|
||||
CommandId::new(),
|
||||
&WorldPatch {
|
||||
declarations: Vec::new(),
|
||||
operations: vec![witness(world.asteroid, world.village, Confidence::Certain)],
|
||||
evidence: Vec::new(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.expect("the witness resolves over committed state");
|
||||
for ground in [
|
||||
PatchGround::Consumer(ConsumerId::of_name("witness.consumer")),
|
||||
PatchGround::Jurisdiction(JurisdictionKey::Uncovered),
|
||||
] {
|
||||
assert_eq!(
|
||||
confine_to_ground(&kernel.state, &resolved, ground),
|
||||
Err(vec![Mismatch::OutsideJurisdiction {
|
||||
site: Site::Operation(0)
|
||||
}]),
|
||||
"{ground:?} admitted a witness"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The digest reads the keys of a subject's own knowledge, so a witness
|
||||
/// moves exactly the digests of the subjects that learned something.
|
||||
#[test]
|
||||
fn a_witness_moves_every_recipients_scope_digest_and_nobody_elses() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let (mut kernel, world, active) = nesting_kernel(directory.path(), "Digest");
|
||||
submit_owner(
|
||||
&mut kernel,
|
||||
&active,
|
||||
operations(vec![ComponentOp::AcquireKnowledge {
|
||||
subject: Ref::Existing(world.neighbour),
|
||||
fact: Ref::Existing(world.asteroid),
|
||||
source: AuthoredSource::Witnessed,
|
||||
confidence: Confidence::Doubted,
|
||||
}]),
|
||||
);
|
||||
let digest = |kernel: &WorldKernel, subject_id: SubjectId| {
|
||||
scope_digest(&kernel.state, DecisionScope { subject_id }).unwrap()
|
||||
};
|
||||
let learner = digest(&kernel, world.villager);
|
||||
let holder = digest(&kernel, world.neighbour);
|
||||
let outsider = digest(&kernel, world.coaster);
|
||||
|
||||
let seeded = kernel.snapshot().unwrap();
|
||||
submit_owner(
|
||||
&mut kernel,
|
||||
&seeded,
|
||||
operations(vec![witness(
|
||||
world.asteroid,
|
||||
world.region,
|
||||
Confidence::Certain,
|
||||
)]),
|
||||
);
|
||||
assert_ne!(digest(&kernel, world.villager), learner);
|
||||
assert_eq!(digest(&kernel, world.neighbour), holder);
|
||||
assert_eq!(digest(&kernel, world.coaster), outsider);
|
||||
}
|
||||
|
||||
/// A witness is the loudest producer of a moved scope: every subject that
|
||||
/// learned something loses its in-flight binding, and every subject that
|
||||
/// already knew keeps it. What the learner sees carries no speaker.
|
||||
#[test]
|
||||
fn a_witness_between_a_bound_opportunity_and_its_exercise_is_a_scope_change() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let (mut kernel, world, active) = nesting_kernel(directory.path(), "Interruption");
|
||||
submit_owner(
|
||||
&mut kernel,
|
||||
&active,
|
||||
operations(vec![ComponentOp::AcquireKnowledge {
|
||||
subject: Ref::Existing(world.neighbour),
|
||||
fact: Ref::Existing(world.asteroid),
|
||||
source: AuthoredSource::Witnessed,
|
||||
confidence: Confidence::Doubted,
|
||||
}]),
|
||||
);
|
||||
let bound = kernel.snapshot().unwrap();
|
||||
let learner = opportunity_for(&bound, world.villager);
|
||||
let holder = opportunity_for(&bound, world.neighbour);
|
||||
submit_owner(
|
||||
&mut kernel,
|
||||
&bound,
|
||||
operations(vec![witness(
|
||||
world.asteroid,
|
||||
world.region,
|
||||
Confidence::Certain,
|
||||
)]),
|
||||
);
|
||||
|
||||
let exercise = |kernel: &mut WorldKernel, opportunity: &DecisionOpportunity| {
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
let speak = *opportunity
|
||||
.affordance_ids
|
||||
.iter()
|
||||
.find(|id| {
|
||||
snapshot
|
||||
.affordances
|
||||
.iter()
|
||||
.any(|entry| entry.id == **id && entry.entry.carries_speech)
|
||||
})
|
||||
.expect("the scope holds a speech-carrying entry");
|
||||
let caller = CallerId::Controller(opportunity.controller_id);
|
||||
kernel.submit(
|
||||
command(
|
||||
&snapshot,
|
||||
CommandId::new(),
|
||||
caller.clone(),
|
||||
CommandBody::ExerciseDecision {
|
||||
invocation: DecisionInvocation {
|
||||
affordance: speak,
|
||||
bindings: Vec::new(),
|
||||
proposed: Vec::new(),
|
||||
speech: Some(Statement::new("The sky moved.").unwrap()),
|
||||
},
|
||||
opportunity: opportunity.clone(),
|
||||
},
|
||||
),
|
||||
&AuthenticatedCaller::fixture(caller),
|
||||
)
|
||||
};
|
||||
let error = exercise(&mut kernel, &learner).unwrap_err();
|
||||
let KernelError::ScopeChanged {
|
||||
scope,
|
||||
expected,
|
||||
actual,
|
||||
} = error
|
||||
else {
|
||||
panic!("expected a changed scope, got {error:?}");
|
||||
};
|
||||
assert_eq!(scope, learner.scope);
|
||||
assert_eq!(expected, learner.scope_digest);
|
||||
assert_ne!(expected, actual);
|
||||
|
||||
// The subject that already held the fact learned nothing, so its
|
||||
// binding survives the same witness.
|
||||
let kept = exercise(&mut kernel, &holder);
|
||||
assert!(matches!(kept, Ok(SubmitReceipt::Applied(_))), "{kept:?}");
|
||||
|
||||
// What the learner now holds names no speaker: it knows the thing, not
|
||||
// the telling.
|
||||
let entry = knows(&kernel, world.villager, world.asteroid).expect("the learner holds it");
|
||||
assert_eq!(entry.source, KnowledgeSource::Witnessed);
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
let held = snapshot
|
||||
.subjects
|
||||
.iter()
|
||||
.find(|subject| subject.id == world.villager)
|
||||
.expect("the learner is snapshotted")
|
||||
.knowledge
|
||||
.iter()
|
||||
.find(|row| row.fact == world.asteroid)
|
||||
.expect("the learner's snapshot carries the fact");
|
||||
assert_eq!(held.source, KnowledgeSource::Witnessed);
|
||||
assert!(
|
||||
held.spoken_at.is_none(),
|
||||
"a witnessed row named a speech act"
|
||||
);
|
||||
}
|
||||
|
||||
/// The slot names a fact and a place, never a subject, so an exercised
|
||||
/// witness lands the subtree with no teller anywhere in it. A slot whose
|
||||
/// place position binds a subject is refused before it is ever declared.
|
||||
#[test]
|
||||
fn a_witness_affordance_lowers_with_no_speaker() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let (mut kernel, world, active) = nesting_kernel(directory.path(), "Affordance");
|
||||
let opportunity = opportunity_for(&active, world.villager);
|
||||
let beacon = *opportunity
|
||||
.affordance_ids
|
||||
.iter()
|
||||
.find(|id| {
|
||||
active
|
||||
.affordances
|
||||
.iter()
|
||||
.any(|entry| entry.id == **id && entry.entry.kind.0 == "beacon")
|
||||
})
|
||||
.expect("the villager holds the beacon entry");
|
||||
let caller = CallerId::Controller(opportunity.controller_id);
|
||||
kernel
|
||||
.submit(
|
||||
command(
|
||||
&active,
|
||||
CommandId::new(),
|
||||
caller.clone(),
|
||||
CommandBody::ExerciseDecision {
|
||||
invocation: DecisionInvocation {
|
||||
affordance: beacon,
|
||||
bindings: vec![
|
||||
RoleBinding {
|
||||
role: Role("fact".into()),
|
||||
target: Target::Entity(world.asteroid),
|
||||
},
|
||||
RoleBinding {
|
||||
role: Role("place".into()),
|
||||
target: Target::Entity(world.region),
|
||||
},
|
||||
],
|
||||
proposed: vec![ProposedEffect {
|
||||
slot: 0,
|
||||
magnitude: Magnitude::None,
|
||||
}],
|
||||
speech: None,
|
||||
},
|
||||
opportunity: opportunity.clone(),
|
||||
},
|
||||
),
|
||||
&AuthenticatedCaller::fixture(caller),
|
||||
)
|
||||
.expect("the beacon lands");
|
||||
assert_eq!(
|
||||
kernel
|
||||
.state
|
||||
.events
|
||||
.last()
|
||||
.expect("a committed event")
|
||||
.effects,
|
||||
vec![ResolvedOp::Witness {
|
||||
fact: world.asteroid,
|
||||
place: world.region,
|
||||
confidence: Confidence::Believed,
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
knows(&kernel, world.villager, world.asteroid),
|
||||
seen(Confidence::Believed)
|
||||
);
|
||||
assert_eq!(
|
||||
knows(&kernel, world.neighbour, world.asteroid),
|
||||
seen(Confidence::Believed)
|
||||
);
|
||||
assert_eq!(knows(&kernel, world.coaster, world.asteroid), None);
|
||||
|
||||
// The second role is a place, and a subject cannot serve it, so a slot
|
||||
// that named one is refused at affordance declaration.
|
||||
let rules = ComponentOpKind::Witness {
|
||||
confidence: Confidence::Believed,
|
||||
}
|
||||
.arity();
|
||||
assert_eq!(rules.len(), 2);
|
||||
assert!(patch::role_kind_fits(
|
||||
&rules[0],
|
||||
RefKind::Entity(EntityKind::Fact)
|
||||
));
|
||||
assert!(patch::role_kind_fits(
|
||||
&rules[1],
|
||||
RefKind::Entity(EntityKind::Place)
|
||||
));
|
||||
assert!(!patch::role_kind_fits(&rules[1], RefKind::Subject(None)));
|
||||
}
|
||||
|
||||
/// A journal carrying a witness replays to digest equality, and the rows it
|
||||
/// wrote pass the shape check with no clause of their own.
|
||||
#[test]
|
||||
fn a_world_with_a_witness_replays() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let (mut kernel, world, active) = nesting_kernel(directory.path(), "Replay");
|
||||
submit_owner(
|
||||
&mut kernel,
|
||||
&active,
|
||||
operations(vec![witness(
|
||||
world.asteroid,
|
||||
world.region,
|
||||
Confidence::Certain,
|
||||
)]),
|
||||
);
|
||||
assert!(journal::verify_state_shape(&kernel.state).is_ok());
|
||||
let world_id = kernel.state.world_id;
|
||||
let committed = kernel.state.clone();
|
||||
drop(kernel);
|
||||
let reopened =
|
||||
WorldKernel::open(directory.path().join("world.cc"), world_id).expect("it replays");
|
||||
assert_eq!(reopened.state, committed);
|
||||
assert_eq!(
|
||||
knows(&reopened, world.villager, world.asteroid),
|
||||
seen(Confidence::Certain)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The clock, commitments, pressure, ordered attention, boundaries, and scale.
|
||||
#[cfg(test)]
|
||||
mod clock_tests {
|
||||
|
|
|
|||
|
|
@ -364,8 +364,9 @@ pub(crate) enum Confidence {
|
|||
Certain,
|
||||
}
|
||||
|
||||
/// Three sources, three writers, no overlap: `Witnessed` and `Evidenced` are
|
||||
/// written by `AcquireKnowledge`, `Told` only by `Communicate`.
|
||||
/// Three sources with named writers: `Witnessed` is written by
|
||||
/// `AcquireKnowledge` and by `Witness`, `Evidenced` by `AcquireKnowledge`, and
|
||||
/// `Told` only by `Communicate`.
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(tag = "source", rename_all = "snake_case")]
|
||||
pub(crate) enum KnowledgeSource {
|
||||
|
|
@ -666,6 +667,12 @@ pub(crate) enum Precondition {
|
|||
/// `CloseForum` are absent because constituting an office or a forum has no
|
||||
/// live in-play reader an affordance would serve; they stay patch-lane only.
|
||||
///
|
||||
/// `Witness` is a slot and `Communicate` is not, because a witnessed event is
|
||||
/// world-caused and names no speaker, while speech is the actor's own act and
|
||||
/// reaches an invocation only through the kernel-owned `carries_speech`
|
||||
/// lowering: a proposer that could `Communicate` would choose a speaker, and a
|
||||
/// proposer that `Witness`es chooses only where.
|
||||
///
|
||||
/// The four civic variants carry their payload on the variant rather than on
|
||||
/// the slot or the invocation: an authority kind and an office name are not
|
||||
/// referents, so they cannot be roles, and a proposer's only degree of freedom
|
||||
|
|
@ -699,6 +706,15 @@ pub(crate) enum ComponentOpKind {
|
|||
AcquireKnowledge {
|
||||
confidence: Confidence,
|
||||
},
|
||||
/// A world-authored event with no speaker: a beacon, a bell, a blast. The
|
||||
/// slot names a fact and a place, never a subject, so a proposer's only
|
||||
/// degree of freedom is which declared place the entry binds. The source is
|
||||
/// `Witnessed` by construction — `ComponentOp::Witness` has no field a
|
||||
/// teller could enter — so this lowering cannot forge one however it is
|
||||
/// bound.
|
||||
Witness {
|
||||
confidence: Confidence,
|
||||
},
|
||||
Forget,
|
||||
/// The kind, the horizon, and the period are fixed on the variant, so a
|
||||
/// proposer's only degree of freedom stays magnitude. `due` is
|
||||
|
|
@ -756,6 +772,7 @@ impl ComponentOpKind {
|
|||
Self::AcquireKnowledge { .. } | Self::Forget => {
|
||||
vec![subject(), RoleKindRule::Exact(FACT)]
|
||||
}
|
||||
Self::Witness { .. } => vec![RoleKindRule::Exact(FACT), RoleKindRule::Exact(PLACE)],
|
||||
// Promisor and counterparty. A `Goal` slot is refused at admission
|
||||
// with `GoalWithCounterparty`: a promise to oneself has no second
|
||||
// referent to bind, so the action lane authors `Routine` and
|
||||
|
|
@ -1072,6 +1089,17 @@ pub(crate) enum ComponentOp {
|
|||
fact: Ref<EntityId>,
|
||||
to: AudienceRef,
|
||||
},
|
||||
/// One witnessed event over a place subtree: a beacon, a bell, a blast, an
|
||||
/// asteroid. It names no speaker, because the row it lands is
|
||||
/// `KnowledgeSource::Witnessed` — there is no teller to forge and none is
|
||||
/// implied. It stores the place, never the recipients: the subtree is
|
||||
/// re-derived at apply from live `positions`. Everyone already holding the
|
||||
/// fact is untouched, and a witness that reaches nobody is refused.
|
||||
Witness {
|
||||
fact: Ref<EntityId>,
|
||||
place: Ref<EntityId>,
|
||||
confidence: Confidence,
|
||||
},
|
||||
Forget {
|
||||
subject: Ref<SubjectId>,
|
||||
fact: Ref<EntityId>,
|
||||
|
|
@ -1704,6 +1732,15 @@ pub(crate) enum ResolvedOp {
|
|||
fact: EntityId,
|
||||
to: Audience,
|
||||
},
|
||||
/// Stores the place, never the recipients. `apply_operation` re-derives the
|
||||
/// subtree from live `positions`, so a forged effect cannot assert a
|
||||
/// landing the world does not have. There is no speaker field, so this
|
||||
/// operation cannot write `KnowledgeSource::Told` however it is forged.
|
||||
Witness {
|
||||
fact: EntityId,
|
||||
place: EntityId,
|
||||
confidence: Confidence,
|
||||
},
|
||||
Forget {
|
||||
subject: SubjectId,
|
||||
fact: EntityId,
|
||||
|
|
@ -3097,23 +3134,29 @@ pub(super) fn resolve_patch(
|
|||
})
|
||||
.collect();
|
||||
// Keys and payload, so an `AcquireKnowledge` that changes nothing is
|
||||
// `NoOperationEffect` before any ID is minted. A `Communicate` never enters:
|
||||
// its fan-out is re-derived at apply, and a telling is a canonical change
|
||||
// whatever the room holds.
|
||||
// `NoOperationEffect` before any ID is minted. `Witness` is modelled here
|
||||
// too: its landing is a subtree of `positions`, both maps are candidate
|
||||
// maps, and so the fan-out it will perform at apply is derivable now — a
|
||||
// subject this patch relocated into the subtree counts, and a witness that
|
||||
// reaches nobody is refused at resolve exactly as `apply_operation`
|
||||
// refuses it.
|
||||
//
|
||||
// This candidate map does not model `Communicate` at all — it has no case
|
||||
// that inserts a `Told` entry into it, so it cannot answer "does this
|
||||
// patch already know what a pending telling would land." That is safe
|
||||
// today only because no `ComponentOp` can construct a `KnowledgeSource`
|
||||
// carrying `Told`: `AcquireKnowledge` takes an `AuthoredSource`, and that
|
||||
// type has exactly two variants, `Witnessed` and `Evidenced` — `Told` is
|
||||
// unrepresentable there by construction (see `AuthoredSource`'s own doc
|
||||
// comment), and `action::no_component_op_kind_lowers_to_a_told_knowledge_write`
|
||||
// pins the one lowering site that could otherwise drift. If a future
|
||||
// operation is ever given the power to write `Told` directly — bypassing
|
||||
// `Communicate`'s own apply-time fan-out — it must model that fan-out into
|
||||
// this candidate graph first, or `resolve_patch` will silently reason
|
||||
// about a knowledge state the apply pass will not produce.
|
||||
// `Communicate` never enters, and the difference is not scale. A telling is
|
||||
// an act whose occurrence does not depend on its reception: its empty
|
||||
// fan-out is legal, so there is nothing for this map to refuse and no
|
||||
// refusal for it to predict. A witnessed event is only its reception, so
|
||||
// its landing is its legality.
|
||||
//
|
||||
// Neither operation can write `Told`. This map can represent a `Told`
|
||||
// entry, but no `ComponentOp` can construct one: `AcquireKnowledge` takes
|
||||
// an `AuthoredSource`, `Witness` takes no source at all, and neither type
|
||||
// has a `Told` variant —
|
||||
// `action::no_component_op_kind_lowers_to_a_told_knowledge_write` pins both
|
||||
// lowering sites that could otherwise drift. If a future operation is ever
|
||||
// given the power to write `Told` directly — bypassing `Communicate`'s own
|
||||
// apply-time fan-out — it must model that fan-out into this candidate graph
|
||||
// first, or `resolve_patch` will silently reason about a knowledge state
|
||||
// the apply pass will not produce.
|
||||
let mut knowledge: BTreeMap<(Key<SubjectId>, Key<EntityId>), KnowledgeCandidate> = state
|
||||
.knowledge
|
||||
.iter()
|
||||
|
|
@ -4255,6 +4298,59 @@ pub(super) fn resolve_patch(
|
|||
});
|
||||
}
|
||||
}
|
||||
ComponentOp::Witness {
|
||||
fact,
|
||||
place,
|
||||
confidence,
|
||||
} => {
|
||||
let fact_key = resolve_entity(
|
||||
Site::Operation(position),
|
||||
EntityKind::Fact,
|
||||
fact,
|
||||
&index,
|
||||
&state.entities,
|
||||
&mut mismatches,
|
||||
);
|
||||
let place_key = resolve_entity(
|
||||
Site::Operation(position),
|
||||
EntityKind::Place,
|
||||
place,
|
||||
&index,
|
||||
&state.entities,
|
||||
&mut mismatches,
|
||||
);
|
||||
let (Some(fact_key), Some(place_key)) = (fact_key, place_key) else {
|
||||
continue;
|
||||
};
|
||||
// The apply-time fan-out, modelled: every candidate subject
|
||||
// standing under the place — canonical positions plus this
|
||||
// patch's own declarations and its own relocations, which
|
||||
// earlier arms have already written into `positions` — less
|
||||
// everyone this map already has holding the fact, which is the
|
||||
// same "already a knower" test `unheld` applies over committed
|
||||
// state.
|
||||
let entry = KnowledgeCandidate {
|
||||
confidence: *confidence,
|
||||
source: KnowledgeSourceKey::Witnessed,
|
||||
};
|
||||
let recipients: Vec<Key<SubjectId>> = positions
|
||||
.iter()
|
||||
.filter(|(subject_key, standing)| {
|
||||
key_covers_place(&place_key, standing, &containers)
|
||||
&& !knowledge.contains_key(&((*subject_key).clone(), fact_key.clone()))
|
||||
})
|
||||
.map(|(subject_key, _)| (*subject_key).clone())
|
||||
.collect();
|
||||
if recipients.is_empty() {
|
||||
mismatches.push(Mismatch::NoOperationEffect {
|
||||
operation: position,
|
||||
});
|
||||
} else {
|
||||
for subject_key in recipients {
|
||||
knowledge.insert((subject_key, fact_key.clone()), entry.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
ComponentOp::SetReach { channel, reach } => {
|
||||
let channel_key = resolve_entity(
|
||||
Site::Operation(position),
|
||||
|
|
@ -4976,6 +5072,15 @@ pub(super) fn resolve_patch(
|
|||
fact: entity_id_of(&key_of(fact)),
|
||||
to: audience_of(to),
|
||||
},
|
||||
ComponentOp::Witness {
|
||||
fact,
|
||||
place,
|
||||
confidence,
|
||||
} => ResolvedOp::Witness {
|
||||
fact: entity_id_of(&key_of(fact)),
|
||||
place: entity_id_of(&key_of(place)),
|
||||
confidence: *confidence,
|
||||
},
|
||||
ComponentOp::Forget { subject, fact } => ResolvedOp::Forget {
|
||||
subject: subject_id_of(&key_of(subject)),
|
||||
fact: entity_id_of(&key_of(fact)),
|
||||
|
|
@ -5685,6 +5790,16 @@ pub(crate) const PATCH_TOOLS: &[PatchTool] = &[
|
|||
variant: "communicate",
|
||||
},
|
||||
},
|
||||
PatchTool {
|
||||
name: "witness",
|
||||
description: "One witnessed event over a place. Every subject standing anywhere under that place learns the fact. There is no speaker: the knowledge lands as witnessed, not as told. Anyone who already holds the fact is untouched, and a witness that reaches nobody is refused. The recipients are re-derived from live positions.",
|
||||
fields: &[
|
||||
field("fact", PatchFieldKind::Reference("fact")),
|
||||
field("place", PatchFieldKind::Reference("place")),
|
||||
field("confidence", PatchFieldKind::Choice(CONFIDENCES)),
|
||||
],
|
||||
shape: PatchToolShape::Operate { variant: "witness" },
|
||||
},
|
||||
PatchTool {
|
||||
name: "forget",
|
||||
description: "Remove one fact from one subject's knowledge.",
|
||||
|
|
@ -6375,6 +6490,39 @@ mod catalog_tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// The witness tool is built from field kinds the catalog already emits, so
|
||||
/// it needs no `CompositeShape` and no emitter arm: its exemplar decodes to
|
||||
/// the variant, exactly one entry accepts it, and its schema is strict on
|
||||
/// the same terms as every other tool's.
|
||||
#[test]
|
||||
fn the_witness_tool_round_trips_and_its_schema_is_strict() {
|
||||
let entry = PATCH_TOOLS
|
||||
.iter()
|
||||
.find(|entry| entry.name == "witness")
|
||||
.expect("the catalog carries a witness tool");
|
||||
let arguments = arguments_for(entry);
|
||||
assert_eq!(arguments.get("op").and_then(Value::as_str), Some("witness"));
|
||||
let operation: ComponentOp =
|
||||
serde_json::from_value(arguments.clone()).expect("the witness exemplar decodes");
|
||||
assert!(matches!(operation, ComponentOp::Witness { .. }));
|
||||
assert_eq!(
|
||||
PATCH_TOOLS
|
||||
.iter()
|
||||
.filter(|other| names(other, &arguments))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
let schema: Value = serde_json::from_str(
|
||||
&patch_tools()
|
||||
.into_iter()
|
||||
.find(|tool| tool.name == "witness")
|
||||
.expect("the emitter names it")
|
||||
.parameters_json,
|
||||
)
|
||||
.unwrap();
|
||||
super::super::tool_schema::assert_strict(&schema, "witness");
|
||||
}
|
||||
|
||||
/// One arguments object per entry, one example value per field, with the
|
||||
/// entry's serde tag and its fixed pairs injected exactly as the evaluator
|
||||
/// injects them.
|
||||
|
|
@ -6469,7 +6617,7 @@ mod catalog_tests {
|
|||
|
||||
// Seven declaration shapes over six `Declaration` variants — `Entity`
|
||||
// splits by kind, because a `kind` field would be a door slammed for
|
||||
// half its values — plus twenty-eight operations and two session tools.
|
||||
// half its values — plus twenty-nine operations and two session tools.
|
||||
// `assert_exhaustive` below breaks the build when a variant is added;
|
||||
// these counts are where the addition is restated.
|
||||
let declarations = PATCH_TOOLS
|
||||
|
|
@ -6480,7 +6628,7 @@ mod catalog_tests {
|
|||
.iter()
|
||||
.filter(|entry| matches!(entry.shape, PatchToolShape::Operate { .. }))
|
||||
.count();
|
||||
assert_eq!((declarations, operations, PATCH_TOOLS.len()), (7, 28, 37));
|
||||
assert_eq!((declarations, operations, PATCH_TOOLS.len()), (7, 29, 38));
|
||||
|
||||
// Every declaration variant the vocabulary owns is reachable, and the
|
||||
// two payload-carrying entity kinds are not exposed as an `Entity`
|
||||
|
|
@ -6564,6 +6712,7 @@ mod catalog_tests {
|
|||
ComponentOp::CloseForum { .. } => "close_forum",
|
||||
ComponentOp::AcquireKnowledge { .. } => "acquire_knowledge",
|
||||
ComponentOp::Communicate { .. } => "communicate",
|
||||
ComponentOp::Witness { .. } => "witness",
|
||||
ComponentOp::Forget { .. } => "forget",
|
||||
ComponentOp::SetReach { .. } => "set_reach",
|
||||
ComponentOp::SetController { .. } => "set_controller",
|
||||
|
|
@ -6623,7 +6772,7 @@ mod catalog_tests {
|
|||
.count();
|
||||
assert_eq!(
|
||||
(declare_tools, operate_tools, PATCH_TOOLS.len()),
|
||||
(7, 28, 37)
|
||||
(7, 29, 38)
|
||||
);
|
||||
|
||||
let declarations = every_declaration();
|
||||
|
|
|
|||
Loading…
Reference in a new issue