File size: 18,637 Bytes
ae73c7f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
"""
Tests for embed_hierarchy.py, eval_hierarchy.py, data_pbdb_taxonomy.py,
and synthetic_tree.py.
"""
from __future__ import annotations
import os
import sys

import pytest
import torch

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from src.embed_hierarchy import HierarchyEmbedding, ranking_loss, negative_sample, train_hierarchy_embedding
from src.eval_hierarchy import mean_rank_and_map, compare_geometries, radius_diagnostics
from src.data_pbdb_taxonomy import build_edge_list, hash_edge_list, TaxonomyEdgeDataset, get_pbdb_taxonomy_dataset
from src.synthetic_tree import generate_synthetic_tree, get_synthetic_tree_dataset
from src.provenance import SchemaValidationError


# --------------------------------------------------------------------- #
# build_edge_list — pure function, no network
# --------------------------------------------------------------------- #

def test_build_edge_list_extracts_real_chain():
    records = [
        {"phylum": "Chordata", "class": "Mammalia", "order": "Carnivora",
         "family": "Canidae", "genus": "Canis"},
    ]
    edges, attrs = build_edge_list(records)
    assert ("Mammalia", "Chordata") in edges
    assert ("Carnivora", "Mammalia") in edges
    assert ("Canidae", "Carnivora") in edges
    assert ("Canis", "Canidae") in edges
    assert attrs["Canis"]["rank"] == "genus"
    assert attrs["Chordata"]["rank"] == "phylum"


def test_build_edge_list_never_fabricates_missing_link():
    """A record missing 'order' must NOT produce a fabricated edge
    (family, class) skipping over the missing rank -- that would invent
    a taxonomic claim the data never made."""
    records = [
        {"phylum": "Chordata", "class": "Mammalia", "order": None,
         "family": "Canidae", "genus": "Canis"},
        {"phylum": "Chordata", "class": "Mammalia", "order": "Carnivora",
         "family": None, "genus": None},
    ]
    edges, _ = build_edge_list(records)
    assert ("Canidae", "Mammalia") not in edges  # would skip the missing 'order'
    assert ("Canidae", None) not in edges
    assert ("Mammalia", "Chordata") in edges  # this link IS fully present
    assert ("Carnivora", "Mammalia") in edges


def test_build_edge_list_raises_on_insufficient_data():
    records = [{"phylum": "Chordata", "class": None, "order": None, "family": None, "genus": None}]
    with pytest.raises(SchemaValidationError) as exc_info:
        build_edge_list(records)
    assert exc_info.value.outcome_code == "INSUFFICIENT_TAXONOMY_EDGES"


def test_build_edge_list_respects_min_rank():
    records = [
        {"phylum": "Chordata", "class": "Mammalia", "order": "Carnivora",
         "family": "Canidae", "genus": "Canis"},
        {"phylum": "Chordata", "class": "Mammalia", "order": "Rodentia",
         "family": "Muridae", "genus": "Mus"},
    ]
    edges, _ = build_edge_list(records, min_rank="family")
    assert ("Canis", "Canidae") not in edges  # genus excluded
    assert ("Canidae", "Carnivora") in edges  # family still included


def test_hash_edge_list_order_independent():
    e1 = [("a", "b"), ("c", "d")]
    e2 = [("c", "d"), ("a", "b")]
    assert hash_edge_list(e1) == hash_edge_list(e2)


def test_hash_edge_list_differs_for_different_edges():
    assert hash_edge_list([("a", "b")]) != hash_edge_list([("a", "c")])


# --------------------------------------------------------------------- #
# TaxonomyEdgeDataset
# --------------------------------------------------------------------- #

def test_taxonomy_edge_dataset_node_indices_stable():
    edges = [("Canis", "Canidae"), ("Canidae", "Carnivora")]
    attrs = {"Canis": {}, "Canidae": {}, "Carnivora": {}}
    ds = TaxonomyEdgeDataset(edges, attrs)
    assert ds.num_nodes == 3
    assert len(ds) == 2
    item = ds[0]
    assert isinstance(item["child_idx"], int)
    assert isinstance(item["parent_idx"], int)


