fix(evidence): preserve legacy formula migration boundaries

This commit is contained in:
2026-08-25 01:28:54 +02:00
parent cc30148b69
commit dcb5acc312
4 changed files with 135 additions and 27 deletions
@@ -24,3 +24,21 @@ def test_reviewed_formula_migration_has_deterministic_provenance_hash():
assert second is not None
assert first.provenance.source_sha256 == second.provenance.source_sha256
assert first.provenance.source_sha256.startswith("sha256:")
def test_incompatible_reviewed_legacy_formula_fails_closed_with_the_original_record():
formula = ConceptFormula(
concept="ablazione",
columns=["testo"],
sql="SELECT 2",
status="reviewed",
sources=["legacy manual"],
)
outcome = formula_store.legacy_formula_to_curated(
formula, legacy_path="formulas/ablazione-2.sql.md",
)
assert outcome.code == "legacy_formula_requires_manual_review"
assert outcome.legacy_path == "formulas/ablazione-2.sql.md"
assert outcome.formula == formula
+56 -1
View File
@@ -104,7 +104,7 @@ def test_reviewed_legacy_formula_becomes_curated_formula_with_stable_provenance(
)
assert migrated is not None
assert migrated.id == "evidence:fascia-pediatrica"
assert migrated.id.startswith("evidence:fascia-pediatrica-")
assert migrated.title == "Fascia pediatrica"
assert migrated.kind == "formula"
assert migrated.payload.concept == formula.concept
@@ -118,6 +118,34 @@ def test_reviewed_legacy_formula_becomes_curated_formula_with_stable_provenance(
).id == migrated.id
def test_reviewed_legacy_formulas_with_the_same_concept_keep_distinct_path_identities():
first = ConceptFormula(
concept="fascia pediatrica",
columns=["clinical.patient.birth_date"],
sql="CASE WHEN age < 18 THEN 'pediatrica' ELSE 'adulta' END",
status="reviewed",
)
second = ConceptFormula(
concept="fascia pediatrica",
columns=["clinical.patient.birth_date"],
sql="CASE WHEN age < 16 THEN 'pediatrica' ELSE 'adulta' END",
status="reviewed",
)
first_migration = formula_store.legacy_formula_to_curated(
first, legacy_path="formulas/fascia-pediatrica-1.sql.md",
)
second_migration = formula_store.legacy_formula_to_curated(
second, legacy_path="formulas/fascia-pediatrica-2.sql.md",
)
assert first_migration is not None
assert second_migration is not None
assert first_migration.id != second_migration.id
assert first_migration.id.startswith("evidence:fascia-pediatrica-")
assert second_migration.id.startswith("evidence:fascia-pediatrica-")
def test_session_formula_proposal_is_versioned_and_is_not_published_evidence(tmp_path):
linking = SchemaLinking(
question="Conta i pazienti pediatrici",
@@ -148,3 +176,30 @@ def test_session_formula_proposal_is_versioned_and_is_not_published_evidence(tmp
"sources": ["Sintetizzata nella sessione"],
"decision_seq": 9,
}]
def test_session_formula_proposals_require_an_unretracted_positive_f4_decision(tmp_path):
linking = SchemaLinking(
question="Conta i pazienti pediatrici",
concept_formulas=[
{"concept": "approvata", "columns": [], "sql": "1", "sources": []},
{"concept": "rifiutata", "columns": [], "sql": "2", "sources": []},
{"concept": "indecisa", "columns": [], "sql": "3", "sources": []},
],
)
decisions = [
DecisionRecord(
seq=3, ts=datetime(2026, 8, 25, tzinfo=UTC), type="concept_formula_approved",
subject="phase:4", detail="approvata",
),
DecisionRecord(
seq=4, ts=datetime(2026, 8, 25, tzinfo=UTC), type="concept_formula_rejected",
subject="phase:4", detail="rifiutata",
),
]
projected = project_session(decisions, linking, tmp_path / "evidence")
assert [(proposal["concept"], proposal["decision_seq"]) for proposal in projected] == [
("approvata", 3),
]
+48 -22
View File
@@ -8,11 +8,12 @@ from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
import yaml
from pydantic import BaseModel
from pydantic import BaseModel, ValidationError
from tht.evidence.canonical import CuratedEvidence
@@ -56,6 +57,16 @@ class ConceptFormula(BaseModel):
return cls.model_validate({**meta, "sql": body.strip("\n")})
@dataclass(frozen=True)
class LegacyFormulaMigrationFailure:
"""A reviewed legacy formula that must be resolved manually before publication."""
code: Literal["legacy_formula_requires_manual_review"]
legacy_path: str
formula: ConceptFormula
problems: tuple[str, ...]
def _next_path(root: Path, slug: str) -> Path:
"""First free <slug>-<n>.sql.md path under root (n starts at 1)."""
root.mkdir(parents=True, exist_ok=True)
@@ -110,7 +121,7 @@ def legacy_formula_to_curated(
formula: ConceptFormula,
*,
legacy_path: str,
) -> CuratedEvidence | None:
) -> CuratedEvidence | LegacyFormulaMigrationFailure | None:
"""Convert one reviewed legacy formula into its deterministic curated counterpart.
Drafts and model-generated formulas have no global publication status. Their
@@ -123,23 +134,38 @@ def legacy_formula_to_curated(
)
source_sha256 = hashlib.sha256(formula.dump().encode("utf-8")).hexdigest()
source_file = legacy_path if legacy_path.startswith("source/") else f"source/{legacy_path}"
return CuratedEvidence.model_validate({
"schema_version": 1,
"id": f"evidence:{formula._slug}",
"title": formula.concept[:1].upper() + formula.concept[1:],
"kind": "formula",
"purposes": ["schema_linking", "sql_generation"],
"applies_to": {"concepts": [formula.concept], "columns": formula.columns},
"language": "it",
"provenance": {
"source_file": source_file,
"source_sha256": f"sha256:{source_sha256}",
"supporting_excerpts": source_notes,
},
"review_items": [],
"payload": {
"concept": formula.concept,
"columns": formula.columns,
"sql": formula.sql,
},
})
# The legacy path is the immutable identity of this unit during migration. Keeping
# its full digest avoids a duplicate public ID when the same concept has reviewed
# competing formulas, while retaining the readable concept slug as the prefix.
legacy_identity = hashlib.sha256(legacy_path.encode("utf-8")).hexdigest()
try:
return CuratedEvidence.model_validate({
"schema_version": 1,
"id": f"evidence:{formula._slug}-{legacy_identity}",
"title": formula.concept[:1].upper() + formula.concept[1:],
"kind": "formula",
"purposes": ["schema_linking", "sql_generation"],
"applies_to": {"concepts": [formula.concept], "columns": formula.columns},
"language": "it",
"provenance": {
"source_file": source_file,
"source_sha256": f"sha256:{source_sha256}",
"supporting_excerpts": source_notes,
},
"review_items": [],
"payload": {
"concept": formula.concept,
"columns": formula.columns,
"sql": formula.sql,
},
})
except ValidationError as error:
return LegacyFormulaMigrationFailure(
code="legacy_formula_requires_manual_review",
legacy_path=legacy_path,
formula=formula,
problems=tuple(sorted(
".".join(str(part) for part in issue["loc"])
for issue in error.errors()
)),
)
+13 -4
View File
@@ -64,11 +64,17 @@ def _formula_proposals(decisions: list["DecisionRecord"], linking: "SchemaLinkin
A proposal remains in the persisted session artifact until a separate curator
imports, reviews, and publishes it in the workspace repository.
"""
approved = {
decision.detail: decision.seq
retracted = {
decision.retracts
for decision in decisions
if decision.type == "concept_formula_approved" and decision.detail
if decision.type == "decision_retracted" and decision.retracts is not None
}
latest_f4_decision = {}
for decision in sorted(decisions, key=lambda item: item.seq):
if decision.seq in retracted or decision.subject != "phase:4" or not decision.detail:
continue
if decision.type in {"concept_formula_approved", "concept_formula_rejected"}:
latest_f4_decision[decision.detail] = decision
proposals = []
for formula in linking.concept_formulas:
if not isinstance(formula, dict):
@@ -83,6 +89,9 @@ def _formula_proposals(decisions: list["DecisionRecord"], linking: "SchemaLinkin
evidence_id = formula.get("evidence_id", formula.get("id", ""))
if isinstance(evidence_id, str) and evidence_id.startswith("evidence:"):
continue
decision = latest_f4_decision.get(concept)
if decision is None or decision.type != "concept_formula_approved":
continue
proposals.append({
"schema_version": 1,
"kind": "formula_proposal",
@@ -91,7 +100,7 @@ def _formula_proposals(decisions: list["DecisionRecord"], linking: "SchemaLinkin
"columns": columns,
"sql": sql,
"sources": formula.get("sources", []),
"decision_seq": approved.get(concept),
"decision_seq": decision.seq,
})
return proposals