Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
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
|