File size: 13,686 Bytes
4afd259
 
 
 
 
 
 
 
 
 
 
 
 
 
461b859
4afd259
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0af19f8
 
 
4afd259
 
 
 
 
 
 
 
 
461b859
4afd259
 
 
461b859
4afd259
 
 
 
 
461b859
4afd259
 
 
461b859
 
 
4afd259
 
 
 
 
 
 
 
 
 
 
 
 
0af19f8
461b859
0af19f8
 
 
 
 
 
 
 
461b859
 
 
0af19f8
461b859
0af19f8
 
 
461b859
 
 
 
 
0af19f8
461b859
 
0af19f8
461b859
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4afd259
 
 
 
 
 
 
 
461b859
4afd259
 
 
461b859
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Construct library versioning after the 2026-08-25 review (spec 0007).

Contract under test:
  * a corrected construct ships as a NEW version; the picker shows only the
    newest, so a review does not duplicate every corrected scale in the UI,
  * superseded versions stay in the database and stay usable by id, so runs and
    reproduction scripts that pinned them keep working,
  * metadata outside item_hash (verification_status in particular) re-syncs onto
    an existing row - a verification pass has to reach an already-seeded DB,
  * the append-only guard on ITEMS is untouched by that,
  * the review landed with the shape spec 0007 describes.
