File size: 13,958 Bytes
aa68ee9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d1fa33b
 
 
 
 
 
 
 
aa68ee9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d1fa33b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aa68ee9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d1fa33b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aa68ee9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
"""
GovBridge India β€” INDRA PoincarΓ© Projection Engine (Sprint 29)

PROJECT INDRA Phase 1: Non-Euclidean Civic Topologies

This engine implements the HyEm (Hyperbolic Embedding) paradigm:
1. Receives oversampled Euclidean candidates from HNSW (400 vectors)
2. Projects them into the PoincarΓ© ball model via conformal exponential map
3. Computes hyperbolic distances (arcosh-based) between query and candidates
4. Returns candidates sorted by hyperbolic distance (ascending)

ARCHITECTURAL CONSTRAINTS:
- ALL NumPy buffers are pre-allocated at __init__ time.
- ZERO heap allocations inside compute methods (GC pressure = 0).
- IEEE 754 boundary enforcement: all vectors clamped to R_MAX = 1 - 1e-3.
- Denominator floor: DENOM_MIN = 1e-15 prevents division-by-zero.
- Negative normΒ² clamping: np.maximum(diff_norm2, 0.0) prevents NaN from
  floating-point variance underflow on identical vectors.

MATHEMATICAL FOUNDATION:
  exp_0(v) = tanh(||v||) * (v / ||v||)    [Conformal exponential map at origin]
  d_H(u,v) = arcosh(1 + 2 * ||u-v||Β² / ((1-||u||Β²)(1-||v||Β²)))

USAGE:
  from indra_engine import IndraProjectionEngine
  engine = IndraProjectionEngine(batch_size=400, dimensions=768)
  distances = engine.project_and_rank(query_vec, candidate_matrix)
"""

import numpy as np
from numpy.typing import NDArray
from typing import Optional
import os

# ── Optional Learned Adapter Weights (Sprint 31) ────────────
# If W.npy and b.npy exist, use learned projection instead of naΓ―ve expmap0.
# Falls back to original tanh(||v||)/||v|| if weights not found.
_WEIGHTS_DIR = os.path.join(os.path.dirname(__file__), "..", "training", "weights")
_W_PATH = os.path.join(_WEIGHTS_DIR, "W.npy")
_B_PATH = os.path.join(_WEIGHTS_DIR, "b.npy")


