core/generate/graph_constraint.py
Shay 83443bd071 feat(adr-0046): PropositionGraph as forward constraint + industry demos
Closes the structural gap identified in the 2026-05-17 assessment:
the PropositionGraph was a post-hoc descriptor of what the field walk
already produced.  It is now a forward constraint that shapes what the
walk is ALLOWED to produce.

== generate/graph_constraint.py (new) ==

GraphConstraint — converts a PropositionGraph into an AdmissibilityRegion
before generate() runs, not after.  The region's allowed_indices are the
intersection of:
  - subject versor neighbourhood (top-k by CGA inner product)
  - object versor neighbourhood (top-k by CGA inner product)
  - any explicitly named node surfaces already in-vocabulary

This is the Pillar 1 → Pillar 2 coupling that was missing:
  geometry (CGA) → structure (graph) → propagation (generate)

build_graph_constraint(graph, vocab, *, top_k) is the public entry.
The region label encodes the graph's root node IDs so the admissibility
trace identifies the constraint source.

== generate/stream.py (updated) ==

generate() already accepts an AdmissibilityRegion.  No new API needed —
graph_constraint.build_graph_constraint() produces one.

== evals/industry_demos/ (new) ==

Four standalone demo scripts that each make ONE falsifiable claim no
transformer-LLM wrapper can reproduce.  Each script runs independently
via `python -m evals.industry_demos.<name>` and exits 0 on pass / 1 on
fail.  Each prints structured evidence to stdout.

  demo_01_forward_constraint.py
    Claim: When the PropositionGraph names subject=light, obj=truth, the
    generation walk is constrained to the CGA neighbourhood of those
    versors BEFORE any tokens are produced.  The allowed_indices set is
    computed from geometry, not from a prompt filter.  Demonstrated by
    showing the AdmissibilityRegion is non-trivial (< full vocab) and
    that all generated tokens score positive CGA inner product against
    the constraint field.

  demo_02_geometry_drives_identity.py
    Claim: Swapping the identity pack (precision_first vs generosity_first)
    on identical input produces structurally different surfaces via the
    manifold alignment path — not via a system-prompt swap.  Demonstrated
    by running two ChatRuntime instances with different identity_pack IDs
    on the same text, showing hedge_rate and identity_score.alignment
    differ, and that the manifold alignment_threshold differs at the
    algebra level (not just the text level).

  demo_03_deterministic_audit.py
    Claim: Three independently constructed ChatRuntime instances on the
    same input produce byte-identical JSONL audit lines.  Demonstrated
    by attaching JsonlBufferSink to each, running chat(), and asserting
    hash equality of the emitted lines (modulo the 'turn' field which is
    per-instance sequential).  This is architectural determinism — not
    seeded randomness.

  demo_04_exact_recall_scale.py
    Claim: CGA vault recall is exact (100%) at N=100, N=1_000, N=10_000.
    The needle versor is recovered at rank-1 by cga_inner scan regardless
    of vault size.  No approximate nearest-neighbour index.  No FAISS.
    No degradation curve.  Demonstrated inline with timing so the
    linear-scan cost is visible alongside the 100% recall.

== tests/test_graph_constraint.py (new) ==

8 tests:
  - build_graph_constraint returns an AdmissibilityRegion
  - allowed_indices is a strict subset of vocab (non-trivial constraint)
  - all constraint indices score positive cga_inner against at least
    one node versor
  - empty graph returns unconstrained region (safe fallback)
  - two-node graph unions both neighbourhoods
  - constraint label encodes root node IDs
  - round-trip: constraint region feeds generate() without raising
  - forward vs post-hoc: constrained walk produces tokens in the
    region; unconstrained walk may not (statistical, seeded vocab)

Co-Authored-By: Perplexity AI
2026-05-17 23:58:30 -07:00

159 lines
5.6 KiB
Python

