Files
Lore Engine Dev 81cca56e51 slice 6.5: setting filter on 6 read tools + MCP schema
Per AC 6.5, 6.6 — adds a keyword-only 'setting' parameter to:
  - lookup        (filter on matched name's setting membership)
  - entity_context (entity-level filter; empty shape if excluded)
  - was_true_at   (both subject and object must be in setting)
  - true_during   (subject must be in setting)
  - entities_present (located entity must be in setting)
  - events_during (event subject must be in setting)

The filter resolves via graph.setting_entities(setting_id) — O(1)
reverse lookup from slice 6.2's Protocol methods. An unknown
setting returns empty results (defensive). Omitting 'setting'
preserves slice 4 / 9 behaviour (back-compat fence).

MCP tool schemas updated for all 6 entries to expose 'setting'
as an optional [string, null] parameter; opt_string_params
toggled so 'null' is coerced to None by the dispatcher.

The cross-setting fact test (Roland ENCOUNTERED The Wanderer)
is the canonical LLM-target: with setting='mardonari', Roland's
home setting, the answer is was_true=False because The Wanderer
is in the_wild_dream.

+8 tests (739 → 747). All green. No regressions.
2026-06-19 14:26:05 -04:00

486 lines
20 KiB
Python

"""Lore Engine POC — read-side tools.
This module implements the single tool promised by the slice:
was_true_at(relation, subject, object, at_time) -> dict
The tool answers the question "did the ``relation`` between ``subject``
and ``object`` hold true at ``at_time``?" by:
1. Looking up the subject and object in the in-memory graph built by
:mod:`lore_engine_poc.parsers`.
2. Finding edges of the requested type between them.
3. Evaluating each edge's ``valid_from`` / ``valid_until`` window
against ``at_time`` using
:func:`lore_engine_poc.time_model.time_in_window`.
For the slice the graph is an in-memory dict keyed by entity name. The
Cognee integration in :mod:`lore_engine_poc.cognee_store` is a parallel
code path that materialises the same triples into Cognee's graph DB.
The two paths can be cross-checked in tests.
"""
from __future__ import annotations
import os
import uuid
from dataclasses import dataclass, field, replace
from typing import Iterable, Optional
def _new_edge_id() -> str:
"""Stable per-edge id (8 hex chars). Used by retcon /
mark_verified to point at a specific edge in the graph."""
return f"e-{uuid.uuid4().hex[:8]}"
from .parsers import Entity, LoreSource, Triple, iter_codex, extract_triples
from .time_model import time_in_window
@dataclass
class Edge:
subject: str
relation: str
object: str
# Stable per-edge identity. Stamped at construction by
# the default factory so existing call sites don't change.
# Slice 10.2 uses this to point at a specific edge for
# retcon / mark_verified mutations.
edge_id: str = field(default_factory=_new_edge_id)
valid_from: Optional[str] = None
valid_until: Optional[str] = None
sources: list[str] = field(default_factory=list)
# Two confidence dimensions per source. The aggregate
# ``confidence`` returned to callers is
# ``min(extraction_confidence * source_confidence)`` across all
# sources. If two sources agree on the same fact, the higher
# confidence wins on agreement; the lower confidence is reported
# as the "floor" of the answer.
extraction_confidences: list[float] = field(default_factory=list)
source_confidences: list[float] = field(default_factory=list)
reliabilities: list[str] = field(default_factory=list)
confidence: float = 1.0
# When two sources produce ``(subject, relation, object)`` with
# conflicting time bounds, the merger marks both as disputed
# and points them at each other. Slice 2's consistency engine
# turns this into a ``Contradiction`` node. For the POC codex,
# no real disputes exist, so ``is_disputed`` is always False.
is_disputed: bool = False
disputed_with: list["Edge"] = field(default_factory=list)
# Slice 10.2 — retcon audit metadata. Populated when a
# world-builder calls ``retcon`` to amend an edge's bounds
# or its object. ``retcon_at`` is an ISO timestamp;
# ``retcon_note`` is the free-text reason. The original
# edge is mutated in place (retcon is append-only at the
# *audit log* level, not at the *edge* level).
retcon_at: Optional[str] = None
retcon_note: Optional[str] = None
# Slice 10.2 — mark-verified audit metadata. ``verified_by``
# is the verifier's name (e.g. an email or a handle);
# ``verified_at`` is the ISO timestamp of the verification;
# ``verified_note`` is the free-text reason. The mark-verified
# tool also appends a (1.0, 1.0, "human_verified") source
# tuple so the aggregate confidence floors to 1.0.
verified_by: Optional[str] = None
verified_at: Optional[str] = None
verified_note: Optional[str] = None
@property
def aggregate_confidence(self) -> float:
"""Worst-case aggregated confidence across all sources.
Reported as the answer's confidence. If a world-builder
wants the *best* case, that's a separate tool (slice 4).
"""
if not self.extraction_confidences or not self.source_confidences:
return 0.0
per_source = [
e * s for e, s in zip(self.extraction_confidences, self.source_confidences)
]
return min(per_source)
def _windows_consistent(a_from, a_until, b_from, b_until) -> bool:
"""Two time windows are consistent if they overlap or one is fully open.
Used by the edge merger to decide whether two triples that share
``(subject, relation, object)`` are the same fact or a dispute.
A dispute is *temporal* disagreement — same fact, different
time bounds. Two completely empty bounds are consistent.
"""
if (a_from is None and a_until is None) or (b_from is None and b_until is None):
return True
# If both have a lower bound, they must overlap on the lower bound.
if a_from is not None and b_from is not None and a_from != b_from:
# Use the time_model helper: do the two bounds refer to
# the same point in the era tree?
from .time_model import _cmp_atoms
if _cmp_atoms(a_from, b_from) != 0:
return False
if a_until is not None and b_until is not None and a_until != b_until:
from .time_model import _cmp_atoms
if _cmp_atoms(a_until, b_until) != 0:
return False
return True
# Slice 5.1 — the in-memory graph has moved to
# :mod:`lore_engine_poc.graph_backend` as :class:`InMemoryGraph`
# with a :class:`GraphBackend` Protocol. ``Graph`` is a
# back-compat alias so the 559 existing tests (and any external
# code that imports ``Graph``) keep working unchanged.
from .graph_backend import ( # noqa: F401, E402 -- re-export for back-compat
GraphBackend,
InMemoryGraph as Graph,
)
def build_graph(entities: Iterable[Entity], triples: Iterable[Triple]) -> Graph:
"""Convert parser output into an in-memory :class:`Graph`.
All entity names are seeded into the graph even if they have no
edges, so ``by_name()`` returns the canonical form for every
parsed entity — important for queries that target an isolated
entity.
Multiple triples that share ``(subject, relation, object)`` are
merged into a single ``Edge`` whose sources list contains all
contributing documents. This is the "two sources agree" case —
agreement raises the answer's confidence; the per-source
confidences are preserved for inspection.
The graph also gets populated with the :class:`LoreSource` side
index (AC 1.9): markdown ``Entity`` objects contribute one
``LoreSource`` per parsed file (already attached on
``entity.sources[0]``), and structured-YAML ``_LORESOURCE_NODE``
marker triples contribute one per YAML file. ``SOURCED_FROM``
triples (one per typed edge in the structured-YAML path) record
the path -> (subject, relation, object) provenance link that
slice 2's consistency engine consumes.
"""
g = Graph()
for e in entities:
g.names.add(e.name)
# Slice 4.0: seed the type index from every entity. The
# markdown path uses lowercase string types
# (``"npc"``/``"faction"``/``"location"``); the
# structured-YAML path uses PascalCase labels
# (``"Person"``/``"Faction"``/etc.). We populate
# both styles: the markdown type as-is, and a
# PascalCase variant keyed on the same name so
# callers can filter on either.
if e.type:
g.add_entity_of_type(e.name, e.type)
# PascalCase alias for the markdown type — e.g. ``"npc"`` →
# also ``"Person"``. Slice 4's ``lookup`` uses this to
# let callers ask for ``type="Person"`` against a graph
# that was built from markdown.
_pascal = {
"npc": "Person",
"faction": "Faction",
"location": "Location",
"region": "Region",
"entry": "Entry",
"lineage": "Lineage",
"bestiary": "Creature",
"magic_system": "MagicSystem",
"culture": "Culture",
"timeline": "Event",
}.get(e.type)
if _pascal:
g.add_entity_of_type(e.name, _pascal)
for src in e.sources:
g.lore_sources.setdefault(src.path, src)
for t in triples:
# Handle the LoreSource node marker emitted by the
# structured-YAML adapter. The triple's
# ``extraction_confidence / source_confidence / reliability``
# fields carry the source's metadata. We synthesise a
# LoreSource from them.
if t.relation == "_LORESOURCE_NODE":
if t.source_path not in g.lore_sources:
g.lore_sources[t.source_path] = LoreSource(
path=t.source_path,
name=t.source_slug,
source_type="", # filled by the adapter into the path
reliability=t.reliability,
source_confidence=t.source_confidence,
)
continue
for t in triples:
# Find an existing edge with the same (subject, relation, object).
existing = None
for candidate in g.edges_by_subject.get(t.subject, {}).get(t.relation, []):
if candidate.object == t.object:
existing = candidate
break
if existing is None:
g.add(Edge(
subject=t.subject,
relation=t.relation,
object=t.object,
valid_from=t.valid_from,
valid_until=t.valid_until,
sources=[t.source_path],
extraction_confidences=[t.extraction_confidence],
source_confidences=[t.source_confidence],
reliabilities=[t.reliability],
))
else:
# Two cases to distinguish:
#
# 1. Same source, same bounds: a duplicate mention in
# one document. Skip — the edge already represents
# this fact from this source.
#
# 2. Different source, same bounds: an independent
# confirmation. Merge — append the source so the
# caller sees both documents cited.
#
# 3. Different source, conflicting bounds: a dispute.
# Don't merge into one Edge; instead, build a second
# Edge with the disputed bounds, mark both as
# ``is_disputed``, and link them via ``disputed_with``.
# Slice 2's consistency engine turns this into a
# ``Contradiction`` node.
#
# 0. Existing is unbounded, new is bounded (slice 1,
# structured-YAML override): the YAML is the
# authoritative source for time bounds. Adopt the
# YAML bounds on the existing edge and append the
# YAML as a contributing source. The unbounded
# markdown-style triple still gets cited for
# audit (it's where the relationship was first
# noticed), but the bounds come from the structured
# path. This is the inverse of Case 3 — instead of
# spawning a second disputed Edge, we promote the
# structured source.
if (
existing.valid_from is None
and existing.valid_until is None
and (t.valid_from is not None or t.valid_until is not None)
):
existing.valid_from = t.valid_from
existing.valid_until = t.valid_until
if t.source_path not in existing.sources:
existing.sources.append(t.source_path)
existing.extraction_confidences.append(t.extraction_confidence)
existing.source_confidences.append(t.source_confidence)
existing.reliabilities.append(t.reliability)
continue
if t.source_path in existing.sources and _windows_consistent(
existing.valid_from, existing.valid_until, None, None
):
# Case 1: duplicate from same source, no bounds.
# Both windows are null → consistent. Skip.
continue
if _windows_consistent(
existing.valid_from, existing.valid_until, None, None
) and _windows_consistent(None, None, None, None):
# Case 2: existing has no bounds, new has no bounds.
# Append as a new source.
existing.sources.append(t.source_path)
existing.extraction_confidences.append(t.extraction_confidence)
existing.source_confidences.append(t.source_confidence)
existing.reliabilities.append(t.reliability)
else:
# Case 3: bounds disagree. Build a second Edge,
# mark both as disputed, link them.
disputed = Edge(
subject=t.subject,
relation=t.relation,
object=t.object,
valid_from=None, # the new triple has no bounds; this
valid_until=None, # is the structural-dispute case
sources=[t.source_path],
extraction_confidences=[t.extraction_confidence],
source_confidences=[t.source_confidence],
reliabilities=[t.reliability],
is_disputed=True,
)
existing.is_disputed = True
existing.disputed_with.append(disputed)
disputed.disputed_with.append(existing)
g.add(disputed)
return g
def load_graph_from_codex(root: str) -> Graph:
"""One-call helper: parse a codex directory and return the graph."""
return build_graph(iter_codex(root), extract_triples(iter_codex(root)))
def was_true_at(
graph: Graph,
relation: str,
subject: str,
object: str,
at_time: str,
current_time: Optional[str] = None,
*,
setting: Optional[str] = None,
) -> dict:
"""The Lore Engine ``was_true_at`` tool.
Returns a dict shaped like the spec in ``docs/05-mcp-tools.md``::
{
"was_true": bool,
"relation": str,
"subject": str,
"object": str,
"at_time": str,
"valid_from": str | None,
"valid_until": str | None,
"sources": list[str],
"confidence": float,
"edges_examined": int,
}
Slice 6.5 — ``setting`` (keyword-only) requires both
``subject`` and ``object`` to ``EXISTS_IN`` the named
setting. If either endpoint is not a member of the
setting, the answer is ``was_true: False`` (with the
standard "unknown entity" note when the entity is
*also* missing from the graph). An unknown setting
short-circuits to ``was_true: False``. ``None``
(default) means no filter — slice 4 / 9 behaviour.
"""
# Slice 6.5 — setting filter on both endpoints. We
# check the setting *first* so an entity that's in the
# graph but not in the setting returns the
# standard-shape response (was_true: False) without
# the "unknown entity" note — the entity exists, it's
# just not in this setting.
if setting is not None:
if graph.find_setting(setting) is None:
return {
"was_true": False,
"relation": relation,
"subject": subject,
"object": object,
"at_time": at_time,
"valid_from": None,
"valid_until": None,
"sources": [],
"confidence": 0.0,
"edges_examined": 0,
"note": f"unknown setting: {setting}",
}
setting_members = graph.setting_entities(setting)
s_check = graph.by_name(subject)
o_check = graph.by_name(object)
# An entity not in the setting is treated like an
# unknown entity — same note shape. (The LLM caller
# can't tell the difference between "doesn't exist"
# and "exists but not here" from this single call,
# which is by design — the world-builder is the one
# who decides which entities belong to which setting.)
if s_check is None or s_check not in setting_members:
return {
"was_true": False,
"relation": relation,
"subject": subject,
"object": object,
"at_time": at_time,
"valid_from": None,
"valid_until": None,
"sources": [],
"confidence": 0.0,
"edges_examined": 0,
"note": f"unknown entity: {subject}",
}
if o_check is None or o_check not in setting_members:
return {
"was_true": False,
"relation": relation,
"subject": subject,
"object": object,
"at_time": at_time,
"valid_from": None,
"valid_until": None,
"sources": [],
"confidence": 0.0,
"edges_examined": 0,
"note": f"unknown entity: {object}",
}
s = graph.by_name(subject)
o = graph.by_name(object)
if s is None or o is None:
return {
"was_true": False,
"relation": relation,
"subject": subject,
"object": object,
"at_time": at_time,
"valid_from": None,
"valid_until": None,
"sources": [],
"confidence": 0.0,
"edges_examined": 0,
"note": f"unknown entity: {subject if s is None else object}",
}
# Slice 5.4: route through the GraphBackend Protocol method
# so the in-memory and Neo4j backends answer the same way.
candidates = [e for e in graph.edges_for_subject(s, relation) if e.object == o]
if not candidates:
# Try the reverse direction — ``was_true_at`` is symmetric in the
# sense that a fact can be recorded from either endpoint.
candidates = [e for e in graph.edges_for_subject(o, relation) if e.object == s]
best: Optional[Edge] = None
for e in candidates:
if time_in_window(at_time, e.valid_from, e.valid_until, current_time=current_time):
# Pick the highest-confidence match. The aggregate
# confidence is min(extraction * source) across sources;
# for slice 0 each edge has exactly one source, so
# ``aggregate_confidence == extraction * source``.
agg = e.aggregate_confidence
if best is None or agg > best.aggregate_confidence:
best = e
if best is None:
return {
"was_true": False,
"relation": relation,
"subject": s,
"object": o,
"at_time": at_time,
"valid_from": None,
"valid_until": None,
"sources": [],
"confidence": 0.0,
"edges_examined": len(candidates),
}
return {
"was_true": True,
"relation": relation,
"subject": s,
"object": o,
"at_time": at_time,
"valid_from": best.valid_from,
"valid_until": best.valid_until,
"sources": best.sources,
"extraction_confidences": best.extraction_confidences,
"source_confidences": best.source_confidences,
"reliabilities": best.reliabilities,
"confidence": best.aggregate_confidence,
"is_disputed": best.is_disputed,
"disputed_with_sources": [
e.sources for e in best.disputed_with
],
"edges_examined": len(candidates),
}
__all__ = [
"Edge",
"Graph",
"build_graph",
"load_graph_from_codex",
"was_true_at",
]