File size: 8,678 Bytes
d58cfe6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Trace geometry, segmentation and persistence, without a model."""
import numpy as np
import pytest

import ulens
from ulens import runtime
from ulens.runtime import Trace, _segment

OPEN, CLOSE = 900, 901


def make_trace(n_blocks=5, T=20, k=4, prompt_len=6, eot=14, seed=0, **kw):
    rng = np.random.default_rng(seed)
    opts: dict = {
        "model": "qwen3.5-4b", "prompt": "p",
        "tokens": list(range(T)), "token_strs": [f"t{i}" for i in range(T)],
        "prompt_len": prompt_len, "eot": eot,
        "entropy": np.concatenate(
            [[np.nan], rng.uniform(0.1, 2.0, size=T - 1)]).astype(np.float32),
        "field": rng.normal(size=(n_blocks, T, k)).astype(np.float32),
        "blocks": list(range(10, 10 + n_blocks)),
        "names": ulens.terms.categories()[:k],
        "readout_block": 12, "generated": "g", "truncated": False,
        "config": {"seed": 1}}
    opts.update(kw)
    return Trace(**opts)


# ------------------------------------------------------------------ segmentation

def test_block_already_open_at_the_prompt_end():
    """The usual case: the chat template opens the channel, the generation closes it."""
    ids = [1, 2, OPEN] + [5, 6, 7, CLOSE, 8, 9]
    eot, trunc = _segment(ids, 3, OPEN, CLOSE)
    assert eot == 6 and ids[eot] == CLOSE and not trunc


def test_block_opened_inside_the_generation():
    ids = [1, 2, 3] + [OPEN, 5, 6, CLOSE, 8]
    eot, trunc = _segment(ids, 3, OPEN, CLOSE)
    assert eot == 6 and ids[eot] == CLOSE and not trunc


def test_opened_and_never_closed_is_all_thinking():
    """What a token budget produces. It must not read as a zero-token answer."""
    ids = [1, 2, OPEN] + [5, 6, 7, 8]
    eot, trunc = _segment(ids, 3, OPEN, CLOSE)
    assert eot == len(ids) - 1 and trunc
    tr = make_trace(T=len(ids), prompt_len=3, eot=eot, truncated=trunc)
    assert tr.think_len == len(ids) - 3
    assert (tr.segments == "answer").sum() == 0


def test_no_thinking_block_at_all_is_all_answer():
    ids = [1, 2, 3] + [5, 6, 7]
    eot, trunc = _segment(ids, 3, OPEN, CLOSE)
    assert eot == 2 and not trunc
    tr = make_trace(T=6, prompt_len=3, eot=eot)
    assert tr.think_len == 0
    assert list(tr.segments) == ["prompt"] * 3 + ["answer"] * 3


def test_a_closer_in_the_prompt_does_not_count():
    """A prompt that mentions the closer must not end the block before it starts."""
    ids = [1, CLOSE, 2, OPEN] + [5, 6, CLOSE, 9]
    eot, _ = _segment(ids, 4, OPEN, CLOSE)
    assert eot == 6


def test_a_reopened_block_uses_the_last_opener():
    ids = [OPEN, 1, CLOSE, 2, OPEN] + [5, 6, CLOSE]
    eot, trunc = _segment(ids, 5, OPEN, CLOSE)
    assert eot == 7 and not trunc


def test_without_a_closer_token_everything_is_thinking():
    ids = [1, 2, 3, 4, 5]
    eot, trunc = _segment(ids, 2, OPEN, None)
    assert eot == len(ids) - 1 and trunc


# ------------------------------------------------------------------ geometry

def test_segments_partition_the_sequence():
    tr = make_trace()
    s = tr.segments
    assert len(s) == tr.n_tokens
    assert (s == "prompt").sum() == tr.prompt_len
    assert (s == "think").sum() == tr.think_len
    assert (s == "answer").sum() == tr.n_tokens - tr.eot - 1


def test_a_cone_series_matches_the_readout_at_the_eot():
    tr = make_trace()
    assert tr.a_cone == pytest.approx(tr.a_cone_series()[tr.eot])
    z = tr.projections()[tr.eot]
    assert tr.a_cone == pytest.approx(float(ulens.a_cone_of(z)))


def test_the_field_agrees_with_a_cone_computed_from_states():
    """a_cone_of on stored projections equals a_cone on the states they came from."""
    rng = np.random.default_rng(1)
    U = ulens._math.lowdin(rng.normal(size=(32, 4)).astype(np.float32))
    H = rng.normal(size=(7, 32)).astype(np.float32)
    z = ulens.projections(H, U)
    np.testing.assert_allclose(ulens.a_cone_of(z), ulens.a_cone(H, U), rtol=1e-6)


def test_surface_is_a_cone_over_depth_and_token():
    tr = make_trace()
    S = tr.surface()
    assert S.shape == (len(tr.blocks), tr.n_tokens)
    np.testing.assert_allclose(S[tr.block_index()], tr.a_cone_series(), rtol=1e-6)


def test_components_are_the_rectified_projections():
    tr = make_trace()
    np.testing.assert_allclose(tr.components(), ulens.rectify(tr.projections()), rtol=1e-6)
    assert np.all(tr.components() > 0)


def test_think_entropy_spans_the_thinking_block_inclusive_of_the_eot():
    tr = make_trace()
    want = tr.entropy[tr.prompt_len: tr.eot + 1].mean()
    assert tr.think_entropy == pytest.approx(want)
    assert tr.u_lens == pytest.approx(tr.a_cone * tr.think_entropy)


def test_the_first_token_has_no_entropy_and_it_is_excluded():
    """Nothing predicted token 0, so its entropy is NaN — and must not reach the mean."""
    tr = make_trace()
    assert np.isnan(tr.entropy[0])
    assert not np.isnan(tr.think_entropy)


def test_an_uncaptured_block_is_refused():
    tr = make_trace()
    with pytest.raises(KeyError, match="was not captured"):
        tr.projections(999)


def test_summary_and_axis_table_render():
    tr = make_trace()
    assert "U-Lens" in tr.summary() and "readout block 12" in tr.summary()
    assert "closed" in tr.summary() and tr.readout_valid
    assert "argmax" in tr.axis_table()


def test_a_truncated_trace_says_the_readout_is_not_the_published_one():
    tr = make_trace(truncated=True)
    assert not tr.readout_valid
    assert "TRUNCATED" in tr.summary() and "not the published one" in tr.summary()
    assert np.isfinite(tr.u_lens)          # still computed; the field is real


def test_a_trace_with_no_thinking_block_is_not_a_valid_readout():
    tr = make_trace(prompt_len=6, eot=5)
    assert tr.think_len == 0 and not tr.readout_valid


# ------------------------------------------------------------------ persistence

def test_trace_round_trips_and_needs_no_model(tmp_path):
    tr = make_trace()
    back = runtime.load(tr.save(tmp_path / "t.npz"))
    np.testing.assert_allclose(back.field, tr.field)
    np.testing.assert_array_equal(np.isnan(back.entropy), np.isnan(tr.entropy))
    assert back.u_lens == pytest.approx(tr.u_lens)
    assert back.tokens == tr.tokens and back.token_strs == tr.token_strs
    assert back.blocks == tr.blocks and back.names == tr.names
    assert back.eot == tr.eot and back.truncated == tr.truncated
    assert back.config == tr.config


def test_the_sidecar_carries_the_scores(tmp_path):
    import json
    tr = make_trace()
    p = tr.save(tmp_path / "t.npz")
    side = json.loads(p.with_suffix(".json").read_text())
    assert side["scores"]["u_lens"] == pytest.approx(tr.u_lens)
    assert side["readout_block"] == tr.readout_block


def test_the_sidecar_is_strict_json_without_a_thinking_block(tmp_path):
    """Thinking off: no thinking-block entropy, no score. The sidecar says null, not NaN."""
    import json
    tr = make_trace(prompt_len=6, eot=5)
    assert tr.think_len == 0
    p = tr.save(tmp_path / "t.npz")
    text = p.with_suffix(".json").read_text()
    assert "NaN" not in text
    side = json.loads(text, parse_constant=lambda c: pytest.fail(f"non-finite {c} in sidecar"))
    assert side["scores"]["think_entropy"] is None and side["scores"]["u_lens"] is None
    assert side["scores"]["a_cone"] == pytest.approx(tr.a_cone)


# ------------------------------------------------------------------ cone stacks

def test_cone_stack_round_trips_and_slices(tmp_path):
    rng = np.random.default_rng(2)
    cats = ulens.terms.categories()
    cones = {b: ulens.axes.from_axes(
        [ulens.Axis(c, rng.normal(size=12).astype(np.float32)) for c in cats],
        model="m", block=b) for b in (3, 5, 9)}
    st = ulens.axes.stack(cones, model="m", meta={"terms_version": "v1"})
    assert st.blocks == [3, 5, 9] and st.U.shape == (3, 12, 4)
    assert st.nearest(6) == 5 and st.nearest(100) == 9
    np.testing.assert_array_equal(st.at(5).U, cones[5].U)
    back = ulens.axes.load_stack(st.save(tmp_path / "s.npz"))
    np.testing.assert_allclose(back.U, st.U)
    assert back.blocks == st.blocks and back.names == st.names


def test_cone_stack_rejects_mismatched_axis_names():
    v = np.ones(8, np.float32)
    a = ulens.axes.from_axes([ulens.Axis("x", v)], block=1)
    b = ulens.axes.from_axes([ulens.Axis("y", v)], block=2)
    with pytest.raises(ValueError, match="expected"):
        ulens.axes.stack({1: a, 2: b})
    with pytest.raises(ValueError, match="at least one cone"):
        ulens.axes.stack({})


def test_stack_index_refuses_an_uncovered_block():
    v = np.ones(8, np.float32)
    st = ulens.axes.stack({4: ulens.axes.from_axes([ulens.Axis("x", v)], block=4)})
    with pytest.raises(KeyError, match="not in this stack"):
        st.index(5)