diff --git a/field/operators.py b/field/operators.py index 2794ed35..a01f0ede 100644 --- a/field/operators.py +++ b/field/operators.py @@ -1,9 +1,28 @@ """ -Manifold-level field operators — graph diffusion and protocol. +Manifold-level field operators — graph diffusion and dual-correction. -Operators transform ManifoldState through algebraic transitions. -Diffusion computes a weighted average of each node with its neighbors -in Cl(4,1) component space, then re-unitizes to the versor manifold. +Two operators implement Axiom 4 (Dual-Correction): + + GraphDiffusionOperator — forward pass: spread context pressure across + edges via damped blending + exponential-map + re-unitization. Self-adjoint. + + ConstraintCorrectionOperator — adjoint pass: apply an incremental + correction rotor on the output node, pulling + it toward the intent-target versor built from + the prompt centroid. Non-self-adjoint. + +Coupled loop (V4 pulse): + + while not converged: + state, delta_fwd = diffusion_op.forward(state) + state, delta_corr = correction_op.adjoint_pass(state) + converged = delta_fwd < eps and delta_corr < eps + +The target is always the same centroid versor that initialised the output +node — diffusion spreads context away from it; correction pulls it back +while incorporating neighbour pressure. The system argues with itself +until both forces balance. """ from __future__ import annotations @@ -13,7 +32,7 @@ from typing import Protocol import numpy as np -from algebra.cl41 import geometric_product +from algebra.cl41 import geometric_product, reverse from field.state import ManifoldState @@ -24,16 +43,22 @@ class Operator(Protocol): """Apply operator, return (new_state, delta_norm).""" ... - def adjoint(self) -> Operator: + def adjoint(self) -> "Operator": """Return the adjoint operator.""" ... -# Cl(4,1) bivector blade classification for the exponential map. +# --------------------------------------------------------------------------- +# Blade classification for the exponential map in Cl(4,1). +# # Blades 9, 12, 14, 15 square to +1 (boost/hyperbolic planes involving e5). # Blades 6-8, 10-11, 13 square to -1 (rotation planes). -# Use cosh/sinh for boosts, cos/sin for rotations — mixing them makes -# re-unitization diverge. +# Use cosh/sinh for boosts, cos/sin for rotations. +# Mixing them causes re-unitization to diverge rather than converge. +# This set was determined empirically by checking which blades satisfy +# e_i * e_i = +1 under the Cl(4,1) metric (+,+,+,+,-) and the specific +# basis ordering used in algebra/cl41.py. +# --------------------------------------------------------------------------- _BOOST_INDICES = frozenset({9, 12, 14, 15}) @@ -44,7 +69,8 @@ def _unitize_f32(v: np.ndarray) -> np.ndarray: R·reverse(R) = 1 exactly in float64, then casts to float32. Works in float64 throughout because algebra.backend's Rust - geometric_product silently returns float32 regardless of input dtype. + geometric_product silently returns float32 regardless of input dtype, + which would corrupt precision during the rotor accumulation loop. """ v64 = np.asarray(v, dtype=np.float64) norm = float(np.linalg.norm(v64)) @@ -86,6 +112,38 @@ def _unitize_f32(v: np.ndarray) -> np.ndarray: return rotor.astype(np.float32) +def _incremental_correction_rotor( + current: np.ndarray, + target: np.ndarray, + rate: float, +) -> np.ndarray: + """Build a small rotor that nudges `current` incrementally toward `target`. + + Rather than computing the full transition rotor (which would jump the + output node all the way to the target in one step and destroy context + pressure from diffusion), we build an incremental step: + + blended = (1 - rate) * current + rate * target + + then close the blend via the exponential map. The correction_rate + controls how much the output node is pulled per iteration. At rate=0 + the output is unchanged; at rate=1 the output node collapses to the + target immediately (collapsing context — not useful). + + This is intentionally the same blend-then-unitize pattern used in + GraphDiffusionOperator.forward(), which is why both operators converge + to the same fixed-point attractor when their forces balance. + """ + c64 = np.asarray(current, dtype=np.float64) + t64 = np.asarray(target, dtype=np.float64) + blended = (1.0 - rate) * c64 + rate * t64 + return _unitize_f32(blended) + + +# --------------------------------------------------------------------------- +# GraphDiffusionOperator — forward pass, self-adjoint +# --------------------------------------------------------------------------- + class GraphDiffusionOperator: """Propagate geometric pressure across graph edges via damped blending. @@ -122,5 +180,98 @@ class GraphDiffusionOperator: delta = float(np.linalg.norm(new_fields - old_fields)) return ManifoldState(fields=new_fields, edges=state.edges, step=state.step + 1), delta - def adjoint(self) -> GraphDiffusionOperator: + def adjoint(self) -> "GraphDiffusionOperator": + return self + + +# --------------------------------------------------------------------------- +# ConstraintCorrectionOperator — adjoint pass, non-self-adjoint +# --------------------------------------------------------------------------- + +class ConstraintCorrectionOperator: + """Pull the output node toward the intent-target versor. + + This is the non-trivial adjoint operator that implements Axiom 4 + (Dual-Correction). GraphDiffusionOperator spreads context pressure + outward across the graph; ConstraintCorrectionOperator restores + intent coherence by pulling the designated output node back toward + the target established from the input prompt. + + Unlike GraphDiffusionOperator, this operator is NOT self-adjoint: + it has a preferred direction (toward the target). Its adjoint() is + the identity (no forward pass — it only acts on the adjoint path). + + The coupling of these two operators in the pulse loop is the closed + loop described in CORE architecture docs: + - Diffusion spreads context (breaks intent coherence slightly) + - Correction restores intent (breaks pure diffusion symmetry) + - They converge to a fixed-point that balances both pressures + + Parameters + ---------- + target_versor : The intent target — the centroid versor built from + the prompt tokens. This is the same versor that + initialises the output node before diffusion begins. + correction_rate : Blend weight toward target per adjoint_pass call. + In (0, 1]. Default 0.3. Lower = smoother correction, + more steps to converge. Higher = faster but risks + overriding context pressure from diffusion. + node_index : Which node in the ManifoldState to correct. + Default -1 (last node = output node in V4 topology). + """ + + def __init__( + self, + target_versor: np.ndarray, + correction_rate: float = 0.3, + node_index: int = -1, + ) -> None: + if not 0.0 < correction_rate <= 1.0: + raise ValueError( + f"correction_rate must be in (0, 1], got {correction_rate}" + ) + self._target = np.asarray(target_versor, dtype=np.float32).copy() + self._rate = float(correction_rate) + self._node = int(node_index) + + @property + def target_versor(self) -> np.ndarray: + """Return a copy of the intent-target versor.""" + return self._target.copy() + + def adjoint_pass( + self, state: ManifoldState + ) -> tuple[ManifoldState, float]: + """Apply one incremental correction step to the output node. + + Computes a blended versor between the current output-node field + and the intent target, closes it via _unitize_f32, and replaces + the output node in a new ManifoldState. + + Returns (new_state, delta) where delta is the L2 norm of the + change on the output node only. Convergence is signalled when + delta < threshold, meaning the output node has settled into a + stable compromise between context pressure and intent pull. + """ + node_idx = self._node % state.fields.shape[0] + old_fields = state.fields + current = old_fields[node_idx] + + corrected = _incremental_correction_rotor(current, self._target, self._rate) + + new_fields = old_fields.copy() + new_fields[node_idx] = corrected + + delta = float(np.linalg.norm(corrected.astype(np.float64) - current.astype(np.float64))) + return ( + ManifoldState(fields=new_fields, edges=state.edges, step=state.step), + delta, + ) + + def forward(self, state: ManifoldState) -> tuple[ManifoldState, float]: + """Identity forward pass — correction acts only on the adjoint path.""" + return state, 0.0 + + def adjoint(self) -> "ConstraintCorrectionOperator": + """Return self — the operator IS the adjoint pass.""" return self diff --git a/scripts/run_pulse.py b/scripts/run_pulse.py index 603a152d..fb2622c0 100644 --- a/scripts/run_pulse.py +++ b/scripts/run_pulse.py @@ -1,38 +1,43 @@ """ Vertical slice: one cognitive pulse from injection to token recall. -V3 — per-token manifold topology with input-driven output node. +V4 — coupled forward-correction loop (Threshold 2: Dual-Correction). -Each input token becomes a graph node initialised from the vocabulary -manifold (compiled pack or GloVe seeder). An output node is initialised -from the centroid of the input tokens — not from a fixed hash — so -diffusion pressure actually encodes input semantics into the output. +Two operators run in lockstep each iteration: -Recall searches the full VocabManifold by CGA inner product. + GraphDiffusionOperator — spreads context pressure across token edges + ConstraintCorrectionOperator — pulls the output node toward the intent target + +Both must converge (delta < threshold) before the pulse ends. +The output node settles into a balance between context influence and +intent coherence — not just diffusion, and not just the target. Usage: python -m scripts.run_pulse "What is truth?" python -m scripts.run_pulse --top-k 10 "Compare knowledge and wisdom" - python -m scripts.run_pulse --no-glove "light" # compiled pack only, no download + python -m scripts.run_pulse --no-glove "light" + python -m scripts.run_pulse --no-correction "grace" # V3 pure-diffusion mode + python -m scripts.run_pulse --correction-rate 0.1 "the beginning" # soft correction Flags: - --top-k N Return N nearest vault words (default 5) - --max-words N Load at most N words from GloVe (default 50000) - --no-glove Use compiled en_core_cognition_v1 pack (70 words, no download) - -v Verbose logging + --top-k N Return N nearest vault words (default 5) + --max-words N Load at most N words from GloVe (default 50000) + --no-glove Use compiled en_core_cognition_v1 pack (no download) + --no-correction Disable ConstraintCorrectionOperator (V3 mode) + --correction-rate R Blend weight toward target per step (default 0.3) + -v Verbose logging """ from __future__ import annotations import argparse import logging -import sys import numpy as np from algebra.backend import cga_inner from algebra.versor import construction_seed_versor -from field.operators import GraphDiffusionOperator +from field.operators import ConstraintCorrectionOperator, GraphDiffusionOperator from field.state import ManifoldState from sensorium.adapters.text import deterministic_hash_versor from vocab.manifold import VocabManifold @@ -45,6 +50,10 @@ TOP_K = 5 COMPILED_PACK_ID = "en_core_cognition_v1" +# --------------------------------------------------------------------------- +# Manifold loading +# --------------------------------------------------------------------------- + def _load_manifold(use_glove: bool, max_words: int) -> VocabManifold: if use_glove: from language_packs.en_seeder import seed_english_manifold @@ -58,6 +67,10 @@ def _load_manifold(use_glove: bool, max_words: int) -> VocabManifold: return manifold +# --------------------------------------------------------------------------- +# Token injection and graph construction +# --------------------------------------------------------------------------- + def _inject_token(token: str, manifold: VocabManifold) -> np.ndarray: """Project one token into Cl(4,1). Manifold lookup first, hash fallback.""" try: @@ -69,9 +82,18 @@ def _inject_token(token: str, manifold: VocabManifold) -> np.ndarray: def _build_manifold( text: str, manifold: VocabManifold, -) -> tuple[ManifoldState, list[str]]: +) -> tuple[ManifoldState, list[str], np.ndarray]: """Build a per-token graph with an input-driven output node. + Returns + ------- + state : ManifoldState with token nodes + output node + node_labels : List of string labels (tokens + '__output__') + target_versor: The prompt-centroid versor — used as the correction + target by ConstraintCorrectionOperator. This is the + intent anchor: what the prompt geometry says the output + should be near, before context diffusion reshapes it. + Topology: - Each input token → one node (versor from manifold or hash fallback) - One output node → initialised from centroid of input versors @@ -88,12 +110,12 @@ def _build_manifold( max_abs = float(np.max(np.abs(centroid))) if max_abs > 1e-9: centroid = centroid * (0.9 / max_abs) - output_versor = construction_seed_versor(centroid).astype(np.float64) + target_versor = construction_seed_versor(centroid).astype(np.float32) node_labels = list(tokens) + ["__output__"] fields = np.stack( [np.asarray(v, dtype=np.float32) for v in token_versors] - + [output_versor.astype(np.float32)], + + [target_versor], axis=0, ) @@ -109,9 +131,13 @@ def _build_manifold( if edges else np.empty((0, 2), dtype=np.int32) ) - return ManifoldState(fields=fields, edges=edge_array), node_labels + return ManifoldState(fields=fields, edges=edge_array), node_labels, target_versor +# --------------------------------------------------------------------------- +# Recall +# --------------------------------------------------------------------------- + def _recall_from_manifold( output_versor: np.ndarray, manifold: VocabManifold, @@ -133,37 +159,74 @@ def _recall_from_manifold( return results +# --------------------------------------------------------------------------- +# Pulse loop +# --------------------------------------------------------------------------- + def run_pulse( text: str, *, top_k: int = TOP_K, max_words: int = 50_000, use_glove: bool = True, + use_correction: bool = True, + correction_rate: float = 0.3, ) -> list[str]: - """Execute one cognitive pulse and return top-k recalled words.""" + """Execute one cognitive pulse and return top-k recalled words. + + Parameters + ---------- + use_correction : Enable ConstraintCorrectionOperator (default True). + Set False to reproduce V3 pure-diffusion behaviour. + correction_rate : Blend weight toward intent target per adjoint_pass + call. Lower = softer correction, more steps. + """ manifold = _load_manifold(use_glove, max_words) - state, node_labels = _build_manifold(text, manifold) - op = GraphDiffusionOperator(damping=0.5) + state, node_labels, target_versor = _build_manifold(text, manifold) + + diffusion_op = GraphDiffusionOperator(damping=0.5) + correction_op = ConstraintCorrectionOperator( + target_versor=target_versor, + correction_rate=correction_rate, + node_index=-1, + ) if use_correction else None n_input = len(node_labels) - 1 - print(f"[pulse] input : {text!r}") - print(f"[pulse] vocab : {len(manifold)} words") - print(f"[pulse] graph : {len(node_labels)} nodes ({n_input} token + output), {state.edges.shape[0]} edges") + print(f"[pulse] input : {text!r}") + print(f"[pulse] vocab : {len(manifold)} words") + print(f"[pulse] graph : {len(node_labels)} nodes ({n_input} token + output), " + f"{state.edges.shape[0]} edges") + print(f"[pulse] correction : {'enabled (rate=%.2f)' % correction_rate if use_correction else 'disabled (V3 mode)'}") + + step = 0 + delta_fwd = float("inf") + delta_corr = float("inf") if use_correction else 0.0 - step = 0 - delta = float("inf") while step < MAX_STEPS: - state, delta = op.forward(state) + # --- Forward pass (diffusion) --- + state, delta_fwd = diffusion_op.forward(state) step = state.step + + # --- Adjoint pass (correction) --- + if correction_op is not None: + state, delta_corr = correction_op.adjoint_pass(state) + if step <= 5 or step % 50 == 0: - print(f"[pulse] step {step:4d} delta={delta:.2e}") - if delta < CONVERGENCE_THRESHOLD: - print(f"[pulse] converged at step {step} (delta={delta:.2e})") + if use_correction: + print(f"[pulse] step {step:4d} Δ_fwd={delta_fwd:.2e} Δ_corr={delta_corr:.2e}") + else: + print(f"[pulse] step {step:4d} delta={delta_fwd:.2e}") + + converged = delta_fwd < CONVERGENCE_THRESHOLD and delta_corr < CONVERGENCE_THRESHOLD + if converged: + print(f"[pulse] converged at step {step} " + f"(Δ_fwd={delta_fwd:.2e}, Δ_corr={delta_corr:.2e})") break else: - print(f"[pulse] WARNING: max_steps ({MAX_STEPS}) reached — delta={delta:.2e}") + print(f"[pulse] WARNING: max_steps ({MAX_STEPS}) reached — " + f"Δ_fwd={delta_fwd:.2e} Δ_corr={delta_corr:.2e}") - output_idx = len(node_labels) - 1 + output_idx = len(node_labels) - 1 output_versor = state.fields[output_idx] results = _recall_from_manifold(output_versor, manifold, top_k) @@ -180,13 +243,16 @@ def run_pulse( # --------------------------------------------------------------------------- def _parse_args() -> argparse.Namespace: - p = argparse.ArgumentParser(description="CORE cognitive pulse (V3)") + p = argparse.ArgumentParser(description="CORE cognitive pulse (V4 — dual correction)") p.add_argument("text", nargs="*", default=["What is truth?"]) - p.add_argument("--top-k", type=int, default=5, metavar="N") - p.add_argument("--max-words", type=int, default=50_000, metavar="N") - p.add_argument("--no-glove", action="store_true", + p.add_argument("--top-k", type=int, default=5, metavar="N") + p.add_argument("--max-words", type=int, default=50_000, metavar="N") + p.add_argument("--no-glove", action="store_true", help="Use compiled pack only (no GloVe download)") - p.add_argument("-v", "--verbose", action="store_true") + p.add_argument("--no-correction", action="store_true", + help="Disable ConstraintCorrectionOperator (V3 mode)") + p.add_argument("--correction-rate", type=float, default=0.3, metavar="R") + p.add_argument("-v", "--verbose", action="store_true") return p.parse_args() @@ -202,4 +268,6 @@ if __name__ == "__main__": top_k=args.top_k, max_words=args.max_words, use_glove=not args.no_glove, + use_correction=not args.no_correction, + correction_rate=args.correction_rate, ) diff --git a/tests/test_pulse_integration.py b/tests/test_pulse_integration.py index c930fad9..04a05214 100644 --- a/tests/test_pulse_integration.py +++ b/tests/test_pulse_integration.py @@ -1,25 +1,41 @@ -"""Integration test — full pulse cycle from injection to vault recall.""" +"""Integration test — full pulse cycle from injection to vault recall. + +Covers both V3 pure-diffusion mode and V4 coupled dual-correction. +""" import numpy as np +import pytest from scripts.run_pulse import run_pulse, _build_manifold from language_packs.compiler import load_pack +from field.operators import ( + ConstraintCorrectionOperator, + GraphDiffusionOperator, +) -class TestPulseIntegration: +@pytest.fixture(scope="module") +def compiled_manifold(): + _, manifold = load_pack("en_core_cognition_v1") + return manifold + + +# --------------------------------------------------------------------------- +# V3 regression — pure diffusion still works +# --------------------------------------------------------------------------- + +class TestPulseDiffusion: def test_full_cycle_completes(self) -> None: words = run_pulse("hello world", use_glove=False) assert isinstance(words, list) assert len(words) > 0 assert all(isinstance(w, str) for w in words) - def test_output_node_changes(self) -> None: - _, manifold = load_pack("en_core_cognition_v1") - state, labels = _build_manifold("test input", manifold) + def test_output_node_changes(self, compiled_manifold) -> None: + state, labels, _ = _build_manifold("test input", compiled_manifold) output_idx = len(labels) - 1 initial_output = state.fields[output_idx].copy() - from field.operators import GraphDiffusionOperator op = GraphDiffusionOperator(damping=0.5) for _ in range(20): state, _ = op.forward(state) @@ -30,11 +46,177 @@ class TestPulseIntegration: w2 = run_pulse("omega", use_glove=False) assert isinstance(w1, list) and isinstance(w2, list) - def test_recall_returns_known_vocab(self) -> None: - _, manifold = load_pack("en_core_cognition_v1") + def test_recall_returns_known_vocab(self, compiled_manifold) -> None: words = run_pulse("wisdom seeker", use_glove=False) for w in words: try: - manifold.get_versor(w) + compiled_manifold.get_versor(w) except KeyError: raise AssertionError(f"{w!r} not in compiled vocab") + + def test_no_correction_mode_matches_v3(self) -> None: + """--no-correction flag reproduces V3 pure-diffusion semantics.""" + words = run_pulse("truth", use_glove=False, use_correction=False) + assert len(words) > 0 + + +# --------------------------------------------------------------------------- +# ConstraintCorrectionOperator unit tests +# --------------------------------------------------------------------------- + +class TestConstraintCorrectionOperator: + def test_correction_pulls_toward_target(self, compiled_manifold) -> None: + """After N correction steps, output node is closer to target than before.""" + state, labels, target_versor = _build_manifold("grace", compiled_manifold) + output_idx = len(labels) - 1 + + op = ConstraintCorrectionOperator( + target_versor=target_versor, + correction_rate=0.3, + node_index=output_idx, + ) + + # Distance before + initial = state.fields[output_idx].astype(np.float64) + target64 = target_versor.astype(np.float64) + dist_before = float(np.linalg.norm(initial - target64)) + + # Apply 10 correction steps (no diffusion — isolate the correction) + for _ in range(10): + state, _ = op.adjoint_pass(state) + + corrected = state.fields[output_idx].astype(np.float64) + dist_after = float(np.linalg.norm(corrected - target64)) + + assert dist_after < dist_before, ( + f"Correction did not pull output toward target: " + f"dist_before={dist_before:.4f}, dist_after={dist_after:.4f}" + ) + + def test_correction_does_not_collapse_instantly(self, compiled_manifold) -> None: + """A single correction step with rate=0.3 does not jump to the target.""" + state, labels, target_versor = _build_manifold("knowledge", compiled_manifold) + output_idx = len(labels) - 1 + + op = ConstraintCorrectionOperator( + target_versor=target_versor, + correction_rate=0.3, + node_index=output_idx, + ) + state, delta = op.adjoint_pass(state) + + corrected = state.fields[output_idx].astype(np.float64) + target64 = target_versor.astype(np.float64) + dist = float(np.linalg.norm(corrected - target64)) + + # Should be meaningfully close but not zero + assert dist > 1e-4, ( + f"Single correction step collapsed to target (dist={dist:.2e}); " + f"rate=0.3 should leave distance > 1e-4" + ) + + def test_correction_rate_zero_raises(self) -> None: + """rate=0.0 is explicitly rejected (identity — use no_correction flag).""" + state, labels, target_versor = _build_manifold( + "test", load_pack("en_core_cognition_v1")[1] + ) + with pytest.raises(ValueError, match="correction_rate"): + ConstraintCorrectionOperator(target_versor=target_versor, correction_rate=0.0) + + def test_correction_maintains_versor_invariant(self, compiled_manifold) -> None: + """Output node versor satisfies V·reverse(V) ≈ ±1 after correction.""" + from algebra.versor import versor_unit_residual + + state, labels, target_versor = _build_manifold("peace", compiled_manifold) + output_idx = len(labels) - 1 + + op = ConstraintCorrectionOperator( + target_versor=target_versor, + correction_rate=0.5, + node_index=output_idx, + ) + for _ in range(5): + state, _ = op.adjoint_pass(state) + + residual = versor_unit_residual( + state.fields[output_idx].astype(np.float64), + allow_negative=True, + ) + assert residual < 1e-5, ( + f"Versor invariant violated after correction: residual={residual:.2e}" + ) + + def test_different_targets_produce_different_corrections(self, compiled_manifold) -> None: + """Correction targets built from different prompts are geometrically distinct.""" + _, _, target_a = _build_manifold("light", compiled_manifold) + _, _, target_b = _build_manifold("darkness", compiled_manifold) + + # targets should differ + dist = float(np.linalg.norm( + target_a.astype(np.float64) - target_b.astype(np.float64) + )) + assert dist > 1e-4, ( + f"Targets for 'light' and 'darkness' are identical (dist={dist:.2e})" + ) + + +# --------------------------------------------------------------------------- +# V4 coupled loop integration +# --------------------------------------------------------------------------- + +class TestCoupledPulse: + def test_coupled_loop_converges(self) -> None: + """Full V4 pulse with correction converges and returns recall.""" + words = run_pulse( + "what is truth", + use_glove=False, + use_correction=True, + correction_rate=0.3, + ) + assert len(words) > 0 + assert all(isinstance(w, str) for w in words) + + def test_correction_changes_recall_vs_pure_diffusion(self) -> None: + """With correction enabled, recall may differ from pure-diffusion mode. + + Both must return valid vocab words. We don't assert they differ + (they may agree on some inputs), but both paths must complete. + """ + words_v3 = run_pulse( + "wisdom", use_glove=False, use_correction=False, + ) + words_v4 = run_pulse( + "wisdom", use_glove=False, use_correction=True, correction_rate=0.3, + ) + assert len(words_v3) > 0 + assert len(words_v4) > 0 + + def test_high_correction_rate_biases_toward_target(self, compiled_manifold) -> None: + """With correction_rate=0.9, the output node should be very close + to the target versor after the loop. + """ + _, labels, target_versor = _build_manifold("hope", compiled_manifold) + output_idx = len(labels) - 1 + + # Run manually to inspect the final output node. + from algebra.backend import cga_inner + + state, labels, target_versor = _build_manifold("hope", compiled_manifold) + diffusion_op = GraphDiffusionOperator(damping=0.5) + correction_op = ConstraintCorrectionOperator( + target_versor=target_versor, + correction_rate=0.9, + node_index=-1, + ) + + for _ in range(100): + state, _ = diffusion_op.forward(state) + state, _ = correction_op.adjoint_pass(state) + + output = state.fields[output_idx].astype(np.float64) + target = target_versor.astype(np.float64) + dist = float(np.linalg.norm(output - target)) + # High correction rate should produce strong convergence toward target. + assert dist < 0.5, ( + f"High correction_rate=0.9 did not pull output close to target: dist={dist:.4f}" + )