Correct actor Persona semantics once

This commit is contained in:
Meta Vens 2026-08-25 14:51:09 +02:00
commit 32ff501b2e
2 changed files with 239 additions and 55 deletions

View file

@ -393,42 +393,63 @@ impl PersonaProjectionEngine {
let mut schema = prompt_schema.clone();
constrain_interpreter_schema(&mut schema, &slice)?;
let permission_guidance = actor_interpreter_guidance(&slice);
let interpreted = run_validated_stage(
self.model.as_ref(),
&ModelStageRequest {
stage: "interpreter".into(),
model: self.interpreter_model.clone(),
snapshot_binding: slice.snapshot_binding.clone(),
lived_stream: build_interpreter_prompt(&InterpreterPrompt {
identity: &slice.actor_id,
typed_context: &typed_context,
lived_stream: &lived.text,
persona_output: &persona.narrative,
output_schema: &serde_json::to_string(&prompt_schema)?,
domain_guidance: &permission_guidance,
}),
output_schema: Some(schema),
source_receipt_ids: slice.source_receipt_ids.clone(),
temperature: Some(0.0),
max_output_tokens: Some(768),
},
)
.await?;
let mut request = ModelStageRequest {
stage: "interpreter".into(),
model: self.interpreter_model.clone(),
snapshot_binding: slice.snapshot_binding.clone(),
lived_stream: build_interpreter_prompt(&InterpreterPrompt {
identity: &slice.actor_id,
typed_context: &typed_context,
lived_stream: &lived.text,
persona_output: &persona.narrative,
output_schema: &serde_json::to_string(&prompt_schema)?,
domain_guidance: &permission_guidance,
}),
output_schema: Some(schema),
source_receipt_ids: slice.source_receipt_ids.clone(),
temperature: Some(0.0),
max_output_tokens: Some(768),
};
let mut interpreter_receipts = Vec::new();
let proposals = loop {
let mut interpreted = run_validated_stage(self.model.as_ref(), &request).await?;
let structured = interpreted
.structured
.clone()
.ok_or_else(|| anyhow!("interpreter produced no typed proposal"))?;
let proposals: PersonaProposalBundle = serde_json::from_value(structured.clone())?;
match validate_actor_proposals(&slice, &proposals) {
Ok(()) => {
interpreter_receipts.push(interpreted.receipt);
break proposals;
}
Err(error) if interpreter_receipts.is_empty() => {
interpreted.receipt.validation_result = "semantic_invalid".into();
interpreted.receipt.local_validation_error =
Some(error.to_string().chars().take(1_000).collect());
interpreter_receipts.push(interpreted.receipt);
self.permit
.require(&slice.actor_id, &slice.snapshot_binding, "interpreter")
.await?;
append_actor_correction(&mut request, &error, &structured);
}
Err(error) => {
return Err(anyhow!(
"actor interpreter failed semantic validation after one correction: {error}"
));
}
}
};
self.permit
.require(&slice.actor_id, &slice.snapshot_binding, "terminal")
.await?;
let proposals: PersonaProposalBundle = serde_json::from_value(
interpreted
.structured
.clone()
.ok_or_else(|| anyhow!("interpreter produced no typed proposal"))?,
)?;
validate_actor_proposals(&slice, &proposals)?;
let mut stage_receipts = vec![projected.receipt, persona.receipt];
stage_receipts.extend(interpreter_receipts);
Ok(PersonaTerminalBundle {
lived_stream: lived,
persona_output: persona.narrative,
proposals,
stage_receipts: vec![projected.receipt, persona.receipt, interpreted.receipt],
stage_receipts,
})
}
}
@ -539,6 +560,17 @@ fn validate_actor_proposals(
Ok(())
}
fn append_actor_correction(
request: &mut ModelStageRequest,
error: &anyhow::Error,
rejected: &serde_json::Value,
) {
request.lived_stream.push_str(&format!(
"\n\nCORRECTION TASK—THE PREVIOUS INTERPRETATION WAS REJECTED.\nREJECTION: {error}\nPREVIOUS_REJECTED_INTERPRETATION:\n{}\nReturn one corrected complete interpretation against the same snapshot, lived stream, Persona output, and exact permissions. Preserve supported speech and private changes. Do not invent speech to justify a delta. If the Persona did not explicitly adopt or present a public self-identifier in its own speech, identity_adoption must be null.",
serde_json::to_string(rejected).unwrap_or_else(|_| "null".into())
));
}
fn cell_projector_context(slice: &PermittedCellSlice) -> serde_json::Value {
serde_json::json!({
"cell_id": slice.cell_id,
@ -2767,6 +2799,19 @@ fn constrain_interpreter_schema(
"deliberate_silence".into(),
serde_json::json!({"type":"boolean"}),
);
root.entry("allOf")
.or_insert_with(|| serde_json::json!([]))
.as_array_mut()
.ok_or_else(|| anyhow!("Persona proposal schema allOf is not an array"))?
.push(serde_json::json!({
"anyOf":[
{"required":["speech"],"properties":{"speech":{"type":"string","minLength":1,"maxLength":1000}}},
{"required":["speech","private_delta"],"properties":{
"speech":{"type":"null"},
"private_delta":{"properties":{"identity_adoption":{"type":"null"}}}
}}
]
}));
if matches!(
slice.interaction_role,
ActorInteractionRole::DirectResponseExpected
@ -4203,6 +4248,132 @@ mod tests {
validate_actor_proposals(&slice, &proposals).unwrap();
}
#[test]
fn actor_interpreter_schema_forbids_silent_identity_adoption() {
let slice = PermittedActorSlice {
actor_id: "npc".into(),
location_id: "room".into(),
snapshot_binding: "campaign:1".into(),
interaction_role: ActorInteractionRole::PresentObserver,
identity_experience: vec!["You are an unnamed patient.".into()],
memories: vec![],
perceived_events: vec!["A regulator is inspected.".into()],
perceived_actors: BTreeMap::from([("player".into(), "Player".into())]),
relationships: vec![],
goals: vec![],
knowledge: vec![],
capabilities: vec![],
pressures: vec![],
affordances: vec![],
source_receipt_ids: vec![],
};
let mut schema = serde_json::to_value(schema_for!(PersonaProposalBundle)).unwrap();
constrain_interpreter_schema(&mut schema, &slice).unwrap();
let validator = jsonschema::validator_for(&schema).unwrap();
let silent_adoption = serde_json::to_value(PersonaProposalBundle {
private_delta: crate::domain::ActorStateDelta {
identity_adoption: Some("Taren".into()),
..Default::default()
},
speech: None,
deliberate_silence: false,
reaction_priority: 0,
world_actions: vec![],
})
.unwrap();
assert!(!validator.is_valid(&silent_adoption));
}
struct CorrectingActorModel {
interpreter_calls: AtomicUsize,
saw_rejected_interpretation: AtomicBool,
}
#[async_trait]
impl ModelPort for CorrectingActorModel {
async fn run(&self, request: &ModelStageRequest) -> Result<String> {
match request.stage.as_str() {
"projector" => Ok("The player asks you to identify yourself.".into()),
"persona" => Ok("My name is Taren.".into()),
"interpreter" => {
let correction = self.interpreter_calls.fetch_add(1, Ordering::SeqCst) > 0;
if correction {
self.saw_rejected_interpretation.store(
request
.lived_stream
.contains("PREVIOUS_REJECTED_INTERPRETATION")
&& request.lived_stream.contains("Rook")
&& request.lived_stream.contains("exact spoken handle"),
Ordering::SeqCst,
);
}
Ok(serde_json::to_string(&PersonaProposalBundle {
private_delta: crate::domain::ActorStateDelta {
identity_adoption: Some(
if correction { "Taren" } else { "Rook" }.into(),
),
..Default::default()
},
speech: Some("My name is Taren.".into()),
deliberate_silence: false,
reaction_priority: 10,
world_actions: vec![],
})?)
}
stage => Err(anyhow!("unexpected fixture stage {stage}")),
}
}
fn provider(&self) -> &'static str {
"correcting-actor-fixture"
}
}
#[tokio::test]
async fn actor_semantic_retry_preserves_same_snapshot_and_receipts() {
let model = Arc::new(CorrectingActorModel {
interpreter_calls: AtomicUsize::new(0),
saw_rejected_interpretation: AtomicBool::new(false),
});
let engine = PersonaProjectionEngine {
model: model.clone(),
permit: Arc::new(AllowAllPermit),
projector_model: "flash".into(),
persona_model: "pro".into(),
interpreter_model: "flash".into(),
};
let output = engine
.execute(PermittedActorSlice {
actor_id: "npc".into(),
location_id: "room".into(),
snapshot_binding: "campaign:1:revision:4".into(),
interaction_role: ActorInteractionRole::DirectResponseExpected,
identity_experience: vec!["You are an unnamed patient.".into()],
memories: vec![],
perceived_events: vec!["The player asks your name.".into()],
perceived_actors: BTreeMap::from([("player".into(), "Player".into())]),
relationships: vec![],
goals: vec![],
knowledge: vec![],
capabilities: vec![],
pressures: vec![],
affordances: vec![],
source_receipt_ids: vec![],
})
.await
.unwrap();
assert!(model.saw_rejected_interpretation.load(Ordering::SeqCst));
assert_eq!(output.stage_receipts.len(), 4);
assert_eq!(
output.stage_receipts[2].validation_result,
"semantic_invalid"
);
assert_eq!(
output.proposals.private_delta.identity_adoption.as_deref(),
Some("Taren")
);
}
#[test]
fn semantic_correction_restates_non_empty_effect_contract() {
let mut request = ModelStageRequest {

View file

@ -705,48 +705,53 @@ return catch-up makes an assessment stale, the typed `assessed` response owns
the transient projection: it presents the replacement assessment and binds its
Roll control to that assessment's current campaign revision.
Informational attempts may reveal an existing `WorldFact`; they may not author
one. `WorldFact` owns branch truth and the location boundaries at which a fact
can be discovered. `ActorState.knowledge` owns only which exact statements that
actor has learned. The compiler or an approval-gated region expansion must
therefore establish a clue before a roll can expose it.
Informational attempts have two distinct lanes. They may acquire or communicate
an existing `WorldFact`, or they may admit one new bounded observation produced
by direct local perception, measurement, inspection, or testing. `WorldFact`
owns branch truth and discovery locations. `ActorState.knowledge` owns which
exact statements that actor has learned. An observation is branch-local rather
than Vault canon and becomes truth only through the confirmed outcome's atomic
kernel commit.
The assessor receives only facts already known by the acting actor or marked
discoverable at that actor's current location. Every proposed knowledge
addition must exactly match one of those statements. A fact known by the acting
actor may be communicated to another present actor; a location-bound fact may
be discovered only by the acting actor. Player attempts and NPC initiative use
the same validator. The model schema enumerates each present recipient
separately and gives that recipient only the exact new facts legal for that
recipient; a generic actor-keyed string map is not an inference affordance. The
kernel repeats the semantic check, atomically updates actor knowledge, and never
creates a fact from model prose.
be discovered only by the acting actor. The separate observation lane is
structurally bound to that actor, at most two concise statements, and must copy
the finding exposed in the visible outcome stake. Local and semantic validation
require the declared means to support that exact current observation and reject
remote events, hidden motives, unsupported identities, diagnoses beyond the
effect ceiling, existing facts, and overlap with the acquisition lane. Player
attempts and NPC initiative use the same validator. On a permitted outcome the
kernel content-addresses the proposition, admits it as branch-local at the
current location, and acquires it for the actor in the same mutation batch.
This ownership makes the negative invariant structural: an invented protocol
number, hidden culprit, remote event, or clue cannot become true because an
assessor wrote it into a successful stake. Unsupported information causes one
same-snapshot correction and then aborts without mutation. A vague promise such
as “identify any faults” is not a valid informational outcome, and an unstated
hidden finding cannot appear after the roll. Player-readable stakes still carry
the exact declarative statement; fact IDs, keys, and slugs remain private typed
references.
number, hidden culprit, remote event, or lore claim cannot become true because
an assessor wrote it into a successful stake. Unsupported information causes
one same-snapshot correction and then aborts without mutation. A vague promise
such as “identify any faults” is not a valid informational outcome, and an
unstated hidden finding cannot appear after the roll. Player-readable stakes
carry the exact declarative statement; fact IDs, keys, and slugs remain private
typed references.
Authority map:
- Owner: `WorldFact` owns truth and discovery locations; `ActorState` owns
learned access.
- Inputs: approved compiler or region-expansion facts, current occupancy,
actor knowledge, and the attempted effect.
- Output: an assessment whose information deltas are a subset of existing
accessible facts.
actor knowledge, exact means, intended effect, and outcome ceiling.
- Output: an assessment whose information deltas are exact accessible facts or
bounded actor-local observations exposed verbatim in its stakes.
- Derived state: the visible stake and story are projections of that exact
assessment and committed revision.
- Forbidden writers: Personas, Interpreters, assessors, and
`apply_world_effect` cannot create facts.
- Forbidden writers: Personas, Interpreters, assessors, and projections may
propose observation text but cannot admit propositions or actor knowledge.
- Shared path: player `Attempt` and `ResolveNpcAction` call the same fact-access
validator and atomic commit primitive.
- Cut line: the former promotion of arbitrary knowledge prose into a new
branch-local `WorldFact` is deleted.
and observation validator and the same atomic commit primitive.
- Cut line: arbitrary knowledge prose remains forbidden; only the explicit
bounded-observation lane may create a branch-local proposition.
### Actor and world action loop
@ -770,6 +775,14 @@ typed deliberate silence. The latter lowers to deterministic visible refusal;
it is not a free-text effect lane. The complete reaction wave still validates
and commits atomically.
Actor Interpreter JSON is schema-bound so silent output cannot carry an
identity adoption. Cross-field semantic failures—such as an adopted handle not
appearing exactly in the actor's own speech—receive one corrected Interpreter
pass against the same snapshot, lived stream, Persona output, and permissions.
Both Interpreter receipts survive, with the rejected one marked
`semantic_invalid`; a second failure aborts the complete reaction wave without
private-state mutation.
The primary player event commits before optional presence casting, reaction
appraisal and initiative. A failure in those later stages is stored
as a rejected-proposal receipt and returned as an accepted committed result