Merge pull request #577 from AssetOverflow/feat/phase2a-meaning-graph

feat(comprehend): MeaningGraph — neutral general-meaning interlingua (Phase 2a)
This commit is contained in:
Shay 2026-06-05 16:00:32 -07:00 committed by GitHub
commit b39a6077ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 478 additions and 0 deletions

View file

@ -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",
]

View file

@ -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)

222
tests/test_meaning_graph.py Normal file
View file

@ -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}"