feat(stage21): implement reproducibility — research_run_hash, full provenance tracking

- compute_research_run_hash(): deterministic SHA256 from query+budget+created_at
- ProvenanceEntry / ProvenanceLog: step-by-step trace with inputs, outputs, metadata, errors
- ResearchRun model: research_run_hash field (64-char hex)
- ResearchRunProvenance SQLAlchemy table for DB persistence
- Orchestrator: auto-log provenance before/after each pipeline step
- 35 tests: hash determinism, entry/log methods, storage model, integration
This commit is contained in:
NSCT Agent
2026-09-05 14:27:45 +00:00
parent f9b761ced7
commit 8ea6269f9a
6 changed files with 800 additions and 2 deletions

View File

@@ -0,0 +1,442 @@
"""Tests für Stage 21 — Provenance & research_run_hash (Reproduzierbarkeit)."""
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timezone
from uuid import UUID, uuid4
import pytest
from nsct.provenance import ProvenanceEntry, ProvenanceLog, compute_research_run_hash
from nsct.orchestration.models import ResearchRun
# ===================================================================
# compute_research_run_hash — Determinismus & Korrektheit
# ===================================================================
class TestComputeResearchRunHash:
"""Tests für compute_research_run_hash()."""
def test_deterministic_same_input(self) -> None:
"""Gleiche Inputs → gleicher Hash."""
query = "Wie wirkt sich KI auf den Arbeitsmarkt?"
budget = {"max_llm_requests": 100, "max_sources": 50}
created_at = "2025-01-15T10:30:00+00:00"
h1 = compute_research_run_hash(query, budget, created_at)
h2 = compute_research_run_hash(query, budget, created_at)
assert h1 == h2
assert len(h1) == 64
def test_different_query_different_hash(self) -> None:
"""Andere Query → anderer Hash."""
budget = {"max_llm_requests": 100}
created_at = "2025-01-15T10:30:00+00:00"
h1 = compute_research_run_hash("Query A", budget, created_at)
h2 = compute_research_run_hash("Query B", budget, created_at)
assert h1 != h2
def test_different_budget_different_hash(self) -> None:
"""Andere Budget → anderer Hash."""
query = "Test query"
created_at = "2025-01-15T10:30:00+00:00"
budget1 = {"max_llm_requests": 50}
budget2 = {"max_llm_requests": 200}
h1 = compute_research_run_hash(query, budget1, created_at)
h2 = compute_research_run_hash(query, budget2, created_at)
assert h1 != h2
def test_different_created_at_different_hash(self) -> None:
"""Andere created_at → anderer Hash."""
query = "Test query"
budget = {"max_llm_requests": 100}
h1 = compute_research_run_hash(query, budget, "2025-01-15T10:30:00+00:00")
h2 = compute_research_run_hash(query, budget, "2025-01-15T11:30:00+00:00")
assert h1 != h2
def test_hash_is_sha256_hex_64_chars(self) -> None:
"""Hash ist 64 Zeichen Hex."""
h = compute_research_run_hash(
"Test",
{"max_llm_requests": 1},
"2025-01-01T00:00:00+00:00",
)
assert len(h) == 64
int(h, 16) # Sollte keine Exception werfen
def test_hash_sha256_verification(self) -> None:
"""Hash stimmt mit manuell berechnetem SHA256 überein."""
query = "Wie wirkt sich KI auf den Arbeitsmarkt?"
budget = {"max_llm_requests": 100, "max_sources": 50}
created_at = "2025-01-15T10:30:00+00:00"
expected_raw = f"{query}|{json.dumps(budget, sort_keys=True, ensure_ascii=False)}|{created_at}"
expected = hashlib.sha256(expected_raw.encode("utf-8")).hexdigest()
assert compute_research_run_hash(query, budget, created_at) == expected
def test_query_stripped(self) -> None:
"""Whitespace am Ende der Query wird gestripped."""
budget = {"max_llm_requests": 100}
created_at = "2025-01-01T00:00:00+00:00"
h1 = compute_research_run_hash(" query ", budget, created_at)
h2 = compute_research_run_hash("query", budget, created_at)
assert h1 == h2
def test_empty_inputs(self) -> None:
"""Leere Inputs ergeben einen deterministischen Hash."""
h1 = compute_research_run_hash("", {}, "")
h2 = compute_research_run_hash("", {}, "")
assert h1 == h2
assert len(h1) == 64
def test_unicode_in_query(self) -> None:
"""Unicode-Characters in Query werden korrekt behandelt."""
budget = {}
created_at = "2025-01-01T00:00:00+00:00"
h1 = compute_research_run_hash("Überprüfung ñ测试", budget, created_at)
h2 = compute_research_run_hash("Überprüfung ñ测试", budget, created_at)
assert h1 == h2
assert len(h1) == 64
def test_budget_order_independence(self) -> None:
"""Budget-Dict Reihenfolge beeinflusst Hash nicht (sort_keys=True)."""
query = "Test"
created_at = "2025-01-01T00:00:00+00:00"
budget1 = {"a": 1, "b": 2, "c": 3}
budget2 = {"c": 3, "a": 1, "b": 2}
h1 = compute_research_run_hash(query, budget1, created_at)
h2 = compute_research_run_hash(query, budget2, created_at)
assert h1 == h2
# ===================================================================
# ProvenanceEntry
# ===================================================================
class TestProvenanceEntry:
"""Tests für ProvenanceEntry."""
def test_basic_entry(self) -> None:
"""Basis-Eintrag mit allen Feldern."""
ts = datetime(2025, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
entry = ProvenanceEntry(
step="stage4_planning",
timestamp=ts,
inputs={"query": "Test"},
outputs={"plan": "minimal"},
)
assert entry.step == "stage4_planning"
assert entry.timestamp == ts
assert entry.inputs == {"query": "Test"}
assert entry.outputs == {"plan": "minimal"}
assert entry.metadata == {}
assert entry.error is None
def test_to_dict(self) -> None:
"""to_dict() erzeugt korrektes Dict."""
ts = datetime(2025, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
entry = ProvenanceEntry(
step="stage5_extracting",
timestamp=ts,
inputs={"url_count": 5},
outputs={"claim_count": 12},
metadata={"provider": "qwen3"},
error=None,
)
d = entry.to_dict()
assert d["step"] == "stage5_extracting"
assert d["timestamp"] == "2025-01-15T10:30:00+00:00"
assert d["inputs"] == {"url_count": 5}
assert d["outputs"] == {"claim_count": 12}
assert d["metadata"] == {"provider": "qwen3"}
assert d["error"] is None
def test_to_dict_with_error(self) -> None:
"""to_dict() mit Fehlermeldung."""
entry = ProvenanceEntry(
step="stage6_fetching",
timestamp=datetime.now(timezone.utc),
inputs={"url": "https://example.com"},
outputs={},
error="Connection timeout",
)
d = entry.to_dict()
assert d["error"] == "Connection timeout"
def test_default_metadata(self) -> None:
"""metadata ist standardmäßig leer."""
entry = ProvenanceEntry(
step="test",
timestamp=datetime.now(timezone.utc),
inputs={},
outputs={},
)
assert entry.metadata == {}
# ===================================================================
# ProvenanceLog
# ===================================================================
class TestProvenanceLog:
"""Tests für ProvenanceLog."""
def test_empty_log(self) -> None:
"""Leerer Log mit 0 Einträgen."""
log = ProvenanceLog(research_run_hash="a" * 64)
assert log.entries == []
assert not log.has_entries()
def test_add_entry(self) -> None:
"""Eintrag hinzufügen und via add() prüfen."""
log = ProvenanceLog(research_run_hash="b" * 64)
entry = ProvenanceEntry(
step="stage4_planning",
timestamp=datetime.now(timezone.utc),
inputs={"query": "test"},
outputs={"plan": "x"},
)
log.add(entry)
assert len(log.entries) == 1
assert log.has_entries()
assert log.entries[0].step == "stage4_planning"
def test_to_dict_empty(self) -> None:
"""to_dict() auf leerem Log."""
log = ProvenanceLog(research_run_hash="c" * 64)
d = log.to_dict()
assert d["research_run_hash"] == "c" * 64
assert d["entries"] == []
assert d["total_steps"] == 0
def test_to_dict_with_entries(self) -> None:
"""to_dict() mit Einträgen."""
ts = datetime(2025, 1, 15, 10, 0, 0, tzinfo=timezone.utc)
log = ProvenanceLog(research_run_hash="d" * 64)
log.add(ProvenanceEntry(step="planning", timestamp=ts, inputs={}, outputs={"plan": "x"}))
log.add(ProvenanceEntry(step="searching", timestamp=ts, inputs={}, outputs={"urls": 5}))
d = log.to_dict()
assert d["total_steps"] == 2
assert len(d["entries"]) == 2
assert d["entries"][0]["step"] == "planning"
assert d["entries"][1]["step"] == "searching"
def test_get_entries_by_step(self) -> None:
"""Filtere Einträge nach Schritt."""
ts = datetime.now(timezone.utc)
log = ProvenanceLog(research_run_hash="e" * 64)
log.add(ProvenanceEntry(step="stage_planning", timestamp=ts, inputs={}, outputs={}))
log.add(ProvenanceEntry(step="stage_searching", timestamp=ts, inputs={}, outputs={}))
log.add(ProvenanceEntry(step="stage_planning", timestamp=ts, inputs={}, outputs={}))
results = log.get_entries_by_step("stage_planning")
assert len(results) == 2
def test_get_entries_by_step_no_match(self) -> None:
"""Filter gibt leere Liste zurück wenn kein Match."""
log = ProvenanceLog(research_run_hash="f" * 64)
assert log.get_entries_by_step("nonexistent") == []
def test_get_last_entry(self) -> None:
"""Letzter Eintrag wird korrekt zurückgegeben."""
ts = datetime.now(timezone.utc)
log = ProvenanceLog(research_run_hash="g" * 64)
log.add(ProvenanceEntry(step="first", timestamp=ts, inputs={}, outputs={}))
log.add(ProvenanceEntry(step="second", timestamp=ts, inputs={}, outputs={}))
last = log.get_last_entry()
assert last is not None
assert last.step == "second"
def test_get_last_entry_empty(self) -> None:
"""Leerer Log → None."""
log = ProvenanceLog(research_run_hash="h" * 64)
assert log.get_last_entry() is None
def test_multiple_adds(self) -> None:
"""Mehrfaches Hinzufügen zählt korrekt."""
log = ProvenanceLog(research_run_hash="i" * 64)
for i in range(10):
log.add(ProvenanceEntry(
step=f"step_{i}",
timestamp=datetime.now(timezone.utc),
inputs={},
outputs={},
))
assert len(log.entries) == 10
assert log.to_dict()["total_steps"] == 10
# ===================================================================
# ResearchRun Model — research_run_hash field
# ===================================================================
class TestResearchRunModel:
"""Tests für ResearchRun mit research_run_hash."""
def test_research_run_hash_default_empty(self) -> None:
"""research_run_hash ist standardmäßig leerer String."""
run = ResearchRun(
query="Test query",
research_id=uuid4(),
)
assert run.research_run_hash == ""
def test_research_run_hash_set(self) -> None:
"""research_run_hash kann gesetzt werden."""
run = ResearchRun(
query="Test query",
research_id=uuid4(),
research_run_hash="a" * 64,
)
assert run.research_run_hash == "a" * 64
assert len(run.research_run_hash) == 64
def test_research_run_hash_length_validation(self) -> None:
"""Zu kurzer/ langer Hash wirft ValidationError."""
with pytest.raises(Exception): # pydantic.ValidationError
ResearchRun(
query="Test",
research_id=uuid4(),
research_run_hash="too_short",
)
# ===================================================================
# Provenance-Tabellen-Model Validierung
# ===================================================================
class TestResearchRunProvenanceModel:
"""Tests für ResearchRunProvenanceModel Schema."""
def test_model_tablename(self) -> None:
"""Tabellenname ist korrekt."""
from nsct.storage.models import ResearchRunProvenanceModel
assert ResearchRunProvenanceModel.__tablename__ == "research_run_provenance"
def test_model_columns_exist(self) -> None:
"""Alle erwarteten Spalten existieren."""
from nsct.storage.models import ResearchRunProvenanceModel
column_names = {c.key for c in ResearchRunProvenanceModel.__table__.columns}
expected = {
"id",
"research_run_id",
"research_run_hash",
"step",
"timestamp",
"inputs_json",
"outputs_json",
"error_json",
"metadata_json",
}
assert expected.issubset(column_names)
def test_model_indexes(self) -> None:
"""Indizes sind korrekt konfiguriert."""
from nsct.storage.models import ResearchRunProvenanceModel
index_names = {idx.name for idx in ResearchRunProvenanceModel.__table__.indexes}
assert "ix_provenance_run_step" in index_names
assert "ix_provenance_run_timestamp" in index_names
def test_model_primary_key(self) -> None:
"""Primärschlüssel ist BigInteger autoincrement."""
from nsct.storage.models import ResearchRunProvenanceModel
pk = ResearchRunProvenanceModel.__table__.primary_key
pk_column = list(pk)[0]
assert pk_column.name == "id"
assert pk_column.autoincrement is True
# ===================================================================
# Orchestrator Provenance-Integration
# ===================================================================
class TestOrchestratorProvenanceIntegration:
"""Tests für Provenance-Integration im Orchestrator."""
def test_orchestrator_has_provenance_import(self) -> None:
"""Provenance-Module werden im Orchestrator importiert."""
from nsct.orchestration.orchestrator import ResearchOrchestrator
# Prüfe dass die Funktion importiert wurde
from nsct.provenance import compute_research_run_hash
assert compute_research_run_hash is not None
def test_orchestrator_has_provenance_methods(self) -> None:
"""Orchestrator hat Provenance-Methoden."""
from nsct.orchestration.orchestrator import ResearchOrchestrator
assert hasattr(ResearchOrchestrator, "get_provenance")
assert hasattr(ResearchOrchestrator, "save_provenance_to_db")
assert hasattr(ResearchOrchestrator, "_log_step_entry")
assert hasattr(ResearchOrchestrator, "_log_step_exit")
def test_get_provenance_returns_dict(self) -> None:
"""get_provenance() gibt Dict zurück."""
from nsct.orchestration.orchestrator import ResearchOrchestrator
assert callable(ResearchOrchestrator.get_provenance)
def test_compute_hash_in_orchestrator_start(self, clean_env: None) -> None:
"""Der Hash wird im start() berechnet und genutzt."""
from nsct.orchestration.orchestrator import ResearchOrchestrator
from nsct.config import AppSettings
settings = AppSettings.from_env()
orch = ResearchOrchestrator(
config=settings,
research_id=uuid4(),
query="Test query",
)
assert orch._research_run_hash == ""
assert orch._provenance is None
def test_research_run_hash_in_models_export(self) -> None:
"""ResearchRun mit hash kann importiert werden."""
run = ResearchRun(
query="Test",
research_id=uuid4(),
research_run_hash="x" * 64,
)
assert run.research_run_hash == "x" * 64