From 1779cf29f2fdb99295773966955663d487b3ce3e Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 18 Oct 2026 10:17:43 -0700 Subject: [PATCH] test: separate topology identity from FP64 table validation --- .../multigrid/run_spacetime_gpu_n64.py | 7 +- .../multigrid/spacetime_table_guard.py | 89 +++++++++++++++++++ .../multigrid/test_spacetime_gpu.py | 34 +++++++ 3 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 tests/validation/multigrid/spacetime_table_guard.py diff --git a/tests/validation/multigrid/run_spacetime_gpu_n64.py b/tests/validation/multigrid/run_spacetime_gpu_n64.py index 7eb1cda..e43fe20 100644 --- a/tests/validation/multigrid/run_spacetime_gpu_n64.py +++ b/tests/validation/multigrid/run_spacetime_gpu_n64.py @@ -17,12 +17,12 @@ sys.path.insert(0, str(Path(__file__).parents[1]/'taylor_green')) from CelerisLab.config import LBMConfig import oracle as tg import spacetime_gpu as gpu +import spacetime_table_guard as table_guard ref = gpu.ref CPU_SHA = '180eef90a1c3f43113fe18cc54981ee4723cfc0cc2724334907ff188022fa770' UNIFORM_SHA = '01e7415ae39456f2de7f0c3d331afb971c49d19353b43038650e32e80737f1e7' CUDA_SHA = 'c47869688f7ac2ec93411c0af233887fe8b77405838299e4ae52c8963fd97fe4' -TABLE_SHA = 'f2e62038dc620c5a10bb61d51a88be2afd80488f2f9f4a10c2677cfbe11009cf' STEPS = (256, 512, 768, 1024) TIMES = (.12, .24, .36, .48) FACTORS = dict(velocity_l2=.8, pressure_l2=1., energy_relative=1.) @@ -127,7 +127,7 @@ def main(): source = dict(commit=subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip(), dirty=bool(subprocess.check_output(['git', 'status', '--porcelain'], cwd=ROOT, text=True)), hashes={p.name: sha(p) for p in (Path(__file__), Path(gpu.__file__), Path(gpu.__file__).with_suffix('.cu'), - Path(__file__).with_name('test_spacetime_gpu.py'))}, + Path(__file__).with_name('test_spacetime_gpu.py'), Path(table_guard.__file__))}, reference_hashes=gpu.PINS, numpy=np.__version__, checkout=str(ROOT)) if source['dirty']: raise RuntimeError('clean committed checkout required') @@ -173,8 +173,7 @@ def main(): run = gpu.SpaceTimeGPU(config, (8,8,56,48), context=context) report.update(device=context.name, sm_arch=context.sm_arch, build_metadata=run.build_metadata, table_sha256=gpu.table_hash(run.tables)) - if report['table_sha256'] != TABLE_SHA: - raise RuntimeError('frozen coordinate tables changed') + report['table_validation'] = table_guard.validate_tables(run.plan,run.tables) initial = ref.cpu.State(tg.initial((64,64),0,64,.0192), tg.initial((128,128),1,64,.0192)) phase = 'warm_reset' initial, report['warmup'] = warm_and_reset(run, initial, check_cap) diff --git a/tests/validation/multigrid/spacetime_table_guard.py b/tests/validation/multigrid/spacetime_table_guard.py new file mode 100644 index 0000000..5334df2 --- /dev/null +++ b/tests/validation/multigrid/spacetime_table_guard.py @@ -0,0 +1,89 @@ +"""N64 admission check: exact topology and independently rational interpolation. + +Raw FP64 bytes are diagnostic: different LAPACK builds need not produce them +identically. The 2e-14 ceiling is the existing interpolation constraint policy, +not a universal LAPACK error theorem. This module never changes runtime weights. +""" +from fractions import Fraction as F +from functools import lru_cache + +import numpy as np +from spacetime_gpu import table_hash + +STRUCTURE_SHA = 'cd1842952c4dd4df82da8015454c25da9be12b983270236cc65b4044fa38e6e4' +ATOL = 2e-14 + + +def _transpose(a): + return list(map(list, zip(*a))) + + +def _multiply(a, b): + return [[sum(x*y for x,y in zip(row,column)) for column in zip(*b)] for row in a] + + +@lru_cache(None) +def exact_weights(kind, points): + """Solve the small documented constrained fit using exact rational arithmetic.""" + if kind == 'face': + a = [[F(1),F(s),F(t),F(s*t)] for t in (0,1) for s in (-1,0,1)] + e = [[F(1),F(s),F(t),F(s)*F(t)] for s,t in points] + b = [[sum(column)/4 for column in zip(*e)]] + d = [[F(int(j==1)) for j in range(6)]] + elif kind == 'corner': + a = [[F(1),F(x),F(y),F(t)] for t in (0,1) for x,y in ((0,0),(-1,0),(1,0),(0,-1),(0,1))] + e = [[F(1),F(x),F(y),F(t)] for x,y,t in points] + b = [[sum(column)/4 for column in zip(*e)], [F(0),F(0),F(0),F(1)]] + d = [[F(int(j==0)) for j in range(10)], [F(int(j==5)-int(j==0)) for j in range(10)]] + else: + raise ValueError('unsupported interpolation kind') + kkt = [r+s for r,s in zip(_multiply(_transpose(a),a),_transpose(b))] + kkt += [row+[F(0)]*len(b) for row in b] + rhs = _transpose(a)+d + n = len(kkt) + augmented = [row+data for row,data in zip(kkt,rhs)] + for j in range(n): + pivot = next(i for i in range(j,n) if augmented[i][j]) + augmented[j],augmented[pivot] = augmented[pivot],augmented[j] + divisor = augmented[j][j] + augmented[j] = [v/divisor for v in augmented[j]] + for i in range(n): + if i != j: + factor = augmented[i][j] + augmented[i] = [x-factor*y for x,y in zip(augmented[i],augmented[j])] + solution = [row[n:] for row in augmented] + if _multiply(kkt,solution) != rhs: + raise AssertionError('exact interpolation solve failed') + return np.array(_multiply(e,solution[:4]), dtype=np.float64) + + +def validate_tables(plan, tables): + """Reject topology, layout, padding, operator, or constraint changes separately.""" + structure = table_hash({key:value for key,value in tables.items() if key != 'weights'}) + if plan.n != 64 or structure != STRUCTURE_SHA: + raise ValueError('frozen integer topology changed') + w = tables['weights'] + if (w.dtype != np.dtype(np.float64) or w.shape != (532,4,10) or len(plan.groups) != w.shape[0] + or not all(a.flags.c_contiguous for a in tables.values()) or not np.isfinite(w).all()): + raise ValueError('weight dtype/shape/layout/finiteness changed') + errors = dict(coefficient=0., constant=0., mean=0., temporal=0.) + for i,g in enumerate(plan.groups): + size = len(g.samples) + if size != tables['sizes'][i] or size != {'face':6,'corner':10}.get(g.kind) or len(g.points) != 4: + raise ValueError('interpolation group coverage/size changed') + if w[i,:,size:].tobytes() != np.zeros((4,10-size),np.float64).tobytes(): + raise ValueError('weight padding is not exact positive zero') + actual = w[i,:,:size] + errors['coefficient'] = max(errors['coefficient'],float(abs(actual-exact_weights(g.kind,g.points)).max())) + errors['constant'] = max(errors['constant'],float(abs(actual.sum(axis=1)-1).max())) + central = np.zeros(size); central[1 if g.kind=='face' else 0] = 1 + errors['mean'] = max(errors['mean'],float(abs(actual.mean(axis=0)-central).max())) + if g.kind == 'corner': + early,late = (g.points.index((0.,0.,t)) for t in (-.25,.25)) + slope = np.zeros(size); slope[0],slope[5] = -1,1 + errors['temporal'] = max(errors['temporal'],float(abs(2*(actual[late]-actual[early])-slope).max())) + if any(error > ATOL for error in errors.values()): + raise ValueError(f'exact interpolation/constraint check failed: {errors}; bound={ATOL}') + return dict(status='passed', structure_sha256=structure, raw_table_sha256=table_hash(tables), + coefficient_oracle='exact rational KKT, rounded once to FP64', atol=ATOL, rtol=0., + maximum_absolute_errors=errors, positive_zero_padding=True) diff --git a/tests/validation/multigrid/test_spacetime_gpu.py b/tests/validation/multigrid/test_spacetime_gpu.py index 6bdff69..d9ecd24 100644 --- a/tests/validation/multigrid/test_spacetime_gpu.py +++ b/tests/validation/multigrid/test_spacetime_gpu.py @@ -193,6 +193,40 @@ class Host(unittest.TestCase): with self.assertRaisesRegex(ValueError,'pinned reference'): trajectory.load_references(path,path) + def test_n64_table_guard_and_balanced_mutation(self): + guard=trajectory.table_guard + plan=ref.Plan(64,(8,8,56,48)); tables=gpu.pack_plan(plan) + self.assertEqual(guard.validate_tables(plan,tables)['status'],'passed') + face=next(i for i,g in enumerate(plan.groups) if g.kind=='face') + changed={key:value.copy() for key,value in tables.items()} + changed['weights'][face,0,0]=np.nextafter(changed['weights'][face,0,0],np.inf) + self.assertEqual(guard.validate_tables(plan,changed)['status'],'passed') + for mutation in ('coordinate','order','dtype','shape','layout','padding','negative_zero','nan','inf','balanced'): + changed={key:value.copy() for key,value in tables.items()} + w=changed['weights'] + if mutation=='coordinate': changed['incoming1'][0,0]^=1 + elif mutation=='order': changed['origins']=changed['origins'][::-1].copy() + elif mutation=='dtype': changed['weights']=w.astype(np.float32) + elif mutation=='shape': changed['weights']=w.reshape(532,10,4) + elif mutation=='layout': changed['weights']=np.asfortranarray(w) + elif mutation=='padding': w[face,0,9]=1e-30 + elif mutation=='negative_zero': w[face,0,9]=-0. + elif mutation=='nan': w[face,0,0]=np.nan + elif mutation=='inf': w[face,0,0]=np.inf + else: + w[face,:2,:2] += 2.**-30*np.array([[1.,-1.],[-1.,1.]]) + np.testing.assert_array_equal(w[face].sum(axis=1),tables['weights'][face].sum(axis=1)) + np.testing.assert_array_equal(w[face].mean(axis=0),tables['weights'][face].mean(axis=0)) + with self.subTest(mutation=mutation), self.assertRaises(ValueError): + guard.validate_tables(plan,changed) + changed={key:value.copy() for key,value in tables.items()} + changed['weights'][-1,0,0]+=1. + with self.assertRaises(ValueError): + guard.validate_tables(SimpleNamespace(n=64,groups=plan.groups[:-1]),changed) + groups=list(plan.groups); groups[0]=replace(groups[0],samples=groups[0].samples[:-1]) + with self.assertRaisesRegex(ValueError,'coverage/size'): + guard.validate_tables(SimpleNamespace(n=64,groups=groups),tables) + class Device(unittest.TestCase): @classmethod