def test_get_pbdb_taxonomy_dataset_works_without_coordinates(tmp_path):
    import os
    from unittest.mock import patch, MagicMock

    classext_only_records = [
        {"occurrence_no": 1001, "phylum": "Chordata", "class": "Mammalia",
         "order": "Carnivora", "family": "Canidae", "genus": "Canis"},
        {"occurrence_no": 1002, "phylum": "Chordata", "class": "Mammalia",
         "order": "Rodentia", "family": "Muridae", "genus": "Mus"},
    ]

    def fake_get(url, params=None, headers=None, timeout=None):
        resp = MagicMock()
        resp.status_code = 200
        resp.json.return_value = {"records": classext_only_records}
        return resp

    with patch("requests.get", side_effect=fake_get):
        dataset, meta, provenance = get_pbdb_taxonomy_dataset(
            base_names=["TestTaxon"],
            cache_dir=str(tmp_path / "cache"),
        )
    assert provenance == "REAL_PBDB_TAXONOMY"
    assert dataset.num_nodes > 0


def test_get_pbdb_taxonomy_dataset_tolerates_genus_missing_from_first_record(tmp_path):
    from unittest.mock import patch, MagicMock

    records_genus_sparse = [
        {"occurrence_no": 1, "phylum": "Chordata", "class": "Mammalia",
         "order": "Carnivora", "family": "Canidae"},  # no genus on this one
        {"occurrence_no": 2, "phylum": "Chordata", "class": "Mammalia",
         "order": "Carnivora", "family": "Canidae", "genus": "Canis"},
    ]

    def fake_get(url, params=None, headers=None, timeout=None):
        resp = MagicMock()
        resp.status_code = 200
        resp.json.return_value = {"records": records_genus_sparse}
        return resp

    with patch("requests.get", side_effect=fake_get):
        dataset, meta, provenance = get_pbdb_taxonomy_dataset(
            base_names=["TestTaxon"],
            cache_dir=str(tmp_path / "cache2"),
        )
    assert provenance == "REAL_PBDB_TAXONOMY"
    assert dataset.num_nodes > 0


# --------------------------------------------------------------------- #
# synthetic_tree
# --------------------------------------------------------------------- #

def test_synthetic_tree_is_connected_and_acyclic():
    edges, attrs = generate_synthetic_tree(n_nodes=50, seed=1)
    assert len(edges) == 49  # exactly n-1 edges for a tree
    assert len(attrs) == 50
    # every non-root node's parent must have strictly smaller depth
    for child, parent in edges:
        assert attrs[child]["depth"] == attrs[parent]["depth"] + 1


def test_synthetic_tree_deterministic_given_seed():
    e1, _ = generate_synthetic_tree(n_nodes=30, seed=42)
    e2, _ = generate_synthetic_tree(n_nodes=30, seed=42)
    assert e1 == e2


def test_balanced_tree_shape_and_depth():
    from src.synthetic_tree import generate_balanced_tree
    edges, attrs = generate_balanced_tree(branching_factor=3, depth=4)
    # geometric series: 1 + 3 + 9 + 27 + 81 = 121 nodes total
    assert len(attrs) == 1 + 3 + 9 + 27 + 81
    assert len(edges) == len(attrs) - 1  # still a tree
    assert max(a["depth"] for a in attrs.values()) == 4
    for child, parent in edges:
        assert attrs[child]["depth"] == attrs[parent]["depth"] + 1


def test_synthetic_tree_dataset_provenance():
    ds, meta, provenance = get_synthetic_tree_dataset(n_nodes=20, seed=0)
    assert provenance == "SYNTHETIC_TREE"
    assert ds.num_nodes == 20


# --------------------------------------------------------------------- #
# HierarchyEmbedding — both geometries
# --------------------------------------------------------------------- #

def test_hierarchy_embedding_rejects_invalid_geometry():
    with pytest.raises(ValueError):
        HierarchyEmbedding(num_nodes=5, dim=4, geometry="spherical")


def test_hierarchy_embedding_points_shape_both_geometries():
    for geom in ("poincare", "euclidean"):
        model = HierarchyEmbedding(num_nodes=10, dim=4, geometry=geom)
        idx = torch.tensor([0, 1, 2])
        pts = model.points(idx)
        assert pts.shape == (3, 4)


