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:
Shay 2026-05-20 12:34:59 -07:00 committed by GitHub
parent 1573064349
commit 5c04123d3f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View 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())