Skip to content

Commit fe95b18

Browse files
authored
test(coverage): boundary + dispatch + topology tests for measurement modules (#18)
Extends the #18 coverage (PR #24 covered multi_hop/ontology_coherence/_graph_mapping; this adds the topology module + boundary/dispatch cases the original pass skipped). 72 tests across 4 files: multi_hop verdict boundaries, ontology claim dispatch, ontology topology metrics, adapter-contract conformance. Additive-only (0 deletions), all green. References upstream #18.
1 parent a7e6050 commit fe95b18

4 files changed

Lines changed: 664 additions & 0 deletions

File tree

tests/test_adapter_contract.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import pytest
2424

2525
from sme.adapters.base import (
26+
ContradictionPair,
2627
Edge,
2728
Entity,
2829
HarnessDescriptor,
@@ -248,3 +249,65 @@ def test_close_is_idempotent(adapter: SMEAdapter) -> None:
248249
being called more than once."""
249250
adapter.close()
250251
adapter.close()
252+
253+
254+
# --- Measurement-feeding optional defaults ----------------------------
255+
#
256+
# Three optional methods have *meaningful* defaults that feed category
257+
# scores: an adapter that doesn't override them must inherit behavior
258+
# that produces an honest zero, never a fabricated signal. These are the
259+
# defaults that, if quietly wrong, would inflate Cat 8 introspection or
260+
# Cat 3 contradiction detection for every non-overriding system.
261+
262+
263+
def test_get_introspection_report_default_is_none(adapter: SMEAdapter) -> None:
264+
"""The ABC default returns ``None`` → Cat 8 scores introspection 0.0
265+
("can't audit its own ontology"). An adapter may override with a dict,
266+
but it must never be anything other than ``None`` or a dict — a
267+
non-dict truthy value would be silently credited capability dimensions.
268+
"""
269+
report = adapter.get_introspection_report()
270+
assert report is None or isinstance(report, dict)
271+
272+
273+
def test_get_contradiction_pairs_returns_typed_list(adapter: SMEAdapter) -> None:
274+
"""The default derives ContradictionPair[] from ``contradicts`` edges
275+
in the snapshot. Adapters with no such edges return ``[]`` (Cat 3
276+
score 0); the contract is that the return is always a list of
277+
ContradictionPair, never raises, and is internally consistent.
278+
"""
279+
pairs = adapter.get_contradiction_pairs()
280+
assert isinstance(pairs, list)
281+
assert all(isinstance(p, ContradictionPair) for p in pairs)
282+
for p in pairs:
283+
# source ids must be present and distinct (a contradiction is
284+
# between two different claims)
285+
assert isinstance(p.source_a, str) and p.source_a
286+
assert isinstance(p.source_b, str) and p.source_b
287+
288+
289+
def test_default_contradiction_pairs_derive_from_contradicts_edges() -> None:
290+
"""A snapshot carrying a ``contradicts`` edge must surface exactly one
291+
ContradictionPair through the *base-class default* — this is the Cat 3
292+
plumbing every adapter inherits for free, so it must fire without any
293+
adapter override."""
294+
295+
class _ContradictingAdapter(MockAdapter):
296+
def get_graph_snapshot(self):
297+
entities = [
298+
Entity(id="x", name="X earns 50k", entity_type="claim"),
299+
Entity(id="y", name="X earns 80k", entity_type="claim"),
300+
Entity(id="z", name="unrelated", entity_type="claim"),
301+
]
302+
edges = [
303+
Edge(source_id="x", target_id="y", edge_type="CONTRADICTS"),
304+
Edge(source_id="x", target_id="z", edge_type="mentions"),
305+
]
306+
return entities, edges
307+
308+
pairs = _ContradictingAdapter().get_contradiction_pairs()
309+
assert len(pairs) == 1
310+
pair = pairs[0]
311+
# node names resolve into the pair via the default node_names map
312+
assert {pair.source_a, pair.source_b} == {"x", "y"}
313+
assert pair.claim_b == "X earns 80k"
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"""Boundary conditions for the Cat 2c multi-hop verdict.
2+
3+
``test_multi_hop.py`` already covers the clear-cut verdict cases (a solid
4+
win, a solid loss, a clean neutral tax). What it does *not* pin are the
5+
threshold boundaries — and the verdict uses strict inequalities at three
6+
of them, so a value sitting exactly on the line is silently classified
7+
the *other* way from what a casual reader expects:
8+
9+
* "beats A" requires recall_delta_pp **> 5**, so a +5.0pp delta does
10+
NOT count as a win.
11+
* "loses to A" requires recall_delta_pp **< -5**, so a -5.0pp delta
12+
does NOT count as a loss.
13+
* "ratio grows" requires last **> first * 1.2**, so a ratio that lands
14+
exactly on first*1.2 is "uniform scale", not "earns complexity".
15+
16+
These off-by-epsilon boundaries are precisely where a verdict would flip
17+
under a tiny measurement perturbation, so they are worth nailing down.
18+
The expected verdicts were verified against the live ``_verdict`` before
19+
being pinned here.
20+
"""
21+
from __future__ import annotations
22+
23+
from sme.categories.multi_hop import Cat2cReport, _verdict
24+
25+
26+
def _report(deltas_a, ratios, deltas_c=None) -> Cat2cReport:
27+
r = Cat2cReport()
28+
r.delta_B_minus_A = deltas_a
29+
r.ratio_B_over_A = ratios
30+
if deltas_c is not None:
31+
r.delta_B_minus_C = deltas_c
32+
return r
33+
34+
35+
# ── ±5pp win/loss boundaries (strict inequality) ──────────────────
36+
37+
38+
def test_exactly_plus_5pp_is_not_a_win():
39+
"""+5.0pp at every depth is on the boundary; the strict ``> 5`` test
40+
means it doesn't count as beating flat → neutral tax, not a win."""
41+
r = _report(
42+
{1: {"recall_delta_pp": 5.0, "tokens_delta": 0},
43+
2: {"recall_delta_pp": 5.0, "tokens_delta": 0}},
44+
{1: 1.05, 2: 1.05},
45+
)
46+
assert _verdict(r)[0] == "structure is a neutral tax"
47+
48+
49+
def test_exactly_minus_5pp_is_not_a_loss():
50+
"""-5.0pp is on the boundary; the strict ``< -5`` test means it
51+
doesn't count as losing → neutral tax, not harmful."""
52+
r = _report(
53+
{1: {"recall_delta_pp": -5.0, "tokens_delta": 0}},
54+
{1: 0.95},
55+
)
56+
assert _verdict(r)[0] == "structure is a neutral tax"
57+
58+
59+
def test_just_over_5pp_is_a_win():
60+
"""5.01pp clears the boundary → counts as beating flat."""
61+
r = _report(
62+
{1: {"recall_delta_pp": 5.01, "tokens_delta": 0},
63+
2: {"recall_delta_pp": 5.01, "tokens_delta": 0}},
64+
{1: 1.05, 2: 1.05}, # flat ratio → uniform scale, but it IS a win
65+
)
66+
assert _verdict(r)[0] == "structure adds value at uniform scale"
67+
68+
69+
def test_just_under_minus_5pp_is_a_loss():
70+
"""-5.01pp clears the boundary the other way → counts as a loss."""
71+
r = _report(
72+
{1: {"recall_delta_pp": -5.01, "tokens_delta": 0}},
73+
{1: 0.9},
74+
)
75+
assert _verdict(r)[0] == "structure harmful at multi-hop"
76+
77+
78+
# ── ratio-grows boundary (last > first * 1.2) ─────────────────────
79+
80+
81+
def test_ratio_exactly_at_1_2x_does_not_grow():
82+
"""last == first * 1.2 is on the boundary; the strict ``>`` means the
83+
ratio is NOT considered to grow → uniform scale, not earns-complexity."""
84+
r = _report(
85+
{1: {"recall_delta_pp": 10.0, "tokens_delta": 0},
86+
2: {"recall_delta_pp": 10.0, "tokens_delta": 0}},
87+
{1: 1.0, 2: 1.2}, # 1.2 == 1.0 * 1.2 exactly
88+
)
89+
assert _verdict(r)[0] == "structure adds value at uniform scale"
90+
91+
92+
def test_ratio_just_over_1_2x_earns_complexity():
93+
"""last just past first * 1.2 → ratio grows → earns complexity."""
94+
r = _report(
95+
{1: {"recall_delta_pp": 10.0, "tokens_delta": 0},
96+
2: {"recall_delta_pp": 40.0, "tokens_delta": 0}},
97+
{1: 1.0, 2: 1.21},
98+
)
99+
assert _verdict(r)[0] == "structure earns complexity (scales with depth)"
100+
101+
102+
# ── single-ratio guard (ratio_grows needs >= 2 points) ────────────
103+
104+
105+
def test_single_hop_ratio_cannot_grow():
106+
"""With only one hop bucket the ratio has nothing to grow against, so
107+
a win is reported as uniform scale, never earns-complexity."""
108+
r = _report(
109+
{1: {"recall_delta_pp": 50.0, "tokens_delta": 0}},
110+
{1: 5.0}, # huge but single-point → ratio_grows stays False
111+
)
112+
assert _verdict(r)[0] == "structure adds value at uniform scale"
113+
114+
115+
# ── infinite-ratio entries are skipped in the grows check ─────────
116+
117+
118+
def test_infinite_ratio_skipped_in_grows_check():
119+
"""An inf ratio (A had zero recall at a depth) is excluded from the
120+
grows comparison; the remaining finite points decide it. Here only
121+
one finite ratio survives, so it can't grow → uniform scale."""
122+
r = _report(
123+
{1: {"recall_delta_pp": 60.0, "tokens_delta": 0},
124+
2: {"recall_delta_pp": 70.0, "tokens_delta": 0}},
125+
{1: float("inf"), 2: 3.0},
126+
)
127+
assert _verdict(r)[0] == "structure adds value at uniform scale"
128+
129+
130+
# ── C-absent narration when A is present ──────────────────────────
131+
132+
133+
def test_no_c_overlap_still_notes_missing_isolation():
134+
"""A populated but no B-C overlap → the verdict still narrates that
135+
the structural contribution could not be isolated, so the reader
136+
isn't misled into thinking C was tested."""
137+
r = _report(
138+
{1: {"recall_delta_pp": 10.0, "tokens_delta": 0}},
139+
{1: 1.3},
140+
)
141+
verdict, details = _verdict(r)
142+
assert verdict == "structure adds value at uniform scale"
143+
assert any("no Condition C" in d for d in details)

0 commit comments

Comments
 (0)