def test_hierarchy_embedding_poincare_points_stay_in_ball():
    model = HierarchyEmbedding(num_nodes=20, dim=4, geometry="poincare", init_scale=1e-3)
    idx = torch.arange(20)
    pts = model.points(idx)
    assert (pts.norm(dim=-1) < 1.0).all()


def test_hierarchy_embedding_distance_symmetric_both_geometries():
    for geom in ("poincare", "euclidean"):
        model = HierarchyEmbedding(num_nodes=10, dim=4, geometry=geom)
        a = model.points(torch.tensor([0, 1]))
        b = model.points(torch.tensor([2, 3]))
        d_ab = model.distance(a, b)
        d_ba = model.distance(b, a)
        assert torch.allclose(d_ab, d_ba, atol=1e-5)


def test_hierarchy_embedding_optimizer_actually_updates_params():
    for geom in ("poincare", "euclidean"):
        model = HierarchyEmbedding(num_nodes=10, dim=4, geometry=geom)
        before = model.emb.detach().clone()
        opt = model.make_optimizer(lr=0.1)
        loss = model.points(torch.arange(10)).pow(2).sum()
        opt.zero_grad()
        loss.backward()
        opt.step()
        after = model.emb.detach().clone()
        assert not torch.allclose(before, after), f"{geom} params did not move"


# --------------------------------------------------------------------- #
# negative_sample — must never collide with the true parent
# --------------------------------------------------------------------- #

def test_negative_sample_never_equals_true_parent():
    child_idx = torch.tensor([0, 1, 2, 3, 4])
    true_parent = torch.tensor([5, 5, 5, 5, 5])  # deliberately narrow node pool
    neg = negative_sample(child_idx, true_parent, num_nodes=6, k=20)  # k=20 forces many collisions
    assert not (neg == true_parent.unsqueeze(1)).any()


def test_negative_sample_never_equals_child_itself():
    child_idx = torch.tensor([0, 1, 2, 3, 4])
    true_parent = torch.tensor([5, 5, 5, 5, 5])
    neg = negative_sample(child_idx, true_parent, num_nodes=6, k=20)
    assert not (neg == child_idx.unsqueeze(1)).any()


# --------------------------------------------------------------------- #
# ranking_loss — verify it actually penalizes the wrong thing on a toy case
# --------------------------------------------------------------------- #

def test_ranking_loss_is_zero_when_parent_much_closer_than_negative():
    model = HierarchyEmbedding(num_nodes=3, dim=2, geometry="euclidean")
    with torch.no_grad():
        model.emb[0] = torch.tensor([0.0, 0.0])   # child
        model.emb[1] = torch.tensor([0.01, 0.0])  # parent: very close
        model.emb[2] = torch.tensor([10.0, 10.0]) # negative: very far
    loss = ranking_loss(
        model, torch.tensor([0]), torch.tensor([1]), torch.tensor([[2]]), margin=1.0,
    )
    assert loss.item() == pytest.approx(0.0, abs=1e-4)


def test_ranking_loss_is_positive_when_negative_closer_than_parent():
    model = HierarchyEmbedding(num_nodes=3, dim=2, geometry="euclidean")
    with torch.no_grad():
        model.emb[0] = torch.tensor([0.0, 0.0])   # child
        model.emb[1] = torch.tensor([10.0, 10.0]) # parent: far
        model.emb[2] = torch.tensor([0.01, 0.0])  # negative: very close (wrong!)
    loss = ranking_loss(
        model, torch.tensor([0]), torch.tensor([1]), torch.tensor([[2]]), margin=1.0,
    )
    assert loss.item() > 0.5


# --------------------------------------------------------------------- #
# mean_rank_and_map — hand-computable toy case
# --------------------------------------------------------------------- #

