#!/usr/bin/env catnip
# Hétéro-associateur linéaire : mémoire associative par matrice de corrélation.
# Associe des clés (stimuli) à des valeurs (réponses) sans entraînement itératif :
# règle de Hebb en forme close, W = Σ_k y_k x_kᵀ, rappel ŷ = W x.
# numpy fait l'algèbre, Catnip orchestre l'apprentissage, le sweep de bruit et le crosstalk.
# Sources:
#   Anderson (1972) "A simple neural network generating an interactive memory", Math. Biosciences
#   Kohonen (1972) "Correlation Matrix Memories", IEEE Trans. Computers C-21(4)

numpy = import('numpy')

n = 8  # dimension des stimuli (clés)
K = 4  # nombre d'associations stockées
m = 3  # dimension des réponses (RGB)

# Valeurs à mémoriser : un nom associé à une couleur RGB normalisée

names = list("feu", "menthe", "océan", "or")
Y = numpy.array(list(
        list(0.90, 0.20, 0.10),
        list(0.10, 0.85, 0.55),
        list(0.05, 0.40, 0.95),
        list(0.95, 0.75, 0.15),
    ))

# Clés orthonormées : colonnes d'une matrice orthogonale (QR d'un tirage fixe).
# Lignes de X = stimuli x_k, deux à deux orthogonaux, de norme 1.

rng = numpy.random.default_rng(0)
qr = numpy.linalg.qr(rng.standard_normal(list(n, K)))
X = numpy.transpose(qr.Q)

# Apprentissage et rappel, en forme close

learn = (Ys, Xs) => { numpy.matmul(numpy.transpose(Ys), Xs) }  # W = Yᵀ X  (m×n)
recall = (W, Xs) => { numpy.matmul(Xs, numpy.transpose(W)) }  # Ŷ = X Wᵀ  (batch)

offdiag = (G): float => { float(numpy.max(numpy.abs(numpy.subtract(G, numpy.eye(K))))) }
fmt_rgb = (v): str => { f"({v[0]:.2f}, {v[1]:.2f}, {v[2]:.2f})" }

struct Assoc {
    name: str; target; recalled; err: float

    display(self) => {
        print(f"  {self.name:<7} cible {fmt_rgb(self.target)}  →  rappel {fmt_rgb(self.recalled)}  (err {self.err:.4f})")
    }
}

build_assocs = (Yhat): list[Assoc] => {
    list(0, 1, 2, 3).[(i) => {
        Assoc(names[i], Y[i], Yhat[i], float(numpy.linalg.norm(numpy.subtract(Yhat[i], Y[i]))))
    }]
}

# Acte 1 — clés orthonormées : rappel parfait

W = learn(Y, X)
Yhat = recall(W, X)

print("⇒ Mémoire à clés orthonormées")
print(f"  Gram hors-diagonale max : {offdiag(numpy.matmul(X, numpy.transpose(X))):.6f}  (0 = orthonormé)")
print(f"  Matrice W : {W.shape[0]}×{W.shape[1]} poids pour {K} associations")
print()
print("⇒ Rappel sur les clés exactes")
build_assocs(Yhat).[(a) => { a.display() }]

# Acte 2 — dégradation gracieuse : probe bruitée

recall_error = (sigma: float): float => {
    Xn = numpy.add(X, numpy.multiply(sigma, rng.standard_normal(list(K, n))))
    float(numpy.mean(numpy.linalg.norm(numpy.subtract(recall(W, Xn), Y), axis=1)))
}

struct NoiseRun {
    sigma: float; err: float

    display(self) => {
        print(f"  σ = {self.sigma:.2f}  →  erreur RGB moyenne {self.err:.4f}")
    }
}

print()
print("⇒ Dégradation gracieuse (stimulus bruité)")
list(0.0, 0.1, 0.25, 0.5, 1.0).[(s) => { NoiseRun(s, recall_error(s)) }].[(r) => { r.display() }]

# Acte 3 — clés corrélées : crosstalk

shared = rng.standard_normal(list(n))
Xc_raw = numpy.add(X, numpy.multiply(0.45, shared))
Xc = numpy.divide(Xc_raw, numpy.linalg.norm(Xc_raw, axis=1, keepdims=True))

Wc = learn(Y, Xc)
Yhat_c = recall(Wc, Xc)

print()
print("⇒ Mémoire à clés corrélées")
print(f"  Gram hors-diagonale max : {offdiag(numpy.matmul(Xc, numpy.transpose(Xc))):.6f}  (≠ 0 = corrélation)")
print()
print("⇒ Rappel sur les clés exactes (crosstalk)")
build_assocs(Yhat_c).[(a) => { a.display() }]

# Synthèse

err_ortho = float(numpy.mean(numpy.linalg.norm(numpy.subtract(Yhat, Y), axis=1)))
err_cross = float(numpy.mean(numpy.linalg.norm(numpy.subtract(Yhat_c, Y), axis=1)))

print()
print("⇒ Bilan")
print(f"  Clés orthonormées : rappel exact (err {err_ortho:.4f}), puis dégradation lente sous le bruit")
print(f"  Clés corrélées    : interférence dès les clés exactes (err {err_cross:.4f})")