core/generate/graph_constraint.py
Shay c01ad748c8 fix(adr-0046): make forward-graph-constraint branch mergeable
The original adr-0046 commit was never run.  Fixes:

- generate/graph_constraint.py: import RegionSource (was the
  non-existent AdmissibilitySource).
- tests/test_graph_constraint.py + demo_01: load pack
  "en_core_cognition_v1" (was "en", which is not a pack ID).
- demo_03: read JsonlBufferSink.lines as a list attribute, not a
  method call.
- demo_04 (exact_recall_scale): DROPPED.  The construction used
  raw standard_normal vectors through unitize_versor and asserted
  cga_inner self-similarity is the population max.  Cl(4,1) has
  mixed signature — cga_inner is not self-maximising for arbitrary
  unitized random vectors — and the demo failed at N=10 000 in
  exactly the way the construction predicts.  The exact-recall
  claim's correct home is ADR-0045 (real vault path, properly
  constructed versors, N up to 100k = 100%).

Doc/index updates:

- ADR-0046 trimmed to three demos, with an explicit note on the
  dropped demo's geometric error and the cross-reference to
  ADR-0045.
- ADR-0046 verification block updated with measured lane numbers
  (smoke 67 / cognition 121 / runtime 19 / algebra 132 /
  teaching 17 / packs 6; core eval cognition unchanged).
- ADR-0046 cross-references ADR-0018 (intent_bridge source of the
  graph) and ADR-0022→ADR-0026 (AdmissibilityRegion contract).
- docs/decisions/README.md: ADR-0046 added to the index and to a
  new "Pillar 1 → 2 → 3 coupling" section linking the graph
  constraint to the existing forward-semantic-control chain.
- evals/industry_demos/__init__.py: invocation list trimmed to
  the three real entry points; removed the aspirational
  "core demo …" subcommands that were never wired.

Verification on this branch:
  tests/test_graph_constraint.py        8 passed
  evals/industry_demos/demo_01..03      exit 0 each
  core test --suite smoke              67 passed
  core test --suite cognition         121 passed
  core test --suite runtime            19 passed
  core test --suite algebra           132 passed
  core test --suite teaching           17 passed
  core test --suite packs               6 passed
  core eval cognition                 intent 100%, versor_closure 100%
2026-05-18 05:57:46 -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, RegionSource
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=RegionSource.INTENT,
)
allowed = _neighbourhood_indices(node_versors, vocab, top_k)
if not allowed:
return AdmissibilityRegion(
allowed_indices=None,
label="graph:unconstrained",
source=RegionSource.INTENT,
)
return AdmissibilityRegion(
allowed_indices=np.asarray(sorted(allowed), dtype=np.int64),
label=_constraint_label(graph),
source=RegionSource.INTENT,
)