research(evals): phi separation probe for ADR-0081 follow-up (#57)
* research(evals): phi separation probe for ADR-0081 follow-up
Lab artifact at evals/lab/phi_separation_probe.py. Tests whether a
candidate embedding
phi : Proposition -> Cl(4,1)
produces a contemplation differential
Delta(chain) = ||sandwich(R_connective, phi(subject)) - phi(object)||
that separates known-compatible chains from synthesized
known-contradicting twins.
Why this exists
---------------
A "Topological Stress Field" miner (read-only Rust kernel sweeping
the vault footprint and emitting SPECULATIVE findings from high-Delta
regions) was discussed as a successor to #55. That miner can only
earn its Rust cycles if Delta actually correlates with semantic
contradiction. Until phi is empirically validated, ||Delta|| is a
hash, not a signal.
This probe is the falsification harness for phi. Promotion criterion
encoded in the run output: ``auc >= 0.80`` on the pair set below
before any geometric stress miner is built.
Method
------
- 21 real chains pulled from teaching/cognition_chains/cognition_chains_v1.jsonl.
- Contradicting twins synthesized via 8 connective-antonym pairs
(requires<->rejects, reveals<->obscures, grounds<->undermines,
supports<->contradicts, enables<->prevents, confirms<->refutes,
informs<->misleads, verifies<->falsifies).
- Two phi candidates: phi.v1.summed_domains (grade-mixed sum of
CGA point embeddings of the lemma's semantic_domains) and
phi.v2.centroid_point (centroid of domain hash points embedded
once, staying on the CGA null cone).
- Two distance metrics: principled CGA point-distance and Frobenius.
Result (v1)
-----------
All four (phi, metric) combinations land at AUC ~ 0.5 (chance).
Distributions for compatible vs contradicting overlap completely
(mean diff <= 0.04). Hash-derived phi does NOT encode contradiction
under any tested metric.
This is the right kind of failure: it tells us the geometric stress
miner has no signal to consume yet, and validates the decision to
not build it speculatively.
Two side findings worth pinning
-------------------------------
1. algebra.versor.versor_apply projects non-null inputs back onto the
unit-versor manifold (runtime field-state closure), collapsing
sum-of-multivectors phi outputs to scalar identity. The probe
uses raw R*F*reverse(R) directly. Any future geometric kernel
needs a raw sandwich primitive distinct from runtime versor_apply.
2. For two CGA null vectors X, Y the correct distance is
d = sqrt(-2 * <X, Y>), not sqrt(-2 * <X-Y, X-Y>). The latter
evaluates to a negative number that f32 numerics silently clamp
to zero. First version of the probe returned identically-zero
distances because of this.
Boundary
--------
- Lives in evals/lab/ (research-only, never imported by runtime).
- No new package surface; no Rust code; no pack/vault writes.
- No tests required (lab convention); the promotion criterion in
the run output is the falsification gate.
* research(evals): add IDF-weighted phi variants (v3, v4)
Adds two more phi candidates to the separation probe:
- phi.v3.idf_weighted — sum of CGA embeddings, weighted per
semantic_domain by smoothed IDF across the pack. Same shape as
v1 (grade-mixed) but rare domains get larger weight than common
ones like ``logos.core`` that appear in most cognition lemmas.
- phi.v4.idf_centroid — null-cone sibling of v3. IDF-weighted
centroid in R^3, embedded once.
Hypothesis tested: v1's null result was "common-domain noise drowning
out the distinguishing axes."
Result
------
All four (phi, metric) combinations still at AUC ~ 0.5:
phi.v1.summed_domains cga AUC=0.481 frob AUC=0.451
phi.v2.centroid_point cga AUC=0.490 frob AUC=0.492
phi.v3.idf_weighted cga AUC=0.481 frob AUC=0.449
phi.v4.idf_centroid cga AUC=0.497 frob AUC=0.501
IDF reweighting does not separate compatible from contradicting.
Diagnostic refinement
---------------------
v4 shows compat mean (0.559) < contra mean (0.572) — directionally
correct (contradictions land farther) but the effect is dwarfed by
the within-group std (~0.24). This is a hint, not signal.
What this *does* tell us: the lemma encoding is not the load-bearing
variable. The bottleneck is the **connective rotor**. Antonym pairs
should produce rotors that send vectors in opposite directions, but
hash-derived R(requires) and R(rejects) are statistically
independent — there is no encoded relationship between a connective
and its antonym in the current scheme.
Next phi candidate worth trying: encode connectives as rotors derived
from a learned or curated antonym structure (e.g., R(antonym) =
reverse(R(original))), so the antonym structure is GEOMETRICALLY
guaranteed instead of coincidentally absent. Until something on the
rotor axis carries structural signal, varying only the lemma
encoding is rearranging deck chairs.
* research(evals): antonym-rotor oracle variants (v5, v6)
Adds two upper-bound probes that hardcode the antonym structure
into rotor space:
R(antonym) := reverse(R(canonical))
so the antonym relationship is geometrically guaranteed instead
of coincidentally absent. This is NOT a phi proposal — it is an
oracle probe. What it measures: "if antonym relations *were*
perfectly encoded geometrically, would the rest of the encoding
separate the two groups?"
Variants:
- phi.v5.centroid_antonym_oracle — v2 lemmas + antonym oracle
- phi.v6.idf_centroid_antonym_oracle — v4 lemmas + antonym oracle
Result
------
Both still at chance:
v5 cga AUC=0.503 frob AUC=0.503
v6 cga AUC=0.526 frob AUC=0.517
v6 shows a slight directional effect — contradicting mean (0.575)
slightly above compatible mean (0.559) — but the gap is dwarfed by
within-group std (~0.20).
Diagnostic (the deeper finding)
-------------------------------
Even with the antonym oracle, the lemma encoding cannot see
contradiction. The reason: for the rotor sandwich to place
phi(subject) NEAR phi(object) on compatible chains, the rotor must
encode the specific subject->object relationship — not just "a
rotation." Hash-derived rotors send phi(subject) to a random
point, so compatible chains have large Delta and contradicting
twins also have large Delta. We never recover the "compatible is
small" half of the separation.
Implication: the lemma encoding itself must carry relational
structure (positions in phi space such that a small canonical set
of rotations consistently take subjects to their related objects),
or the encoding must be jointly learned with the connective rotors
against a coherence loss. Either way, hash-derived phi cannot work
in principle — not just in this implementation.
This quantitatively validates ADR-0081's thesis that phi is the
critical-path research blocker. It is not a tuning problem.
Refactor:
- delta_cga / delta_frobenius now take both phi_l and phi_c so
new variants can vary the connective encoder independently.
- _PHI_VARIANTS is now (name, phi_l, phi_c) triples.
* research(evals): corpus-graph aware phi variants (v7, v8)
Adds two structural-only graph-aware phi candidates:
phi.v7.corpus_graph — corpus neighborhood centroid
phi.v8.corpus_graph_antonym_oracle — v7 lemmas + antonym oracle rotors
For each lemma, embed the centroid (in R^3) of hash points derived
from its graph neighborhood in the reviewed teaching corpus:
out_signature = "OUT:" + connective + "/" + object_lemma
in_signature = "IN:" + subject_lemma + "/" + connective
Lemmas with similar neighborhoods (same connectives used toward the
same kinds of partners) land near each other in R^3.
CAVEAT: structural only. This does NOT fit lemma positions to
satisfy R_c * phi(s) ~ phi(o) along the corpus relations. A joint
fit (TransE-style) would require a training loop, train/test split,
and convergence criteria — outside the single-file lab probe shape.
Result
------
v7 cga AUC=0.451 frob AUC=0.474
v8 cga AUC=0.444 frob AUC=0.458
Both lower than chance — contradicting twins land *closer* on average
than compatible ones, but within 1 std (~0.29), so it is noise, not
signal. The structural opposite of what would pass.
Closure on closed-form phi
--------------------------
The probe has now systematically falsified every closed-form phi
candidate available without training:
v1-v2: hash-derived domain encodings — chance
v3-v4: IDF-weighted domain encodings — chance
v5-v6: above + antonym oracle on connectives — chance
v7-v8: corpus-graph neighborhood encoding — chance (anti)
No reweighting of domains, no oracle on connectives, no graph-aware
neighborhood centroid is enough. This is consistent across 8
variants and 4 (lemma, connective) encoding combinations.
Remaining options
-----------------
1. Trained phi (TransE/RotatE-style): fit lemma + connective
embeddings jointly against a corpus coherence loss. Tiny
corpus (21 chains) means heavy overfitting risk; need
leave-one-out cross-validation to report honestly. Real
infrastructure, not a probe.
2. Larger labelled corpus: 21 chains is too few to discriminate
"encoding cannot work" from "encoding cannot work *on this
data*." Expanding the teaching corpus would let the probe
distinguish those.
3. Park geometric contemplation. The falsification stands; the
ADR-0080 contemplation loop remains the operational read-only
doctrine. Geometric stress mining waits until a forcing
function appears.
Recommendation: option 3. This probe has earned its keep — it
quantitatively validated ADR-0081's "phi is the load-bearing
research blocker" thesis across the full closed-form design space.
This commit is contained in:
parent
1573064349
commit
5c04123d3f
1 changed files with 583 additions and 0 deletions
583
evals/lab/phi_separation_probe.py
Normal file
583
evals/lab/phi_separation_probe.py
Normal file
|
|
@ -0,0 +1,583 @@
|
|||
"""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·<X-Y, X-Y>`` 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 · <X, Y>) 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·<s_rotated, o>`` 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())
|
||||
Loading…
Reference in a new issue