ccr-platform / backend /tests /test_construct_library_versions.py
devaanand's picture
fix(constructs): resolve the review's open questions from Noor's answers
0af19f8
Raw
History Blame Contribute Delete
13.7 kB
"""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