"""Lab Eval: φ Separation Probe (research-only). Tests whether a candidate embedding φ : Proposition → Cl(4,1) produces a contemplation differential Δ(chain) = ‖ versor_apply(R_connective, φ(subject)) − φ(object) ‖ that *separates* known-compatible chains from synthesized known-contradicting twins. This is the load-bearing prerequisite for ADR-0081 follow-up work. Until separation is empirically demonstrated, ‖Δ‖ is a hash, not an insight — and no geometric stress miner should consume Rust cycles to compute it over the full vault footprint. WHAT THIS PROBE IS A bench measurement. Outputs Δ distributions for two groups (compatible / contradicting) under a candidate φ, plus a simple threshold-sweep separation report (best-threshold accuracy, ROC AUC). WHAT THIS PROBE IS NOT A production code path. Not invoked by runtime, packs, vault, or contemplation loop. Lives in evals/lab/ as a research artifact. PROMOTION CRITERION AUC ≥ 0.80 on the contradiction set below before any geometric miner is built. Below that, φ is not separating signal from coincidence; building a kernel sweep over it would ratify noise. To run: python -m evals.lab.phi_separation_probe """ from __future__ import annotations import hashlib import json import math from collections import Counter from dataclasses import dataclass from functools import lru_cache from pathlib import Path from typing import Callable, Iterable import numpy as np from algebra.cga import cga_inner, embed_point from algebra.cl41 import ( N_COMPONENTS, geometric_product, grade_count, grade_start, reverse, ) from algebra.versor import normalize_to_versor from chat.pack_grounding import _pack_index def _raw_sandwich(V: np.ndarray, F: np.ndarray) -> np.ndarray: """Raw R·F·rev(R) without runtime-closure projection. ``algebra.versor.versor_apply`` is the runtime field-state path: it projects non-null outputs back onto the unit-versor manifold (collapsing sum-of-points encodings to scalar identity). For the φ probe we want the geometric truth, not the field-state closure — so we sandwich at the raw geometric-product level. """ return geometric_product(geometric_product(V, F), reverse(V)) # --------------------------------------------------------------------------- # Candidate φ — v1 # --------------------------------------------------------------------------- # All choices below are *candidates*. The probe exists to falsify # them. Each design choice is annotated so the next iteration can # vary one knob at a time. _R3_DIM = 3 _RNG_SEED_LEMMA = "phi.v1.lemma" _RNG_SEED_CONN = "phi.v1.connective" def _stable_r3(token: str, salt: str) -> np.ndarray: """Hash a string token to a stable point in R^3. SHA-256(salt + token), take first 12 bytes as three int32s, map to [-1, 1]. Pure-function, deterministic across runs. """ digest = hashlib.sha256(f"{salt}:{token}".encode("utf-8")).digest() ints = np.frombuffer(digest[:12], dtype=np.int32) return (ints.astype(np.float32) / np.float32(2**31)).reshape(_R3_DIM) def phi_lemma_summed_domains(lemma: str) -> np.ndarray: """φ.v1: sum of CGA point embeddings of the lemma's semantic_domains. Domains are the load-bearing structure the pack already commits to. Sum is grade-mixed — the rotor can engage non-trivial subspaces. NOT on the null cone (sum of nulls isn't null). """ pack = _pack_index() domains = pack.get(lemma.strip().lower()) if domains is None: return embed_point(_stable_r3(lemma, _RNG_SEED_LEMMA + ".oov")) if not domains: return embed_point(_stable_r3(lemma, _RNG_SEED_LEMMA + ".nodomains")) acc = np.zeros(N_COMPONENTS, dtype=np.float32) for d in domains: acc += embed_point(_stable_r3(d, _RNG_SEED_LEMMA)) return acc @lru_cache(maxsize=1) def _domain_idf() -> dict[str, float]: """Inverse-document-frequency weight per semantic_domain. Treats each lemma's ``semantic_domains`` list as a document and weights each domain by ``log((N + 1) / (df + 1)) + 1`` (smooth IDF — avoids divide-by-zero, keeps every domain positive-weighted so singletons still contribute). Rare domains (those appearing in few lemmas) carry more identity signal than common ones like ``logos.core`` that appear across most cognition lemmas. IDF lets the rotor act on the distinguishing axes instead of being dominated by the shared background. """ pack = _pack_index() n_docs = len(pack) df: Counter[str] = Counter() for domains in pack.values(): for d in set(domains): df[d] += 1 return { d: math.log((n_docs + 1) / (count + 1)) + 1.0 for d, count in df.items() } def phi_lemma_idf_weighted(lemma: str) -> np.ndarray: """φ.v3: IDF-weighted sum of CGA point embeddings. Same shape as v1 (grade-mixed sum) but each domain's contribution is scaled by its inverse-document-frequency in the pack. Tests whether v1's null result is "encoding random" or "common-domain noise drowning out the distinguishing axes." """ pack = _pack_index() domains = pack.get(lemma.strip().lower()) if domains is None: return embed_point(_stable_r3(lemma, _RNG_SEED_LEMMA + ".oov")) if not domains: return embed_point(_stable_r3(lemma, _RNG_SEED_LEMMA + ".nodomains")) idf = _domain_idf() acc = np.zeros(N_COMPONENTS, dtype=np.float32) for d in domains: weight = float(idf.get(d, 1.0)) acc += weight * embed_point(_stable_r3(d, _RNG_SEED_LEMMA)) return acc def phi_lemma_idf_centroid(lemma: str) -> np.ndarray: """φ.v4: IDF-weighted centroid in R^3, embedded once. The null-cone sibling of v3. Computes a weighted centroid of the lemma's domain hash points and embeds once via the CGA point map, so the principled CGA distance interpretation still holds. """ pack = _pack_index() domains = pack.get(lemma.strip().lower()) if domains is None: return embed_point(_stable_r3(lemma, _RNG_SEED_LEMMA + ".oov")) if not domains: return embed_point(_stable_r3(lemma, _RNG_SEED_LEMMA + ".nodomains")) idf = _domain_idf() pts = np.stack([_stable_r3(d, _RNG_SEED_LEMMA) for d in domains]) weights = np.asarray([float(idf.get(d, 1.0)) for d in domains], dtype=np.float32) centroid = (weights[:, None] * pts).sum(axis=0) / max(weights.sum(), 1e-9) return embed_point(centroid) # --------------------------------------------------------------------------- # Corpus-graph aware φ (v7, v8) — structural only, no training # --------------------------------------------------------------------------- # Treats the reviewed teaching corpus as a directed multigraph where # lemmas are nodes and (intent, connective) pairs are edge labels. # Each lemma's encoding is the centroid (in R^3) of hash points # derived from its graph neighborhood: # # out_signature = hash("OUT:" + connective + "/" + object_lemma) # in_signature = hash("IN:" + subject_lemma + "/" + connective) # # Lemmas with similar neighborhoods (same connectives used toward # the same kinds of partners) land near each other in R^3, then # embed once via the CGA point map. Stays on the null cone. # # CAVEAT — structural only. This does NOT fit lemma positions to # satisfy R_c · φ(s) ≈ φ(o) along the corpus relations. Such a # joint fit (TransE-style) would require a training loop, a # train/test split, and convergence criteria — outside the # single-file lab probe shape. If even this structural variant # fails to separate, the lab probe has reached the limit of what # closed-form φ can prove; the next move is training. @lru_cache(maxsize=1) def _corpus_graph() -> dict[str, list[tuple[str, str]]]: """Build {lemma: [(direction, signature_token), ...]} from the corpus. ``direction`` is ``"OUT"`` or ``"IN"`` and the signature token is a deterministic string capturing the role-partner pair. Cached once because the corpus files are immutable inputs. """ graph: dict[str, list[tuple[str, str]]] = {} for path in _CHAIN_CORPORA: if not path.exists(): continue for line in path.read_text(encoding="utf-8").splitlines(): if not line.strip(): continue row = json.loads(line) s = str(row["subject"]).strip().lower() o = str(row["object"]).strip().lower() c = str(row.get("connective", "")).strip().lower() graph.setdefault(s, []).append(("OUT", f"{c}/{o}")) graph.setdefault(o, []).append(("IN", f"{s}/{c}")) return graph def phi_lemma_corpus_graph(lemma: str) -> np.ndarray: """φ.v7: centroid of corpus-neighborhood hash points. Closed-form, no fitting. Tests whether *structural* position in the corpus graph (which connectives + which partners a lemma participates with) carries enough signal to separate compatible from contradicting chains under the rotor sandwich. """ graph = _corpus_graph() edges = graph.get(lemma.strip().lower()) if not edges: return embed_point( _stable_r3(lemma, _RNG_SEED_LEMMA + ".graph.unconnected") ) pts = np.stack( [_stable_r3(f"{direction}:{token}", _RNG_SEED_LEMMA + ".graph") for direction, token in edges] ) return embed_point(pts.mean(axis=0)) def phi_lemma_centroid_point(lemma: str) -> np.ndarray: """φ.v2: centroid of domain hash points in R^3, embedded once. Stays on the CGA null cone (single conformal point). The rotor sandwich preserves the null property algebraically, which means the principled CGA distance ``-2·`` actually equals a Euclidean squared distance between the rotated and target points. This is the geometrically honest variant. """ pack = _pack_index() domains = pack.get(lemma.strip().lower()) if domains is None: return embed_point(_stable_r3(lemma, _RNG_SEED_LEMMA + ".oov")) if not domains: return embed_point(_stable_r3(lemma, _RNG_SEED_LEMMA + ".nodomains")) pts = np.stack([_stable_r3(d, _RNG_SEED_LEMMA) for d in domains]) return embed_point(pts.mean(axis=0)) def phi_connective(connective: str) -> np.ndarray: """φ(connective): hash → grade-2 bivector → unit rotor. v1 design: connectives are *relations*, not nouns, so they live in the rotor block, not the point block. We seed grade-2 (the bivector subspace) from a hash and run normalize_to_versor to land on the unit-rotor manifold. """ seed = np.zeros(N_COMPONENTS, dtype=np.float32) g2_start = grade_start(2) g2_count = grade_count(2) # SHA-256 yields 32 bytes (8 int32s); grade-2 in Cl(4,1) has 10 # basis bivectors, so we chain two hashes to fill the block. base = f"{_RNG_SEED_CONN}:{connective.strip().lower()}" raw = hashlib.sha256(base.encode("utf-8")).digest() raw += hashlib.sha256((base + ":pad").encode("utf-8")).digest() ints = np.frombuffer(raw[: 4 * g2_count], dtype=np.int32) seed[g2_start : g2_start + g2_count] = ( ints.astype(np.float32) / np.float32(2**31) ) # Scalar component non-zero so normalize_to_versor doesn't degenerate. seed[0] = 1.0 return normalize_to_versor(seed) # --------------------------------------------------------------------------- # Δ — contemplation differential # --------------------------------------------------------------------------- PhiLemma = Callable[[str], np.ndarray] PhiConnective = Callable[[str], np.ndarray] def delta_cga( chain_subject: str, connective: str, chain_object: str, phi_l: PhiLemma, phi_c: PhiConnective, ) -> float: """Δ via CGA point-distance: d = sqrt(-2 · ) for null X, Y. Geometrically principled only when φ_l returns null vectors. The rotor sandwich preserves null-ness, so s_rotated stays on the cone and ``-2·`` equals the Euclidean squared distance between the underlying R^3 points. """ s = phi_l(chain_subject) o = phi_l(chain_object) r = phi_c(connective) s_rotated = _raw_sandwich(r, s) dsq = -2.0 * cga_inner(s_rotated, o) return float(np.sqrt(max(dsq, 0.0))) def delta_frobenius( chain_subject: str, connective: str, chain_object: str, phi_l: PhiLemma, phi_c: PhiConnective, ) -> float: """Δ via raw multivector coefficient L2. Not the principled CGA metric (CLAUDE.md forbids it on hot paths) but reported as a sanity check — separation here without separation under CGA points to which subspace carries signal. """ s = phi_l(chain_subject) o = phi_l(chain_object) r = phi_c(connective) s_rotated = _raw_sandwich(r, s) return float(np.linalg.norm(s_rotated - o)) # --------------------------------------------------------------------------- # Pair set — compatible chains + synthesized contradicting twins # --------------------------------------------------------------------------- # Compatible chains come from the *actual* reviewed corpus, so we are # not testing against synthetic data on both sides. # # Contradicting twins are formed by swapping the connective with a # semantic antonym. The contradiction is structural (same subject, # same intent, same object, opposite relation). If φ is sound, the # rotor should send φ(subject) away from φ(object) under the # antonym — yielding larger Δ. _ANTONYMS: dict[str, str] = { "requires": "rejects", "reveals": "obscures", "grounds": "undermines", "supports": "contradicts", "enables": "prevents", "confirms": "refutes", "informs": "misleads", "verifies": "falsifies", } # --------------------------------------------------------------------------- # Antonym-paired connective encoder # --------------------------------------------------------------------------- # CAVEAT — this is an *oracle* probe, not a φ proposal. # # By enforcing R(antonym) = reverse(R(original)) we hardcode the # antonym relationship into rotor space rather than discovering it # from any underlying semantic structure. That tells us nothing # about whether the pack content secretly contains antonym signal. # # What it DOES measure: an upper bound. "*If* antonym relations # were perfectly encoded geometrically, would the rest of the # encoding (lemmas, sandwich, distance) separate the two groups?" # # - High AUC under this variant ⇒ the lemma encoding is adequate # and the bottleneck is the missing connective-relation map; # building one becomes the next research target. # - Low AUC under this variant ⇒ even with the antonym oracle, # the rest of the encoding can't see contradiction; the lemma # encoding is also broken. _REVERSE_ANTONYMS: dict[str, str] = {v: k for k, v in _ANTONYMS.items()} def phi_connective_antonym_paired(connective: str) -> np.ndarray: """φ_c with antonym = reverse-rotor oracle. For each (a, b) ∈ ANTONYMS we pin R(a) := phi_connective(a) (the canonical member) and define R(b) := reverse(R(a)). Connectives not in the table fall back to the v1 hash rotor. """ key = connective.strip().lower() if key in _ANTONYMS: return phi_connective(key) canonical = _REVERSE_ANTONYMS.get(key) if canonical is not None: return reverse(phi_connective(canonical)) return phi_connective(key) _CHAIN_CORPORA: tuple[Path, ...] = ( Path("teaching/cognition_chains/cognition_chains_v1.jsonl"), ) @dataclass(frozen=True) class Pair: chain_id: str subject: str intent: str connective: str object: str antonym: str def _load_pairs() -> tuple[Pair, ...]: out: list[Pair] = [] for path in _CHAIN_CORPORA: if not path.exists(): continue for line in path.read_text(encoding="utf-8").splitlines(): if not line.strip(): continue row = json.loads(line) conn = str(row.get("connective", "")).lower() antonym = _ANTONYMS.get(conn) if antonym is None: continue out.append( Pair( chain_id=str(row["chain_id"]), subject=str(row["subject"]), intent=str(row["intent"]), connective=conn, object=str(row["object"]), antonym=antonym, ) ) return tuple(out) # --------------------------------------------------------------------------- # Separation report # --------------------------------------------------------------------------- def _auc(compatible: list[float], contradicting: list[float]) -> float: """Rank-based AUC: P(Δ_contradicting > Δ_compatible). 1.0 = perfect separation (every contradiction Δ greater than every compatible Δ). 0.5 = chance. """ if not compatible or not contradicting: return float("nan") wins = 0 ties = 0 total = 0 for c in compatible: for x in contradicting: total += 1 if x > c: wins += 1 elif x == c: ties += 1 return (wins + 0.5 * ties) / total def _best_threshold_accuracy( compatible: list[float], contradicting: list[float] ) -> tuple[float, float]: """Sweep thresholds, return (best_accuracy, threshold).""" if not compatible or not contradicting: return (float("nan"), float("nan")) candidates = sorted(set(compatible + contradicting)) n = len(compatible) + len(contradicting) best_acc = 0.0 best_t = candidates[0] for t in candidates: # Decision rule: Δ > t ⇒ contradiction. correct = sum(1 for c in compatible if c <= t) + sum( 1 for x in contradicting if x > t ) acc = correct / n if acc > best_acc: best_acc = acc best_t = t return (best_acc, float(best_t)) def _summarise(label: str, values: Iterable[float]) -> dict[str, object]: arr = np.asarray(list(values), dtype=np.float64) return { "label": label, "n": int(arr.size), "min": float(arr.min()), "max": float(arr.max()), "mean": float(arr.mean()), "median": float(np.median(arr)), "std": float(arr.std(ddof=0)), } _PHI_VARIANTS: tuple[tuple[str, PhiLemma, PhiConnective], ...] = ( ("phi.v1.summed_domains", phi_lemma_summed_domains, phi_connective), ("phi.v2.centroid_point", phi_lemma_centroid_point, phi_connective), ("phi.v3.idf_weighted", phi_lemma_idf_weighted, phi_connective), ("phi.v4.idf_centroid", phi_lemma_idf_centroid, phi_connective), # v5 — antonym-oracle upper bound (see phi_connective_antonym_paired). ( "phi.v5.centroid_antonym_oracle", phi_lemma_centroid_point, phi_connective_antonym_paired, ), ( "phi.v6.idf_centroid_antonym_oracle", phi_lemma_idf_centroid, phi_connective_antonym_paired, ), # v7-v8 — corpus-graph aware φ (structural only, no training). ( "phi.v7.corpus_graph", phi_lemma_corpus_graph, phi_connective, ), ( "phi.v8.corpus_graph_antonym_oracle", phi_lemma_corpus_graph, phi_connective_antonym_paired, ), ) def run() -> dict: pairs = _load_pairs() variants: dict[str, dict] = {} for phi_name, phi_l, phi_c in _PHI_VARIANTS: metrics: dict[str, dict] = {} for metric_name, fn in (("cga", delta_cga), ("frobenius", delta_frobenius)): compat: list[float] = [] contra: list[float] = [] for p in pairs: compat.append(fn(p.subject, p.connective, p.object, phi_l, phi_c)) contra.append(fn(p.subject, p.antonym, p.object, phi_l, phi_c)) auc = _auc(compat, contra) best_acc, best_t = _best_threshold_accuracy(compat, contra) metrics[metric_name] = { "compatible": _summarise("compatible", compat), "contradicting": _summarise("contradicting", contra), "auc": auc, "best_threshold": best_t, "best_threshold_accuracy": best_acc, "promotion_passed": ( bool(auc >= 0.80) if not np.isnan(auc) else False ), } variants[phi_name] = metrics return { "promotion_criterion": "auc >= 0.80", "n_pairs": len(pairs), "antonym_table": _ANTONYMS, "variants": variants, } def main() -> int: report = run() print(json.dumps(report, indent=2, sort_keys=True)) return 0 if __name__ == "__main__": # pragma: no cover raise SystemExit(main())