Files
2026-09-30 10:45:48 +08:00

205 lines
9.8 KiB
Python

"""Synthetic tests only; no saved scientific results are accessed."""
import copy
import json
import unittest
import numpy as np
from structure_metrics import aggregate_structure, MODES, SEEDS, METRICS
def records():
rows = []
for mode in MODES:
for odor in range(6):
design = np.zeros(686, bool)
design[odor] = True
for seed in SEEDS:
count = np.zeros(686, np.int32)
count[odor] = 10
count[10] = seed - SEEDS[0]
count[11] = odor
rows.append(dict(mode=mode, odor_index=odor, seed=seed,
counts=count, design_mask=design.copy()))
return rows
class StructureMetricsTests(unittest.TestCase):
def test_fixed_complete_inventory_and_pair_specific_baseline(self):
result = aggregate_structure(records())
self.assertEqual(result['record_count'], 60)
self.assertEqual(result['design']['design_union_count'], 6)
self.assertEqual(result['design']['common_non_design_count'], 680)
self.assertEqual(result['design']['common_non_design_column_positions'], list(range(6, 686)))
for mode in MODES:
self.assertEqual(result['modes'][mode]['trials'], 30)
for view_name in ('fixed_686', 'common_non_design'):
view = result['modes'][mode][view_name]
self.assertEqual(len(view['within_comparisons']), 60)
self.assertEqual(len(view['between_comparisons']), 75)
self.assertEqual(len(view['pairs']), 15)
for pair in view['pairs']:
self.assertEqual(len(pair['within_comparisons']), 20)
self.assertEqual(len(pair['between_comparisons']), 5)
self.assertEqual({row['odor_index'] for row in pair['within_comparisons']},
{pair['odor_index_a'], pair['odor_index_b']})
self.assertEqual([row['seed'] for row in pair['between_comparisons']], list(SEEDS))
json.dumps(result, allow_nan=False)
def test_primary_retains_design_columns_and_common_view_is_fixed(self):
result = aggregate_structure(records())['modes'][MODES[0]]
pair = result['fixed_686']['pairs'][0]
common_pair = result['common_non_design']['pairs'][0]
# Odors zero and one differ by two ten-count design axes plus one
# shared-coordinate count; same-seed variability cancels between.
self.assertEqual(pair['between']['euclidean_distance']['mean'], np.sqrt(201.))
self.assertEqual(common_pair['between']['euclidean_distance']['mean'], 1.)
self.assertEqual(pair['within']['euclidean_distance']['mean'], 2.)
self.assertEqual(common_pair['within']['euclidean_distance']['mean'], 2.)
def test_input_order_does_not_change_result(self):
rows = records()
expected = aggregate_structure(rows)
actual = aggregate_structure(list(reversed(rows)))
self.assertEqual(expected, actual)
def test_inputs_unchanged_and_readonly_arrays_accepted(self):
rows = records()
before = copy.deepcopy(rows)
for row in rows:
row['counts'].setflags(write=False)
row['design_mask'].setflags(write=False)
aggregate_structure(rows)
for old, new in zip(before, rows):
np.testing.assert_array_equal(old['counts'], new['counts'])
np.testing.assert_array_equal(old['design_mask'], new['design_mask'])
self.assertFalse(new['counts'].flags.writeable)
def test_zero_vectors_do_not_become_similarity_evidence(self):
rows = records()
for row in rows:
row['counts'].fill(0)
result = aggregate_structure(rows)
for mode in MODES:
for name in ('fixed_686', 'common_non_design'):
view = result['modes'][mode][name]
for metric in ('cosine_distance', 'pearson_correlation', 'jaccard'):
self.assertIsNone(view['within'][metric]['mean'])
self.assertEqual(view['within'][metric]['valid_comparisons'], 0)
self.assertEqual(view['between'][metric]['undefined_comparisons'], 75)
self.assertEqual(view['between']['euclidean_distance']['mean'], 0.)
json.dumps(result, allow_nan=False)
def test_constant_nonzero_vectors_have_undefined_pearson(self):
rows = records()
for row in rows:
row['counts'].fill(1)
view = aggregate_structure(rows)['modes'][MODES[0]]['fixed_686']
self.assertIsNone(view['within']['pearson_correlation']['mean'])
self.assertEqual(view['within']['jaccard']['mean'], 1.)
self.assertAlmostEqual(view['within']['cosine_distance']['mean'], 0., places=14)
def test_empty_common_population_is_nonapplicable(self):
rows = records()
for row in rows:
row['design_mask'].fill(True)
result = aggregate_structure(rows)
self.assertEqual(result['design']['common_non_design_count'], 0)
for mode in MODES:
view = result['modes'][mode]['common_non_design']
self.assertFalse(view['applicable'])
self.assertEqual(view['dimensions'], 0)
for metric in METRICS:
self.assertIsNone(view['between'][metric]['mean'])
self.assertEqual(view['within'][metric]['undefined_comparisons'], 60)
def test_int32_extrema_are_promoted_before_subtraction_and_norm(self):
rows = records()
maximum = np.iinfo(np.int32).max
for row in rows:
row['counts'].fill(0)
row['counts'][row['odor_index']] = maximum
result = aggregate_structure(rows)['modes'][MODES[0]]['fixed_686']
distance = result['pairs'][0]['between']['euclidean_distance']['mean']
self.assertAlmostEqual(distance / float(maximum), np.sqrt(2.))
self.assertEqual(result['between']['cosine_distance']['mean'], 1.)
json.dumps(result, allow_nan=False)
def test_missing_record_or_extra_repeat_rejected(self):
rows = records()
for bad in (rows[:-1], rows + [copy.deepcopy(rows[0])]):
with self.assertRaisesRegex(ValueError, 'Exactly 60'):
aggregate_structure(bad)
def test_duplicate_identity_rejected_even_with_sixty_rows(self):
rows = records()
rows[-1] = copy.deepcopy(rows[0])
with self.assertRaisesRegex(ValueError, 'Duplicate CAL identity'):
aggregate_structure(rows)
def test_mask_change_across_seed_rejected(self):
rows = records()
rows[1]['design_mask'][100] = True
with self.assertRaisesRegex(ValueError, 'mask changed across seed or mode'):
aggregate_structure(rows)
def test_mask_change_across_mode_rejected(self):
rows = records()
for row in rows:
if row['mode'] == MODES[1] and row['odor_index'] == 0:
row['design_mask'][100] = True
with self.assertRaisesRegex(ValueError, 'mask changed across seed or mode'):
aggregate_structure(rows)
def test_negative_counts_and_incorrect_dtype_or_shape_rejected(self):
changes = [('negative', lambda row: row['counts'].__setitem__(0, -1)),
('counts64', lambda row: row.update(counts=row['counts'].astype(np.int64))),
('counts_float', lambda row: row.update(counts=row['counts'].astype(np.float64))),
('shape', lambda row: row.update(counts=np.zeros(685, np.int32))),
('mask_dtype', lambda row: row.update(design_mask=row['design_mask'].astype(np.uint8))),
('mask_shape', lambda row: row.update(design_mask=np.zeros(685, bool)))]
for label, change in changes:
with self.subTest(label=label):
rows = records()
change(rows[0])
with self.assertRaises(ValueError):
aggregate_structure(rows)
def test_strict_record_schema_and_id_ranges(self):
changes = [lambda row: row.update(extra=1), lambda row: row.pop('counts'),
lambda row: row.update(mode='new_mode'), lambda row: row.update(odor_index=6),
lambda row: row.update(odor_index=True), lambda row: row.update(seed=51106),
lambda row: row.update(seed=float(SEEDS[0]))]
for change in changes:
rows = records()
change(rows[0])
with self.assertRaises(ValueError):
aggregate_structure(rows)
def test_partial_undefined_metrics_report_counts_without_imputation(self):
rows = records()
# One zero-count record per mode. Four of the 60 within comparisons
# and five of the 75 between comparisons then have undefined cosine.
for row in rows:
if row['odor_index'] == 0 and row['seed'] == SEEDS[0]:
row['counts'].fill(0)
view = aggregate_structure(rows)['modes'][MODES[0]]['fixed_686']
self.assertEqual(view['within']['cosine_distance']['undefined_comparisons'], 4)
self.assertEqual(view['between']['cosine_distance']['undefined_comparisons'], 5)
self.assertEqual(view['within']['cosine_distance']['valid_comparisons'], 56)
self.assertEqual(view['between']['cosine_distance']['valid_comparisons'], 70)
def test_one_common_channel_pearson_is_undefined(self):
rows = records()
for row in rows:
row['design_mask'][:685] = True
row['counts'][685] = row['odor_index'] + 1
view = aggregate_structure(rows)['modes'][MODES[0]]['common_non_design']
self.assertEqual(view['dimensions'], 1)
self.assertIsNone(view['between']['pearson_correlation']['mean'])
self.assertEqual(view['between']['cosine_distance']['mean'], 0.)
if __name__ == '__main__':
unittest.main()