"""
generate/graph_constraint.py — PropositionGraph as forward AdmissibilityRegion.
This module closes the structural gap identified 2026-05-17:
Before: PropositionGraph was built AFTER generate() ran, from
the walk's nearest-node results. It described what the
field already produced.
After: PropositionGraph is converted into an AdmissibilityRegion
BEFORE generate() runs. The region constrains which vocab
indices the walk may visit, derived purely from the CGA
geometry of the graph's named nodes.
This is the Pillar 1 → Pillar 2 → Pillar 3 coupling:
geometry (CGA versor neighbourhood) →
structure (PropositionGraph nodes) →
propagation (AdmissibilityRegion fed to generate())
Design constraints (matching the seven axioms):
- Geometry-first: the allowed set is determined by CGA inner product
against node versors, not by string matching or rule lists.
- Propagation-over-mutation: the region is computed once before
propagation begins; nothing inside generate() is mutated.
- Dual-correction: an empty graph returns an unconstrained region
(identity / pass-through) so the caller's fallback path is safe.
- Reconstruction-over-storage: the region encodes the constraint
lightly (an index set + label); it does not store every versor.
- Compilation-last: no tensors, no kernels — the index set is a
plain frozenset until AdmissibilityRegion wraps it.
"""
from __future__ import annotations
import numpy as np
from algebra.cga import cga_inner
from generate.admissibility import AdmissibilityRegion, AdmissibilitySource
from generate.graph_planner import PropositionGraph
_DEFAULT_TOP_K = 8
def _node_versors(
graph: PropositionGraph,
vocab,
) -> list[np.ndarray]:
"""Collect CGA versors for every named surface in the graph.
Checks subject, predicate, and obj for each node. Surfaces not in
vocabulary are silently skipped — the constraint degrades gracefully
rather than raising on OOV nodes.
"""
versors: list[np.ndarray] = []
seen: set[str] = set()
for node in graph.nodes:
for surface in (node.subject, node.predicate, node.obj):
surface = surface.strip().casefold()
if not surface or surface in seen or surface.startswith("<"):
continue
seen.add(surface)
try:
v = vocab.get_versor(surface)
versors.append(np.asarray(v, dtype=np.float32))
except KeyError:
continue
return versors
def _neighbourhood_indices(
node_versors: list[np.ndarray],
vocab,
top_k: int,
) -> frozenset[int]:
"""Union the top-k CGA-nearest indices for each node versor.
For each anchor versor, scan the vocabulary and collect the
top_k indices with the highest cga_inner score. Union all
neighbourhoods — the region allows any index that is close to
ANY named graph node.
This is an exact scan (O(|vocab| * |nodes|)). Vocab sizes in
CORE are bounded (language packs, not embedding tables), so this
is fast in practice.
"""
indices: set[int] = set()
n = len(vocab)
for anchor in node_versors:
scores: list[tuple[float, int]] = []
for idx in range(n):
v = vocab.get_versor_at(idx)
score = float(cga_inner(np.asarray(v, dtype=np.float32), anchor))
scores.append((score, idx))
scores.sort(key=lambda x: -x[0])
for score, idx in scores[:top_k]:
if score > 0.0:
indices.add(idx)
return frozenset(indices)
def _constraint_label(graph: PropositionGraph) -> str:
"""Stable label encoding the graph's root node IDs."""
roots = graph.roots()
if not roots:
roots = tuple(n.node_id for n in graph.nodes)
return "graph:" + ",".join(sorted(roots))
def build_graph_constraint(
graph: PropositionGraph,
vocab,
*,
top_k: int = _DEFAULT_TOP_K,
) -> AdmissibilityRegion:
"""Convert a PropositionGraph into an AdmissibilityRegion.
The region's allowed_indices is the union of the CGA top-k
neighbourhoods of every named surface in the graph. The walk
is constrained to visit only indices in this set.
Empty graph (no nodes, or all OOV nodes) → unconstrained region.
This preserves the existing fallback contract: unknown-domain
inputs that produce empty graphs get the full vocab walk, not
a zero-index set that would trigger immediate exhaustion.
Parameters
----------
graph : PropositionGraph
The graph whose named nodes define the constraint geometry.
vocab : Vocabulary
The vocabulary over which index neighbourhoods are computed.
top_k : int
Number of nearest vocab indices to admit per node versor.
Default 8 — keeps the constraint meaningful (< full vocab)
while allowing sufficient combinatorial freedom for fluent
token sequences.
"""
node_versors = _node_versors(graph, vocab)
if not node_versors:
# Empty or fully OOV graph → unconstrained (safe passthrough).
return AdmissibilityRegion(
allowed_indices=None,
label="graph:unconstrained",
source=AdmissibilitySource.INTENT,
)
allowed = _neighbourhood_indices(node_versors, vocab, top_k)
if not allowed:
return AdmissibilityRegion(
allowed_indices=None,
label="graph:unconstrained",
source=AdmissibilitySource.INTENT,
)
return AdmissibilityRegion(
allowed_indices=np.asarray(sorted(allowed), dtype=np.int64),
label=_constraint_label(graph),
source=AdmissibilitySource.INTENT,
)