class IndraProjectionEngine:
    """
    Zero-allocation PoincarΓ© ball projection engine.

    Pre-allocates all working buffers at construction time.
    No heap allocations occur during compute_poincare_distances()
    or map_to_poincare_ball_inplace().
    """

    # ── Numerical Constants ──────────────────────────────────
    # Maximum radius in PoincarΓ© ball. Vectors beyond this are
    # clipped to prevent arcosh(1 + 2 * inf / 0) = NaN.
    R_MAX: float = 1.0 - 1e-3   # 0.999

    # Minimum denominator value. Prevents div-by-zero when a
    # vector lies exactly on the ball boundary (||v|| β‰ˆ 1.0).
    DENOM_MIN: float = 1e-15

    # Minimum norm for normalization. Vectors with ||v|| < this
    # are treated as zero vectors and mapped to the origin.
    NORM_MIN: float = 1e-8

    def __init__(self, batch_size: int = 400, dimensions: int = 768):
        """
        Pre-allocate all working memory.

        Args:
            batch_size: Maximum number of candidate vectors per query.
            dimensions: Embedding dimensionality (768 for Nomic).
        """
        self.batch_size = batch_size
        self.dimensions = dimensions

        # ── Pre-allocated buffers ────────────────────────────
        # Candidate matrix projected into PoincarΓ© ball
        self._poincare_candidates = np.zeros(
            (batch_size, dimensions), dtype=np.float32
        )

        # Query vector projected into PoincarΓ© ball
        self._poincare_query = np.zeros(dimensions, dtype=np.float32)

        # Scratch buffers for distance computation
        self._norms_sq = np.zeros(batch_size, dtype=np.float64)
        self._query_norm_sq = np.float64(0.0)
        self._diff = np.zeros(
            (batch_size, dimensions), dtype=np.float32
        )
        self._diff_norm_sq = np.zeros(batch_size, dtype=np.float64)
        self._distances = np.zeros(batch_size, dtype=np.float64)
        self._denom = np.zeros(batch_size, dtype=np.float64)

        # Buffer for norms during projection
        self._proj_norms = np.zeros(batch_size, dtype=np.float32)

    # ── Learned Adapter (Sprint 31) ───────────────────────────
    _adapter_W: Optional[NDArray[np.float32]] = None
    _adapter_b: Optional[NDArray[np.float32]] = None
    _adapter_loaded: bool = False

    def _load_adapter_weights(self) -> bool:
        """
        Attempt to load learned W.npy and b.npy from training/weights/.

        Returns True if weights loaded successfully, False otherwise.
        Falls back to naΓ―ve projection on failure.
        """
        if self._adapter_loaded:
            return self._adapter_W is not None

        self._adapter_loaded = True

        try:
            if os.path.exists(_W_PATH) and os.path.exists(_B_PATH):
                W = np.load(_W_PATH)
                b = np.load(_B_PATH)

                # Validate shapes
                if W.shape != (768, 128) or b.shape != (128,) or self.dimensions != 768:
                    return False

                self._adapter_W = W.astype(np.float32)
                self._adapter_b = b.astype(np.float32)

                # Reallocate PoincarΓ© buffers for 128-dim
                self._poincare_candidates = np.zeros(
                    (self.batch_size, 128), dtype=np.float32
                )
                self._poincare_query = np.zeros(128, dtype=np.float32)
                self._diff = np.zeros(
                    (self.batch_size, 128), dtype=np.float32
                )

                return True
        except Exception:
            pass

        return False

    def map_to_poincare_ball_inplace(
        self,
        vectors: NDArray[np.float32],
        out: NDArray[np.float32],
        norms_buf: Optional[NDArray[np.float32]] = None,
    ) -> None:
        """
        Conformal exponential map at the origin: exp_0(v) = tanh(||v||) * (v / ||v||)

        Projects Euclidean vectors into the PoincarΓ© ball IN-PLACE
        (writes to the `out` buffer). No allocations.

        Args:
            vectors: Input Euclidean vectors, shape (N, D) or (D,).
            out: Output buffer, same shape as vectors.
            norms_buf: Pre-allocated buffer for norms. If None, uses self._proj_norms.
        """
        is_1d = vectors.ndim == 1

        # ── Sprint 31: Use learned adapter if available ───────
        if self._load_adapter_weights() and self._adapter_W is not None:
            if is_1d:
                # Single vector: y = x @ W + b, then expmap0
                projected = vectors @ self._adapter_W + self._adapter_b  # (128,)
                norm = np.linalg.norm(projected).astype(np.float32)
                if norm < self.NORM_MIN:
                    out[:128] = 0.0
                else:
                    scale = np.float32(np.tanh(norm) / norm)
                    result = projected * scale
                    out_norm = np.linalg.norm(result).astype(np.float32)
                    if out_norm > self.R_MAX:
                        result *= np.float32(self.R_MAX / out_norm)
                    out[:128] = result
            else:
                n = vectors.shape[0]
                projected = vectors[:n] @ self._adapter_W + self._adapter_b  # (n, 128)
                for i in range(n):
                    norm_val = np.linalg.norm(projected[i]).astype(np.float32)
                    if norm_val < self.NORM_MIN:
                        out[i, :128] = 0.0
                    else:
                        scale = np.float32(np.tanh(norm_val) / norm_val)
                        result = projected[i] * scale
                        out_norm = np.linalg.norm(result).astype(np.float32)
                        if out_norm > self.R_MAX:
                            result *= np.float32(self.R_MAX / out_norm)
                        out[i, :128] = result
            return

        if is_1d:
            # Single vector case (query)
            norm = np.linalg.norm(vectors).astype(np.float32)
            if norm < self.NORM_MIN:
                out[:] = 0.0
                return
            scale = np.float32(np.tanh(norm) / norm)
            np.multiply(vectors, scale, out=out)
            # Radial boundary clip
            out_norm = np.linalg.norm(out).astype(np.float32)
            if out_norm > self.R_MAX:
                np.multiply(out, np.float32(self.R_MAX / out_norm), out=out)
        else:
            # Batch case (candidates)
            n = vectors.shape[0]
            if norms_buf is None:
                norms_buf = self._proj_norms

            # Compute L2 norms: ||v_i||
            np.einsum('ij,ij->i', vectors[:n], vectors[:n], out=norms_buf[:n])
            np.sqrt(norms_buf[:n], out=norms_buf[:n])

            for i in range(n):
                norm_val = norms_buf[i]
                if norm_val < self.NORM_MIN:
                    out[i, :] = 0.0
                else:
                    scale = np.float32(np.tanh(norm_val) / norm_val)
                    np.multiply(vectors[i], scale, out=out[i])

                    # Radial boundary clip per vector
                    out_norm = np.linalg.norm(out[i]).astype(np.float32)
                    if out_norm > self.R_MAX:
                        np.multiply(
                            out[i],
                            np.float32(self.R_MAX / out_norm),
                            out=out[i]
                        )

    def compute_poincare_distances(
        self,
        query_vec: NDArray[np.float32],
        candidate_matrix: NDArray[np.float32],
        n_candidates: int,
    ) -> NDArray[np.float64]:
        """
        Compute hyperbolic distances between query and candidates in
        the PoincarΓ© ball model.

        Formula:
          d_H(u, v) = arcosh(1 + 2 * ||u - v||Β² / ((1 - ||u||Β²) * (1 - ||v||Β²)))

        All computations use pre-allocated buffers. Zero heap allocations.

        Args:
            query_vec: Euclidean query vector, shape (D,).
            candidate_matrix: Euclidean candidate vectors, shape (N, D).
            n_candidates: Actual number of candidates (may be < batch_size).

        Returns:
            Hyperbolic distances array, shape (n_candidates,).
        """
        n = min(n_candidates, self.batch_size)

        # ── Step 1: Project query into PoincarΓ© ball ─────────
        self.map_to_poincare_ball_inplace(
            query_vec, self._poincare_query
        )

        # ── Step 2: Project candidates into PoincarΓ© ball ────
        self.map_to_poincare_ball_inplace(
            candidate_matrix[:n],
            self._poincare_candidates[:n],
            norms_buf=self._proj_norms
        )

        # ── Step 3: Compute ||u||Β² (query norm squared) ──────
        self._query_norm_sq = np.float64(
            np.dot(self._poincare_query, self._poincare_query)
        )

        # ── Step 4: Compute ||v_i||Β² (candidate norms squared) ─
        np.einsum(
            'ij,ij->i',
            self._poincare_candidates[:n].astype(np.float64),
            self._poincare_candidates[:n].astype(np.float64),
            out=self._norms_sq[:n]
        )

        # ── Step 5: Compute ||u - v_i||Β² ─────────────────────
        np.subtract(
            self._poincare_candidates[:n],
            self._poincare_query,
            out=self._diff[:n]
        )
        np.einsum(
            'ij,ij->i',
            self._diff[:n].astype(np.float64),
            self._diff[:n].astype(np.float64),
            out=self._diff_norm_sq[:n]
        )

        # GUARD: Floating-point variance underflow.
        # Identical vectors can produce tiny negative values like -1e-7
        # due to IEEE 754 rounding. Clamp to zero.
        np.maximum(self._diff_norm_sq[:n], 0.0, out=self._diff_norm_sq[:n])

        # ── Step 6: Compute denominator ──────────────────────
        # denom = (1 - ||u||Β²) * (1 - ||v_i||Β²)
        np.subtract(1.0, self._norms_sq[:n], out=self._denom[:n])
        np.multiply(
            self._denom[:n],
            (1.0 - self._query_norm_sq),
            out=self._denom[:n]
        )

        # GUARD: Denominator floor. If a vector lies exactly on the
        # boundary (||v|| β‰ˆ 1.0), denom β†’ 0, causing infinity.
        np.maximum(self._denom[:n], self.DENOM_MIN, out=self._denom[:n])

        # ── Step 7: Compute arcosh argument ──────────────────
        # arg = 1 + 2 * ||u - v||Β² / denom
        np.divide(self._diff_norm_sq[:n], self._denom[:n], out=self._distances[:n])
        np.multiply(self._distances[:n], 2.0, out=self._distances[:n])
        np.add(self._distances[:n], 1.0, out=self._distances[:n])

        # GUARD: arcosh domain enforcement. arcosh(x) requires x >= 1.
        # Clamp to exactly 1.0 (distance = 0) if numerical error
        # pushes below 1.0.
        np.maximum(self._distances[:n], 1.0, out=self._distances[:n])

        # ── Step 8: Final hyperbolic distance ────────────────
        np.arccosh(self._distances[:n], out=self._distances[:n])

        return self._distances[:n].copy()  # Return a copy (safe for caller)

    def project_and_rank(
        self,
        query_vec: NDArray[np.float32],
        candidate_matrix: NDArray[np.float32],
        n_candidates: int,
    ) -> NDArray[np.intp]:
        """
        Full pipeline: project + compute distances + return sorted indices.

        Args:
            query_vec: Euclidean query embedding, shape (D,).
            candidate_matrix: Euclidean candidate embeddings, shape (N, D).
            n_candidates: Actual number of candidates.

        Returns:
            Array of indices sorted by ascending hyperbolic distance.
        """
        distances = self.compute_poincare_distances(
            query_vec, candidate_matrix, n_candidates
        )
        return np.argsort(distances)