def test_mean_rank_and_map_perfect_recovery_gives_rank_one():
    model = HierarchyEmbedding(num_nodes=4, dim=2, geometry="euclidean")
    with torch.no_grad():
        model.emb[0] = torch.tensor([0.0, 0.0])   # child
        model.emb[1] = torch.tensor([0.1, 0.0])   # true parent: closest
        model.emb[2] = torch.tensor([5.0, 0.0])
        model.emb[3] = torch.tensor([9.0, 0.0])
    metrics = mean_rank_and_map(model, edges=[(0, 1)])
    assert metrics["mean_rank"] == pytest.approx(1.0)
    assert metrics["mrr"] == pytest.approx(1.0)
    assert metrics["hits@1"] == pytest.approx(1.0)


def test_mean_rank_and_map_worst_case_gives_high_rank():
    model = HierarchyEmbedding(num_nodes=4, dim=2, geometry="euclidean")
    with torch.no_grad():
        model.emb[0] = torch.tensor([0.0, 0.0])   # child
        model.emb[1] = torch.tensor([9.0, 0.0])   # true parent: farthest
        model.emb[2] = torch.tensor([0.1, 0.0])
        model.emb[3] = torch.tensor([0.2, 0.0])
    metrics = mean_rank_and_map(model, edges=[(0, 1)])
    assert metrics["mean_rank"] == pytest.approx(3.0)  # 2 nodes strictly closer
    assert metrics["hits@1"] == 0.0


def test_compare_geometries_returns_deltas():
    p_model = HierarchyEmbedding(num_nodes=5, dim=2, geometry="poincare")
    e_model = HierarchyEmbedding(num_nodes=5, dim=2, geometry="euclidean")
    result = compare_geometries(p_model, e_model, test_edges=[(0, 1), (1, 2)])
    assert "delta_mrr" in result
    assert "poincare" in result and "euclidean" in result


def test_radius_diagnostics_shape():
    model = HierarchyEmbedding(num_nodes=5, dim=3, geometry="poincare")
    depths = {i: i for i in range(5)}
    result = radius_diagnostics(model, depths)
    assert "radius_depth_correlation" in result
    assert -1.0 <= result["radius_depth_correlation"] <= 1.0 + 1e-6


# --------------------------------------------------------------------- #
# End-to-end training, on the synthetic tree (real run, small + fast)
# --------------------------------------------------------------------- #

def test_train_hierarchy_embedding_loss_decreases_both_geometries():
    ds, meta, provenance = get_synthetic_tree_dataset(n_nodes=40, seed=7)
    for geom in ("poincare", "euclidean"):
        model, metrics = train_hierarchy_embedding(
            ds, geometry=geom, dim=4, epochs=15, batch_size=16,
            lr=0.05, neg_samples=5, seed=0,
        )
        history = metrics["loss_history"]
        assert history[-1] < history[0], f"{geom}: loss did not decrease ({history[0]} -> {history[-1]})"


def test_softmax_ranking_loss_penalizes_correctly():
    from src.embed_hierarchy import softmax_ranking_loss
    model = HierarchyEmbedding(num_nodes=3, dim=2, geometry="euclidean")
    with torch.no_grad():
        model.emb[0] = torch.tensor([0.0, 0.0])
        model.emb[1] = torch.tensor([0.01, 0.0])   # parent: very close
        model.emb[2] = torch.tensor([10.0, 10.0])  # negative: very far
    loss_good = softmax_ranking_loss(
        model, torch.tensor([0]), torch.tensor([1]), torch.tensor([[2]]),
    )
    with torch.no_grad():
        model.emb[1] = torch.tensor([10.0, 10.0])  # parent: now far
        model.emb[2] = torch.tensor([0.01, 0.0])   # negative: now close (wrong!)
    loss_bad = softmax_ranking_loss(
        model, torch.tensor([0]), torch.tensor([1]), torch.tensor([[2]]),
    )
    assert loss_good.item() < loss_bad.item()
    assert loss_good.item() < 0.01


def test_train_hierarchy_embedding_softmax_loss_type_runs_and_decreases():
    ds, meta, provenance = get_synthetic_tree_dataset(n_nodes=40, seed=7)
    model, metrics = train_hierarchy_embedding(
        ds, geometry="poincare", dim=4, epochs=15, batch_size=16,
        lr=0.05, neg_samples=5, seed=0, loss_type="softmax",
    )
    history = metrics["loss_history"]
    assert history[-1] < history[0]


