diff --git a/generate/meaning_graph/__init__.py b/generate/meaning_graph/__init__.py new file mode 100644 index 00000000..5882cd9c --- /dev/null +++ b/generate/meaning_graph/__init__.py @@ -0,0 +1,27 @@ +"""MeaningGraph — the neutral general-meaning interlingua (Phase 2a, COMPREHEND). + +The refusal-first, provenance-carrying structure that the field-decode produces +and the domain reasoners project from. Sibling of the binding-graph (ADR-0132): +where the binding-graph carries quantity/equation meaning, the MeaningGraph +carries GENERAL meaning — entities and n-ary named relations — and stays neutral +to the engine substrate (no algebra/field import) so two independent decodings +can meet there honestly. +""" + +from __future__ import annotations + +from generate.meaning_graph.model import ( + Entity, + MeaningGraph, + MeaningGraphError, + MeaningSpan, + Relation, +) + +__all__ = [ + "Entity", + "MeaningGraph", + "MeaningGraphError", + "MeaningSpan", + "Relation", +] diff --git a/generate/meaning_graph/model.py b/generate/meaning_graph/model.py new file mode 100644 index 00000000..46e1683d --- /dev/null +++ b/generate/meaning_graph/model.py @@ -0,0 +1,229 @@ +"""Frozen data model for the MeaningGraph — the general-meaning interlingua. + +This module is the typed boundary between comprehended natural language and the +domain reasoners. Like ``generate.binding_graph.model`` it holds *only data* — +no parser, no solver, no algebra — and every dataclass is ``frozen=True, +slots=True`` with immutable ``tuple`` collections. + +Refusal-first: invalid construction raises ``MeaningGraphError`` rather than +silently coercing. Neutral by design: imports nothing from ``algebra`` / +``field`` / ``numpy`` / the engine, so the structure is a fair meeting point for +two independent decodings (INV-26-style neutrality). + +Distinct from the binding-graph in two deliberate ways: + + - it carries GENERAL meaning (entities + n-ary named relations), not + quantities/equations; + - it imposes **no acyclicity** constraint. A cycle in general relations + ("A loves B, B loves A") is well-formed, not the circular *reasoning* the + binding-graph's equation DAG forbids. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +class MeaningGraphError(ValueError): + """Raised on invalid MeaningGraph construction; never silently coerces.""" + + +def _require_non_empty_str(value: object, field_name: str) -> None: + if not isinstance(value, str) or value == "": + raise MeaningGraphError(f"{field_name} must be a non-empty str; got {value!r}") + + +def _require_identifier(value: object, field_name: str) -> None: + _require_non_empty_str(value, field_name) + assert isinstance(value, str) + if not value.isidentifier(): + raise MeaningGraphError( + f"{field_name} must be a Python identifier; got {value!r}" + ) + + +# --------------------------------------------------------------------------- # +# MeaningSpan — provenance +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True, slots=True) +class MeaningSpan: + """An immutable pointer back to a ``[start, end)`` slice of the NL source. + + ``text`` is retained verbatim so downstream tooling can audit the span + without re-reading the source document. + """ + + source_id: str + start: int + end: int + text: str + + def __post_init__(self) -> None: + _require_non_empty_str(self.source_id, "MeaningSpan.source_id") + if not isinstance(self.start, int) or isinstance(self.start, bool): + raise MeaningGraphError(f"MeaningSpan.start must be int; got {self.start!r}") + if not isinstance(self.end, int) or isinstance(self.end, bool): + raise MeaningGraphError(f"MeaningSpan.end must be int; got {self.end!r}") + if self.start < 0: + raise MeaningGraphError(f"MeaningSpan.start must be >= 0; got {self.start}") + if self.end <= self.start: + raise MeaningGraphError( + f"MeaningSpan.end must be > start; got start={self.start}, end={self.end}" + ) + _require_non_empty_str(self.text, "MeaningSpan.text") + + def to_canonical_string(self) -> str: + return f"{self.source_id}[{self.start}:{self.end}]" + + +# --------------------------------------------------------------------------- # +# Entity +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True, slots=True) +class Entity: + """A referent lifted from language: a stable id + surface name + provenance. + + ``entity_id`` is a Python identifier so relations can key it safely. + ``kind`` is an optional, open free-text class hint (e.g. "person", + "number"); it carries NO closed vocabulary yet (defer-substrate-vocab — + a closed taxonomy is a deliberate later extension driven by a real use case). + """ + + entity_id: str + name: str + span: MeaningSpan + kind: str | None = None + + def __post_init__(self) -> None: + _require_identifier(self.entity_id, "Entity.entity_id") + _require_non_empty_str(self.name, "Entity.name") + if not isinstance(self.span, MeaningSpan): + raise MeaningGraphError( + f"Entity.span must be a MeaningSpan; got {type(self.span).__name__}" + ) + if self.kind is not None and (not isinstance(self.kind, str) or self.kind == ""): + raise MeaningGraphError( + f"Entity.kind must be None or a non-empty str; got {self.kind!r}" + ) + + +# --------------------------------------------------------------------------- # +# Relation +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True, slots=True) +class Relation: + """An n-ary named predicate over entity ids, with provenance and polarity. + + ``predicate`` is a free-text relation name (e.g. ``"mother_of"``); like + ``Entity.kind`` it carries no closed vocabulary yet. ``arguments`` is the + *ordered* tuple of entity ids the predicate relates (arity >= 1). ``negated`` + captures polarity ("A is NOT the mother of B") as first-class structure. + """ + + predicate: str + arguments: tuple[str, ...] + span: MeaningSpan + negated: bool = False + + def __post_init__(self) -> None: + _require_non_empty_str(self.predicate, "Relation.predicate") + if not isinstance(self.arguments, tuple): + raise MeaningGraphError( + f"Relation.arguments must be a tuple; got {type(self.arguments).__name__}" + ) + if len(self.arguments) == 0: + raise MeaningGraphError("Relation.arguments must be non-empty (arity >= 1)") + for arg in self.arguments: + _require_identifier(arg, "Relation.arguments entry") + if not isinstance(self.span, MeaningSpan): + raise MeaningGraphError( + f"Relation.span must be a MeaningSpan; got {type(self.span).__name__}" + ) + if not isinstance(self.negated, bool): + raise MeaningGraphError( + f"Relation.negated must be a bool; got {self.negated!r}" + ) + + @property + def arity(self) -> int: + return len(self.arguments) + + +# --------------------------------------------------------------------------- # +# MeaningGraph +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True, slots=True) +class MeaningGraph: + """Top-level immutable container of comprehended meaning. + + Cross-collection invariants enforced at construction: + + - ``entities`` carries unique ``entity_id`` values; + - every ``Relation`` argument references a known entity. + + No acyclicity constraint (see module docstring). Collections are emitted in + *given* order; the graph is identity-preserving by design. + """ + + entities: tuple[Entity, ...] = field(default_factory=tuple) + relations: tuple[Relation, ...] = field(default_factory=tuple) + provenance: tuple[MeaningSpan, ...] = field(default_factory=tuple) + + def __post_init__(self) -> None: + for name, value, item_type in ( + ("entities", self.entities, Entity), + ("relations", self.relations, Relation), + ("provenance", self.provenance, MeaningSpan), + ): + if not isinstance(value, tuple): + raise MeaningGraphError( + f"MeaningGraph.{name} must be a tuple; got {type(value).__name__}" + ) + for item in value: + if not isinstance(item, item_type): + raise MeaningGraphError( + f"MeaningGraph.{name} entries must be {item_type.__name__}; " + f"got {type(item).__name__}" + ) + + known_ids: set[str] = set() + for ent in self.entities: + if ent.entity_id in known_ids: + raise MeaningGraphError( + f"Duplicate Entity.entity_id: {ent.entity_id!r}" + ) + known_ids.add(ent.entity_id) + + for rel in self.relations: + for arg in rel.arguments: + if arg not in known_ids: + raise MeaningGraphError( + f"Relation {rel.predicate!r} references unknown entity_id {arg!r}" + ) + + def to_canonical_string(self) -> str: + """Deterministic string serialization for stable hashing / replay.""" + lines: list[str] = [] + for ent in self.entities: + lines.append( + f"E {ent.entity_id} {ent.name} kind={ent.kind} " + f"span={ent.span.to_canonical_string()}" + ) + for rel in self.relations: + args = ",".join(rel.arguments) + polarity = "not " if rel.negated else "" + lines.append( + f"R {polarity}{rel.predicate}({args}) " + f"span={rel.span.to_canonical_string()}" + ) + for span in self.provenance: + lines.append(f"P {span.to_canonical_string()} text={span.text}") + return "\n".join(lines) diff --git a/tests/test_meaning_graph.py b/tests/test_meaning_graph.py new file mode 100644 index 00000000..bf7f0245 --- /dev/null +++ b/tests/test_meaning_graph.py @@ -0,0 +1,222 @@ +"""MeaningGraph — Phase 2a (COMPREHEND): the neutral general-meaning interlingua. + +The refusal-first, provenance-carrying structure the field-decode produces and +the domain reasoners project from. Unlike the binding-graph (ADR-0132, +quantity/equation-shaped), this carries GENERAL meaning: entities + n-ary named +relations. Every test below must MEANINGFULLY FAIL under the violation it names +(CLAUDE.md schema-defined-proof-obligation rule) — a refusal-first model is only +real if construction refuses the malformed. +""" + +from __future__ import annotations + +import pytest + +from generate.meaning_graph.model import ( + Entity, + MeaningGraph, + MeaningGraphError, + MeaningSpan, + Relation, +) + + +def _span(text: str = "Alice", start: int = 0) -> MeaningSpan: + return MeaningSpan(source_id="s1", start=start, end=start + len(text), text=text) + + +# --------------------------------------------------------------------------- # +# MeaningSpan — provenance, refusal-first +# --------------------------------------------------------------------------- # + + +def test_span_holds_halfopen_interval() -> None: + sp = MeaningSpan(source_id="doc", start=2, end=7, text="lice ") + assert sp.to_canonical_string() == "doc[2:7]" + + +def test_span_refuses_empty_end_le_start() -> None: + with pytest.raises(MeaningGraphError): + MeaningSpan(source_id="doc", start=5, end=5, text="x") + with pytest.raises(MeaningGraphError): + MeaningSpan(source_id="doc", start=5, end=4, text="x") + + +def test_span_refuses_empty_source_or_text() -> None: + with pytest.raises(MeaningGraphError): + MeaningSpan(source_id="", start=0, end=1, text="x") + with pytest.raises(MeaningGraphError): + MeaningSpan(source_id="doc", start=0, end=1, text="") + + +# --------------------------------------------------------------------------- # +# Entity — refusal-first +# --------------------------------------------------------------------------- # + + +def test_entity_constructs() -> None: + e = Entity(entity_id="alice", name="Alice", span=_span()) + assert e.entity_id == "alice" + assert e.kind is None + + +def test_entity_refuses_non_identifier_id() -> None: + # entity_id must be a Python identifier so it can key relations safely. + with pytest.raises(MeaningGraphError): + Entity(entity_id="al ice", name="Alice", span=_span()) + with pytest.raises(MeaningGraphError): + Entity(entity_id="", name="Alice", span=_span()) + + +def test_entity_refuses_empty_name() -> None: + with pytest.raises(MeaningGraphError): + Entity(entity_id="alice", name="", span=_span()) + + +# --------------------------------------------------------------------------- # +# Relation — n-ary named predicate, refusal-first +# --------------------------------------------------------------------------- # + + +def test_relation_constructs_binary() -> None: + r = Relation(predicate="mother_of", arguments=("alice", "bob"), span=_span("is the mother of")) + assert r.arity == 2 + assert r.negated is False + + +def test_relation_refuses_empty_predicate() -> None: + with pytest.raises(MeaningGraphError): + Relation(predicate="", arguments=("alice", "bob"), span=_span()) + + +def test_relation_refuses_zero_arguments() -> None: + # A relation with no arguments is not a relation. + with pytest.raises(MeaningGraphError): + Relation(predicate="rains", arguments=(), span=_span()) + + +def test_relation_refuses_non_identifier_argument() -> None: + with pytest.raises(MeaningGraphError): + Relation(predicate="mother_of", arguments=("alice", "b ob"), span=_span()) + + +def test_relation_carries_negation() -> None: + r = Relation(predicate="equal_to", arguments=("a", "b"), span=_span(), negated=True) + assert r.negated is True + + +# --------------------------------------------------------------------------- # +# MeaningGraph — cross-collection referential integrity +# --------------------------------------------------------------------------- # + + +def _alice_bob_graph() -> MeaningGraph: + return MeaningGraph( + entities=( + Entity(entity_id="alice", name="Alice", span=_span("Alice", 0)), + Entity(entity_id="bob", name="Bob", span=_span("Bob", 21)), + ), + relations=( + Relation( + predicate="mother_of", + arguments=("alice", "bob"), + span=_span("is the mother of", 6), + ), + ), + ) + + +def test_graph_constructs_and_is_frozen() -> None: + g = _alice_bob_graph() + assert len(g.entities) == 2 + assert len(g.relations) == 1 + with pytest.raises((AttributeError, Exception)): + g.entities = () # type: ignore[misc] + + +def test_graph_refuses_duplicate_entity_id() -> None: + with pytest.raises(MeaningGraphError): + MeaningGraph( + entities=( + Entity(entity_id="alice", name="Alice", span=_span()), + Entity(entity_id="alice", name="Alicia", span=_span()), + ), + relations=(), + ) + + +def test_graph_refuses_relation_referencing_unknown_entity() -> None: + # The decisive integrity check: a relation argument must name a known entity. + with pytest.raises(MeaningGraphError): + MeaningGraph( + entities=(Entity(entity_id="alice", name="Alice", span=_span()),), + relations=( + Relation(predicate="mother_of", arguments=("alice", "bob"), span=_span()), + ), + ) + + +def test_graph_allows_relation_cycles() -> None: + # Distinct from the binding-graph: general relations MAY cycle + # ("A loves B, B loves A" is well-formed, not circular reasoning). + g = MeaningGraph( + entities=( + Entity(entity_id="a", name="A", span=_span("A", 0)), + Entity(entity_id="b", name="B", span=_span("B", 2)), + ), + relations=( + Relation(predicate="loves", arguments=("a", "b"), span=_span("loves", 1)), + Relation(predicate="loves", arguments=("b", "a"), span=_span("loves", 4)), + ), + ) + assert len(g.relations) == 2 + + +# --------------------------------------------------------------------------- # +# Determinism + neutrality +# --------------------------------------------------------------------------- # + + +def test_canonical_string_is_deterministic_and_bites() -> None: + a = _alice_bob_graph().to_canonical_string() + b = _alice_bob_graph().to_canonical_string() + assert a == b + # A different predicate must change the canonical form (it bites). + moved = MeaningGraph( + entities=( + Entity(entity_id="alice", name="Alice", span=_span("Alice", 0)), + Entity(entity_id="bob", name="Bob", span=_span("Bob", 21)), + ), + relations=( + Relation(predicate="sister_of", arguments=("alice", "bob"), span=_span("is the mother of", 6)), + ), + ) + assert moved.to_canonical_string() != a + + +def test_negation_changes_canonical_form() -> None: + pos = Relation(predicate="equal_to", arguments=("a", "b"), span=_span()) + neg = Relation(predicate="equal_to", arguments=("a", "b"), span=_span(), negated=True) + base = (Entity(entity_id="a", name="A", span=_span("A", 0)), + Entity(entity_id="b", name="B", span=_span("B", 2))) + g_pos = MeaningGraph(entities=base, relations=(pos,)) + g_neg = MeaningGraph(entities=base, relations=(neg,)) + assert g_pos.to_canonical_string() != g_neg.to_canonical_string() + + +def test_model_is_neutral_imports_no_algebra_or_field() -> None: + # INV-26-style neutrality: the interlingua must not couple to the engine + # substrate, so two independent decodings can meet there honestly. + import ast + import pathlib + + src = pathlib.Path("generate/meaning_graph/model.py").read_text(encoding="utf-8") + tree = ast.parse(src) + imported: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imported.update(a.name.split(".")[0] for a in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + imported.add(node.module.split(".")[0]) + forbidden = {"algebra", "field", "numpy", "evals", "core", "chat", "vault", "session"} + assert not (imported & forbidden), f"meaning_graph.model coupled to {imported & forbidden}"