"""
Acoustic Collapse Theory v7  —  "Resonant Retrieval"
=====================================================

Lineage / what changed and WHY (read against results.txt of v0-v6):

  v0/v1: the ODE 'thinking' loop was a no-op ('shape change 0.0000', collapse
         at t=1). Removed the decorative loop; replaced with a loop that does
         real work (see below).
  v2:    raw dot-product with no normalisation -> score was just a proxy for
         sentence length (300-444), always passed the gate. Fixed: every
         spectrum is L2-normalised, similarity is bounded cosine in [0,1].
  v4:    phase coherence printed ~1.000 for EVERY input incl. random noise ->
         zero discrimination. Removed phase entirely; it carried no usable
         cross-sentence information (word-order phase is not comparable
         between two different sentences).
  v5:    energy absorption > 100% and NaN -> unbounded, undefined. Fixed by
         normalisation + guarded division.
  ALL:   the ODE transform was applied to the QUERY only, never the KB, so each
         'thinking' step pushed the query AWAY from the thing it was matched
         against. Inverted: the loop now pulls the query TOWARD the corpus
         manifold (pseudo-relevance feedback / Rocchio). That is a real
         algorithm that genuinely sharpens retrieval, and it honours the
         'iterate until the wave settles -> collapse' metaphor honestly.

What this actually is, stated plainly: a compact, deterministic TF-IDF
sparse-spectrum retriever with relevance-feedback query refinement. The
acoustic vocabulary is kept because it maps cleanly to the design and the
representation (a 1-D IDF-weighted hashed spectrum) is genuinely
ESP32-friendly. The wins over v0-v6 come from fixing the math, not from
more elaborate physics.

Representation improvements that increase the SEMANTIC reach cheaply:
  - features = word unigrams + word bigrams + character trigrams.
    char-trigrams let 'murder'/'murderer'/'murdered' partially overlap, so
    morphological / fuzzy matches survive the hash (pure bigram hashing in
    v0-v6 could not do this). Still O(tokens) and tiny in memory.
"""

import os
import numpy as np
from math import log