def test_train_hierarchy_embedding_rejects_invalid_loss_type():
    ds, meta, provenance = get_synthetic_tree_dataset(n_nodes=20, seed=1)
    with pytest.raises(ValueError):
        train_hierarchy_embedding(ds, dim=2, epochs=1, loss_type="not_a_real_loss")


def test_train_hierarchy_embedding_burn_in_restores_lr_after_burn_in():
    ds, meta, provenance = get_synthetic_tree_dataset(n_nodes=20, seed=1)
    model, metrics = train_hierarchy_embedding(
        ds, geometry="euclidean", dim=4, epochs=10, batch_size=8,
        lr=0.05, neg_samples=5, seed=0, burn_in_epochs=3, burn_in_lr_mult=0.1,
    )
    assert all(torch.isfinite(torch.tensor(x)) for x in metrics["loss_history"])
    assert len(metrics["loss_history"]) == 10


def test_learnable_curvature_does_not_diverge_on_the_scenario_that_broke_it():
    ds, meta, provenance = get_synthetic_tree_dataset(
        n_nodes=200, seed=0, tree_type="balanced", branching_factor=3, depth=5,
    )
    c_min, c_max = 0.1, 3.0
    model, metrics = train_hierarchy_embedding(
        ds, geometry="poincare", dim=2, epochs=60, batch_size=64,
        lr=0.02, neg_samples=10, seed=0, loss_type="softmax",
        burn_in_epochs=10, burn_in_lr_mult=0.1, learnable_c=True,
        curvature_lr_mult=0.1, c_min=c_min, c_max=c_max,
    )

    assert all(torch.isfinite(torch.tensor(x)) for x in metrics["loss_history"]), \
        "loss went non-finite at some epoch"

    for epoch_i, c_val in enumerate(metrics["c_history"]):
        assert c_min - 1e-6 <= c_val <= c_max + 1e-6, \
            f"epoch {epoch_i}: curvature {c_val} escaped [{c_min}, {c_max}]"

    for epoch_i, (c_val, max_norm) in enumerate(zip(metrics["c_history"], metrics["max_norm_history"])):
        current_radius = 0.99 / (c_val ** 0.5)
        assert max_norm < current_radius, \
            (f"epoch {epoch_i}: max point norm {max_norm:.4f} outside the CURRENT "
             f"ball radius {current_radius:.4f} for c={c_val:.4f} -- points are "
             f"invalid under the curvature they're actually being evaluated at")


def test_clip_to_ball_keeps_points_strictly_inside_current_curvature_radius():
    model = HierarchyEmbedding(num_nodes=10, dim=4, geometry="poincare", init_scale=1e-3)
    with torch.no_grad():
        model.emb.data.mul_(10000.0)  # deliberately push far outside valid range
    model.clip_to_ball(margin=0.95)
    norms = model.emb.detach().norm(dim=-1)
    current_radius = 1.0 / model.manifold.c.clamp_min(1e-8).sqrt()
    assert (norms <= current_radius + 1e-6).all()


def test_clip_to_ball_is_noop_for_euclidean():
    model = HierarchyEmbedding(num_nodes=10, dim=4, geometry="euclidean")
    before = model.emb.detach().clone()
    model.clip_to_ball()
    after = model.emb.detach().clone()
    assert torch.allclose(before, after)


def test_clamp_curvature_actually_constrains_the_leaf_not_a_derived_view():
    model = HierarchyEmbedding(num_nodes=5, dim=2, geometry="poincare", learnable_c=True, c=1.0)
    with torch.no_grad():
        model.manifold.isp_c.fill_(100.0)  # would push c to a huge value
    model.clamp_curvature(c_min=0.1, c_max=3.0)
    assert model.manifold.c.item() <= 3.0 + 1e-4

    with torch.no_grad():
        model.manifold.isp_c.fill_(-100.0)  # would push c toward 0
    model.clamp_curvature(c_min=0.1, c_max=3.0)
    assert model.manifold.c.item() >= 0.1 - 1e-4


if __name__ == "__main__":
    sys.exit(pytest.main([__file__, "-v"]))