"""

import io
import subprocess
import time

import pytest
import yaml
from fastapi.testclient import TestClient

from app.construct_lib import CONSTRUCTS_DIR, load_yaml_constructs, sync_library
from app.db import SessionLocal
from app.main import app
from app.models import Construct

CSV = (
    "id,text\n"
    "1,I am deeply satisfied with my life and grateful every day.\n"
    "2,The bus was late again this morning.\n"
    "3,My life is close to my ideal in most ways.\n"
)


@pytest.fixture(scope="module")
def client():
    with TestClient(app) as c:  # lifespan seeds the construct library
        yield c


def wait_for_job(client, job_id, timeout=10.0):
    deadline = time.time() + timeout
    while time.time() < deadline:
        job = client.get(f"/api/jobs/{job_id}").json()
        if job["status"] in ("completed", "failed"):
            return job
        time.sleep(0.05)
    raise TimeoutError(f"Job {job_id} did not finish within {timeout}s")


# ------------------------------------------------- superseding in the listing
def test_v2_constructs_supersede_v1_in_listing(client):
    listed = [c for c in client.get("/api/constructs").json() if c["is_seed"]]
    slugs = [c["name"] for c in listed]
    assert len(slugs) == len(set(slugs)), "a construct is listed more than once"

    db = SessionLocal()
    try:
        rows = db.query(Construct).filter_by(is_seed=True).all()
        newest = {}
        for r in rows:
            newest[r.construct_slug] = max(newest.get(r.construct_slug, 0), r.version or 1)
        assert len(rows) > len(newest), "expected at least one superseded version seeded"
        assert len(listed) == len(newest)
    finally:
        db.close()

    # every listed construct is the newest version of its slug
    by_hash = {c["item_hash"]: c for c in listed}
    db = SessionLocal()
    try:
        for c in listed:
            row = db.query(Construct).filter_by(id=c["id"]).one()
            assert (row.version or 1) == newest[row.construct_slug]
    finally:
        db.close()
    assert by_hash  # hashes are unique per listed construct


def test_superseded_version_still_resolvable_and_runnable(client):
    """A run that pinned an old version must keep working: the row stays, and a
    job can still be created against it even though the picker hides it."""
    db = SessionLocal()
    try:
        archived = (
            db.query(Construct)
            .filter_by(is_seed=True, verification_status="archived")
            .first()
        )
        assert archived is not None, "no superseded version in the library"
        archived_id, slug, version = archived.id, archived.construct_slug, archived.version
    finally:
        db.close()

    assert version == 1
    listed_ids = {c["id"] for c in client.get("/api/constructs").json()}
    assert archived_id not in listed_ids, "superseded version leaked into the picker"

    project = client.post("/api/projects", json={"name": "Superseded", "description": ""}).json()
    corpus = client.post(
        f"/api/projects/{project['id']}/corpora",
        files={"file": ("c.csv", io.BytesIO(CSV.encode()), "application/octet-stream")},
    ).json()
    job = client.post(
        "/api/jobs",
        json={
            "project_id": project["id"],
            "corpus_id": corpus["id"],
            "construct_ids": [archived_id],
            "text_column": "text",
            "model_name": "fake-deterministic",
        },
    )
    assert job.status_code == 201, job.text
    done = wait_for_job(client, job.json()["id"])
    assert done["status"] == "completed"
    meta = client.get(f"/api/jobs/{done['id']}/metadata").json()
    snap = meta["construct_snapshot"] if "construct_snapshot" in meta else meta["constructs"][0]
    assert snap["construct_id"] == slug
    assert snap["version"] == 1


# ------------------------------------------------------- metadata re-sync
def test_sync_updates_verification_status_in_place(client):
    """Status lives outside item_hash, so a review must reach an existing row
    without inventing a new version."""
    db = SessionLocal()
    try:
        row = (
            db.query(Construct)
            .filter_by(is_seed=True, verification_status="verified")
            .first()
        )
        assert row is not None
        row_id, original = row.id, row.verification_status
        row.verification_status = "needs_verification"  # simulate a pre-review DB
        row.name = "stale name"
        db.commit()

        report = sync_library(db)
        assert report["updated"] >= 1

        refreshed = db.query(Construct).filter_by(id=row_id).one()
        assert refreshed.verification_status == original
        assert refreshed.name != "stale name"
    finally:
        db.close()


def test_sync_still_refuses_item_change_under_same_version(client):
    """The append-only guard covers ITEMS and must survive the metadata sync."""
    db = SessionLocal()
    row = db.query(Construct).filter_by(is_seed=True).first()
    row_id, real_hash = row.id, row.item_hash
    try:
        row.item_hash = "0" * 64  # pretend the YAML items changed under this version
        db.commit()
        with pytest.raises(RuntimeError, match="append-only"):
            sync_library(db)
    finally:
        # restore explicitly: the corruption was committed, so a rollback would
        # leave it in place and every later sync_library in the session would fail
        db.rollback()
        db.query(Construct).filter_by(id=row_id).one().item_hash = real_hash
        db.commit()
        db.close()


# ------------------------------------------------------- the review itself
def test_review_applied_expected_shape():
    """Spec 0007's headline numbers, asserted against the YAML library."""
    constructs = load_yaml_constructs()
    by_status = {}
    for c in constructs:
        by_status.setdefault(c["verification_status"], []).append(c)

    assert len(by_status["archived"]) == 23, "superseded v1 files"
    assert len(by_status["verified"]) == 88
    assert len(by_status["needs_verification"]) == 6

    live = [c for c in constructs if c["verification_status"] != "archived"]
    assert len({c["construct_id"] for c in live}) == 94, "one live version per construct"

    reverse = sum(
        1 for c in live for i in c["items"] if i.get("reverse_scored")
    )
    assert reverse == 96, "reverse flags after the review (was 35)"

    # Everything still unverified is unverified for a recorded reason.
    pending = sorted(c["construct_id"] for c in by_status["needs_verification"])
    assert pending == sorted(
        [
            # PI decision: the "I" prefix CCR adds to IPIP stems
            "ipip_50_item_big_five_factor_markers_agreeableness",
            "ipip_50_item_big_five_factor_markers_conscientiousness",
            "ipip_50_item_big_five_factor_markers_emotional_stability_neuroticism",
            "ipip_50_item_big_five_factor_markers_extraversion",
            "ipip_50_item_big_five_factor_markers_intellect_imagination",
            # PI decision: restoring the shared K10 stem onto each item
            "k10",
        ]
    )
    for c in by_status["needs_verification"]:
        assert (c.get("review") or {}).get("notes"), \
            f"{c['construct_id']}: unverified without a recorded reason"


def test_every_live_construct_records_who_verified_it():
    """`verified` is only meaningful with provenance attached."""
    for c in load_yaml_constructs():
        if c["verification_status"] != "verified":
            continue
        review = c.get("review") or {}
        assert review.get("reviewer"), f"{c['construct_id']}: verified without a reviewer"
        assert review.get("date"), f"{c['construct_id']}: verified without a review date"


