Correct actor Persona semantics once
This commit is contained in:
parent
740ecf0b52
commit
32ff501b2e
2 changed files with 239 additions and 55 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue