From 270ad19bdbeb3ef4caf66ba60b16f85da6a943bf Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Thu, 14 May 2026 14:39:05 +0000 Subject: [PATCH] =?UTF-8?q?feat(trinity=5Floss):=20L-S51=20=CF=86-prior-aw?= =?UTF-8?q?are=20ternary=20contrastive=20loss?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the Trinity loss function for JEPA-T training. Formula: sim(a,b) = dot_ternary(a,b) / 64 L_triplet = max(0, margin + sim(a,n) - sim(a,p)) [margin=0.5] L_phi_prior = 0.382 * (zeros_a + zeros_p + zeros_n) / 192 L_total = L_triplet + 0.1 * L_phi_prior Files: crates/trinity_loss/Cargo.toml — crate manifest (Apache-2.0) crates/trinity_loss/src/lib.rs — dot_ternary, sim, zero_count, phi_prior_term, trinity_loss crates/trinity_loss/tests/loss.rs — 10 hand-computed triplets (±1e-4) + 50 LFSR stability crates/trinity_loss/python_ref/ — NumPy reference (docs only) crates/trinity_loss/README.md — usage + formula docs Constraints: - R1 CROWN: Rust only in cargo build; Python is reference-only - All functions deterministic, allocation-free, no std::time - Apache-2.0 cargo test -p trinity_loss: 26 tests passed (0 failed) Closes #809 DOI: 10.5281/zenodo.19227877 --- Cargo.lock | 4 + Cargo.toml | 2 + crates/trinity_loss/Cargo.toml | 16 + crates/trinity_loss/README.md | 85 ++++++ .../python_ref/trinity_loss_ref.py | 137 +++++++++ crates/trinity_loss/src/lib.rs | 179 +++++++++++ crates/trinity_loss/tests/loss.rs | 282 ++++++++++++++++++ 7 files changed, 705 insertions(+) create mode 100644 crates/trinity_loss/Cargo.toml create mode 100644 crates/trinity_loss/README.md create mode 100644 crates/trinity_loss/python_ref/trinity_loss_ref.py create mode 100644 crates/trinity_loss/src/lib.rs create mode 100644 crates/trinity_loss/tests/loss.rs diff --git a/Cargo.lock b/Cargo.lock index 5fd3c87f35..d05ff4477e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6795,6 +6795,10 @@ dependencies = [ "serde_json", ] +[[package]] +name = "trinity_loss" +version = "0.1.0" + [[package]] name = "trios-a2a" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 8e5a8a9473..32ed84e80b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,6 +76,8 @@ members = [ "crates/trios-mesh-node", # Trinity Secure Chat (EPIC trinity-fpga#28) "crates/trios-chat", + # L-S51 — Trinity Loss (φ-prior-aware ternary contrastive loss for JEPA-T) + "crates/trinity_loss", # Trinity Secure Chat — Ring Architecture (Wave-3, trinity-fpga#28) "crates/trios-chat/rings/CR-CHAT-00", "crates/trios-chat/rings/CR-CHAT-01", diff --git a/crates/trinity_loss/Cargo.toml b/crates/trinity_loss/Cargo.toml new file mode 100644 index 0000000000..f23c3ad44e --- /dev/null +++ b/crates/trinity_loss/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "trinity_loss" +version = "0.1.0" +edition = "2021" +authors = ["Dmitrii Vasilev "] +license = "Apache-2.0" +description = "φ-prior-aware ternary contrastive loss (Trinity Loss) for JEPA-T" +repository = "https://github.com/gHashTag/trios" + +[lib] +name = "trinity_loss" +path = "src/lib.rs" + +[[test]] +name = "loss" +path = "tests/loss.rs" diff --git a/crates/trinity_loss/README.md b/crates/trinity_loss/README.md new file mode 100644 index 0000000000..cde22c3c3c --- /dev/null +++ b/crates/trinity_loss/README.md @@ -0,0 +1,85 @@ +# trinity_loss + +φ-prior-aware ternary contrastive loss (Trinity Loss) for JEPA-T. + +[![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) + +## Overview + +Trinity Loss is a triplet-margin loss tailored for ternary neural networks +inspired by the golden ratio φ. It penalises both poor positive/negative +separation and excess sparsity in ternary representations. + +## Formula + +``` +sim(a, b) = dot_ternary(a, b) / 64 +L_triplet = max(0, margin + sim(a, n) - sim(a, p)) +L_phi_prior = φ⁻² · (||a||₀ + ||p||₀ + ||n||₀) / 192 +L_total = L_triplet + λ · L_phi_prior +``` + +| Constant | Value | Meaning | +|-------------|--------|---------------------------------------| +| φ⁻² | 0.382 | Golden-ratio inverse square (≈ 1/φ²) | +| margin | 0.5 | Triplet loss margin | +| λ (lambda) | 0.1 | φ-prior weight | + +where `||x||₀` denotes the number of **zero** entries in the ternary vector x, +and the denominator 192 = 3 × 64 normalises across the full triplet. + +## Anchor + +- φ² + φ⁻² = 3 +- DOI: 10.5281/zenodo.19227877 + +## Public API + +```rust +use trinity_loss::{dot_ternary, sim, zero_count, phi_prior_term, trinity_loss, + DEFAULT_MARGIN, DEFAULT_LAMBDA}; + +let a = [1i8; 64]; +let p = [1i8; 64]; +let n = [-1i8; 64]; + +// Individual components +let dp = dot_ternary(&a, &p); // → 64 (i32) +let s = sim(&a, &p); // → 1.0 (f32) +let z = zero_count(&a); // → 0 (u32) +let lp = phi_prior_term(&a, &p, &n); // → 0.0 (f32) + +// Full loss (margin=0.5, λ=0.1) +let loss = trinity_loss(&a, &p, &n, DEFAULT_MARGIN, DEFAULT_LAMBDA); +``` + +## Properties + +- **Deterministic**: identical inputs always produce identical outputs. +- **Allocation-free**: no heap allocation; all computation is in-register. +- **No `std::time`**: safe for `#![no_std]`-adjacent usage. +- **R1 CROWN**: only Rust is compiled; `python_ref/` is reference docs only. + +## Tests + +```bash +cargo test -p trinity_loss +``` + +Runs: +- 10 deterministic hand-computed triplets (±1e-4 tolerance) +- 50 LFSR-random stability tests (determinism + non-negativity + finiteness) + +## Python Reference + +`python_ref/trinity_loss_ref.py` provides a NumPy implementation of the same +formula for independent verification. It is **not** part of `cargo build` or +`cargo test`. + +```bash +python3 python_ref/trinity_loss_ref.py # prints PASS/FAIL for all 10 cases +``` + +## License + +Apache-2.0 — see [LICENSE](../../LICENSE). diff --git a/crates/trinity_loss/python_ref/trinity_loss_ref.py b/crates/trinity_loss/python_ref/trinity_loss_ref.py new file mode 100644 index 0000000000..c25442b562 --- /dev/null +++ b/crates/trinity_loss/python_ref/trinity_loss_ref.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# Trinity Loss — Golden Python Reference (informational only) +# Author: Dmitrii Vasilev +# +# This file is a reference implementation matching the Rust crate +# `trinity_loss`. It is NOT part of the cargo build or cargo test suite. +# Used for independent numerical verification of the Rust implementation. +# +# Formula: +# sim(a,b) = dot(a,b) / 64 # ternary cosine analogue +# L_triplet = max(0, margin + sim(a,n) - sim(a,p)) +# L_phi_prior = phi_inv_sq * (||a||₀ + ||p||₀ + ||n||₀) / 192 +# L_total = L_triplet + lambda * L_phi_prior +# +# where ||x||₀ counts zero entries, phi_inv_sq = φ⁻² ≈ 0.382. + +from __future__ import annotations +import numpy as np +from typing import Sequence + +# Constants (mirror of src/lib.rs) +PHI_INV_SQ: float = 0.382 +DEFAULT_MARGIN: float = 0.5 +DEFAULT_LAMBDA: float = 0.1 + + +def dot_ternary(a: Sequence[int], b: Sequence[int]) -> int: + """Ternary dot product of two length-64 ternary vectors.""" + a = np.asarray(a, dtype=np.int32) + b = np.asarray(b, dtype=np.int32) + assert len(a) == 64 and len(b) == 64, "Vectors must have length 64" + return int(np.dot(a, b)) + + +def sim(a: Sequence[int], b: Sequence[int]) -> float: + """Ternary similarity: dot_ternary(a, b) / 64.""" + return dot_ternary(a, b) / 64.0 + + +def zero_count(a: Sequence[int]) -> int: + """Count of zero entries in a ternary vector (ℓ₀-zero norm).""" + return int(np.sum(np.asarray(a) == 0)) + + +def phi_prior_term( + a: Sequence[int], + p: Sequence[int], + n: Sequence[int], +) -> float: + """φ-prior sparsity penalty: PHI_INV_SQ * total_zeros / 192.""" + total_zeros = zero_count(a) + zero_count(p) + zero_count(n) + return PHI_INV_SQ * total_zeros / 192.0 + + +def trinity_loss( + a: Sequence[int], + p: Sequence[int], + n: Sequence[int], + margin: float = DEFAULT_MARGIN, + lam: float = DEFAULT_LAMBDA, +) -> float: + """Full Trinity loss for one (anchor, positive, negative) triplet. + + Args: + a: anchor ternary vector (length 64, elements in {-1, 0, 1}) + p: positive ternary vector (semantically similar to a) + n: negative ternary vector (semantically dissimilar from a) + margin: triplet margin (default 0.5) + lam: λ weighting for φ-prior term (default 0.1) + + Returns: + Scalar loss value ≥ 0. + """ + sim_ap = sim(a, p) + sim_an = sim(a, n) + l_triplet = max(0.0, margin + sim_an - sim_ap) + l_phi = phi_prior_term(a, p, n) + return l_triplet + lam * l_phi + + +# ── Verification against the 10 hand-computed test cases ──────────────────── + +def _run_verification() -> None: + import math + + cases = [ + # (label, a, p, n, expected_loss) + ("T01", [1]*64, [1]*64, [-1]*64, 0.0), + ("T02", [0]*64, [0]*64, [0]*64, 0.5 + 0.1 * (0.382 * 192 / 192)), + ("T03", + ([1,0,-1]*21+[1]), + ([1,0,-1]*21+[1]), + ([-1,0,1]*21+[-1]), + 0.0 + 0.1 * (0.382 * 63 / 192)), + ("T04", + [1]*32+[-1]*32, [-1]*64, [1]*64, 0.5), + ("T05", + [0]*32+[1]*32, [1]*32+[0]*32, [-1]*64, + 0.0 + 0.1 * (0.382 * 64 / 192)), + ("T06", + [1,-1]*32, [1,-1]*32, [-1,1]*32, 0.0), + ("T07", + [0]*64, [1]*32+[-1]*32, [1]*32+[-1]*32, + 0.5 + 0.1 * (0.382 * 64 / 192)), + ("T08", + [1]*16+[0]*16+[-1]*16+[0]*16, + [1]*16+[0]*16+[-1]*16+[0]*16, + [-1]*16+[0]*16+[1]*16+[0]*16, + 0.0 + 0.1 * (0.382 * 96 / 192)), + ("T09", + [1]*64, [-1]*32+[0]*32, [1]*32+[0]*32, + 1.5 + 0.1 * (0.382 * 64 / 192)), + ("T10", + [0]*48+[1]*16, [0]*48+[1]*16, [0]*48+[-1]*16, + 0.0 + 0.1 * (0.382 * 144 / 192)), + ] + + tol = 1e-4 + all_pass = True + for label, a, p, n, expected in cases: + got = trinity_loss(a, p, n) + ok = math.isclose(got, expected, abs_tol=tol) + status = "PASS" if ok else "FAIL" + if not ok: + all_pass = False + print(f"[{status}] {label}: got={got:.7f} expected={expected:.7f} diff={abs(got-expected):.2e}") + + if all_pass: + print("\nAll 10 hand-computed cases PASSED.") + else: + print("\nSome cases FAILED!") + raise SystemExit(1) + + +if __name__ == "__main__": + _run_verification() diff --git a/crates/trinity_loss/src/lib.rs b/crates/trinity_loss/src/lib.rs new file mode 100644 index 0000000000..2d2ad91931 --- /dev/null +++ b/crates/trinity_loss/src/lib.rs @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: Apache-2.0 +// Trinity Loss — φ-prior-aware ternary contrastive loss for JEPA-T +// Author: Dmitrii Vasilev +// +// Formula: +// sim(a,b) = dot_ternary(a,b) / 64 +// L_triplet = max(0, margin + sim(a,n) - sim(a,p)) +// L_phi_prior = phi_inv_sq * (zero_count(a) + zero_count(p) + zero_count(n)) / 192 +// L_total = L_triplet + lambda * L_phi_prior +// +// where phi_inv_sq = φ⁻² ≈ 0.382, margin = 0.5, lambda = 0.1. +// +// All functions are deterministic, allocation-free (no heap), and use no std::time. + +/// φ⁻² = 1/(φ²) ≈ 0.381966... — truncated to 3 significant figures per spec. +pub const PHI_INV_SQ: f32 = 0.382; + +/// Default margin for the triplet loss (φ⁻² ≈ 0.5 is the spec value). +pub const DEFAULT_MARGIN: f32 = 0.5; + +/// Default λ weighting for the φ-prior term. +pub const DEFAULT_LAMBDA: f32 = 0.1; + +/// Compute the ternary dot product of two 64-element ternary vectors. +/// +/// Elements are expected to be in {-1, 0, 1}; the function is defined for +/// all i8 values but is meaningful only for ternary inputs. +/// +/// # Examples +/// ``` +/// use trinity_loss::dot_ternary; +/// let a = [1i8; 64]; +/// let b = [1i8; 64]; +/// assert_eq!(dot_ternary(&a, &b), 64); +/// ``` +#[inline] +pub fn dot_ternary(a: &[i8; 64], b: &[i8; 64]) -> i32 { + let mut acc: i32 = 0; + let mut i = 0; + while i < 64 { + acc += (a[i] as i32) * (b[i] as i32); + i += 1; + } + acc +} + +/// Ternary cosine analogue: `dot_ternary(a, b) / 64`. +/// +/// Range is [-1.0, 1.0] for unit ternary vectors; can exceed ±1 for dense +/// vectors with only non-zero entries (maximum = 1.0, minimum = -1.0 for +/// properly ternary {-1,0,1} inputs). +/// +/// # Examples +/// ``` +/// use trinity_loss::sim; +/// let a = [1i8; 64]; +/// let b = [-1i8; 64]; +/// assert_eq!(sim(&a, &b), -1.0_f32); +/// ``` +#[inline] +pub fn sim(a: &[i8; 64], b: &[i8; 64]) -> f32 { + dot_ternary(a, b) as f32 / 64.0 +} + +/// Count the number of zero entries in a ternary vector (ℓ₀ norm of zeros). +/// +/// # Examples +/// ``` +/// use trinity_loss::zero_count; +/// let a = [0i8; 64]; +/// assert_eq!(zero_count(&a), 64); +/// ``` +#[inline] +pub fn zero_count(a: &[i8; 64]) -> u32 { + let mut cnt: u32 = 0; + let mut i = 0; + while i < 64 { + if a[i] == 0 { + cnt += 1; + } + i += 1; + } + cnt +} + +/// The φ-prior sparsity penalty term (scalar). +/// +/// ```text +/// L_phi_prior = PHI_INV_SQ * (zero_count(a) + zero_count(p) + zero_count(n)) / 192 +/// ``` +/// +/// 192 = 3 × 64 normalises by the total number of entries across the triplet. +/// +/// # Examples +/// ``` +/// use trinity_loss::phi_prior_term; +/// let a = [0i8; 64]; +/// let p = [0i8; 64]; +/// let n = [0i8; 64]; +/// // All zeros: phi_prior = 0.382 * 192 / 192 = 0.382 +/// let expected = 0.382_f32; +/// assert!((phi_prior_term(&a, &p, &n) - expected).abs() < 1e-4); +/// ``` +#[inline] +pub fn phi_prior_term(a: &[i8; 64], p: &[i8; 64], n: &[i8; 64]) -> f32 { + let zeros = zero_count(a) + zero_count(p) + zero_count(n); + PHI_INV_SQ * (zeros as f32) / 192.0 +} + +/// Compute the full Trinity loss for one (anchor, positive, negative) triplet. +/// +/// ```text +/// L_triplet = max(0, margin + sim(a,n) - sim(a,p)) +/// L_phi = PHI_INV_SQ * (zero_count(a)+zero_count(p)+zero_count(n)) / 192 +/// L_total = L_triplet + lambda * L_phi +/// ``` +/// +/// # Arguments +/// * `a` – anchor ternary vector +/// * `p` – positive ternary vector (semantically similar to anchor) +/// * `n` – negative ternary vector (semantically dissimilar from anchor) +/// * `margin` – triplet margin (default 0.5 = `DEFAULT_MARGIN`) +/// * `lambda` – φ-prior weighting (default 0.1 = `DEFAULT_LAMBDA`) +/// +/// # Examples +/// ``` +/// use trinity_loss::{trinity_loss, DEFAULT_MARGIN, DEFAULT_LAMBDA}; +/// let a = [1i8; 64]; +/// let p = [1i8; 64]; +/// let n = [-1i8; 64]; +/// // Perfect triplet: sim(a,p)=1, sim(a,n)=-1 → L_triplet=0, no zeros → L_total=0 +/// assert_eq!(trinity_loss(&a, &p, &n, DEFAULT_MARGIN, DEFAULT_LAMBDA), 0.0_f32); +/// ``` +#[inline] +pub fn trinity_loss(a: &[i8; 64], p: &[i8; 64], n: &[i8; 64], margin: f32, lambda: f32) -> f32 { + let sim_ap = sim(a, p); + let sim_an = sim(a, n); + let l_triplet = (margin + sim_an - sim_ap).max(0.0); + let l_phi = phi_prior_term(a, p, n); + l_triplet + lambda * l_phi +} + +#[cfg(test)] +mod unit_tests { + use super::*; + + #[test] + fn dot_all_ones() { + let a = [1i8; 64]; + let b = [1i8; 64]; + assert_eq!(dot_ternary(&a, &b), 64); + } + + #[test] + fn dot_opposite() { + let a = [1i8; 64]; + let b = [-1i8; 64]; + assert_eq!(dot_ternary(&a, &b), -64); + } + + #[test] + fn sim_range() { + let a = [1i8; 64]; + let b = [1i8; 64]; + assert!((sim(&a, &b) - 1.0).abs() < 1e-6); + } + + #[test] + fn zero_count_full() { + let a = [0i8; 64]; + assert_eq!(zero_count(&a), 64); + } + + #[test] + fn zero_count_none() { + let a = [1i8; 64]; + assert_eq!(zero_count(&a), 0); + } +} diff --git a/crates/trinity_loss/tests/loss.rs b/crates/trinity_loss/tests/loss.rs new file mode 100644 index 0000000000..54b6c6da58 --- /dev/null +++ b/crates/trinity_loss/tests/loss.rs @@ -0,0 +1,282 @@ +// SPDX-License-Identifier: Apache-2.0 +// Trinity Loss — integration tests +// Author: Dmitrii Vasilev +// +// 10 deterministic hand-computed triplets (±1e-4 tolerance). +// 50 LFSR-random stability triplets (self-consistency, no Python dependency). + +use trinity_loss::{dot_ternary, phi_prior_term, sim, trinity_loss, zero_count, DEFAULT_LAMBDA, DEFAULT_MARGIN}; + +const TOL: f32 = 1e-4; + +// ─── helpers ──────────────────────────────────────────────────────────────── + +fn assert_close(actual: f32, expected: f32, label: &str) { + let diff = (actual - expected).abs(); + assert!( + diff <= TOL, + "{label}: got {actual:.7}, expected {expected:.7}, diff {diff:.2e}" + ); +} + +/// Build a [i8; 64] from a repeated pattern slice (cycled to fill 64 slots). +fn from_pattern(pat: &[i8]) -> [i8; 64] { + let mut out = [0i8; 64]; + for i in 0..64 { + out[i] = pat[i % pat.len()]; + } + out +} + +// ─── Hand-computed triplets ────────────────────────────────────────────────── +// +// Reference computations (all verified against python_ref/trinity_loss_ref.py): +// PHI_INV_SQ = 0.382 +// sim(a,b) = dot_ternary(a,b) / 64 +// L_trip = max(0, margin + sim(a,n) - sim(a,p)) +// L_phi = 0.382 * (zeros_a + zeros_p + zeros_n) / 192 +// L_total = L_trip + 0.1 * L_phi + +/// T1: all-ones anchor, all-ones positive, all-(-1) negative. +/// sim(a,p)=1, sim(a,n)=-1 → L_trip=0, zeros=0 → L_total=0.0 +#[test] +fn t01_perfect_triplet() { + let a = [1i8; 64]; + let p = [1i8; 64]; + let n = [-1i8; 64]; + assert_close(trinity_loss(&a, &p, &n, DEFAULT_MARGIN, DEFAULT_LAMBDA), 0.0, "T01"); +} + +/// T2: all-zeros anchor, positive, negative. +/// sim(a,p)=0, sim(a,n)=0 → L_trip=max(0,0.5)=0.5 +/// zeros=64+64+64=192 → L_phi=0.382 +/// L_total=0.5+0.1*0.382=0.5382 +#[test] +fn t02_all_zeros() { + let a = [0i8; 64]; + let p = [0i8; 64]; + let n = [0i8; 64]; + assert_close(trinity_loss(&a, &p, &n, DEFAULT_MARGIN, DEFAULT_LAMBDA), 0.5382, "T02"); +} + +/// T3: a = [1,0,-1]*21+[1], p = a (identical), n = -a. +/// dot(a,a)=43 → sim(a,p)=43/64≈0.671875, sim(a,n)=-0.671875 +/// L_trip=max(0,0.5-1.34375)=0 +/// zeros_a=zeros_p=zeros_n=21, total=63 → L_phi=0.382*63/192≈0.125344 +/// L_total=0+0.1*0.125344≈0.012534 +#[test] +fn t03_mixed_pattern() { + let pat: [i8; 3] = [1, 0, -1]; + let a = from_pattern(&pat); + let p = a; + let mut n = a; + for x in n.iter_mut() { *x = x.saturating_neg(); } + let expected = 0.0f32 + 0.1 * (0.382 * 63.0 / 192.0); + assert_close(trinity_loss(&a, &p, &n, DEFAULT_MARGIN, DEFAULT_LAMBDA), expected, "T03"); +} + +/// T4: a=[1]*32+[-1]*32, p=[-1]*64, n=[1]*64. +/// sim(a,p)=0, sim(a,n)=0 → L_trip=0.5 +/// zeros=0 → L_phi=0 +/// L_total=0.5 +#[test] +fn t04_orthogonal_both() { + let mut a = [1i8; 64]; + for i in 32..64 { a[i] = -1; } + let p = [-1i8; 64]; + let n = [1i8; 64]; + assert_close(trinity_loss(&a, &p, &n, DEFAULT_MARGIN, DEFAULT_LAMBDA), 0.5, "T04"); +} + +/// T5: a=[0]*32+[1]*32, p=[1]*32+[0]*32, n=[-1]*64. +/// dot(a,p)=0 → sim(a,p)=0 +/// dot(a,n)=-32 → sim(a,n)=-0.5 +/// L_trip=max(0,0.5-0.5-0)=0 +/// zeros_a=32, zeros_p=32, zeros_n=0, total=64 → L_phi=0.382*64/192≈0.127333 +/// L_total=0+0.1*0.127333≈0.012733 +#[test] +fn t05_half_zeros_neg_close() { + let mut a = [0i8; 64]; + for i in 32..64 { a[i] = 1; } + let mut p = [1i8; 64]; + for i in 32..64 { p[i] = 0; } + let n = [-1i8; 64]; + let expected = 0.1 * (0.382 * 64.0 / 192.0); + assert_close(trinity_loss(&a, &p, &n, DEFAULT_MARGIN, DEFAULT_LAMBDA), expected, "T05"); +} + +/// T6: a=[1,-1]*32, p=a (identical), n=[-1,1]*32. +/// sim(a,p)=1, sim(a,n)=-1 → L_trip=0 +/// zeros=0 → L_total=0 +#[test] +fn t06_alternating_perfect() { + let a = from_pattern(&[1i8, -1]); + let p = a; + let n = from_pattern(&[-1i8, 1]); + assert_close(trinity_loss(&a, &p, &n, DEFAULT_MARGIN, DEFAULT_LAMBDA), 0.0, "T06"); +} + +/// T7: a=[0]*64, p=[1]*32+[-1]*32, n=[1]*32+[-1]*32 (p==n). +/// sim(a,p)=0, sim(a,n)=0 → L_trip=0.5 +/// zeros_a=64, zeros_p=0, zeros_n=0, total=64 → L_phi=0.382*64/192≈0.127333 +/// L_total=0.5+0.1*0.127333≈0.512733 +#[test] +fn t07_zero_anchor_equal_pn() { + let a = [0i8; 64]; + let mut p = [1i8; 64]; + for i in 32..64 { p[i] = -1; } + let n = p; + let expected = 0.5 + 0.1 * (0.382 * 64.0 / 192.0); + assert_close(trinity_loss(&a, &p, &n, DEFAULT_MARGIN, DEFAULT_LAMBDA), expected, "T07"); +} + +/// T8: a=[1]*16+[0]*16+[-1]*16+[0]*16, p=a, n=[-1]*16+[0]*16+[1]*16+[0]*16. +/// dot(a,p)=32, sim(a,p)=0.5 +/// dot(a,n)=-32, sim(a,n)=-0.5 +/// L_trip=max(0,0.5-0.5-0.5)=0 +/// zeros_a=zeros_p=zeros_n=32, total=96 → L_phi=0.382*96/192=0.191 +/// L_total=0+0.1*0.191=0.0191 +#[test] +fn t08_half_zeros_flipped_neg() { + let mut a = [0i8; 64]; + for i in 0..16 { a[i] = 1; } + for i in 32..48 { a[i] = -1; } + let p = a; + let mut n = [0i8; 64]; + for i in 0..16 { n[i] = -1; } + for i in 32..48 { n[i] = 1; } + let expected = 0.1 * (0.382 * 96.0 / 192.0); + assert_close(trinity_loss(&a, &p, &n, DEFAULT_MARGIN, DEFAULT_LAMBDA), expected, "T08"); +} + +/// T9: a=[1]*64, p=[-1]*32+[0]*32, n=[1]*32+[0]*32. +/// sim(a,p)=-0.5, sim(a,n)=0.5 +/// L_trip=max(0,0.5+0.5+0.5)=1.5 +/// zeros_a=0, zeros_p=32, zeros_n=32, total=64 → L_phi=0.382*64/192≈0.127333 +/// L_total=1.5+0.1*0.127333≈1.512733 +#[test] +fn t09_worst_case_loss() { + let a = [1i8; 64]; + let mut p = [-1i8; 64]; + for i in 32..64 { p[i] = 0; } + let mut n = [1i8; 64]; + for i in 32..64 { n[i] = 0; } + let expected = 1.5 + 0.1 * (0.382 * 64.0 / 192.0); + assert_close(trinity_loss(&a, &p, &n, DEFAULT_MARGIN, DEFAULT_LAMBDA), expected, "T09"); +} + +/// T10: a=[0]*48+[1]*16, p=a, n=[0]*48+[-1]*16. +/// dot(a,p)=16, sim(a,p)=0.25 +/// dot(a,n)=-16, sim(a,n)=-0.25 +/// L_trip=max(0,0.5-0.25-0.25)=max(0,0)=0 +/// zeros_a=zeros_p=zeros_n=48, total=144 → L_phi=0.382*144/192=0.2865 +/// L_total=0+0.1*0.2865=0.028650 +#[test] +fn t10_sparse_anchor() { + let mut a = [0i8; 64]; + for i in 48..64 { a[i] = 1; } + let p = a; + let mut n = [0i8; 64]; + for i in 48..64 { n[i] = -1; } + let expected = 0.1 * (0.382 * 144.0 / 192.0); + assert_close(trinity_loss(&a, &p, &n, DEFAULT_MARGIN, DEFAULT_LAMBDA), expected, "T10"); +} + +// ─── LFSR-random stability tests ──────────────────────────────────────────── +// +// 50 triplets generated by a 32-bit Galois LFSR (polynomial 0xB400_0000). +// Elements are mapped: 0→-1, 1→0, 2→1 (3-valued from 2 bits of LFSR output). +// Each test asserts that trinity_loss is non-negative and that calling it twice +// returns the same value (determinism check). + +fn lfsr_next(state: &mut u32) -> u32 { + let lsb = *state & 1; + *state >>= 1; + if lsb != 0 { + *state ^= 0xB400_0000; + } + *state +} + +fn lfsr_ternary_vec(state: &mut u32) -> [i8; 64] { + let mut v = [0i8; 64]; + for slot in v.iter_mut() { + let bits = (lfsr_next(state) & 3) % 3; // 0,1,2 + *slot = (bits as i8) - 1; // -1,0,1 + } + v +} + +#[test] +fn lfsr_stability_50() { + let mut state: u32 = 0xDEAD_BEEF; + for trial in 0..50u32 { + let a = lfsr_ternary_vec(&mut state); + let p = lfsr_ternary_vec(&mut state); + let n = lfsr_ternary_vec(&mut state); + + let loss1 = trinity_loss(&a, &p, &n, DEFAULT_MARGIN, DEFAULT_LAMBDA); + let loss2 = trinity_loss(&a, &p, &n, DEFAULT_MARGIN, DEFAULT_LAMBDA); + + assert_eq!( + loss1, loss2, + "LFSR trial {trial}: trinity_loss is not deterministic" + ); + assert!( + loss1 >= 0.0, + "LFSR trial {trial}: loss is negative ({loss1})" + ); + assert!( + loss1.is_finite(), + "LFSR trial {trial}: loss is not finite ({loss1})" + ); + } +} + +// ─── Sub-function unit sanity checks ──────────────────────────────────────── + +#[test] +fn sub_dot_ternary_cross() { + let a = [1i8; 64]; + let b = [-1i8; 64]; + assert_eq!(dot_ternary(&a, &b), -64); +} + +#[test] +fn sub_sim_orthogonal() { + let mut a = [1i8; 64]; + let mut b = [0i8; 64]; + // a = [1,-1]*32, b = [0,1]*32 → dot=0 + for i in (0..64).step_by(2) { a[i] = 1; a[i+1] = -1; b[i] = 0; b[i+1] = 1; } + // dot = sum over 32 pairs of (1*0 + (-1)*1) = -1 per pair = -32, not 0 + // Use truly orthogonal: a=[1]*32+[0]*32, b=[0]*32+[1]*32 + let mut a2 = [0i8; 64]; + let mut b2 = [0i8; 64]; + for i in 0..32 { a2[i] = 1; } + for i in 32..64 { b2[i] = 1; } + assert!((sim(&a2, &b2) - 0.0).abs() < 1e-6); +} + +#[test] +fn sub_zero_count_mixed() { + let mut a = [0i8; 64]; + for i in 0..32 { a[i] = 1; } + assert_eq!(zero_count(&a), 32); +} + +#[test] +fn sub_phi_prior_all_nonzero() { + let a = [1i8; 64]; + let p = [1i8; 64]; + let n = [1i8; 64]; + assert!((phi_prior_term(&a, &p, &n) - 0.0).abs() < 1e-6); +} + +#[test] +fn sub_phi_prior_all_zero() { + let a = [0i8; 64]; + let p = [0i8; 64]; + let n = [0i8; 64]; + // 0.382 * 192 / 192 = 0.382 + assert_close(phi_prior_term(&a, &p, &n), 0.382, "phi_prior_all_zero"); +}