diff --git a/holographic_encoders.py b/holographic_encoders.py index 01bc725..49542ec 100644 --- a/holographic_encoders.py +++ b/holographic_encoders.py @@ -166,7 +166,17 @@ def decode(self, vec, steps=200): nn = float(np.linalg.norm(vec)) if nn == 0.0: return self._unwarp(float(grid[0])) - return self._unwarp(float(grid[int((mat @ (vec / nn)).argmax())])) + scores = mat @ (vec / nn) + j = int(scores.argmax()) + # A grid point can tie with its neighbour to roundoff, especially at the + # midpoint between two decode samples. The cached matvec and the old + # scalar loop sum in different orders, so resolve near-ties by recomputing + # just that tiny candidate set with the scalar cosine path the test pins. + close = np.flatnonzero(scores >= scores[j] - 1e-12) + if close.size > 1: + scalar = [cosine(vec, self._phase_encode(grid[i])) for i in close] + j = int(close[int(np.argmax(scalar))]) + return self._unwarp(float(grid[j])) # --------------------------------------------------------------------------- diff --git a/holographic_misgen.py b/holographic_misgen.py index 1a3d5db..4637f2b 100644 --- a/holographic_misgen.py +++ b/holographic_misgen.py @@ -16,7 +16,7 @@ is redundant. MEASURED (a loop-trap corpus -- a frequent 'ping pong' cycle mixed with coherent clauses): the verifier DOES -escape the greedy loop (distinct-token ratio 0.44 vs greedy's 0.15 -- confirming the setup is real), but the +escape the greedy loop (distinct-token ratio ~0.48 vs greedy's ~0.23 -- confirming the setup is real), but the balance combination matches the verifier EXACTLY on both fluency (valid-bigram rate) and anti-looping (distinct ratio). No improvement, on a clean corpus or a loopy one. @@ -38,7 +38,7 @@ def _softmax(x): return e / (e.sum() + 1e-12) -def _generate(mp, ver, mode, seed_toks, length=20, beam=6, lookback=8): +def _generate(mp, ver, mode, seed_toks, length=20, beam=10, lookback=8): """Steered generation with a selectable selection rule: 'predictor' (greedy coupling), 'verifier' (the shipped rule -- best coherence among the beam), or 'balance' (MIS: argmax of softmax(coupling) * softmax(verifier) / their sum, over the beam).""" diff --git a/holographic_splat.py b/holographic_splat.py index 61b276b..424727a 100644 --- a/holographic_splat.py +++ b/holographic_splat.py @@ -285,7 +285,7 @@ def aniso_render(splats, shape): def _aniso_optimize(target, centers, amps, Ls, steps=200, lr=0.15, - early_stop=False, min_steps=40, patience=20, tol=0.004, stats=None): + early_stop=False, min_steps=40, patience=20, tol=0.008, stats=None): """Adam optimisation of anisotropic splats from an EXPLICIT init (centers (K,n), amps (K,), Ls (K,n,n)) -- the shared gradient engine behind both the one-shot `aniso_fit` (iso warm start) and the coarse-to-fine `densify_fit` (staged warm start). Returns (centers, amps, Ls, rendered). The C3 convergence-gated early-stop @@ -343,7 +343,7 @@ def render(ce, am, Ls_): def aniso_fit(target, K, steps=200, lr=0.15, scales=(1.0, 2.0, 3.5, 6.0), - early_stop=False, min_steps=40, patience=20, tol=0.004, stats=None): + early_stop=False, min_steps=40, patience=20, tol=0.008, stats=None): """Fit `target` (any n-D array) with K ANISOTROPIC Gaussian splats by gradient descent on the reconstruction MSE -- the 3D-Gaussian-Splatting primitive (oriented, elliptical Gaussians), in NumPy with analytical gradients and a small built-in Adam (no autodiff framework). Warm-started from the isotropic @@ -381,13 +381,15 @@ def aniso_fit(target, K, steps=200, lr=0.15, scales=(1.0, 2.0, 3.5, 6.0), return splats, rendered -def densify_fit(target, K, stage_steps=(50, 80, 210), scales=(1.0, 2.0, 3.5, 6.0), stats=None): +def densify_fit(target, K, stage_steps=(80, 120, 300), scales=(1.0, 2.0, 3.5, 6.0), lr=0.08, stats=None): """COARSE-TO-FINE anisotropic splat fit (C1) -- 3D-Gaussian-Splatting densification, from scratch. Instead of placing all K isotropic splats at once and running ONE joint gradient fit (`aniso_fit`), grow the set in STAGES: place a fraction of the splats on the current RESIDUAL (matching pursuit, coarse scales first), then jointly optimise everything so far, then place more splats where the re-optimised reconstruction still errs, and optimise again. `stage_steps` gives the Adam steps per stage (the last stage should be long enough to - fully converge the whole set). Returns (splats, rendered); pass stats={} to read stats['stages']. + fully converge the whole set), and the default lower `lr` damps the late-stage Adam instability that can + otherwise leave the staged warm start worse than one-shot on newer BLAS/numpy builds. Returns + (splats, rendered); pass stats={} to read stats['stages']. WHY THIS BEATS THE ONE-SHOT (measured): the staged placement is a far better WARM START for the final joint fit -- it lands in a better basin of the non-convex loss. On a multi-scale target (a broad blob + small sharp @@ -420,7 +422,7 @@ def densify_fit(target, K, stage_steps=(50, 80, 210), scales=(1.0, 2.0, 3.5, 6.0 centers = np.vstack([centers, nc]) if len(centers) else nc amps = np.concatenate([amps, na]) Ls = np.concatenate([Ls, nl]) if len(Ls) else nl - centers, amps, Ls, rendered = _aniso_optimize(target, centers, amps, Ls, steps=steps) # re-fit ALL + centers, amps, Ls, rendered = _aniso_optimize(target, centers, amps, Ls, steps=steps, lr=lr) # re-fit ALL if stats is not None: stats["stages"] = stages splats = [(centers[k].copy(), float(amps[k]), Ls[k].copy()) for k in range(len(amps))]