class AcousticCollapseTheoryV7:
    def __init__(self,
                 num_frequencies=8192,      # sparse, so larger = fewer hash collisions, ~free
                 accept_threshold=0.22,     # min RAW cosine to accept a match
                 margin_threshold=0.02,     # min gap between top-1 and top-2 to be 'confident'
                 feedback_alpha=0.7,        # how much of the original query to keep each step
                 feedback_topk=5,           # neighbours blended in during 'collapse'
                 max_ode_steps=6):
        self.NUM_FREQS = num_frequencies
        self.ACCEPT = accept_threshold
        self.MARGIN = margin_threshold
        self.ALPHA = feedback_alpha
        self.TOPK = feedback_topk
        self.MAX_STEPS = max_ode_steps

        self.kb_triggers = None      # dense matrix (N x F) of normalised trigger spectra
        self.kb_responses = []       # response text aligned to rows of kb_triggers
        self.kb_trigger_text = []    # the matched-trigger text, for inspection
        self.idf = np.ones(self.NUM_FREQS)
        self.total_docs = 0
        print(f"[ACT v7] Resonant Retrieval cortex online ({self.NUM_FREQS} bins).")

    # ---- hashing -----------------------------------------------------------
    def _hash(self, token):
        h = 5381
        for ch in token:
            h = ((h << 5) + h) + ord(ch)
        return h % self.NUM_FREQS

    def _features(self, text):
        """unigrams + bigrams + char-trigrams. Returns a list of token strings."""
        words = [w.strip(".,!?;:\"'()[]{}_-") for w in text.lower().split()]
        words = [w for w in words if w]
        feats = list(words)                                   # unigrams
        feats += [f"{words[i]} {words[i+1]}"                  # bigrams
                  for i in range(len(words) - 1)]
        joined = " ".join(words)
        feats += [f"#{joined[i:i+3]}"                         # char trigrams
                  for i in range(len(joined) - 2)]
        return feats

    # ---- encoding ----------------------------------------------------------
    def encode(self, text, apply_idf=True):
        """Text -> L2-normalised IDF-weighted sparse spectrum (real, bounded)."""
        spec = np.zeros(self.NUM_FREQS, dtype=np.float64)
        for feat in self._features(text):
            f = self._hash(feat)
            spec[f] += self.idf[f] if apply_idf else 1.0
        n = np.linalg.norm(spec)
        return spec / n if n > 0 else spec

    # ---- the 'collapse' loop: pseudo-relevance feedback --------------------
    def _collapse(self, q, verbose=True):
        """
        Iteratively pull the query toward the corpus manifold.
        Each step: find current best neighbours, blend their centroid into the
        query, renormalise. Stops ('collapses') when the top match stops
        changing -- a genuine fixed point, not a decorative timer.
        """
        prev_top = -1
        for t in range(1, self.MAX_STEPS + 1):
            sims = self.kb_triggers @ q                       # cosine (all unit norm)
            order = np.argpartition(-sims, self.TOPK)[:self.TOPK]
            top = order[np.argmax(sims[order])]

            if top == prev_top and t > 1:
                if verbose:
                    print(f"[COLLAPSE] Fixed point at t={t} (top match stable).")
                break
            prev_top = top

            centroid = self.kb_triggers[order].mean(axis=0)
            q = self.ALPHA * q + (1.0 - self.ALPHA) * centroid
            n = np.linalg.norm(q)
            q = q / n if n > 0 else q
            if verbose:
                print(f"[COLLAPSE] t={t}: refining toward corpus "
                      f"(best cos so far {sims[top]:.3f})")
        return q

    # ---- training ----------------------------------------------------------
    def train(self, books_dir="books"):
        if not os.path.exists(books_dir):
            print(f"[ERROR] '{books_dir}' not found."); return

        print(f"[TRAIN] Scanning '{books_dir}'...")
        sentences = []
        df = np.zeros(self.NUM_FREQS)                          # document frequency

        for fn in os.listdir(books_dir):
            if fn.endswith((".txt", ".md")):
                with open(os.path.join(books_dir, fn), encoding="utf-8",
                          errors="ignore") as fh:
                    lines = [ln.strip() for ln in fh if len(ln.strip()) > 15]
                sentences.extend(lines)
                for ln in lines:
                    seen = {self._hash(f) for f in self._features(ln)}
                    for f in seen:
                        df[f] += 1

        self.total_docs = len(sentences)
        # smoothed idf, never zero so a rare bin is loud but a unseen bin is neutral
        self.idf = np.log((1.0 + self.total_docs) / (1.0 + df)) + 1.0

        # build dense trigger matrix once (N x F); fine for a few k sentences.
        # On ESP32 you would stream these instead of holding them all in RAM.
        triggers = np.zeros((len(sentences) - 1, self.NUM_FREQS))
        for i in range(len(sentences) - 1):
            triggers[i] = self.encode(sentences[i])
            self.kb_responses.append(sentences[i + 1])
            self.kb_trigger_text.append(sentences[i])
        self.kb_triggers = triggers
        print(f"[TRAIN] Complete. {len(self.kb_responses)} associations "
              f"over {self.total_docs} sentences.\n")

    # ---- query -------------------------------------------------------------
    def query(self, prompt, verbose=True):
        if self.kb_triggers is None:
            return "Knowledge base empty."
        if verbose:
            print(f"Input:   {prompt}")

        q0 = self.encode(prompt)
        if np.linalg.norm(q0) == 0:
            print("[RESULT] Empty spectrum.\nOutput:  [REJECTED - no usable features]\n" + "-"*60)
            return "[REJECTED]"

        # 1. RAW lexical evidence on the actual query -> this gates accept/reject.
        #    Feedback must never be allowed to fabricate confidence from noise.
        raw_sims = self.kb_triggers @ q0
        raw_best = int(np.argmax(raw_sims))
        raw_score = float(raw_sims[raw_best])
        tmp = raw_sims.copy(); tmp[raw_best] = -1
        raw_margin = raw_score - float(tmp.max())

        confident = raw_score >= self.ACCEPT and raw_margin >= self.MARGIN
        if verbose:
            print(f"[EVIDENCE] raw cosine={raw_score:.3f}  margin={raw_margin:.3f}  "
                  f"({'ACCEPT' if confident else 'REJECT'})")
        if not confident:
            if verbose:
                print("Output:  [REJECTED - no confident resonance]\n" + "-"*60)
            return "[REJECTED]"

        # 2. The query passed the gate, so a real signal exists. Now let the
        #    collapse loop refine WHICH neighbour in that cluster answers best.
        q = self._collapse(q0, verbose=verbose)
        ref_sims = self.kb_triggers @ q
        best = int(np.argmax(ref_sims))

        if verbose:
            print(f"[RESULT] refined cosine={float(ref_sims[best]):.3f}")
            print(f"Matched: {self.kb_trigger_text[best]}")
            print(f"Output:  {self.kb_responses[best]}\n" + "-"*60)
        return self.kb_responses[best]


if __name__ == "__main__":
    ai = AcousticCollapseTheoryV7()
    ai.train("books")
    print("=== STARTING EVALUATION ===")
    for p in ["What is the main concept?",
              "Explain the theory of relativity",
              "The quantum state collapses",
              "Poirot looked at the murder scene",
              "Random words apple bicycle gravity"]:
        ai.query(p)
