Merge pull request #577 from AssetOverflow/feat/phase2a-meaning-graph
feat(comprehend): MeaningGraph — neutral general-meaning interlingua (Phase 2a)
This commit is contained in:
commit
b39a6077ec
3 changed files with 478 additions and 0 deletions
27
generate/meaning_graph/__init__.py
Normal file
27
generate/meaning_graph/__init__.py
Normal 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",
|
||||
]
|
||||
229
generate/meaning_graph/model.py
Normal file
229
generate/meaning_graph/model.py
Normal 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
222
tests/test_meaning_graph.py
Normal 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}"
|
||||
Loading…
Reference in a new issue