def test_superseded_files_keep_their_original_items():
    """Append-only means a PUBLISHED version's ITEMS are never rewritten.

    Compares each tracked construct against git rather than against its own v2 -
    comparing v1 to v2 would pass even if v1's items had been quietly edited,
    which is the exact failure this guards.

    "Published" means merged to the default branch, so the baseline is the
    merge-base with main, not HEAD. A version being drafted on a feature branch
    can still be revised (that is what review is for); once it lands on main a
    run can have used it, and from then on this test freezes it.
    """
    repo = CONSTRUCTS_DIR.parents[2]
    base = subprocess.run(
        ["git", "merge-base", "HEAD", "main"], cwd=repo, capture_output=True, text=True
    )
    if base.returncode != 0:  # not a git checkout, or no main (packaged install)
        pytest.skip("not a git checkout with a main branch")
    baseline = base.stdout.strip()

    checked = 0
    for path in sorted(CONSTRUCTS_DIR.glob("*.yaml")):
        rel = path.relative_to(repo)
        show = subprocess.run(
            ["git", "show", f"{baseline}:{rel.as_posix()}"], cwd=repo, capture_output=True, text=True
        )
        if show.returncode != 0:
            continue  # new since main, nothing published to compare against
        committed = yaml.safe_load(show.stdout)
        current = yaml.safe_load(path.read_text())
        if committed["version"] != current["version"]:
            continue
        assert [i["text"] for i in committed["items"]] == [
            i["text"] for i in current["items"]
        ], f"{path.name}: item text changed under an existing version"
        assert [bool(i.get("reverse_scored")) for i in committed["items"]] == [
            bool(i.get("reverse_scored")) for i in current["items"]
        ], f"{path.name}: reverse flags changed under an existing version"
        assert committed["language"] == current["language"], path.name
        checked += 1
    assert checked > 50, f"expected to check most of the library, only saw {checked}"


def test_superseded_versions_really_differ_from_their_replacement():
    """A v2 that matches its v1 item-for-item would be pure version churn."""
    superseded = [
        c for c in load_yaml_constructs() if c["verification_status"] == "archived"
    ]
    assert superseded
    for old in superseded:
        newer = yaml.safe_load(
            (CONSTRUCTS_DIR / f"{old['construct_id']}_v2.yaml").read_text()
        )
        assert newer["version"] == 2 and old["version"] == 1
        old_items = [(i["text"], bool(i.get("reverse_scored"))) for i in old["items"]]
        new_items = [(i["text"], bool(i.get("reverse_scored"))) for i in newer["items"]]
        assert old_items != new_items, f"{old['construct_id']}: v2 with identical items"


def test_all_reversed_construct_warns_about_score_direction(client):
    """Flipping every item in a construct flips what a high score means; the run
    must say so rather than leaving the results page to claim otherwise."""
    listed = client.get("/api/constructs").json()
    target = next(
        c for c in listed
        if c["is_seed"] and c["reverse_scored"] and all(c["reverse_scored"])
    )

    project = client.post("/api/projects", json={"name": "Reversed", "description": ""}).json()
    corpus = client.post(
        f"/api/projects/{project['id']}/corpora",
        files={"file": ("c.csv", io.BytesIO(CSV.encode()), "application/octet-stream")},
    ).json()
    job = client.post(
        "/api/jobs",
        json={
            "project_id": project["id"],
            "corpus_id": corpus["id"],
            "construct_ids": [target["id"]],
            "text_column": "text",
            "model_name": "fake-deterministic",
        },
    ).json()
    done = wait_for_job(client, job["id"])
    assert done["status"] == "completed"

    summary = client.get(f"/api/jobs/{done['id']}/results").json()["summary"]
    codes = [w["code"] for w in summary["warnings"]]
    assert "CONSTRUCT_ALL_ITEMS_REVERSED" in codes, codes
    msg = next(
        w["message"] for w in summary["warnings"]
        if w["code"] == "CONSTRUCT_ALL_ITEMS_REVERSED"
    )
    assert "opposite" in msg.lower()


def test_normal_construct_does_not_warn_about_direction(client):
    listed = client.get("/api/constructs").json()
    normal = next(
        c for c in listed if c["is_seed"] and not any(c["reverse_scored"])
    )
    project = client.post("/api/projects", json={"name": "Normal", "description": ""}).json()
    corpus = client.post(
        f"/api/projects/{project['id']}/corpora",
        files={"file": ("c.csv", io.BytesIO(CSV.encode()), "application/octet-stream")},
    ).json()
    job = client.post(
        "/api/jobs",
        json={
            "project_id": project["id"],
            "corpus_id": corpus["id"],
            "construct_ids": [normal["id"]],
            "text_column": "text",
            "model_name": "fake-deterministic",
        },
    ).json()
    done = wait_for_job(client, job["id"])
    summary = client.get(f"/api/jobs/{done['id']}/results").json()["summary"]
    codes = [w["code"] for w in summary["warnings"]]
    assert "CONSTRUCT_ALL_ITEMS_REVERSED" not in codes