"""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