summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYurenHao0426 <Blackhao0426@gmail.com>2026-08-01 16:20:35 -0500
committerYurenHao0426 <Blackhao0426@gmail.com>2026-08-01 16:20:35 -0500
commit735f9c7fd202d0eaed9183094d84d365e0e5404d (patch)
tree075b5b9212568d9d956776ce47d911b8e055c105
parent08fd63b8fee62ccdc284380c9832900ee83f9ede (diff)
Test the new instruments; fix an O(1/n) bias in the degree decomposition
Four tests around today's additions. Two failed on first run and both were worth having. The degree decomposition left an O(1/n) residual on a field that is purely additive: excluding the diagonal makes the two-way design unbalanced, so one pass of row and column means does not remove a pure degree effect. Swept to convergence instead. At N=256 the correction moves the reported variance shares by under 0.001, so the refutation of the hubness hypothesis stands unchanged -- but the instrument that produced it now does what it claims. The other failure was the test's own scale: two random 16-dimensional subspaces of R^64 overlap above 0.7 by chance, which is why the real measurements are made at N=256 where the null sits at 1.0. Co-Authored-By: Claude <noreply@anthropic.com>
-rw-r--r--artifacts/vg_5k/field_anatomy.json16
-rw-r--r--artifacts/vg_5k/np_struct128.json11
-rw-r--r--artifacts/vg_5k/np_struct64.json11
-rw-r--r--artifacts/vg_5k/shared_rank_phrase.json265
-rw-r--r--logs/obj_base_gpu.log2
-rw-r--r--logs/rho_full_width.log0
-rw-r--r--tests/test_core.py89
-rw-r--r--worldalign/field_anatomy.py29
-rw-r--r--worldalign/natural_pipeline.py42
-rw-r--r--worldalign/rho_at_full_width.py137
10 files changed, 580 insertions, 22 deletions
diff --git a/artifacts/vg_5k/field_anatomy.json b/artifacts/vg_5k/field_anatomy.json
index 740136c..1e0f276 100644
--- a/artifacts/vg_5k/field_anatomy.json
+++ b/artifacts/vg_5k/field_anatomy.json
@@ -9,31 +9,31 @@
},
{
"component": "degree model only",
- "correlation": 0.22252881188815218
+ "correlation": 0.2225288118881524
},
{
"component": "residual after degree",
- "correlation": 0.6709906297296612
+ "correlation": 0.670990871461728
},
{
"component": "residual after top-1 eigen",
- "correlation": 0.4579725907578625
+ "correlation": 0.45797259075786245
},
{
"component": "residual after top-2 eigen",
- "correlation": 0.4555001418827857
+ "correlation": 0.45550014188278554
},
{
"component": "residual after top-5 eigen",
- "correlation": 0.3413229646188751
+ "correlation": 0.3413229646188753
},
{
"component": "residual after top-10 eigen",
- "correlation": 0.07969017846523709
+ "correlation": 0.07969017846523718
}
],
- "visual_degree_variance_share": 0.009036045331312099,
- "text_degree_variance_share": 0.04921092158161093,
+ "visual_degree_variance_share": 0.00910733535353353,
+ "text_degree_variance_share": 0.049599171923929736,
"it_threshold_at_size": 0.29435250562886867,
"residual_above_it_threshold": true,
"reading": "If the residual correlation is far below the raw correlation, the headline statistic is inflated by nuisance structure that carries no matching information, and the residual is the number that should be compared against the recovery threshold."
diff --git a/artifacts/vg_5k/np_struct128.json b/artifacts/vg_5k/np_struct128.json
new file mode 100644
index 0000000..c035e3b
--- /dev/null
+++ b/artifacts/vg_5k/np_struct128.json
@@ -0,0 +1,11 @@
+{
+ "protocol": "Continuous states, content projection fitted per modality on scenes outside the evaluated set. Hidden pairs are read only for the correlation.",
+ "samples": 256,
+ "views": 1,
+ "kept_directions": 64,
+ "weight_power": 0.5,
+ "moment_degree": 2,
+ "phrase_encoding": "structured",
+ "field_correlation_at_truth": 0.7252267991153296,
+ "recovery_threshold": 0.9
+} \ No newline at end of file
diff --git a/artifacts/vg_5k/np_struct64.json b/artifacts/vg_5k/np_struct64.json
new file mode 100644
index 0000000..1a98e55
--- /dev/null
+++ b/artifacts/vg_5k/np_struct64.json
@@ -0,0 +1,11 @@
+{
+ "protocol": "Continuous states, content projection fitted per modality on scenes outside the evaluated set. Hidden pairs are read only for the correlation.",
+ "samples": 256,
+ "views": 1,
+ "kept_directions": 64,
+ "weight_power": 0.5,
+ "moment_degree": 2,
+ "phrase_encoding": "structured",
+ "field_correlation_at_truth": 0.7246401498403082,
+ "recovery_threshold": 0.9
+} \ No newline at end of file
diff --git a/artifacts/vg_5k/shared_rank_phrase.json b/artifacts/vg_5k/shared_rank_phrase.json
new file mode 100644
index 0000000..80d6565
--- /dev/null
+++ b/artifacts/vg_5k/shared_rank_phrase.json
@@ -0,0 +1,265 @@
+{
+ "protocol": "Effective rank from the participation ratio of each field's spectrum; shared directions from principal angles between the two leading eigenspaces. No pairing enters beyond the alignment the fields already carry.",
+ "width": 32,
+ "rows": [
+ {
+ "label": "mean-phrase-0.716",
+ "size": 256,
+ "correlation": 0.7157944505313559,
+ "visual_effective_rank": 39.69132496762981,
+ "text_effective_rank": 47.98422953390366,
+ "shared_directions_cos_gt_0.7": 16,
+ "shuffled_null": 1.0,
+ "shared_above_null": 15.0,
+ "principal_cosines_top8": [
+ 0.987,
+ 0.978,
+ 0.96,
+ 0.941,
+ 0.934,
+ 0.922,
+ 0.899,
+ 0.89
+ ],
+ "spectrum_profile": [
+ {
+ "index": 0,
+ "best_text_overlap": 0.8406807387214822
+ },
+ {
+ "index": 1,
+ "best_text_overlap": 0.6748753041856854
+ },
+ {
+ "index": 2,
+ "best_text_overlap": 0.7174150319587717
+ },
+ {
+ "index": 3,
+ "best_text_overlap": 0.4742164704027202
+ },
+ {
+ "index": 4,
+ "best_text_overlap": 0.7105187496542062
+ },
+ {
+ "index": 5,
+ "best_text_overlap": 0.5435228035336661
+ },
+ {
+ "index": 6,
+ "best_text_overlap": 0.5173316630294161
+ },
+ {
+ "index": 7,
+ "best_text_overlap": 0.3720956303455928
+ },
+ {
+ "index": 8,
+ "best_text_overlap": 0.4031225977235423
+ },
+ {
+ "index": 9,
+ "best_text_overlap": 0.5909531802492844
+ },
+ {
+ "index": 10,
+ "best_text_overlap": 0.373881786434362
+ },
+ {
+ "index": 11,
+ "best_text_overlap": 0.2816693575587397
+ },
+ {
+ "index": 12,
+ "best_text_overlap": 0.21822240451244762
+ },
+ {
+ "index": 13,
+ "best_text_overlap": 0.3929212823566225
+ },
+ {
+ "index": 14,
+ "best_text_overlap": 0.4024276537497661
+ },
+ {
+ "index": 15,
+ "best_text_overlap": 0.2659834432053085
+ }
+ ]
+ },
+ {
+ "label": "structured-wv64",
+ "size": 256,
+ "correlation": 0.7246401498403082,
+ "visual_effective_rank": 39.69132496762981,
+ "text_effective_rank": 39.044391234462196,
+ "shared_directions_cos_gt_0.7": 15,
+ "shuffled_null": 1.0,
+ "shared_above_null": 14.0,
+ "principal_cosines_top8": [
+ 0.991,
+ 0.979,
+ 0.957,
+ 0.94,
+ 0.924,
+ 0.913,
+ 0.884,
+ 0.877
+ ],
+ "spectrum_profile": [
+ {
+ "index": 0,
+ "best_text_overlap": 0.8699128451599867
+ },
+ {
+ "index": 1,
+ "best_text_overlap": 0.5919394988285451
+ },
+ {
+ "index": 2,
+ "best_text_overlap": 0.664910963201995
+ },
+ {
+ "index": 3,
+ "best_text_overlap": 0.5718467670534315
+ },
+ {
+ "index": 4,
+ "best_text_overlap": 0.6155246177540001
+ },
+ {
+ "index": 5,
+ "best_text_overlap": 0.520789559417958
+ },
+ {
+ "index": 6,
+ "best_text_overlap": 0.5726871443670466
+ },
+ {
+ "index": 7,
+ "best_text_overlap": 0.359948345526124
+ },
+ {
+ "index": 8,
+ "best_text_overlap": 0.3629235665536516
+ },
+ {
+ "index": 9,
+ "best_text_overlap": 0.3191775375612409
+ },
+ {
+ "index": 10,
+ "best_text_overlap": 0.23839966477101518
+ },
+ {
+ "index": 11,
+ "best_text_overlap": 0.3902646088886306
+ },
+ {
+ "index": 12,
+ "best_text_overlap": 0.2525566443339542
+ },
+ {
+ "index": 13,
+ "best_text_overlap": 0.4143287992176083
+ },
+ {
+ "index": 14,
+ "best_text_overlap": 0.35641603055069626
+ },
+ {
+ "index": 15,
+ "best_text_overlap": 0.1905065808689813
+ }
+ ]
+ },
+ {
+ "label": "structured-wv128",
+ "size": 256,
+ "correlation": 0.7252267991153296,
+ "visual_effective_rank": 39.69132496762981,
+ "text_effective_rank": 48.61469812212951,
+ "shared_directions_cos_gt_0.7": 16,
+ "shuffled_null": 1.0,
+ "shared_above_null": 15.0,
+ "principal_cosines_top8": [
+ 0.987,
+ 0.978,
+ 0.956,
+ 0.936,
+ 0.933,
+ 0.919,
+ 0.908,
+ 0.882
+ ],
+ "spectrum_profile": [
+ {
+ "index": 0,
+ "best_text_overlap": 0.8836896656200444
+ },
+ {
+ "index": 1,
+ "best_text_overlap": 0.6662527722108562
+ },
+ {
+ "index": 2,
+ "best_text_overlap": 0.6895209294138209
+ },
+ {
+ "index": 3,
+ "best_text_overlap": 0.5068523620638157
+ },
+ {
+ "index": 4,
+ "best_text_overlap": 0.6826302737796026
+ },
+ {
+ "index": 5,
+ "best_text_overlap": 0.5697249361142291
+ },
+ {
+ "index": 6,
+ "best_text_overlap": 0.5326573257977141
+ },
+ {
+ "index": 7,
+ "best_text_overlap": 0.37933461331439877
+ },
+ {
+ "index": 8,
+ "best_text_overlap": 0.3322445785255883
+ },
+ {
+ "index": 9,
+ "best_text_overlap": 0.5083883525562566
+ },
+ {
+ "index": 10,
+ "best_text_overlap": 0.3237366787528124
+ },
+ {
+ "index": 11,
+ "best_text_overlap": 0.38425794698324
+ },
+ {
+ "index": 12,
+ "best_text_overlap": 0.1960410706899702
+ },
+ {
+ "index": 13,
+ "best_text_overlap": 0.4644524929154713
+ },
+ {
+ "index": 14,
+ "best_text_overlap": 0.2559185730403851
+ },
+ {
+ "index": 15,
+ "best_text_overlap": 0.2515038306148867
+ }
+ ]
+ }
+ ],
+ "reading": "The narrow modality is the one to upgrade. A shared count far below both effective ranks means each side is rich but they are rich about different things."
+} \ No newline at end of file
diff --git a/logs/obj_base_gpu.log b/logs/obj_base_gpu.log
index de196aa..c3ea786 100644
--- a/logs/obj_base_gpu.log
+++ b/logs/obj_base_gpu.log
@@ -1,3 +1,3 @@
Using a slow image processor as `use_fast` is unset and a slow processor was saved with this model. `use_fast=True` will be the default behavior in v4.52, even if the model was saved with a slow processor. This will result in minor differences in outputs. You'll still be able to use a slow processor with `use_fast=False`.
`torch_dtype` is deprecated! Use `dtype` instead!
- segment: 0%| | 0/250 [00:00<?, ?it/s] segment: 0%| | 1/250 [00:02<10:41, 2.58s/it] segment: 1%| | 2/250 [00:04<08:49, 2.13s/it] segment: 1%| | 3/250 [00:06<08:11, 1.99s/it] segment: 2%|▏ | 4/250 [00:08<07:52, 1.92s/it] segment: 2%|▏ | 5/250 [00:09<07:41, 1.88s/it] segment: 2%|▏ | 6/250 [00:11<07:02, 1.73s/it] segment: 3%|▎ | 7/250 [00:12<06:28, 1.60s/it] segment: 3%|▎ | 8/250 [00:13<06:06, 1.52s/it] segment: 4%|▎ | 9/250 [00:15<05:50, 1.45s/it] segment: 4%|▍ | 10/250 [00:16<05:40, 1.42s/it] segment: 4%|▍ | 11/250 [00:17<05:31, 1.39s/it] segment: 5%|▍ | 12/250 [00:19<05:26, 1.37s/it] segment: 5%|▌ | 13/250 [00:20<05:23, 1.37s/it] segment: 6%|▌ | 14/250 [00:21<05:20, 1.36s/it] segment: 6%|▌ | 15/250 [00:23<05:16, 1.35s/it] segment: 6%|▋ | 16/250 [00:24<05:14, 1.34s/it] segment: 7%|▋ | 17/250 [00:25<05:12, 1.34s/it] segment: 7%|▋ | 18/250 [00:27<05:10, 1.34s/it] segment: 8%|▊ | 19/250 [00:28<05:07, 1.33s/it] segment: 8%|▊ | 20/250 [00:29<05:06, 1.33s/it] segment: 8%|▊ | 21/250 [00:31<05:04, 1.33s/it] segment: 9%|▉ | 22/250 [00:32<05:01, 1.32s/it] segment: 9%|▉ | 23/250 [00:33<04:59, 1.32s/it] segment: 10%|▉ | 24/250 [00:35<04:58, 1.32s/it] segment: 10%|█ | 25/250 [00:36<04:57, 1.32s/it] segment: 10%|█ | 26/250 [00:37<04:56, 1.32s/it] segment: 11%|█ | 27/250 [00:39<04:56, 1.33s/it] segment: 11%|█ | 28/250 [00:40<04:55, 1.33s/it] segment: 12%|█▏ | 29/250 [00:41<04:54, 1.33s/it] segment: 12%|█▏ | 30/250 [00:43<04:53, 1.33s/it] segment: 12%|█▏ | 31/250 [00:44<04:51, 1.33s/it] segment: 13%|█▎ | 32/250 [00:45<04:48, 1.32s/it] segment: 13%|█▎ | 33/250 [00:47<04:46, 1.32s/it] segment: 14%|█▎ | 34/250 [00:48<04:44, 1.32s/it] segment: 14%|█▍ | 35/250 [00:49<04:43, 1.32s/it] segment: 14%|█▍ | 36/250 [00:51<04:43, 1.32s/it] segment: 15%|█▍ | 37/250 [00:52<04:41, 1.32s/it] segment: 15%|█▌ | 38/250 [00:53<04:40, 1.32s/it] segment: 16%|█▌ | 39/250 [00:55<04:39, 1.32s/it] segment: 16%|█▌ | 40/250 [00:56<04:36, 1.32s/it] segment: 16%|█▋ | 41/250 [00:57<04:34, 1.31s/it] segment: 17%|█▋ | 42/250 [00:59<04:33, 1.32s/it] segment: 17%|█▋ | 43/250 [01:00<04:32, 1.32s/it] segment: 18%|█▊ | 44/250 [01:01<04:31, 1.32s/it] segment: 18%|█▊ | 45/250 [01:03<04:34, 1.34s/it] segment: 18%|█▊ | 46/250 [01:04<04:31, 1.33s/it] segment: 19%|█▉ | 47/250 [01:05<04:27, 1.32s/it] segment: 19%|█▉ | 48/250 [01:06<04:25, 1.32s/it] segment: 20%|█▉ | 49/250 [01:08<04:23, 1.31s/it] segment: 20%|██ | 50/250 [01:09<04:21, 1.31s/it] segment: 20%|██ | 51/250 [01:10<04:21, 1.31s/it] segment: 21%|██ | 52/250 [01:12<04:20, 1.31s/it] segment: 21%|██ | 53/250 [01:13<04:19, 1.32s/it] segment: 22%|██▏ | 54/250 [01:14<04:17, 1.32s/it] segment: 22%|██▏ | 55/250 [01:16<04:16, 1.32s/it] segment: 22%|██▏ | 56/250 [01:17<04:15, 1.32s/it] segment: 23%|██▎ | 57/250 [01:18<04:14, 1.32s/it] segment: 23%|██▎ | 58/250 [01:20<04:13, 1.32s/it] \ No newline at end of file
+ segment: 0%| | 0/250 [00:00<?, ?it/s] segment: 0%| | 1/250 [00:02<10:41, 2.58s/it] segment: 1%| | 2/250 [00:04<08:49, 2.13s/it] segment: 1%| | 3/250 [00:06<08:11, 1.99s/it] segment: 2%|▏ | 4/250 [00:08<07:52, 1.92s/it] segment: 2%|▏ | 5/250 [00:09<07:41, 1.88s/it] segment: 2%|▏ | 6/250 [00:11<07:02, 1.73s/it] segment: 3%|▎ | 7/250 [00:12<06:28, 1.60s/it] segment: 3%|▎ | 8/250 [00:13<06:06, 1.52s/it] segment: 4%|▎ | 9/250 [00:15<05:50, 1.45s/it] segment: 4%|▍ | 10/250 [00:16<05:40, 1.42s/it] segment: 4%|▍ | 11/250 [00:17<05:31, 1.39s/it] segment: 5%|▍ | 12/250 [00:19<05:26, 1.37s/it] segment: 5%|▌ | 13/250 [00:20<05:23, 1.37s/it] segment: 6%|▌ | 14/250 [00:21<05:20, 1.36s/it] segment: 6%|▌ | 15/250 [00:23<05:16, 1.35s/it] segment: 6%|▋ | 16/250 [00:24<05:14, 1.34s/it] segment: 7%|▋ | 17/250 [00:25<05:12, 1.34s/it] segment: 7%|▋ | 18/250 [00:27<05:10, 1.34s/it] segment: 8%|▊ | 19/250 [00:28<05:07, 1.33s/it] segment: 8%|▊ | 20/250 [00:29<05:06, 1.33s/it] segment: 8%|▊ | 21/250 [00:31<05:04, 1.33s/it] segment: 9%|▉ | 22/250 [00:32<05:01, 1.32s/it] segment: 9%|▉ | 23/250 [00:33<04:59, 1.32s/it] segment: 10%|▉ | 24/250 [00:35<04:58, 1.32s/it] segment: 10%|█ | 25/250 [00:36<04:57, 1.32s/it] segment: 10%|█ | 26/250 [00:37<04:56, 1.32s/it] segment: 11%|█ | 27/250 [00:39<04:56, 1.33s/it] segment: 11%|█ | 28/250 [00:40<04:55, 1.33s/it] segment: 12%|█▏ | 29/250 [00:41<04:54, 1.33s/it] segment: 12%|█▏ | 30/250 [00:43<04:53, 1.33s/it] segment: 12%|█▏ | 31/250 [00:44<04:51, 1.33s/it] segment: 13%|█▎ | 32/250 [00:45<04:48, 1.32s/it] segment: 13%|█▎ | 33/250 [00:47<04:46, 1.32s/it] segment: 14%|█▎ | 34/250 [00:48<04:44, 1.32s/it] segment: 14%|█▍ | 35/250 [00:49<04:43, 1.32s/it] segment: 14%|█▍ | 36/250 [00:51<04:43, 1.32s/it] segment: 15%|█▍ | 37/250 [00:52<04:41, 1.32s/it] segment: 15%|█▌ | 38/250 [00:53<04:40, 1.32s/it] segment: 16%|█▌ | 39/250 [00:55<04:39, 1.32s/it] segment: 16%|█▌ | 40/250 [00:56<04:36, 1.32s/it] segment: 16%|█▋ | 41/250 [00:57<04:34, 1.31s/it] segment: 17%|█▋ | 42/250 [00:59<04:33, 1.32s/it] segment: 17%|█▋ | 43/250 [01:00<04:32, 1.32s/it] segment: 18%|█▊ | 44/250 [01:01<04:31, 1.32s/it] segment: 18%|█▊ | 45/250 [01:03<04:34, 1.34s/it] segment: 18%|█▊ | 46/250 [01:04<04:31, 1.33s/it] segment: 19%|█▉ | 47/250 [01:05<04:27, 1.32s/it] segment: 19%|█▉ | 48/250 [01:06<04:25, 1.32s/it] segment: 20%|█▉ | 49/250 [01:08<04:23, 1.31s/it] segment: 20%|██ | 50/250 [01:09<04:21, 1.31s/it] segment: 20%|██ | 51/250 [01:10<04:21, 1.31s/it] segment: 21%|██ | 52/250 [01:12<04:20, 1.31s/it] segment: 21%|██ | 53/250 [01:13<04:19, 1.32s/it] segment: 22%|██▏ | 54/250 [01:14<04:17, 1.32s/it] segment: 22%|██▏ | 55/250 [01:16<04:16, 1.32s/it] segment: 22%|██▏ | 56/250 [01:17<04:15, 1.32s/it] segment: 23%|██▎ | 57/250 [01:18<04:14, 1.32s/it] segment: 23%|██▎ | 58/250 [01:20<04:13, 1.32s/it] segment: 24%|██▎ | 59/250 [01:21<04:11, 1.32s/it] segment: 24%|██▍ | 60/250 [01:22<04:10, 1.32s/it] segment: 24%|██▍ | 61/250 [01:24<04:08, 1.32s/it] segment: 25%|██▍ | 62/250 [01:25<04:07, 1.32s/it] segment: 25%|██▌ | 63/250 [01:26<04:06, 1.32s/it] segment: 26%|██▌ | 64/250 [01:27<04:04, 1.31s/it] segment: 26%|██▌ | 65/250 [01:29<04:03, 1.32s/it] segment: 26%|██▋ | 66/250 [01:30<04:01, 1.32s/it] segment: 27%|██▋ | 67/250 [01:31<04:01, 1.32s/it] segment: 27%|██▋ | 68/250 [01:33<03:58, 1.31s/it] segment: 28%|██▊ | 69/250 [01:34<03:57, 1.31s/it] segment: 28%|██▊ | 70/250 [01:35<03:56, 1.31s/it] segment: 28%|██▊ | 71/250 [01:37<03:56, 1.32s/it] segment: 29%|██▉ | 72/250 [01:38<03:54, 1.32s/it] segment: 29%|██▉ | 73/250 [01:39<03:52, 1.31s/it] segment: 30%|██▉ | 74/250 [01:41<03:52, 1.32s/it] segment: 30%|███ | 75/250 [01:42<03:51, 1.32s/it] segment: 30%|███ | 76/250 [01:43<03:49, 1.32s/it] segment: 31%|███ | 77/250 [01:45<03:47, 1.32s/it] segment: 31%|███ | 78/250 [01:46<03:47, 1.32s/it] segment: 32%|███▏ | 79/250 [01:47<03:44, 1.31s/it] segment: 32%|███▏ | 80/250 [01:49<03:43, 1.32s/it] segment: 32%|███▏ | 81/250 [01:50<03:42, 1.32s/it] segment: 33%|███▎ | 82/250 [01:51<03:41, 1.32s/it] segment: 33%|███▎ | 83/250 [01:53<03:39, 1.31s/it] segment: 34%|███▎ | 84/250 [01:54<03:37, 1.31s/it] segment: 34%|███▍ | 85/250 [01:55<03:37, 1.32s/it] segment: 34%|███▍ | 86/250 [01:56<03:35, 1.31s/it] segment: 35%|███▍ | 87/250 [01:58<03:34, 1.31s/it] segment: 35%|███▌ | 88/250 [01:59<03:33, 1.32s/it] segment: 36%|███▌ | 89/250 [02:00<03:32, 1.32s/it] segment: 36%|███▌ | 90/250 [02:02<03:30, 1.32s/it] segment: 36%|███▋ | 91/250 [02:03<03:30, 1.32s/it] segment: 37%|███▋ | 92/250 [02:04<03:29, 1.32s/it] segment: 37%|███▋ | 93/250 [02:06<03:28, 1.32s/it] segment: 38%|███▊ | 94/250 [02:07<03:26, 1.33s/it] segment: 38%|███▊ | 95/250 [02:08<03:24, 1.32s/it] segment: 38%|███▊ | 96/250 [02:10<03:23, 1.32s/it] segment: 39%|███▉ | 97/250 [02:11<03:21, 1.32s/it] segment: 39%|███▉ | 98/250 [02:12<03:20, 1.32s/it] segment: 40%|███▉ | 99/250 [02:14<03:18, 1.32s/it] segment: 40%|████ | 100/250 [02:15<03:16, 1.31s/it] segment: 40%|████ | 101/250 [02:16<03:15, 1.31s/it] segment: 41%|████ | 102/250 [02:18<03:14, 1.31s/it] segment: 41%|████ | 103/250 [02:19<03:13, 1.31s/it] segment: 42%|████▏ | 104/250 [02:20<03:11, 1.31s/it] segment: 42%|████▏ | 105/250 [02:21<03:10, 1.31s/it] segment: 42%|████▏ | 106/250 [02:23<03:09, 1.32s/it] segment: 43%|████▎ | 107/250 [02:24<03:08, 1.32s/it] segment: 43%|████▎ | 108/250 [02:25<03:06, 1.31s/it] segment: 44%|████▎ | 109/250 [02:27<03:04, 1.31s/it] segment: 44%|████▍ | 110/250 [02:28<03:03, 1.31s/it] segment: 44%|████▍ | 111/250 [02:29<03:03, 1.32s/it] segment: 45%|████▍ | 112/250 [02:31<03:02, 1.32s/it] segment: 45%|████▌ | 113/250 [02:32<03:00, 1.32s/it] segment: 46%|████▌ | 114/250 [02:33<02:59, 1.32s/it] segment: 46%|████▌ | 115/250 [02:35<02:57, 1.31s/it] segment: 46%|████▋ | 116/250 [02:36<02:56, 1.32s/it] segment: 47%|████▋ | 117/250 [02:37<02:55, 1.32s/it] segment: 47%|████▋ | 118/250 [02:39<02:54, 1.32s/it] segment: 48%|████▊ | 119/250 [02:40<02:52, 1.32s/it] segment: 48%|████▊ | 120/250 [02:41<02:50, 1.31s/it] segment: 48%|████▊ | 121/250 [02:43<02:48, 1.31s/it] segment: 49%|████▉ | 122/250 [02:44<02:47, 1.31s/it] segment: 49%|████▉ | 123/250 [02:45<02:46, 1.31s/it] segment: 50%|████▉ | 124/250 [02:46<02:45, 1.32s/it] segment: 50%|█████ | 125/250 [02:48<02:43, 1.31s/it] segment: 50%|█████ | 126/250 [02:49<02:42, 1.31s/it] segment: 51%|█████ | 127/250 [02:50<02:40, 1.31s/it] segment: 51%|█████ | 128/250 [02:52<02:39, 1.31s/it] segment: 52%|█████▏ | 129/250 [02:53<02:38, 1.31s/it] segment: 52%|█████▏ | 130/250 [02:54<02:37, 1.31s/it] segment: 52%|█████▏ | 131/250 [02:56<02:36, 1.32s/it] segment: 53%|█████▎ | 132/250 [02:57<02:35, 1.32s/it] segment: 53%|█████▎ | 133/250 [02:58<02:33, 1.31s/it] segment: 54%|█████▎ | 134/250 [03:00<02:31, 1.31s/it] segment: 54%|█████▍ | 135/250 [03:01<02:30, 1.31s/it] segment: 54%|█████▍ | 136/250 [03:02<02:28, 1.31s/it] segment: 55%|█████▍ | 137/250 [03:03<02:28, 1.31s/it] segment: 55%|█████▌ | 138/250 [03:05<02:26, 1.31s/it] segment: 56%|█████▌ | 139/250 [03:06<02:25, 1.31s/it] segment: 56%|█████▌ | 140/250 [03:07<02:23, 1.31s/it] segment: 56%|█████▋ | 141/250 [03:09<02:22, 1.31s/it] segment: 57%|█████▋ | 142/250 [03:10<02:21, 1.31s/it] segment: 57%|█████▋ | 143/250 [03:11<02:20, 1.31s/it] segment: 58%|█████▊ | 144/250 [03:13<02:19, 1.32s/it] segment: 58%|█████▊ | 145/250 [03:14<02:18, 1.32s/it] segment: 58%|█████▊ | 146/250 [03:15<02:18, 1.33s/it] segment: 59%|█████▉ | 147/250 [03:17<02:15, 1.31s/it] segment: 59%|█████▉ | 148/250 [03:18<02:13, 1.31s/it] segment: 60%|█████▉ | 149/250 [03:19<02:12, 1.31s/it] segment: 60%|██████ | 150/250 [03:21<02:11, 1.32s/it] segment: 60%|██████ | 151/250 [03:22<02:10, 1.32s/it] segment: 61%|██████ | 152/250 [03:23<02:09, 1.32s/it] segment: 61%|██████ | 153/250 [03:25<02:08, 1.32s/it] segment: 62%|██████▏ | 154/250 [03:26<02:07, 1.33s/it] segment: 62%|██████▏ | 155/250 [03:27<02:05, 1.32s/it] segment: 62%|██████▏ | 156/250 [03:29<02:04, 1.33s/it] segment: 63%|██████▎ | 157/250 [03:30<02:02, 1.32s/it] segment: 63%|██████▎ | 158/250 [03:31<02:01, 1.32s/it] segment: 64%|██████▎ | 159/250 [03:33<02:02, 1.35s/it] segment: 64%|██████▍ | 160/250 [03:34<02:00, 1.34s/it] segment: 64%|██████▍ | 161/250 [03:35<01:58, 1.34s/it] segment: 65%|██████▍ | 162/250 [03:37<01:57, 1.33s/it] segment: 65%|██████▌ | 163/250 [03:38<01:55, 1.33s/it] segment: 66%|██████▌ | 164/250 [03:39<02:00, 1.40s/it] segment: 66%|██████▌ | 165/250 [03:41<02:09, 1.53s/it] segment: 66%|██████▋ | 166/250 [03:43<02:16, 1.62s/it] segment: 67%|██████▋ | 167/250 [03:45<02:19, 1.68s/it] segment: 67%|██████▋ | 168/250 [03:47<02:21, 1.73s/it] segment: 68%|██████▊ | 169/250 [03:49<02:22, 1.76s/it] segment: 68%|██████▊ | 170/250 [03:50<02:22, 1.79s/it] segment: 68%|██████▊ | 171/250 [03:52<02:22, 1.80s/it] segment: 69%|██████▉ | 172/250 [03:54<02:21, 1.82s/it] segment: 69%|██████▉ | 173/250 [03:56<02:20, 1.82s/it] segment: 70%|██████▉ | 174/250 [03:58<02:19, 1.83s/it] segment: 70%|███████ | 175/250 [04:00<02:19, 1.86s/it] segment: 70%|███████ | 176/250 [04:02<02:17, 1.85s/it] segment: 71%|███████ | 177/250 [04:03<02:15, 1.85s/it] segment: 71%|███████ | 178/250 [04:05<02:13, 1.86s/it] segment: 72%|███████▏ | 179/250 [04:07<02:11, 1.86s/it] segment: 72%|███████▏ | 180/250 [04:09<02:09, 1.85s/it] segment: 72%|███████▏ | 181/250 [04:11<02:07, 1.84s/it] segment: 73%|███████▎ | 182/250 [04:13<02:05, 1.84s/it] segment: 73%|███████▎ | 183/250 [04:15<02:03, 1.84s/it] segment: 74%|███████▎ | 184/250 [04:16<02:01, 1.84s/it] segment: 74%|███████▍ | 185/250 [04:18<01:59, 1.84s/it] segment: 74%|███████▍ | 186/250 [04:20<01:58, 1.85s/it] segment: 75%|███████▍ | 187/250 [04:22<01:56, 1.85s/it] segment: 75%|███████▌ | 188/250 [04:24<01:54, 1.85s/it] segment: 76%|███████▌ | 189/250 [04:26<01:52, 1.85s/it] segment: 76%|███████▌ | 190/250 [04:27<01:50, 1.85s/it] segment: 76%|███████▋ | 191/250 [04:29<01:49, 1.86s/it] segment: 77%|███████▋ | 192/250 [04:31<01:47, 1.86s/it] segment: 77%|███████▋ | 193/250 [04:33<01:45, 1.85s/it] segment: 78%|███████▊ | 194/250 [04:35<01:43, 1.85s/it] segment: 78%|███████▊ | 195/250 [04:37<01:41, 1.85s/it] segment: 78%|███████▊ | 196/250 [04:39<01:40, 1.86s/it] segment: 79%|███████▉ | 197/250 [04:40<01:38, 1.86s/it] segment: 79%|███████▉ | 198/250 [04:42<01:36, 1.85s/it] segment: 80%|███████▉ | 199/250 [04:44<01:34, 1.85s/it] segment: 80%|████████ | 200/250 [04:46<01:32, 1.85s/it] segment: 80%|████████ | 201/250 [04:48<01:30, 1.85s/it] segment: 81%|████████ | 202/250 [04:50<01:28, 1.85s/it] segment: 81%|████████ | 203/250 [04:52<01:26, 1.85s/it] segment: 82%|████████▏ | 204/250 [04:53<01:25, 1.85s/it] segment: 82%|████████▏ | 205/250 [04:55<01:23, 1.85s/it] segment: 82%|████████▏ | 206/250 [04:57<01:22, 1.87s/it] segment: 83%|████████▎ | 207/250 [04:59<01:20, 1.86s/it] segment: 83%|████████▎ | 208/250 [05:01<01:18, 1.86s/it] segment: 84%|████████▎ | 209/250 [05:03<01:16, 1.86s/it] segment: 84%|████████▍ | 210/250 [05:05<01:15, 1.88s/it] segment: 84%|████████▍ | 211/250 [05:06<01:12, 1.87s/it] segment: 85%|████████▍ | 212/250 [05:08<01:10, 1.86s/it] segment: 85%|████████▌ | 213/250 [05:10<01:08, 1.85s/it] segment: 86%|████████▌ | 214/250 [05:12<01:06, 1.86s/it] segment: 86%|████████▌ | 215/250 [05:14<01:04, 1.85s/it] segment: 86%|████████▋ | 216/250 [05:16<01:02, 1.85s/it] segment: 87%|████████▋ | 217/250 [05:18<01:01, 1.85s/it] segment: 87%|████████▋ | 218/250 [05:19<00:59, 1.86s/it] segment: 88%|████████▊ | 219/250 [05:21<00:57, 1.85s/it] segment: 88%|████████▊ | 220/250 [05:23<00:55, 1.85s/it] segment: 88%|████████▊ | 221/250 [05:25<00:53, 1.85s/it] segment: 89%|████████▉ | 222/250 [05:27<00:51, 1.85s/it] segment: 89%|████████▉ | 223/250 [05:29<00:49, 1.85s/it] segment: 90%|████████▉ | 224/250 [05:31<00:48, 1.85s/it] segment: 90%|█████████ | 225/250 [05:32<00:46, 1.84s/it] segment: 90%|█████████ | 226/250 [05:34<00:44, 1.84s/it] segment: 91%|█████████ | 227/250 [05:36<00:42, 1.84s/it] segment: 91%|█████████ | 228/250 [05:38<00:40, 1.85s/it] segment: 92%|█████████▏| 229/250 [05:40<00:38, 1.85s/it] segment: 92%|█████████▏| 230/250 [05:42<00:36, 1.84s/it] segment: 92%|█████████▏| 231/250 [05:43<00:34, 1.84s/it] segment: 93%|█████████▎| 232/250 [05:45<00:33, 1.84s/it] segment: 93%|█████████▎| 233/250 [05:47<00:31, 1.85s/it] segment: 94%|█████████▎| 234/250 [05:49<00:29, 1.86s/it] segment: 94%|█████████▍| 235/250 [05:51<00:28, 1.88s/it] segment: 94%|█████████▍| 236/250 [05:53<00:26, 1.87s/it] segment: 95%|█████████▍| 237/250 [05:56<00:30, 2.35s/it] segment: 95%|█████████▌| 238/250 [05:58<00:26, 2.20s/it] segment: 96%|█████████▌| 239/250 [06:00<00:22, 2.09s/it] segment: 96%|█████████▌| 240/250 [06:02<00:20, 2.01s/it] segment: 96%|█████████▋| 241/250 [06:04<00:17, 1.96s/it] segment: 97%|█████████▋| 242/250 [06:05<00:15, 1.92s/it] segment: 97%|█████████▋| 243/250 [06:07<00:13, 1.90s/it] segment: 98%|█████████▊| 244/250 [06:09<00:11, 1.88s/it] segment: 98%|█████████▊| 245/250 [06:11<00:09, 1.87s/it] segment: 98%|█████████▊| 246/250 [06:13<00:07, 1.86s/it] segment: 99%|█████████▉| 247/250 [06:15<00:05, 1.86s/it] \ No newline at end of file
diff --git a/logs/rho_full_width.log b/logs/rho_full_width.log
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/logs/rho_full_width.log
diff --git a/tests/test_core.py b/tests/test_core.py
index 50d3aad..09e58f1 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -234,3 +234,92 @@ def test_caption_encoding_matches_declared_factors():
assert states.shape == (2, 9)
assert states[0][0] > 0 and states[0][2 + 2] > 0 and states[0][2 + 4 + 0] > 0
assert states[1][1] > 0 and states[1][2 + 0] > 0 and states[1][2 + 4 + 1] > 0
+
+
+def test_shared_rank_separates_aligned_from_shuffled():
+ """The shared-direction count must sit at the null when nothing is shared."""
+ import numpy as np
+ from worldalign.shared_rank import spectrum
+
+ size, width = 192, 16
+ generator = np.random.default_rng(0)
+ common = generator.normal(size=(size, 8))
+ shared_field = common @ common.T
+ visual = shared_field + 0.05 * generator.normal(size=(size, size))
+ text = shared_field + 0.05 * generator.normal(size=(size, size))
+ visual, text = (visual + visual.T) / 2, (text + text.T) / 2
+
+ def count(first, second):
+ _, first_vectors = spectrum(first)
+ _, second_vectors = spectrum(second)
+ cosines = np.linalg.svd(
+ first_vectors[:, :width].T @ second_vectors[:, :width], compute_uv=False
+ )
+ return int((np.clip(cosines, 0, 1) > 0.7).sum())
+
+ order = generator.permutation(size)
+ aligned = count(visual, text)
+ shuffled = count(visual, text[np.ix_(order, order)])
+ assert aligned >= 8, aligned
+ assert shuffled <= 2, shuffled
+
+
+def test_degree_decomposition_is_exact_and_orthogonal():
+ """Fitted plus residual must reconstruct the field off the diagonal."""
+ import numpy as np
+ from worldalign.field_anatomy import degree_part
+
+ generator = np.random.default_rng(1)
+ size = 32
+ rows = generator.normal(size=(size, 1))
+ field = rows + rows.T + 0.1 * generator.normal(size=(size, size))
+ np.fill_diagonal(field, 0.0)
+ fitted, residual = degree_part(field)
+ mask = ~np.eye(size, dtype=bool)
+ assert np.allclose(fitted[mask] + residual[mask], field[mask])
+ # a field that is purely additive leaves almost nothing in the residual
+ pure = rows + rows.T
+ np.fill_diagonal(pure, 0.0)
+ _, pure_residual = degree_part(pure)
+ assert np.abs(pure_residual[mask]).max() < 1e-9
+
+
+def test_third_moment_kernel_raises_field_rank():
+ """A degree-3 set kernel must span more directions than a degree-2 one."""
+ import numpy as np
+ from worldalign.natural_pipeline import moment_field_degree
+
+ generator = torch.Generator().manual_seed(0)
+ sets = [torch.randn(4, 8, generator=generator) for _ in range(24)]
+ second = moment_field_degree(sets, 2, 8).double().numpy()
+ third = moment_field_degree(sets, 3, 8).double().numpy()
+
+ def effective_rank(matrix):
+ values = np.abs(np.linalg.eigvalsh((matrix + matrix.T) / 2))
+ weights = values / values.sum()
+ weights = weights[weights > 1e-15]
+ return float(np.exp(-(weights * np.log(weights)).sum()))
+
+ assert effective_rank(third) > effective_rank(second)
+
+
+def test_structured_phrase_encoding_separates_head_from_modifier():
+ """Word order must change the encoding, which averaging cannot express."""
+ import numpy as np
+
+ vectors = {"red": np.array([1.0, 0.0]), "bus": np.array([0.0, 1.0])}
+
+ def structured(tokens):
+ head = vectors[tokens[-1]]
+ rest = (
+ np.mean([vectors[t] for t in tokens[:-1]], axis=0)
+ if len(tokens) > 1
+ else np.zeros(2)
+ )
+ return np.concatenate([head, rest])
+
+ assert not np.allclose(structured(["red", "bus"]), structured(["bus", "red"]))
+ assert np.allclose(
+ np.mean([vectors[t] for t in ["red", "bus"]], axis=0),
+ np.mean([vectors[t] for t in ["bus", "red"]], axis=0),
+ )
diff --git a/worldalign/field_anatomy.py b/worldalign/field_anatomy.py
index 6156018..9e04ec6 100644
--- a/worldalign/field_anatomy.py
+++ b/worldalign/field_anatomy.py
@@ -49,23 +49,38 @@ def correlate(first: np.ndarray, second: np.ndarray) -> float:
return float(np.corrcoef(offdiagonal(first), offdiagonal(second))[0, 1])
-def degree_part(matrix: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
+def degree_part(
+ matrix: np.ndarray, sweeps: int = 200
+) -> tuple[np.ndarray, np.ndarray]:
"""Split into the additive row/column-mean model and its residual.
The two-way additive fit m + r_i + c_j is what a pure busyness effect
produces: every entry explained by how prominent each of its two scenes
is, with nothing said about the pair.
+
+ The fit is swept to convergence rather than taken in one pass. With the
+ diagonal excluded the design is unbalanced, so a single pass of row and
+ column means leaves an O(1/n) piece of a pure degree effect behind --
+ small, but it is exactly the quantity this function exists to remove.
+ Alternating the two sweeps converges to the correct fit.
"""
size = len(matrix)
mask = ~np.eye(size, dtype=bool)
- work = matrix.copy().astype(np.float64)
- np.fill_diagonal(work, np.nan)
+ work = np.where(mask, matrix.astype(np.float64), np.nan)
grand = np.nanmean(work)
- rows = np.nanmean(work, axis=1, keepdims=True) - grand
- cols = np.nanmean(work, axis=0, keepdims=True) - grand
+ rows = np.zeros((size, 1))
+ cols = np.zeros((1, size))
+ for _ in range(sweeps):
+ residual = work - (grand + rows + cols)
+ step = np.nanmean(residual, axis=1, keepdims=True)
+ rows = rows + step
+ residual = work - (grand + rows + cols)
+ step_columns = np.nanmean(residual, axis=0, keepdims=True)
+ cols = cols + step_columns
+ if max(np.abs(step).max(), np.abs(step_columns).max()) < 1e-12:
+ break
fitted = grand + rows + cols
- residual = np.where(mask, work - fitted, 0.0)
- return np.where(mask, fitted, 0.0), residual
+ return np.where(mask, fitted, 0.0), np.where(mask, work - fitted, 0.0)
def spectral_strip(matrix: np.ndarray, components: int) -> np.ndarray:
diff --git a/worldalign/natural_pipeline.py b/worldalign/natural_pipeline.py
index 1a4f63e..1844a6d 100644
--- a/worldalign/natural_pipeline.py
+++ b/worldalign/natural_pipeline.py
@@ -53,6 +53,9 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--views", type=int, default=1)
parser.add_argument("--moment-degree", type=int, default=2, choices=[2, 3])
parser.add_argument("--third-width", type=int, default=12)
+ parser.add_argument(
+ "--phrase-encoding", default="mean", choices=["mean", "structured"]
+ )
parser.add_argument("--seed", type=int, default=0)
parser.add_argument("--output", default="artifacts/vg_5k/natural_pipeline.pt")
return parser.parse_args()
@@ -160,13 +163,39 @@ def main() -> None:
vectors = word_vectors(load_phrases(vg_dir / "text_nodes.jsonl", "region_closed"), args)
+ width = args.word_vectors
+
+ def encode_phrase(phrase: str) -> np.ndarray | None:
+ """One vector per region description.
+
+ Averaging every word of a phrase collapses "red bus" and "bus red"
+ and, worse, mixes the thing named with what is said about it. The
+ synthetic world never had to face this because its captions were
+ encoded into separate factor blocks. Splitting the head from its
+ modifiers recovers part of that structure from English word order
+ alone -- the last token of an English noun phrase is its head -- which
+ is a fact about one language, not a claim about the other modality,
+ so it stays inside Tier 0.
+ """
+ tokens = [t for t in re.findall(r"[a-z]+", phrase.lower()) if t in vectors]
+ if not tokens:
+ return None
+ if args.phrase_encoding == "mean":
+ return np.mean([vectors[t] for t in tokens], axis=0)
+ head = vectors[tokens[-1]]
+ modifiers = (
+ np.mean([vectors[t] for t in tokens[:-1]], axis=0)
+ if len(tokens) > 1
+ else np.zeros(width)
+ )
+ return np.concatenate([head, modifiers])
+
def language_state(node: str) -> np.ndarray | None:
- rows = []
- for phrase in records[node]["region_closed"]:
- hits = [vectors[token] for token in re.findall(r"[a-z]+", phrase.lower())
- if token in vectors]
- if hits:
- rows.append(np.mean(hits, axis=0))
+ rows = [
+ row
+ for row in (encode_phrase(p) for p in records[node]["region_closed"])
+ if row is not None
+ ]
return np.stack(rows) if rows else None
def vision_state(node: str, view: int) -> np.ndarray | None:
@@ -245,6 +274,7 @@ def main() -> None:
"kept_directions": keep,
"weight_power": args.weight_power,
"moment_degree": args.moment_degree,
+ "phrase_encoding": args.phrase_encoding,
"field_correlation_at_truth": correlation,
"recovery_threshold": 0.9,
}
diff --git a/worldalign/rho_at_full_width.py b/worldalign/rho_at_full_width.py
new file mode 100644
index 0000000..c30c5cc
--- /dev/null
+++ b/worldalign/rho_at_full_width.py
@@ -0,0 +1,137 @@
+"""The other axis: recovery against correlation with the shared width intact.
+
+The truncation ladder varied the width at nearly fixed correlation. This
+varies the correlation at full width, by adding independent noise to both
+fields, and the two together give the picture the single statistic could not.
+
+The control matters because it guards against overcorrecting. Showing that
+width is a separate necessary condition does not show that correlation stopped
+mattering, and natural data is short on both -- correlation 0.725 against 0.93,
+width 15 against 26. If full-width fields still recover at a correlation near
+0.72, then width is what natural data lacks; if they do not, both gaps are real
+and the correlation remains a target rather than a discredited one.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+
+import numpy as np
+import torch
+
+from .common import write_json
+from .shared_rank import spectrum
+from .spectral_match import grampa
+from .synth_fast_gate import ClosedFormEnergy, all_swaps, steepest_descent
+from .synth_triangle_gate import standardized
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--fields", default="artifacts/synth_v1/fields_tier0_ws_256.pt")
+ parser.add_argument("--trials", type=int, default=3)
+ parser.add_argument("--device", default="cuda:3")
+ parser.add_argument(
+ "--noise", type=float, nargs="+",
+ default=[0.0, 0.2, 0.35, 0.5, 0.7, 0.9, 1.2],
+ )
+ parser.add_argument("--output", default="artifacts/synth_v1/rho_at_full_width.json")
+ return parser.parse_args()
+
+
+def offdiagonal(matrix: np.ndarray) -> np.ndarray:
+ return matrix[~np.eye(len(matrix), dtype=bool)]
+
+
+def normalise(matrix: np.ndarray) -> np.ndarray:
+ values = offdiagonal(matrix)
+ out = (matrix - values.mean()) / values.std()
+ np.fill_diagonal(out, 0.0)
+ return out
+
+
+def symmetric_noise(size: int, generator) -> np.ndarray:
+ raw = generator.normal(size=(size, size))
+ out = (raw + raw.T) / np.sqrt(2.0)
+ np.fill_diagonal(out, 0.0)
+ return out
+
+
+def shared_count(visual: np.ndarray, text: np.ndarray, width: int = 32) -> int:
+ _, visual_vectors = spectrum(visual)
+ _, text_vectors = spectrum(text)
+ cosines = np.clip(
+ np.linalg.svd(
+ visual_vectors[:, :width].T @ text_vectors[:, :width], compute_uv=False
+ ), 0.0, 1.0,
+ )
+ return int((cosines > 0.7).sum())
+
+
+def main() -> None:
+ args = parse_args()
+ state = torch.load(args.fields, map_location="cpu", weights_only=False)
+ visual_clean = normalise(state["visual_field"].double().numpy())
+ text_clean = normalise(state["text_field"].double().numpy())
+ size = len(visual_clean)
+ device = torch.device(args.device)
+ swaps = all_swaps(size, device)
+
+ rows = []
+ for level in args.noise:
+ correlations, accuracies, widths = [], [], []
+ for trial in range(args.trials):
+ generator = np.random.default_rng(1000 + trial)
+ visual = normalise(
+ visual_clean + level * symmetric_noise(size, generator)
+ )
+ text = normalise(text_clean + level * symmetric_noise(size, generator))
+ mask = ~np.eye(size, dtype=bool)
+ correlations.append(float(np.corrcoef(visual[mask], text[mask])[0, 1]))
+ widths.append(shared_count(visual, text))
+
+ hidden = generator.permutation(size)
+ shuffled = text[np.ix_(hidden, hidden)]
+ columns = grampa(visual, shuffled, 1.0)
+ energy = ClosedFormEnergy(
+ standardized(torch.from_numpy(shuffled).to(device)).float(),
+ standardized(torch.from_numpy(visual).to(device)).float(),
+ 1.0, 1.0, 256,
+ )
+ final, _ = steepest_descent(
+ energy, torch.from_numpy(columns.copy()).to(device), swaps, 4000
+ )
+ accuracies.append(
+ float((hidden[final.cpu().numpy()] == np.arange(size)).mean())
+ )
+ row = {
+ "noise": level,
+ "correlation": float(np.mean(correlations)),
+ "shared_directions": float(np.mean(widths)),
+ "recovery": float(np.mean(accuracies)),
+ }
+ rows.append(row)
+ print(
+ f"noise={level:<5} rho={row['correlation']:.3f} "
+ f"shared={row['shared_directions']:5.1f} "
+ f"recovery={row['recovery']:.3f}",
+ flush=True,
+ )
+
+ summary = {
+ "protocol": (
+ "Independent symmetric noise added to both fields, so the shared "
+ "spectrum stays wide while the correlation falls. Hidden pairing "
+ "generated per trial and read only for scoring."
+ ),
+ "size": size,
+ "chance": 1.0 / size,
+ "rows": rows,
+ }
+ print(json.dumps({"chance": 1.0 / size}))
+ write_json(args.output, summary)
+
+
+if __name__ == "__main__":
+ main()