core/evals/lab/phi_separation_probe.py
Shay 5c04123d3f
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.
2026-05-20 12:34:59 -07:00

583 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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