diff --git a/docs/validation/espfi-compact-lineage-20261013.json b/docs/validation/espfi-compact-lineage-20261013.json new file mode 100644 index 000000000..4cf8b336d --- /dev/null +++ b/docs/validation/espfi-compact-lineage-20261013.json @@ -0,0 +1,52 @@ +{ + "decision": "INCONCLUSIVE", + "date": "2026-10-13", + "hypothesis": "Compact export lineage and retention metadata must remain bound to the exact baseline receipt and model bytes admitted by MotionBundle, even if the source paths change after validation.", + "baseline": { + "commit": "8282a6b21e83756f636d377c4fe5be4a1e489792", + "tree": "6a4e65ec1dc9d22e946c6a39978ee6e291543201", + "source_sha256": { + "scripts/csi_training/espfi_motion.py": "c6a8529ccf9d9f834383a2bdf5b14626764ec7ca2ade7ad5d4c1c14797d76054", + "scripts/csi_training/espfi_motion_compact.py": "8da946e1c575ae20b96dc3fdb229bb85cdc5054f6c2efb774aca506773770c2e", + "scripts/tests/test_espfi_motion_compact.py": "109031ec230bb0aeadd26acabe2f5c119d714b4c71f9fc28351cf801176c65c5" + }, + "adversarial_result": "FAIL: a forged second receipt read changed emitted parent lineage" + }, + "candidate_source_sha256": { + "scripts/csi_training/espfi_motion.py": "25d4f84e16c0b699654caef5c19614ef1d23c7ef0cc1e224e931cc5b95195b63", + "scripts/csi_training/espfi_motion_compact.py": "33886d88705410646787a73b05474e36e50be0d4a26535147a1a3bfaddd4b037", + "scripts/tests/test_espfi_motion_compact.py": "68b562b1e31ef21ce1db10457f0965d5a882e853e3d8aa40240e82fc47a798e3" + }, + "data_and_split_policy": "No dataset, split, preprocessing, model weights, thresholds, predictions, or final-test payloads were read or changed.", + "acceptance": { + "adversarial_lineage_and_size_test": "PASS", + "focused_compact_tests": "4/4 PASS", + "motion_regression_tests": "40/40 PASS", + "broader_csi_tests": "72 PASS; collection stopped on missing local optional h5py dependency", + "diff_check": "PASS", + "independent_security_review": "NO BLOCK", + "hosted_ci": "PENDING" + }, + "load_benchmark": { + "workload": "500 alternating loads after 20 warmups; 100 traced loads per arm", + "baseline": { + "p50_ms": 0.974565, + "p95_ms": 1.322553, + "peak_bytes": 16793369 + }, + "candidate": { + "p50_ms": 0.98026, + "p95_ms": 1.285652, + "peak_bytes": 16793409 + }, + "frozen_gate": "candidate p95 <= max(1.2 * baseline p95, baseline p95 + 0.1 ms) and peak <= baseline + 4096 bytes", + "result": "PASS" + }, + "security_scope": { + "fixed": "Parent receipt hash, model hash, model byte count, and retention gate use the exact buffers validated by MotionBundle.", + "residual": "Initial multi-path intake is not an authenticated atomic directory snapshot; coherent pre-validation substitution remains outside this patch." + }, + "cost": "Existing authorized ruOS CPU only; monetary cost unknown.", + "rollback": "Revert the containing commit.", + "next_action": "Require terminal hosted CI at the exact published head before accepting the candidate." +} diff --git a/scripts/csi_training/espfi_motion.py b/scripts/csi_training/espfi_motion.py index 8d56dd85d..4fd027d8c 100644 --- a/scripts/csi_training/espfi_motion.py +++ b/scripts/csi_training/espfi_motion.py @@ -4,6 +4,7 @@ Independent feature experiment, not a reproduction of an external model. Only grouped training folds select a candidate; final test arrays are absent. """ import argparse +import hashlib import json from pathlib import Path import time @@ -88,7 +89,8 @@ class MotionBundle: def __init__(self, directory): root = Path(directory) status = json.loads(_read(root/'status.json', 65536)) - report = json.loads(_read(root/'metrics.json', 16*1024**2)) + report_bytes = _read(root/'metrics.json', 16*1024**2) + report = json.loads(report_bytes) if (not isinstance(status, dict) or not isinstance(report, dict) or status.get('status') != 'complete' or status.get('promotionAuthorized') is not False or report.get('status') != 'complete' or report.get('promotionAuthorized') is not False @@ -99,8 +101,8 @@ class MotionBundle: if self.candidate not in GRID: raise ValueError('Unknown candidate') data = _read(root/'model.safetensors', 1024**2) - import hashlib - if hashlib.sha256(data).hexdigest() != report.get('model_sha256'): + model_sha256 = hashlib.sha256(data).hexdigest() + if model_sha256 != report.get('model_sha256'): raise ValueError('Model hash mismatch') self.state = load(data) width = WIDTHS[self.candidate['family']] @@ -112,6 +114,13 @@ class MotionBundle: for k, shape in expected.items()) or not (self.state['feature_scale'] > 0).all()): raise ValueError('Invalid motion model state') + provenance = report.get('provenance', {}) + self._verified_manifest_sha256 = (provenance.get('manifest_sha256') + if isinstance(provenance, dict) else None) + self._verified_model_sha256 = model_sha256 + self._verified_model_bytes = len(data) + self._verified_receipt_sha256 = hashlib.sha256(report_bytes).hexdigest() + def predict(self, values): return predict_features(self.state, motion_features(values, self.candidate['family'])) diff --git a/scripts/csi_training/espfi_motion_compact.py b/scripts/csi_training/espfi_motion_compact.py index 038975560..9cfd35b52 100644 --- a/scripts/csi_training/espfi_motion_compact.py +++ b/scripts/csi_training/espfi_motion_compact.py @@ -89,10 +89,8 @@ def export(parts, provenance, baseline_directory, output, max_seconds=600): raise ValueError('Invalid time budget') _validate(parts) baseline = MotionBundle(baseline_directory) - prior_bytes = _read(Path(baseline_directory)/'metrics.json',16*1024**2) - prior = json.loads(prior_bytes) if (baseline.candidate != BASELINE or not provenance.get('manifest_sha256') - or prior.get('provenance',{}).get('manifest_sha256') != provenance['manifest_sha256']): + or baseline._verified_manifest_sha256 != provenance['manifest_sha256']): raise ValueError('Baseline candidate or provenance mismatch') root = Path(output) root.mkdir(parents=True, exist_ok=False) @@ -148,15 +146,15 @@ def export(parts, provenance, baseline_directory, output, max_seconds=600): timings[name].append((time.perf_counter_ns()-before)/1e6) latency = {k:{'p50_ms':float(np.percentile(v,50)),'p95_ms':float(np.percentile(v,95))} for k,v in timings.items()} size = (root/'model.safetensors').stat().st_size - old_size = (Path(baseline_directory)/'model.safetensors').stat().st_size + old_size = baseline._verified_model_bytes checks = {'model_below_10percent':size<.1*old_size, 'parity_all_admitted':True, 'p95_within_5percent':latency['candidate']['p95_ms']<=1.05*latency['incumbent']['p95_ms']} metadata = {'status':'complete','promotionAuthorized':False,'spec':SPEC, 'model_sha256':digest(root/'model.safetensors'), 'random_map_sha256':{k:tensor_hash(v) for k,v in generated.items()}, - 'baseline_model_sha256':prior['model_sha256'], - 'baseline_receipt_sha256':hashlib.sha256(prior_bytes).hexdigest(), + 'baseline_model_sha256':baseline._verified_model_sha256, + 'baseline_receipt_sha256':baseline._verified_receipt_sha256, 'provenance':{'manifest_sha256':provenance['manifest_sha256']}} report = dict(metadata, provenance=provenance, evidence='SYNTHETIC' if provenance.get('evidence')=='SYNTHETIC' else 'MEASURED', parity=parity,latency=latency,model_bytes=size,baseline_model_bytes=old_size, diff --git a/scripts/tests/test_espfi_motion_compact.py b/scripts/tests/test_espfi_motion_compact.py index 59ae6622b..f55c7a3dd 100644 --- a/scripts/tests/test_espfi_motion_compact.py +++ b/scripts/tests/test_espfi_motion_compact.py @@ -1,9 +1,11 @@ """Synthetic storage and numerical-parity contracts, not sensing evidence.""" +import hashlib import json from pathlib import Path import sys import tempfile import unittest +from unittest.mock import patch import numpy as np from safetensors.numpy import load, save_file sys.path.insert(0,str(Path(__file__).resolve().parents[1])) @@ -12,6 +14,7 @@ from csi_training.espfi_motion import MotionBundle from csi_training.espfi_train import digest, write_json from test_espfi_context import make_baseline from test_espfi_generalize import fixture +import csi_training.espfi_motion_compact as compact_module class CompactMotionTests(unittest.TestCase): @@ -104,5 +107,43 @@ class CompactMotionTests(unittest.TestCase): with self.assertRaisesRegex(ValueError,'Invalid compact'): CompactMotionBundle(root/'compact') + def test_export_parent_lineage_uses_verified_baseline_generation(self): + parts,provenance=fixture() + with tempfile.TemporaryDirectory() as tmp: + root=Path(tmp) + baseline=root/'baseline' + make_baseline(parts,provenance,baseline) + expected_model=digest(baseline/'model.safetensors') + expected_receipt=digest(baseline/'metrics.json') + expected_model_bytes=(baseline/'model.safetensors').stat().st_size + forged=json.loads((baseline/'metrics.json').read_text()) + forged['model_sha256']='f'*64 + forged_bytes=json.dumps(forged,separators=(',',':'),sort_keys=True).encode() + forged_receipt=hashlib.sha256(forged_bytes).hexdigest() + real_read=compact_module._read + injected=[] + swapped=[] + + def replace_second_receipt(path,limit): + if Path(path)==baseline/'metrics.json': + injected.append(Path(path)) + return forged_bytes + result=real_read(path,limit) + if Path(path)==root/'compact'/'model.safetensors' and not swapped: + (baseline/'model.safetensors').write_bytes(b'x') + swapped.append(Path(path)) + return result + + with patch.object(compact_module,'_read',side_effect=replace_second_receipt): + report=export(parts,provenance,baseline,root/'compact') + metadata=json.loads((root/'compact'/'metadata.json').read_text()) + self.assertEqual(injected,[]) + self.assertEqual(len(swapped),1) + self.assertEqual(metadata['baseline_model_sha256'],expected_model) + self.assertEqual(metadata['baseline_receipt_sha256'],expected_receipt) + self.assertNotEqual(metadata['baseline_receipt_sha256'],forged_receipt) + self.assertEqual(report['baseline_model_bytes'],expected_model_bytes) + self.assertTrue(report['retention_checks']['model_below_10percent']) + if __name__=='__main__': unittest.main()