core/generate/meaning_graph/projectors.py
Shay f66f2ee47f feat(comprehend): propositional-logic comprehension (4th domain, flagship oracle)
Adds comprehension_propositional — the comprehension organ now reads the classic
propositional ARGUMENT FORMS end-to-end into the flagship deductive_logic ROBDD
oracle (the most robustly independent gold in the repo). The neutral MeaningGraph
now feeds FOUR independent oracles (set-membership, syllogism-validity,
total-ordering, propositional-entailment) from one interlingua — the Option-B
interlingua thesis validated.

reader.py: propositional templates (atoms are chunked NP ids; fits the existing
entities + n-ary relations + negation model — NO interlingua change, propositional
is not arithmetic-quantities):
  - "if <P> then <Q>"        -> implies(P, Q)
  - "not <P>"                -> asserted(P, negated=True)
  - "<P> or <Q>"             -> or(P, Q)
  - "<P>" (single token)     -> asserted(P)   (bare-atom, single-token only to
                                                keep the parse-or-refuse floor)
  - "therefore <prop>"       -> query of the same predicate
Relations now carry a negated flag end-to-end (asserted negation).

projectors.py: to_deductive_logic serializes propositional relations/query into
formula strings (keyword operators the oracle tokenizer accepts); returns None
(refusal) unless the comprehension is purely propositional, so categorical/ordering
comprehensions never leak into the entailment oracle.

evals: new evals/propositional_logic/v1 (12 cases — modus ponens/tollens,
hypothetical & disjunctive syllogism, the affirming-consequent / denying-antecedent
fallacies which the oracle marks "unknown"; gold = oracle verdict) + gold-only
runner + evals/comprehension/propositional_runner.py. Oracle "refused" (formula
unevaluable) is treated as a decline, never a wrong.

Scores: comprehension_propositional 12/12 wrong=0 (full coverage); no regression on
the 3 existing lanes (8/8, 7/8, 7/8). Capability index breadth 6->7, score
0.917231 -> 0.928622, wrong_total 0, digest 51df7bba…

Tests: reader propositional templates; to_deductive_logic projector tests;
end-to-end full-coverage wrong=0; propositional generative round-trip added to the
wrong=0 property suite (verified to BITE under a reversed-implies mutation);
capability breadth 6->7. 115 targeted + 87 smoke green. Lane SHAs 8/9 (sole miss =
public_demo env wall-clock flake; deductive_logic_v1 unchanged).
2026-06-05 23:24:54 -07:00

177 lines
6.8 KiB
Python

