formal_ai/world_model.rs
1//! Symbolic world models and contexts — issue #649.
2//!
3//! Issue #649 asks the project to reason with **symbolic world models** on the
4//! links network rather than embeddings: throughout a dialogue build a
5//! **current-state** context and a **target-state** context, expose their
6//! **difference**, let the user and agent **synchronize** the target, **merge**
7//! and **split** context models, keep **each context a links network**, use
8//! [relative-meta-logic](https://github.com/link-foundation/relative-meta-logic)
9//! so that **statements are dependent** and **changing the world recalculates all
10//! statement probabilities**, and ultimately **predict the consequences of an
11//! action** by simulating it.
12//!
13//! The [design case study](../docs/case-studies/issue-649/README.md) shows the
14//! feature is an *audit-and-wire* task over machinery that already exists. This
15//! module supplies the missing connections:
16//!
17//! * A first-class [`Context`] = an id, a links network ([`SubstitutionGraph`]),
18//! and a set of dependent [`Statement`]s. This is the STRIPS *state* / *goal*
19//! container, re-expressed over doublets.
20//! * An [`Action`] = a set of add/delete link edits (STRIPS *effects*).
21//! * [`Context::recalculate`] — a JTMS-style fixpoint that re-fires
22//! [`StatementAssessment`] for every dependent statement whenever the context
23//! changes, so *any* edit recalculates *all* statement probabilities and the
24//! values converge deterministically on the relative-meta-logic decimal grid.
25//! * [`Context::difference`] — the STRIPS *goal − state* delta between two
26//! contexts (links to add / remove, plus functional conflicts).
27//! * [`Context::predict`] — *predict = apply the action to a clone, recalculate,
28//! and diff*, so the real world model is never mutated (issue-649's headline
29//! requirement).
30//! * [`Context::merge_from`] / [`Context::split_off`] — ATMS-style context
31//! combination and separation, reusing union-by-id semantics.
32//! * [`WorldModel`] — the per-dialogue holder of the `current`, `target`, and
33//! shared `general` contexts.
34//!
35//! Everything here is pure symbolic arithmetic over caller-supplied links and
36//! evidence: no clocks, no randomness, no network. Values are snapped to the RML
37//! decimal grid so the trace is byte-for-byte reproducible.
38
39use std::collections::{BTreeMap, BTreeSet};
40use std::fmt::Write as _;
41
42use crate::engine::stable_id;
43use crate::relative_meta_logic::{
44 RelativeEvidence, SourceTier, Stance, StatementAssessment, TruthValue, ASSUMED_TRUE_PRIOR,
45};
46use crate::substitution::{SubstitutionGraph, SubstitutionLink};
47
48/// Upper bound on relaxation passes in [`Context::recalculate`].
49///
50/// The cascade re-evaluates every statement from the current values of its
51/// dependencies; because truth values are snapped to a finite decimal grid a
52/// monotone cascade converges in a handful of passes. This constant is a
53/// non-termination backstop for the pathological case of a negative-feedback
54/// dependency cycle (a statement whose dependency contradicts it), which can
55/// oscillate rather than settle. It is multiplied by the statement count so a
56/// deep dependency chain still relaxes fully before the guard trips.
57const MAX_RECALCULATION_PASSES_PER_STATEMENT: usize = 4;
58
59/// How one statement depends on another inside a context.
60///
61/// A dependency is a directed edge from the dependent statement to the statement
62/// it relies on, tagged with the [`Stance`] the dependency takes: a
63/// [`Stance::Supports`] edge raises the dependent's probability as the relied-on
64/// statement becomes more true (a JTMS *positive justification*), a
65/// [`Stance::Contradicts`] edge lowers it (a *negative justification*).
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct Dependency {
68 /// The id of the statement this one depends on.
69 pub on: String,
70 /// Whether the relied-on statement supports or contradicts this one.
71 pub stance: Stance,
72}
73
74impl Dependency {
75 /// A positive justification: `id` becomes more true as `on` does.
76 #[must_use]
77 pub fn supports(on: impl Into<String>) -> Self {
78 Self {
79 on: on.into(),
80 stance: Stance::Supports,
81 }
82 }
83
84 /// A negative justification: `id` becomes less true as `on` becomes true.
85 #[must_use]
86 pub fn contradicts(on: impl Into<String>) -> Self {
87 Self {
88 on: on.into(),
89 stance: Stance::Contradicts,
90 }
91 }
92}
93
94/// A dependent statement inside a [`Context`].
95///
96/// A statement carries its own assumed-true `prior` and base `evidence` (weighed
97/// exactly as [`StatementAssessment`] does elsewhere), plus zero or more
98/// [`Dependency`] edges onto other statements in the same context. Its `truth`
99/// is the value [`Context::recalculate`] last computed; it is graph-visible via
100/// [`Context::links_notation`].
101#[derive(Debug, Clone, PartialEq)]
102pub struct Statement {
103 /// Content-addressed id, stable over the statement text.
104 pub id: String,
105 /// The statement text.
106 pub text: String,
107 /// The assumed-true prior before any evidence or dependency is weighed.
108 pub prior: TruthValue,
109 /// Base evidence weighed against this statement.
110 pub evidence: Vec<RelativeEvidence>,
111 /// Edges onto the statements this one depends on.
112 pub dependencies: Vec<Dependency>,
113 /// The probability last computed by [`Context::recalculate`].
114 pub truth: TruthValue,
115}
116
117impl Statement {
118 /// Build a statement with the module default [`ASSUMED_TRUE_PRIOR`] and no
119 /// dependencies. Its initial `truth` is its own evidence-only posterior.
120 #[must_use]
121 pub fn new(text: impl Into<String>) -> Self {
122 let text = text.into();
123 let id = stable_id("world_statement", &text);
124 let prior = TruthValue::new(ASSUMED_TRUE_PRIOR);
125 let truth = StatementAssessment::assess(&text, prior, &[]).posterior;
126 Self {
127 id,
128 text,
129 prior,
130 evidence: Vec::new(),
131 dependencies: Vec::new(),
132 truth,
133 }
134 }
135
136 /// Attach base evidence, returning `self` for chaining.
137 #[must_use]
138 pub fn with_evidence(mut self, evidence: RelativeEvidence) -> Self {
139 self.evidence.push(evidence);
140 self
141 }
142
143 /// Attach a dependency edge, returning `self` for chaining.
144 #[must_use]
145 pub fn with_dependency(mut self, dependency: Dependency) -> Self {
146 self.dependencies.push(dependency);
147 self
148 }
149
150 /// Override the assumed-true prior, returning `self` for chaining.
151 #[must_use]
152 pub fn with_prior(mut self, prior: impl Into<TruthValue>) -> Self {
153 self.prior = prior.into();
154 self
155 }
156}
157
158/// An action to simulate against a context: a set of link edits.
159///
160/// This is the STRIPS *effect* set re-expressed over doublets — `remove` is the
161/// delete list, `add` is the add list. [`Context::predict`] applies it to a
162/// clone so the real world model is never touched.
163#[derive(Debug, Clone, Default, PartialEq, Eq)]
164pub struct Action {
165 /// A human-readable name for the action (e.g. `"open the door"`).
166 pub name: String,
167 /// Links the action deletes from the state.
168 pub remove: Vec<SubstitutionLink>,
169 /// Links the action adds to the state.
170 pub add: Vec<SubstitutionLink>,
171}
172
173impl Action {
174 /// Start an empty action with a name.
175 #[must_use]
176 pub fn new(name: impl Into<String>) -> Self {
177 Self {
178 name: name.into(),
179 remove: Vec::new(),
180 add: Vec::new(),
181 }
182 }
183
184 /// Add an "assert this link" effect, returning `self` for chaining.
185 #[must_use]
186 pub fn adding(mut self, from: impl Into<String>, to: impl Into<String>) -> Self {
187 self.add.push(SubstitutionLink::new(from, to));
188 self
189 }
190
191 /// Add a "retract this link" effect, returning `self` for chaining.
192 #[must_use]
193 pub fn removing(mut self, from: impl Into<String>, to: impl Into<String>) -> Self {
194 self.remove.push(SubstitutionLink::new(from, to));
195 self
196 }
197
198 /// A content-addressed id, stable over the action's name and effects.
199 #[must_use]
200 pub fn id(&self) -> String {
201 let mut canonical = format!("name:{};", self.name);
202 for link in &self.remove {
203 let _ = write!(canonical, "remove:{};", link.pattern_text());
204 }
205 for link in &self.add {
206 let _ = write!(canonical, "add:{};", link.pattern_text());
207 }
208 stable_id("world_action", &canonical)
209 }
210}
211
212/// A named world/context model: a links network plus its dependent statements.
213///
214/// Each context is *always* a links network — the [`SubstitutionGraph`] is the
215/// network — satisfying issue-649's "each context is always a links network"
216/// requirement. Statements live alongside it and their dependency structure is
217/// mirrored into the graph so the whole context serializes to Links Notation.
218#[derive(Debug, Clone, Default, PartialEq)]
219pub struct Context {
220 /// The context id.
221 pub id: String,
222 /// The world-state links network (STRIPS atoms).
223 links: SubstitutionGraph,
224 /// The dependent statements, keyed by id for stable iteration and merge.
225 statements: BTreeMap<String, Statement>,
226}
227
228impl Context {
229 /// An empty context with the given id.
230 #[must_use]
231 pub fn new(id: impl Into<String>) -> Self {
232 Self {
233 id: id.into(),
234 links: SubstitutionGraph::new(),
235 statements: BTreeMap::new(),
236 }
237 }
238
239 /// Assert a world-state atom (a link). Returns `true` if newly added.
240 pub fn assert_link(&mut self, from: &str, to: &str) -> bool {
241 self.links.insert_link(from, to)
242 }
243
244 /// Retract a world-state atom (a link). Returns `true` if it was present.
245 pub fn retract_link(&mut self, from: &str, to: &str) -> bool {
246 self.links.remove_link(from, to)
247 }
248
249 /// Whether the state atom `from -> to` holds.
250 #[must_use]
251 pub fn holds(&self, from: &str, to: &str) -> bool {
252 self.links.contains_link(from, to)
253 }
254
255 /// Every world-state atom currently in the context, sorted.
256 #[must_use]
257 pub fn links(&self) -> Vec<SubstitutionLink> {
258 self.links.links()
259 }
260
261 /// Read-only view of the statements, keyed by id.
262 #[must_use]
263 pub const fn statements(&self) -> &BTreeMap<String, Statement> {
264 &self.statements
265 }
266
267 /// Look up a statement by id.
268 #[must_use]
269 pub fn statement(&self, id: &str) -> Option<&Statement> {
270 self.statements.get(id)
271 }
272
273 /// Insert (or replace) a statement, then recalculate the whole context so
274 /// the new statement's dependencies and every dependent value settle. Returns
275 /// the id of the inserted statement.
276 pub fn add_statement(&mut self, statement: Statement) -> String {
277 let id = statement.id.clone();
278 self.statements.insert(id.clone(), statement);
279 let _ = self.recalculate();
280 id
281 }
282
283 /// Insert or replace a batch of statements, then recalculate the context once.
284 ///
285 /// This is equivalent to repeated [`Self::add_statement`] calls in final
286 /// state, but avoids a full fixpoint calculation after every intermediate
287 /// insertion. Repository-scale importers should use this boundary so their
288 /// cost grows with the finished statement graph rather than every prefix of it.
289 pub fn extend_statements(
290 &mut self,
291 statements: impl IntoIterator<Item = Statement>,
292 ) -> RecalculationReport {
293 for statement in statements {
294 self.statements.insert(statement.id.clone(), statement);
295 }
296 self.recalculate()
297 }
298
299 /// Re-evaluate every statement to a fixpoint (JTMS-style cascade).
300 ///
301 /// Each pass recomputes every statement's [`TruthValue`] from its own
302 /// evidence plus the *current* values of the statements it depends on
303 /// (supporting dependencies raise it, contradicting ones lower it), reusing
304 /// [`StatementAssessment::assess`]. Passes repeat until no snapped value
305 /// changes (converged) or the `MAX_RECALCULATION_PASSES_PER_STATEMENT`
306 /// bound trips. The dependency structure is mirrored into the links network
307 /// so the context stays a single inspectable graph.
308 pub fn recalculate(&mut self) -> RecalculationReport {
309 let ids: Vec<String> = self.statements.keys().cloned().collect();
310 let before: BTreeMap<String, TruthValue> = ids
311 .iter()
312 .map(|id| (id.clone(), self.statements[id].truth))
313 .collect();
314 let limit = ids
315 .len()
316 .saturating_mul(MAX_RECALCULATION_PASSES_PER_STATEMENT)
317 .max(MAX_RECALCULATION_PASSES_PER_STATEMENT);
318
319 let mut iterations = 0;
320 let mut converged = false;
321 while iterations < limit {
322 iterations += 1;
323 let snapshot: BTreeMap<String, TruthValue> = ids
324 .iter()
325 .map(|id| (id.clone(), self.statements[id].truth))
326 .collect();
327 let mut changed = false;
328 for id in &ids {
329 let recomputed = self.assess_with_dependencies(id, &snapshot);
330 if recomputed != self.statements[id].truth {
331 if let Some(statement) = self.statements.get_mut(id) {
332 statement.truth = recomputed;
333 }
334 changed = true;
335 }
336 }
337 if !changed {
338 converged = true;
339 break;
340 }
341 }
342
343 self.sync_statement_links();
344
345 let updated = ids
346 .iter()
347 .filter_map(|id| {
348 let after = self.statements[id].truth;
349 let previous = before.get(id).copied().unwrap_or(after);
350 (after != previous).then(|| StatementChange {
351 statement_id: id.clone(),
352 text: self.statements[id].text.clone(),
353 before: previous,
354 after,
355 })
356 })
357 .collect();
358
359 RecalculationReport {
360 iterations,
361 converged,
362 updated,
363 }
364 }
365
366 /// Assess one statement from its own evidence plus the current truth values
367 /// of its dependencies, expressed as synthesized full-trust evidence so the
368 /// existing relative-meta-logic kernel does the combination.
369 fn assess_with_dependencies(
370 &self,
371 id: &str,
372 snapshot: &BTreeMap<String, TruthValue>,
373 ) -> TruthValue {
374 let statement = &self.statements[id];
375 let mut evidence = statement.evidence.clone();
376 for dependency in &statement.dependencies {
377 if let Some(value) = snapshot.get(&dependency.on) {
378 // A dependency is a first-party justification: its strength is the
379 // relied-on statement's own current probability, weighed at full
380 // trust so a certainly-true supporter fully supports.
381 evidence.push(RelativeEvidence::new(
382 format!("statement:{}", dependency.on),
383 SourceTier::OriginalFirstParty,
384 dependency.stance,
385 *value,
386 ));
387 }
388 }
389 StatementAssessment::assess(&statement.text, statement.prior, &evidence).posterior
390 }
391
392 /// Mirror statement existence, dependency edges, and truth values into the
393 /// links network so the context is one inspectable links graph.
394 fn sync_statement_links(&mut self) {
395 // Drop stale statement-layer links, then re-emit from the current state.
396 for link in self.links.links() {
397 if is_statement_layer_link(&link) {
398 self.links.remove_link(&link.from, &link.to);
399 }
400 }
401 for statement in self.statements.values() {
402 self.links.insert_link(&statement.id, "world:statement");
403 self.links
404 .insert_link(&statement.id, &format!("truth:{}", statement.truth));
405 for dependency in &statement.dependencies {
406 self.links.insert_link(
407 &statement.id,
408 &format!("{}:{}", dependency.stance.slug(), dependency.on),
409 );
410 }
411 }
412 }
413
414 /// Apply an action's link edits in place, then recalculate.
415 ///
416 /// This mutates the context — use [`Self::predict`] to simulate an action
417 /// against a clone without touching the real state.
418 pub fn apply_action(&mut self, action: &Action) -> RecalculationReport {
419 for link in &action.remove {
420 self.links.remove_link(&link.from, &link.to);
421 }
422 for link in &action.add {
423 self.links.insert_link(&link.from, &link.to);
424 }
425 self.recalculate()
426 }
427
428 /// Predict the consequences of an action **without mutating** this context.
429 ///
430 /// Clones the context, applies the action to the clone, recalculates, and
431 /// diffs the clone against the original — so the returned [`Prediction`]
432 /// reports exactly what *would* change (state links added/removed plus every
433 /// statement whose probability moves) while the real world model is left
434 /// untouched. This is issue-649's headline capability.
435 #[must_use]
436 pub fn predict(&self, action: &Action) -> Prediction {
437 let mut probe = self.clone();
438 probe.apply_action(action);
439 let difference = self.difference(&probe);
440 let statement_changes = probe.statement_changes_against(self);
441 Prediction {
442 action_id: action.id(),
443 action_name: action.name.clone(),
444 added: difference.to_add,
445 removed: difference.to_remove,
446 statement_changes,
447 result: probe,
448 }
449 }
450
451 /// The STRIPS-style delta from `self` (current) to `target` (goal).
452 ///
453 /// `to_add` is what `target` has that `self` lacks, `to_remove` is what
454 /// `self` has that `target` lacks, and `conflicting` pairs links that share
455 /// a `from` but disagree on `to` (a functional conflict the sync must
456 /// resolve). Only world-state atoms are diffed; statement-layer bookkeeping
457 /// links are ignored.
458 #[must_use]
459 pub fn difference(&self, target: &Self) -> ContextDiff {
460 let current: BTreeSet<SubstitutionLink> = self.state_links();
461 let goal: BTreeSet<SubstitutionLink> = target.state_links();
462
463 let to_add: Vec<SubstitutionLink> = goal.difference(¤t).cloned().collect();
464 let to_remove: Vec<SubstitutionLink> = current.difference(&goal).cloned().collect();
465
466 let mut conflicting = Vec::new();
467 for removed in &to_remove {
468 for added in &to_add {
469 if removed.from == added.from && removed.to != added.to {
470 conflicting.push(LinkConflict {
471 current: removed.clone(),
472 target: added.clone(),
473 });
474 }
475 }
476 }
477
478 ContextDiff {
479 to_add,
480 to_remove,
481 conflicting,
482 }
483 }
484
485 /// Merge another context into this one (ATMS context combination).
486 ///
487 /// World-state links are unioned; statements are unioned by id with the
488 /// incoming context winning ties (last-writer-wins, matching
489 /// [`crate::memory_sync::merge_union_by_id`]). The result is recalculated so
490 /// dependencies that now cross the merged contexts settle.
491 pub fn merge_from(&mut self, other: &Self) -> RecalculationReport {
492 for link in other.state_links() {
493 self.links.insert_link(&link.from, &link.to);
494 }
495 for (id, statement) in &other.statements {
496 self.statements.insert(id.clone(), statement.clone());
497 }
498 self.recalculate()
499 }
500
501 /// Split a child context off this one (ATMS context separation).
502 ///
503 /// The child gets copies of the named statements plus every world-state link
504 /// that references one of them (by id) — the sub-network relevant to those
505 /// statements — leaving `self` unchanged. Shared links are copied, so the
506 /// two contexts can then diverge independently. Unknown ids are ignored.
507 #[must_use]
508 pub fn split_off(&self, child_id: impl Into<String>, statement_ids: &[String]) -> Self {
509 let mut child = Self::new(child_id);
510 let selected: BTreeSet<&String> = statement_ids
511 .iter()
512 .filter(|id| self.statements.contains_key(*id))
513 .collect();
514 for id in &selected {
515 if let Some(statement) = self.statements.get(*id) {
516 child.statements.insert((*id).clone(), statement.clone());
517 }
518 }
519 for link in self.state_links() {
520 if selected.contains(&link.from) || selected.contains(&link.to) {
521 child.links.insert_link(&link.from, &link.to);
522 }
523 }
524 let _ = child.recalculate();
525 child
526 }
527
528 /// Render the whole context — world-state atoms and statement layer — as
529 /// Links Notation for exact, glass-box explanation.
530 #[must_use]
531 pub fn links_notation(&self) -> String {
532 self.links.links_notation()
533 }
534
535 /// The world-state atoms only (statement-layer bookkeeping links removed).
536 fn state_links(&self) -> BTreeSet<SubstitutionLink> {
537 self.links
538 .links()
539 .into_iter()
540 .filter(|link| !is_statement_layer_link(link))
541 .collect()
542 }
543
544 /// The statements whose truth differs from their value in `baseline`.
545 fn statement_changes_against(&self, baseline: &Self) -> Vec<StatementChange> {
546 self.statements
547 .values()
548 .filter_map(|statement| {
549 let before = baseline
550 .statements
551 .get(&statement.id)
552 .map_or(statement.truth, |previous| previous.truth);
553 (before != statement.truth).then(|| StatementChange {
554 statement_id: statement.id.clone(),
555 text: statement.text.clone(),
556 before,
557 after: statement.truth,
558 })
559 })
560 .collect()
561 }
562}
563
564/// Whether a link belongs to the statement bookkeeping layer rather than the
565/// world state, so diffs and merges can operate on state atoms alone.
566fn is_statement_layer_link(link: &SubstitutionLink) -> bool {
567 link.to == "world:statement"
568 || link.to.starts_with("truth:")
569 || link.to.starts_with("supports:")
570 || link.to.starts_with("contradicts:")
571 || link.to.starts_with("neutral:")
572}
573
574/// The result of [`Context::recalculate`].
575#[derive(Debug, Clone, PartialEq)]
576pub struct RecalculationReport {
577 /// How many relaxation passes ran.
578 pub iterations: usize,
579 /// Whether the cascade reached a fixpoint before the guard tripped.
580 pub converged: bool,
581 /// The statements whose truth value moved during this recalculation.
582 pub updated: Vec<StatementChange>,
583}
584
585/// One statement's probability moving from `before` to `after`.
586#[derive(Debug, Clone, PartialEq)]
587pub struct StatementChange {
588 /// The statement id.
589 pub statement_id: String,
590 /// The statement text.
591 pub text: String,
592 /// The probability before the change.
593 pub before: TruthValue,
594 /// The probability after the change.
595 pub after: TruthValue,
596}
597
598/// Two links that share a `from` but disagree on `to` — a functional conflict
599/// the current→target sync must resolve.
600#[derive(Debug, Clone, PartialEq, Eq)]
601pub struct LinkConflict {
602 /// The link as it stands in the current context.
603 pub current: SubstitutionLink,
604 /// The link as the target context wants it.
605 pub target: SubstitutionLink,
606}
607
608/// The difference between two contexts' world states (STRIPS goal − state).
609#[derive(Debug, Clone, Default, PartialEq, Eq)]
610pub struct ContextDiff {
611 /// Links present in the target but missing from the current context.
612 pub to_add: Vec<SubstitutionLink>,
613 /// Links present in the current context but missing from the target.
614 pub to_remove: Vec<SubstitutionLink>,
615 /// Same-`from`, different-`to` conflicts between the two contexts.
616 pub conflicting: Vec<LinkConflict>,
617}
618
619impl ContextDiff {
620 /// Whether the two contexts already agree (nothing to add, remove, or
621 /// resolve).
622 #[must_use]
623 pub const fn is_empty(&self) -> bool {
624 self.to_add.is_empty() && self.to_remove.is_empty() && self.conflicting.is_empty()
625 }
626
627 /// Render the delta as Links Notation for exact explanation.
628 #[must_use]
629 pub fn links_notation(&self) -> String {
630 let mut out = String::from("context_diff\n");
631 for link in &self.to_add {
632 let _ = writeln!(out, " to_add \"{}\"", link.pattern_text());
633 }
634 for link in &self.to_remove {
635 let _ = writeln!(out, " to_remove \"{}\"", link.pattern_text());
636 }
637 for conflict in &self.conflicting {
638 let _ = writeln!(
639 out,
640 " conflict \"{} vs {}\"",
641 conflict.current.pattern_text(),
642 conflict.target.pattern_text()
643 );
644 }
645 out.trim_end().to_owned()
646 }
647}
648
649/// The consequences of an action predicted against a context, without mutating
650/// it. Returned by [`Context::predict`].
651#[derive(Debug, Clone, PartialEq)]
652pub struct Prediction {
653 /// The simulated action's content-addressed id.
654 pub action_id: String,
655 /// The simulated action's human-readable name.
656 pub action_name: String,
657 /// State links the action would add.
658 pub added: Vec<SubstitutionLink>,
659 /// State links the action would remove.
660 pub removed: Vec<SubstitutionLink>,
661 /// Statements whose probability the action would move.
662 pub statement_changes: Vec<StatementChange>,
663 /// The full post-action context (a copy; the original is untouched).
664 pub result: Context,
665}
666
667impl Prediction {
668 /// Whether the action would change nothing (no state edit lands and no
669 /// statement moves).
670 #[must_use]
671 pub const fn is_noop(&self) -> bool {
672 self.added.is_empty() && self.removed.is_empty() && self.statement_changes.is_empty()
673 }
674}
675
676/// The per-dialogue world model: a `current` context, a `target` context, and a
677/// shared `general` context that per-dialogue contexts merge into.
678///
679/// This is the top-level holder issue-649 describes — *"at any stage of the
680/// dialogue we have the current representation of the world … and also the
681/// target representation the user wants"* — with `general` as the *"entire world
682/// model / general context"* a dialogue context can be merged into or split
683/// from.
684#[derive(Debug, Clone, Default, PartialEq)]
685pub struct WorldModel {
686 /// The current state of the (partial) world discussed in the dialogue.
687 pub current: Context,
688 /// The target state the user wants.
689 pub target: Context,
690 /// The shared world model per-dialogue contexts merge into.
691 pub general: Context,
692}
693
694impl WorldModel {
695 /// A fresh world model with three empty, distinctly-named contexts.
696 #[must_use]
697 pub fn new() -> Self {
698 Self {
699 current: Context::new("current"),
700 target: Context::new("target"),
701 general: Context::new("general"),
702 }
703 }
704
705 /// The difference from the current state to the target state (what the agent
706 /// must achieve). Directly exposes issue-649's *"difference from the current
707 /// state"* at any dialogue stage.
708 #[must_use]
709 pub fn difference(&self) -> ContextDiff {
710 self.current.difference(&self.target)
711 }
712
713 /// Predict an action's consequences against the current state without
714 /// mutating it.
715 #[must_use]
716 pub fn predict(&self, action: &Action) -> Prediction {
717 self.current.predict(action)
718 }
719
720 /// Whether the current state already satisfies the target (the difference is
721 /// empty) — i.e. the dialogue's goal is reached.
722 #[must_use]
723 pub fn target_reached(&self) -> bool {
724 self.difference().is_empty()
725 }
726
727 /// Merge the current dialogue context into the shared general world model
728 /// (ATMS context combination), returning the recalculation report.
729 pub fn commit_current_to_general(&mut self) -> RecalculationReport {
730 self.general.merge_from(&self.current)
731 }
732}