diff --git a/docs/espfi-model-optimization.md b/docs/espfi-model-optimization.md index b9e220efc..7e6b16ec9 100644 --- a/docs/espfi-model-optimization.md +++ b/docs/espfi-model-optimization.md @@ -333,3 +333,38 @@ or with the prior float32 spectral deployment artifact. The earlier security workflow fails on preexisting Rust dependency alerts in unchanged `v2/Cargo.lock`; the Python checks pass. That remains a release gate, alongside independent room/person confirmation. No SOTA or promotion claim. + + +## Measured float32 seeded inference + +The optional float32 export converts the fitted tensors and regenerated map +at load. The RFF normalization constant now follows the state dtype, avoiding +an unintended NumPy float64 promotion. Existing float64 metadata without a +precision field remains supported. Unsupported precision values are rejected. + +```python +from csi_training.espfi_seeded import export_seeded, SeededKernelBundle +baseline = SeededKernelBundle('/private/espfi/seeded-001') +export_seeded(baseline.state, '/private/espfi/seeded-002', precision='float32') +candidate = SeededKernelBundle('/private/espfi/seeded-002') +``` + +MEASURED: all 70 development class predictions are unchanged. Maximum absolute +logit error is 1.119643e-6, below the predeclared 1e-4 tolerance. Reload is exact. +Weights occupy 8,220 bytes plus 1,069 metadata bytes. Retained model tensors use +247,996 bytes instead of 495,992, a 50% reduction. Feature extraction and the +temporary map generated during loading remain float64, so total process memory +and peak loading memory are not halved. This precision conversion is not +bitwise lossless and establishes no new accuracy or final-test result. + +A 1,000-pair benchmark alternated call order and cycled all 70 development +records. Float64 p50/p95 were 0.401590/0.568780 ms; float32 were +0.390610/0.556713 ms. The 2.73% median difference is too small for a reliable +speed claim on a shared host. Float32 median load time across 30 loads was +1.633650 ms. Input checks, feature extraction and classification are included; +acquisition, temporal pooling and I/O are excluded. NumPy 2.4.4, Python 3.12.3, +x86_64 and one BLAS thread were used. + +Decision: retain the verified precision option for smaller model state and +storage. Keep float64 for numerical comparison. Generalization weaknesses, +independent room/person evaluation and the existing Rust security gate remain. diff --git a/scripts/csi_training/espfi_kernel.py b/scripts/csi_training/espfi_kernel.py index 35bcd445f..261d8dab9 100644 --- a/scripts/csi_training/espfi_kernel.py +++ b/scripts/csi_training/espfi_kernel.py @@ -27,7 +27,7 @@ def transformed(state, values): values = np.asarray(values, dtype=state['feature_mean'].dtype) z = (values - state['feature_mean']) / state['feature_scale'] if 'projection' in state: - return np.sqrt(2. / state['phase'].size) * np.cos(z @ state['projection'] + state['phase']) + return np.asarray(np.sqrt(2. / state['phase'].size), dtype=z.dtype) * np.cos(z @ state['projection'] + state['phase']) return z diff --git a/scripts/csi_training/espfi_seeded.py b/scripts/csi_training/espfi_seeded.py index bbe53d697..934cbe190 100644 --- a/scripts/csi_training/espfi_seeded.py +++ b/scripts/csi_training/espfi_seeded.py @@ -1,4 +1,4 @@ -"""Lossless seed encoding for the fixed 128-dimensional CSI RFF candidate.""" +"""Seed encoding with optional measured float32 conversion for fixed CSI RFF.""" import hashlib import json from pathlib import Path @@ -18,33 +18,38 @@ def random_map(): 'phase': rng.uniform(0, 2*np.pi, 128)} def tensor_hash(value): return hashlib.sha256(np.ascontiguousarray(value).tobytes()).hexdigest() -def validate(state): +def validate(state, dtype=np.float64): if set(state) != set(SHAPES) or any( - state[k].shape != shape or state[k].dtype != np.float64 or + state[k].shape != shape or state[k].dtype != dtype or not np.isfinite(state[k]).all() for k, shape in SHAPES.items() ) or not (state['feature_scale'] > 0).all(): raise ValueError('Invalid compact state') -def export_seeded(state, output): +def export_seeded(state, output, precision='float64'): + if precision not in ('float64', 'float32'): + raise ValueError('Unsupported precision') + dtype = np.dtype(precision) generated = random_map() if set(state) != set(SHAPES) | set(generated) or any( not np.array_equal(state[k], generated[k]) for k in generated ): raise ValueError('Projection does not match frozen seed recipe') - compact = {k: np.ascontiguousarray(state[k]) for k in SHAPES} - validate(compact) + compact = {k: np.ascontiguousarray(state[k], dtype=dtype) for k in SHAPES} + validate(compact, dtype) root = Path(output) root.mkdir(exist_ok=False, parents=True) save_file(compact, str(root/'model.safetensors')) report = {'status': 'complete', 'promotionAuthorized': False, 'spec': SPEC, - 'model_sha256': digest(root/'model.safetensors'), + 'precision': precision, 'model_sha256': digest(root/'model.safetensors'), 'model_bytes': (root/'model.safetensors').stat().st_size, 'random_map_sha256': {k: tensor_hash(v) for k,v in generated.items()}, 'numpy_version': np.__version__, 'implementation_sha256': digest(__file__), 'feature_implementation_sha256': digest(Path(__file__).with_name('espfi_generalize.py')), 'stored_scalars': sum(v.size for v in compact.values()), 'runtime_scalars': sum(v.size for v in state.values()), - 'scope': 'Lossless on-disk encoding only; projection regenerated at load, runtime memory unchanged'} + 'runtime_state_bytes': sum(v.size for v in state.values()) * dtype.itemsize, + 'kernel_implementation_sha256': digest(Path(__file__).with_name('espfi_kernel.py')), + 'scope': 'Seed encoded map; float32 requires measured parity, float64 preserves original state'} write_json(root/'metadata.json', report) return report @@ -60,12 +65,16 @@ class SeededKernelBundle: raise ValueError('Invalid compact contract') if digest(root/'model.safetensors') != report.get('model_sha256'): raise ValueError('Compact model hash mismatch') + precision = report.get('precision', 'float64') + if precision not in ('float64', 'float32'): + raise ValueError('Unsupported precision') + dtype = np.dtype(precision) state = load_file(str(root/'model.safetensors')) - validate(state) + validate(state, dtype) generated = random_map() if {k:tensor_hash(v) for k,v in generated.items()} != report.get('random_map_sha256'): raise ValueError('Runtime random generator differs from export') - state.update(generated) + state.update({k: np.ascontiguousarray(v, dtype=dtype) for k, v in generated.items()}) self.state = state def predict(self, amplitudes): diff --git a/scripts/tests/test_espfi_seeded.py b/scripts/tests/test_espfi_seeded.py index ef14bca5b..6c755ea11 100644 --- a/scripts/tests/test_espfi_seeded.py +++ b/scripts/tests/test_espfi_seeded.py @@ -34,3 +34,40 @@ class SeededTests(unittest.TestCase): with tempfile.TemporaryDirectory() as tmp: with self.assertRaisesRegex(ValueError,'seed recipe'): export_seeded(state,Path(tmp)/'model') + + def test_float32_export_uses_half_runtime_state(self): + amplitudes = np.exp(np.random.default_rng(3).normal(size=(14,52,190))) + x = features(amplitudes, SPEC['family']) + state = fit(x, np.arange(14)%7, dict(SPEC, kind='rff')) + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + report = export_seeded(state, root/'single', precision='float32') + model = SeededKernelBundle(root/'single') + self.assertEqual(model.predict(amplitudes).dtype, np.float32) + self.assertTrue(all(v.dtype == np.float32 for v in model.state.values())) + self.assertEqual(report['runtime_state_bytes'], 61999*4) + np.testing.assert_allclose(predict_features(state,x),model.predict(amplitudes),atol=1e-5,rtol=1e-5) + np.testing.assert_array_equal(predict_features(state,x).argmax(1),model.predict(amplitudes).argmax(1)) + import json + p = root/'single/metadata.json' + metadata = json.loads(p.read_text()) + metadata['precision'] = 'float16' + p.write_text(json.dumps(metadata)) + with self.assertRaisesRegex(ValueError,'Unsupported precision'): + SeededKernelBundle(root/'single') + + def test_legacy_float64_metadata_still_loads(self): + x = np.random.default_rng(4).normal(size=(14,468)) + state = fit(x,np.arange(14)%7,dict(SPEC,kind='rff')) + with tempfile.TemporaryDirectory() as tmp: + path=Path(tmp)/'model' + export_seeded(state,path) + import json + p=path/'metadata.json' + metadata=json.loads(p.read_text()) + del metadata['precision'] + p.write_text(json.dumps(metadata)) + loaded=SeededKernelBundle(path) + self.assertTrue(all(v.dtype==np.float64 for v in loaded.state.values())) + with self.assertRaisesRegex(ValueError,'Unsupported precision'): + export_seeded(state,Path(tmp)/'invalid',precision='float16')