"""Projectors — map a comprehended ``MeaningGraph`` into a reasoner's input shape.
The comprehension reader produces a neutral ``MeaningGraph``; a projector adapts
it to a specific independent-gold reasoner so the reader can be scored end-to-end
(prose -> MeaningGraph -> projection -> oracle -> answer vs gold). Projectors hold
NO decision logic — they only re-shape; the verdict is the independent oracle's.
"""
from __future__ import annotations
from typing import Any
from generate.meaning_graph.reader import Comprehension
def to_set_membership(comp: Comprehension) -> tuple[dict[str, Any], dict[str, Any]] | None:
"""Project into ``(structure, query)`` for ``evals.set_membership.oracle``.
Returns ``None`` when the comprehension does not carry exactly one membership
question (nothing to ask the oracle) — the caller treats that as a refusal.
"""
graph = comp.meaning_graph
individuals = sorted({e.entity_id for e in graph.entities if e.kind == "individual"})
classes = sorted({e.entity_id for e in graph.entities if e.kind == "class"})
member_facts = [
(r.arguments[0], r.arguments[1])
for r in graph.relations
if r.predicate == "member" and not r.negated
]
subset_facts = [
(r.arguments[0], r.arguments[1])
for r in graph.relations
if r.predicate == "subset" and not r.negated
]
member_queries = [q for q in comp.queries if q.predicate == "member" and not q.negated]
subset_queries = [q for q in comp.queries if q.predicate == "subset" and not q.negated]
if len(member_queries) + len(subset_queries) != 1:
return None
sets = [
{"id": cid, "members": sorted({ind for ind, cls in member_facts if cls == cid})}
for cid in classes
]
structure = {
"elements": individuals,
"sets": sets,
"subsets": [{"subset": a, "superset": b} for a, b in subset_facts],
}
if member_queries:
q = member_queries[0]
query = {"kind": "member", "element": q.arguments[0], "set": q.arguments[1]}
else:
q = subset_queries[0]
query = {"kind": "subset", "subset": q.arguments[0], "superset": q.arguments[1]}
return structure, query
#: Categorical predicate -> Aristotelian form for the syllogism oracle.
_PRED_FORM = {"subset": "A", "disjoint": "E", "intersects": "I", "some_not": "O"}
#: Finite-model domain size for syllogism validity (matches the gold lane).
_SYLLOGISM_DOMAIN_SIZE = 3
def to_syllogism(comp: Comprehension) -> tuple[dict[str, Any], dict[str, Any]] | None:
"""Project into ``(structure, query)`` for ``evals.syllogism.oracle``.
Premises are the categorical relations (subset/disjoint/intersects/some_not);
the single categorical query is the conclusion. Returns ``None`` when the
comprehension is not exactly one categorical conclusion over >=1 premise — the
caller treats that as a refusal (nothing honestly askable of this oracle).
"""
graph = comp.meaning_graph
premises = [
{"form": _PRED_FORM[r.predicate], "subject": r.arguments[0], "predicate": r.arguments[1]}
for r in graph.relations
if r.predicate in _PRED_FORM and not r.negated
]
conclusions = [q for q in comp.queries if q.predicate in _PRED_FORM and not q.negated]
if not premises or len(comp.queries) != 1 or len(conclusions) != 1:
return None
c = conclusions[0]
terms = sorted({e.entity_id for e in graph.entities if e.kind == "class"})
if len(terms) < 2:
return None
structure = {
"terms": terms,
"domain_size": _SYLLOGISM_DOMAIN_SIZE,
"premises": premises,
}
query = {
"kind": "validity",
"conclusion": {
"form": _PRED_FORM[c.predicate],
"subject": c.arguments[0],
"predicate": c.arguments[1],
},
}
return structure, query
def to_total_ordering(comp: Comprehension) -> tuple[dict[str, Any], dict[str, Any]] | None:
"""Project into ``(structure, query)`` for ``evals.total_ordering.oracle``.
Facts are ``less(lo, hi)`` relations; the single query is a sort or compare.
Returns ``None`` when the comprehension does not carry exactly one ordering
query — the caller treats that as a refusal.
"""
graph = comp.meaning_graph
less_facts = [
(r.arguments[0], r.arguments[1])
for r in graph.relations
if r.predicate == "less" and not r.negated
]
order_queries = [q for q in comp.queries if q.predicate in ("sort", "compare")]
if len(comp.queries) != 1 or len(order_queries) != 1:
return None
q = order_queries[0]
item_set = {item for pair in less_facts for item in pair}
if q.predicate == "compare":
item_set.update(q.arguments)
structure = {
"items": sorted(item_set),
"relations": [{"less": lo, "greater": hi} for lo, hi in less_facts],
}
if q.predicate == "sort":
query = {"kind": "sort", "order": q.arguments[0]}
else:
query = {"kind": "compare", "left": q.arguments[0], "right": q.arguments[1]}
return structure, query
#: Propositional predicates serialized into formula strings for the ROBDD oracle.
_PROP_PREDICATES = frozenset({"implies", "or", "asserted"})
def _formula(predicate: str, args: tuple[str, ...], negated: bool) -> str | None:
"""Serialize a propositional relation/query into a deductive_logic formula
string (keyword operators the oracle tokenizer accepts)."""
if predicate == "asserted":
return f"not {args[0]}" if negated else args[0]
if predicate == "implies":
return f"{args[0]} implies {args[1]}"
if predicate == "or":
return f"{args[0]} or {args[1]}"
return None
def to_deductive_logic(comp: Comprehension) -> tuple[tuple[str, ...], str] | None:
"""Project into ``(premises, query)`` formula strings for
``evals.deductive_logic.oracle.oracle_entailment``.
Returns ``None`` (treated as a refusal) unless the comprehension is purely
propositional with >=1 premise and exactly one propositional query — so a
categorical/ordering comprehension never leaks into the entailment oracle.
"""
graph = comp.meaning_graph
premises: list[str] = []
for r in graph.relations:
if r.predicate not in _PROP_PREDICATES:
return None # a non-propositional relation -> not this domain
formula = _formula(r.predicate, r.arguments, r.negated)
if formula is None:
return None
premises.append(formula)
prop_queries = [q for q in comp.queries if q.predicate in _PROP_PREDICATES]
if not premises or len(comp.queries) != 1 or len(prop_queries) != 1:
return None
query = _formula(prop_queries[0].predicate, prop_queries[0].arguments, prop_queries[0].negated)
if query is None:
return None
return tuple(premises), query