From 8adc08574646b2ca2655c0df94dbe1de7e18d670 Mon Sep 17 00:00:00 2001 From: mptyl Date: Mon, 24 Aug 2026 20:52:56 +0200 Subject: [PATCH] feat(evidence): resolve curated evidence explicitly --- .../tests/fixtures/approved_cli_surface.json | 1 + harness/tests/test_cli_surface.py | 2 +- harness/tests/test_evidence_authoring.py | 190 +++++++++++++++++- harness/tests/test_evidence_cli.py | 77 +++++++ harness/tht/cli/evidence_cmd.py | 36 ++++ harness/tht/evidence/__init__.py | 4 + harness/tht/evidence/authoring.py | 136 +++++++++++++ 7 files changed, 442 insertions(+), 4 deletions(-) diff --git a/harness/tests/fixtures/approved_cli_surface.json b/harness/tests/fixtures/approved_cli_surface.json index a994c298..c52211ee 100644 --- a/harness/tests/fixtures/approved_cli_surface.json +++ b/harness/tests/fixtures/approved_cli_surface.json @@ -11,6 +11,7 @@ "decision add-batch", "decision add-join-set", "evidence prepare", + "evidence resolve", "evidence validate", "memory promote", "memory save-one", diff --git a/harness/tests/test_cli_surface.py b/harness/tests/test_cli_surface.py index 553cf3f9..91593cf5 100644 --- a/harness/tests/test_cli_surface.py +++ b/harness/tests/test_cli_surface.py @@ -32,7 +32,7 @@ def test_typer_tree_matches_the_approved_command_surface(): approved = _approved_surface() expected = set(approved["maintained"]) | set(approved["enhanced"]) - assert len(approved["maintained"]) == 57 + assert len(approved["maintained"]) == 58 assert len(approved["enhanced"]) == 8 assert len(approved["erased"]) == 14 assert not (expected & set(approved["erased"])) diff --git a/harness/tests/test_evidence_authoring.py b/harness/tests/test_evidence_authoring.py index 3325ab70..d0b02ad0 100644 --- a/harness/tests/test_evidence_authoring.py +++ b/harness/tests/test_evidence_authoring.py @@ -316,18 +316,18 @@ class _Restructurer(EvidenceRestructurer): return tuple(self.candidates) -def _candidate(*, title="Fascia pediatrica", existing_id=None): +def _candidate(*, title="Fascia pediatrica", existing_id=None, kind="domain", payload=None): return RestructureCandidate.model_validate({ "schema_version": 1, "existing_id": existing_id, "title": title, - "kind": "domain", + "kind": kind, "purposes": ["disambiguation"], "applies_to": {"concepts": ["fascia pediatrica"]}, "language": "it", "supporting_excerpts": ["I pazienti sotto i 18 anni sono pediatrici."], "review_items": [], - "payload": {"rule": "La fascia pediatrica comprende i minori."}, + "payload": payload or {"rule": "La fascia pediatrica comprende i minori."}, }) @@ -378,6 +378,47 @@ def test_prepare_rejects_dirty_curated_state_before_model_call(tmp_path): assert restructurer.requests == [] +def test_prepare_refuses_an_incompatible_pipeline_version_without_writing(tmp_path): + source_text = "I pazienti sotto i 18 anni sono pediatrici." + _write_workspace(tmp_path, _evidence(source_text), source_text) + manifest_path = tmp_path / "evidence" / "manifest.yaml" + curated_path = tmp_path / "evidence" / "curated" / "domain" / "fascia-pediatrica.md" + manifest_path.write_text( + manifest_path.read_text(encoding="utf-8").replace("evidence-authoring-v1", "evidence-authoring-v2"), + encoding="utf-8", + ) + original_manifest = manifest_path.read_text(encoding="utf-8") + original_curated = curated_path.read_text(encoding="utf-8") + + with pytest.raises(EvidencePreparationError, match="pipeline_upgrade_required"): + prepare_workspace_evidence(tmp_path, restructurer=_Restructurer([]), git_status=lambda _: ()) + + assert manifest_path.read_text(encoding="utf-8") == original_manifest + assert curated_path.read_text(encoding="utf-8") == original_curated + + +def test_prepare_preserves_an_id_when_reclassified_and_allocates_a_new_id_for_a_split(tmp_path): + source_text = "I pazienti sotto i 18 anni sono pediatrici." + _write_workspace(tmp_path, _evidence(source_text), source_text) + source_path = tmp_path / "evidence" / "source" / "domain" / "patient.md" + source_path.write_text(source_text + " I pazienti sopra i 18 anni sono adulti.\n", encoding="utf-8") + restructurer = _Restructurer([ + _candidate( + existing_id="evidence:fascia-pediatrica", + kind="glossary", + payload={"definition": "Paziente con meno di 18 anni."}, + ), + _candidate(title="Fascia adulta"), + ]) + + report = prepare_workspace_evidence(tmp_path, restructurer=restructurer, git_status=lambda _: ()) + + assert report.created == ("evidence:fascia-adulta",) + documents = {document.id: document for document in load_curated_tree(tmp_path / "evidence" / "curated")} + assert documents["evidence:fascia-pediatrica"].kind == "glossary" + assert documents["evidence:fascia-adulta"].kind == "domain" + + def test_prepare_retains_units_from_a_removed_source_as_blocking_orphans(tmp_path): source_text = "I pazienti sotto i 18 anni sono pediatrici." _write_workspace(tmp_path, _evidence(source_text), source_text) @@ -437,3 +478,146 @@ def test_prepare_marks_an_omitted_prior_unit_for_human_review(tmp_path): ] retained = load_curated_tree(tmp_path / "evidence" / "curated")[0] assert retained.review_items[0].code == "source_no_longer_supports_unit" + + +def test_resolve_retires_an_orphan_and_updates_curated_content_and_manifest_atomically(tmp_path): + from tht.evidence import resolve_workspace_evidence + + source_text = "I pazienti sotto i 18 anni sono pediatrici." + _write_workspace(tmp_path, _evidence(source_text), source_text) + source_path = tmp_path / "evidence" / "source" / "domain" / "patient.md" + source_path.unlink() + prepare_workspace_evidence(tmp_path, restructurer=_Restructurer([]), git_status=lambda _: ()) + + report = resolve_workspace_evidence( + tmp_path, + "evidence:fascia-pediatrica", + retire=True, + git_status=lambda _: (), + ) + + assert report.action == "retired" + assert report.evidence_id == "evidence:fascia-pediatrica" + assert not (tmp_path / "evidence" / "curated" / "domain" / "fascia-pediatrica.md").exists() + manifest = load_manifest(tmp_path / "evidence" / "manifest.yaml") + assert manifest.orphans == () + assert manifest.sources == {} + + +def test_resolve_relinks_an_unit_and_updates_its_manifest_membership(tmp_path): + from tht.evidence import resolve_workspace_evidence + + source_text = "I pazienti sotto i 18 anni sono pediatrici." + _write_workspace(tmp_path, _evidence(source_text), source_text) + next_source = tmp_path / "evidence" / "source" / "domain" / "patient-age.md" + next_source.write_text(source_text, encoding="utf-8") + + report = resolve_workspace_evidence( + tmp_path, + "evidence:fascia-pediatrica", + source="source/domain/patient-age.md", + git_status=lambda _: (), + ) + + assert report.action == "relinked" + assert report.source_file == "source/domain/patient-age.md" + document = load_curated_tree(tmp_path / "evidence" / "curated")[0] + assert document.provenance.source_file == "source/domain/patient-age.md" + manifest = load_manifest(tmp_path / "evidence" / "manifest.yaml") + assert manifest.sources["source/domain/patient.md"].units == () + assert manifest.sources["source/domain/patient-age.md"].units == ("evidence:fascia-pediatrica",) + assert validate_workspace_evidence(tmp_path).publishable is True + + +def test_resolve_relink_clears_an_unsupported_review_item_when_the_new_source_supports_it(tmp_path): + from tht.evidence import resolve_workspace_evidence + + source_text = "I pazienti sotto i 18 anni sono pediatrici." + _write_workspace(tmp_path, _evidence(source_text), source_text) + source_path = tmp_path / "evidence" / "source" / "domain" / "patient.md" + source_path.write_text("La classificazione pediatrica non รจ documentata.\n", encoding="utf-8") + prepare_workspace_evidence(tmp_path, restructurer=_Restructurer([]), git_status=lambda _: ()) + replacement_source = tmp_path / "evidence" / "source" / "domain" / "patient-revised.md" + replacement_source.write_text(source_text, encoding="utf-8") + + report = resolve_workspace_evidence( + tmp_path, + "evidence:fascia-pediatrica", + source="source/domain/patient-revised.md", + git_status=lambda _: (), + ) + + assert report.findings == () + document = load_curated_tree(tmp_path / "evidence" / "curated")[0] + assert all(item.code != "source_no_longer_supports_unit" for item in document.review_items) + assert validate_workspace_evidence(tmp_path).publishable is True + + +def test_resolve_refuses_an_unsafe_relink_without_writing(tmp_path): + from tht.evidence import EvidencePreparationError, resolve_workspace_evidence + + source_text = "I pazienti sotto i 18 anni sono pediatrici." + _write_workspace(tmp_path, _evidence(source_text), source_text) + curated_path = tmp_path / "evidence" / "curated" / "domain" / "fascia-pediatrica.md" + manifest_path = tmp_path / "evidence" / "manifest.yaml" + original_curated = curated_path.read_text(encoding="utf-8") + original_manifest = manifest_path.read_text(encoding="utf-8") + + with pytest.raises(EvidencePreparationError, match="source_invalid"): + resolve_workspace_evidence( + tmp_path, + "evidence:fascia-pediatrica", + source="../outside.md", + git_status=lambda _: (), + ) + + assert curated_path.read_text(encoding="utf-8") == original_curated + assert manifest_path.read_text(encoding="utf-8") == original_manifest + + +def test_resolve_refuses_a_relink_through_a_symlinked_source_directory(tmp_path): + from tht.evidence import EvidencePreparationError, resolve_workspace_evidence + + source_text = "I pazienti sotto i 18 anni sono pediatrici." + _write_workspace(tmp_path, _evidence(source_text), source_text) + curated_path = tmp_path / "evidence" / "curated" / "domain" / "fascia-pediatrica.md" + manifest_path = tmp_path / "evidence" / "manifest.yaml" + original_curated = curated_path.read_text(encoding="utf-8") + original_manifest = manifest_path.read_text(encoding="utf-8") + source_root = tmp_path / "evidence" / "source" + outside_source = tmp_path / "outside-source" + source_root.rename(outside_source) + source_root.symlink_to(outside_source, target_is_directory=True) + + with pytest.raises(EvidencePreparationError, match="source_invalid"): + resolve_workspace_evidence( + tmp_path, + "evidence:fascia-pediatrica", + source="source/domain/patient.md", + git_status=lambda _: (), + ) + + assert curated_path.read_text(encoding="utf-8") == original_curated + assert manifest_path.read_text(encoding="utf-8") == original_manifest + + +def test_resolve_rejects_any_dirty_worktree_without_writing(tmp_path): + from tht.evidence import EvidencePreparationError, resolve_workspace_evidence + + source_text = "I pazienti sotto i 18 anni sono pediatrici." + _write_workspace(tmp_path, _evidence(source_text), source_text) + curated_path = tmp_path / "evidence" / "curated" / "domain" / "fascia-pediatrica.md" + manifest_path = tmp_path / "evidence" / "manifest.yaml" + original_curated = curated_path.read_text(encoding="utf-8") + original_manifest = manifest_path.read_text(encoding="utf-8") + + with pytest.raises(EvidencePreparationError, match="worktree_dirty"): + resolve_workspace_evidence( + tmp_path, + "evidence:fascia-pediatrica", + retire=True, + git_status=lambda _: (" M README.md",), + ) + + assert curated_path.read_text(encoding="utf-8") == original_curated + assert manifest_path.read_text(encoding="utf-8") == original_manifest diff --git a/harness/tests/test_evidence_cli.py b/harness/tests/test_evidence_cli.py index 25d7477e..ea182c50 100644 --- a/harness/tests/test_evidence_cli.py +++ b/harness/tests/test_evidence_cli.py @@ -10,9 +10,86 @@ def test_evidence_authoring_commands_are_distinct_from_runtime_preprocessing(): assert result.exit_code == 0 assert "prepare" in result.output + assert "resolve" in result.output assert "validate" in result.output +def test_evidence_resolve_json_is_pristine(monkeypatch, tmp_path): + from tht.cli import evidence_cmd + from tht.evidence.authoring import EvidenceResolutionReport + + monkeypatch.setattr(evidence_cmd, "_canonical_worktree", lambda root: root) + monkeypatch.setattr(evidence_cmd, "resolve_workspace_evidence", lambda *args, **kwargs: EvidenceResolutionReport( + action="retired", + evidence_id="evidence:fascia-pediatrica", + source_file=None, + findings=(), + )) + + result = CliRunner().invoke(app, [ + "evidence", "resolve", str(tmp_path), "evidence:fascia-pediatrica", "--retire", "--json", + ]) + + assert result.exit_code == 0 + assert result.stderr == "" + assert json.loads(result.stdout) == { + "action": "retired", + "evidenceId": "evidence:fascia-pediatrica", + "findings": [], + "operation": "evidence_resolve", + "schemaVersion": 1, + "sourceFile": None, + "status": "resolved", + } + + +def test_evidence_resolve_reports_unsafe_source_as_cli_misuse(monkeypatch, tmp_path): + from tht.cli import evidence_cmd + from tht.evidence import EvidencePreparationError + + monkeypatch.setattr(evidence_cmd, "_canonical_worktree", lambda root: root) + monkeypatch.setattr( + evidence_cmd, + "resolve_workspace_evidence", + lambda *args, **kwargs: (_ for _ in ()).throw(EvidencePreparationError("source_invalid")), + ) + + result = CliRunner().invoke(app, [ + "evidence", "resolve", str(tmp_path), "evidence:fascia-pediatrica", "--source", "source/domain/x.md", "--json", + ]) + + assert result.exit_code == 2 + assert result.stderr == "" + assert json.loads(result.stdout) == { + "code": "source_invalid", + "operation": "evidence_resolve", + "schemaVersion": 1, + "status": "failed", + } + + +def test_evidence_resolve_reports_structural_findings_as_operational_failure(monkeypatch, tmp_path): + from tht.cli import evidence_cmd + from tht.evidence import ValidationFinding + from tht.evidence.authoring import EvidenceResolutionReport + + monkeypatch.setattr(evidence_cmd, "_canonical_worktree", lambda root: root) + monkeypatch.setattr(evidence_cmd, "resolve_workspace_evidence", lambda *args, **kwargs: EvidenceResolutionReport( + action="relinked", + evidence_id="evidence:fascia-pediatrica", + source_file="source/domain/patient.md", + findings=(ValidationFinding( + "error", "supporting_excerpt_missing", "source/domain/patient.md", "Excerpt missing.", + ),), + )) + + result = CliRunner().invoke(app, [ + "evidence", "resolve", str(tmp_path), "evidence:fascia-pediatrica", "--source", "source/domain/patient.md", + ]) + + assert result.exit_code == 1 + + def test_evidence_validate_json_is_pristine_and_reports_review_required(monkeypatch, tmp_path): from tht.cli import evidence_cmd from tht.evidence import ValidationFinding, ValidationReport diff --git a/harness/tht/cli/evidence_cmd.py b/harness/tht/cli/evidence_cmd.py index 21fd2ec8..2994a141 100644 --- a/harness/tht/cli/evidence_cmd.py +++ b/harness/tht/cli/evidence_cmd.py @@ -14,6 +14,7 @@ from tht.evidence import ( EvidencePreparationError, PiEvidenceRestructurer, prepare_workspace_evidence, + resolve_workspace_evidence, validate_workspace_evidence, ) @@ -103,3 +104,38 @@ def validate_cmd( if all(finding.code in {"orphaned_unit", "unresolved_review_item"} for finding in report.findings): raise typer.Exit(code=3) raise typer.Exit(code=1) + + +@evidence_app.command("resolve") +def resolve_cmd( + workspace_root: Path, + evidence_id: str, + retire: Annotated[bool, typer.Option(help="Retire the Evidence unit.")] = False, + source: Annotated[Path | None, typer.Option(help="Relink the unit to this source/ path.")] = None, + json_output: Annotated[bool, typer.Option("--json", help="Write machine JSON to stdout.")] = False, +) -> None: + """Explicitly retire or relink one Evidence unit without publishing it.""" + if retire == (source is not None): + raise typer.BadParameter("choose exactly one of --retire or --source") + root = _canonical_worktree(workspace_root) + try: + report = resolve_workspace_evidence(root, evidence_id, retire=retire, source=source) + except EvidencePreparationError as error: + _emit({"schemaVersion": 1, "operation": "evidence_resolve", "status": "failed", "code": error.code}, json_output) + exit_code = 2 if error.code in { + "evidence_id_invalid", "resolve_mode_invalid", "source_invalid", + } else 1 + raise typer.Exit(code=exit_code) from error + _emit({ + "schemaVersion": 1, + "operation": "evidence_resolve", + "status": "resolved", + "action": report.action, + "evidenceId": report.evidence_id, + "sourceFile": report.source_file, + "findings": _findings_payload(report.findings), + }, json_output) + if report.findings: + if all(finding.code in {"orphaned_unit", "unresolved_review_item"} for finding in report.findings): + raise typer.Exit(code=3) + raise typer.Exit(code=1) diff --git a/harness/tht/evidence/__init__.py b/harness/tht/evidence/__init__.py index 54fbb8be..184bdb92 100644 --- a/harness/tht/evidence/__init__.py +++ b/harness/tht/evidence/__init__.py @@ -5,6 +5,7 @@ from tht.evidence.authoring import ( EvidenceManifest, EvidencePreparationError, EvidencePreparationReport, + EvidenceResolutionReport, EvidenceRestructurer, PiEvidenceRestructurer, RestructureCandidate, @@ -14,6 +15,7 @@ from tht.evidence.authoring import ( dump_manifest, load_manifest, prepare_workspace_evidence, + resolve_workspace_evidence, validate_workspace_evidence, ) from tht.evidence.canonical import ( @@ -54,6 +56,7 @@ __all__ = [ "EvidenceManifest", "EvidencePreparationError", "EvidencePreparationReport", + "EvidenceResolutionReport", "EvidenceRestructurer", "EvidenceSource", "EvidenceSourceError", @@ -80,6 +83,7 @@ __all__ = [ "prepare_workspace_evidence", "project_session", "resolve_citation", + "resolve_workspace_evidence", "validate_corpus_workspace", "validate_namespaced_value", "validate_safe_metadata", diff --git a/harness/tht/evidence/authoring.py b/harness/tht/evidence/authoring.py index 73f51eb4..b259f9e3 100644 --- a/harness/tht/evidence/authoring.py +++ b/harness/tht/evidence/authoring.py @@ -173,6 +173,16 @@ class EvidencePreparationReport: model_calls: int +@dataclass(frozen=True) +class EvidenceResolutionReport: + """The result of one curator-directed Evidence resolution.""" + + action: Literal["retired", "relinked"] + evidence_id: str + source_file: str | None + findings: tuple[ValidationFinding, ...] + + class ManifestSource(StrictModel): sha256: str units: tuple[str, ...] @@ -531,6 +541,100 @@ def prepare_workspace_evidence( ) +def resolve_workspace_evidence( + workspace_root: Path, + evidence_id: str, + *, + retire: bool = False, + source: str | Path | None = None, + git_status: Callable[[Path], tuple[str, ...]] | None = None, +) -> EvidenceResolutionReport: + """Retire or relink one Evidence unit as one staged curator update. + + The operation intentionally neither creates a commit nor publishes anything. Both + the curated tree and the managed manifest are replaced only after the staged tree + has been written and structurally validated. + """ + if retire == (source is not None): + raise EvidencePreparationError("resolve_mode_invalid") + if not is_evidence_id(evidence_id): + raise EvidencePreparationError("evidence_id_invalid") + + workspace_root = workspace_root.resolve() + evidence_root = workspace_root / "evidence" + _reject_dirty_worktree(workspace_root, git_status or _git_status) + try: + manifest = load_manifest(evidence_root / "manifest.yaml") + documents = load_curated_tree(evidence_root / "curated") + except (OSError, ValidationError, ValueError) as error: + raise EvidencePreparationError("authoring_state_invalid") from error + + documents_by_id = {document.id: document for document in documents} + if len(documents_by_id) != len(documents): + raise EvidencePreparationError("duplicate_evidence_id") + if evidence_id not in documents_by_id: + raise EvidencePreparationError("evidence_not_found") + + source_units = {path: tuple(entry.units) for path, entry in manifest.sources.items()} + orphaned = set(manifest.orphans) + source_file: str | None = None + action: Literal["retired", "relinked"] + + if retire: + action = "retired" + del documents_by_id[evidence_id] + for path, units in tuple(source_units.items()): + source_units[path] = tuple(unit for unit in units if unit != evidence_id) + orphaned.discard(evidence_id) + else: + action = "relinked" + assert source is not None + source_file = _resolve_source_file(evidence_root, source) + source_text = _load_resolution_source(evidence_root, source_file) + document = documents_by_id[evidence_id] + review_items = document.review_items + if all(_normalize(excerpt) in source_text for excerpt in document.provenance.supporting_excerpts): + review_items = tuple( + item for item in review_items if item.code != "source_no_longer_supports_unit" + ) + documents_by_id[evidence_id] = document.model_copy(update={ + "provenance": document.provenance.model_copy(update={ + "source_file": source_file, + "source_sha256": _source_hash(source_text), + }), + "review_items": review_items, + }) + for path, units in tuple(source_units.items()): + source_units[path] = tuple(unit for unit in units if unit != evidence_id) + source_units[source_file] = tuple(sorted((*source_units.get(source_file, ()), evidence_id))) + orphaned.discard(evidence_id) + + sources = { + path: ManifestSource( + sha256=( + _source_hash(_load_resolution_source(evidence_root, path)) + if path == source_file + else manifest.sources[path].sha256 + ), + units=units, + ) + for path, units in sorted(source_units.items()) + } + next_manifest = EvidenceManifest( + schema_version=1, + pipeline_version=manifest.pipeline_version, + sources=sources, + orphans=tuple(sorted(orphaned)), + ) + findings = _stage_and_apply_authoring_tree(workspace_root, documents_by_id, next_manifest) + return EvidenceResolutionReport( + action=action, + evidence_id=evidence_id, + source_file=source_file, + findings=findings, + ) + + def _empty_manifest() -> EvidenceManifest: return EvidenceManifest( schema_version=1, @@ -558,6 +662,30 @@ def _load_preparation_manifest(path: Path, *, upgrade: bool) -> EvidenceManifest raise EvidencePreparationError("authoring_state_invalid") from error +def _resolve_source_file(evidence_root: Path, source: str | Path) -> str: + raw_source = source.as_posix() if isinstance(source, Path) else source + try: + source_file = validate_source_file(raw_source) + except ValueError as error: + raise EvidencePreparationError("source_invalid", raw_source) from error + path = evidence_root / source_file + ancestor = evidence_root + for part in Path(source_file).parts: + ancestor /= part + if ancestor.is_symlink(): + raise EvidencePreparationError("source_invalid", source_file) + if not path.is_file() or path.stat().st_size > MAX_AUTHORING_FILE_BYTES: + raise EvidencePreparationError("source_invalid", source_file) + return source_file + + +def _load_resolution_source(evidence_root: Path, source_file: str) -> str: + try: + return normalize_source_text((evidence_root / source_file).read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError) as error: + raise EvidencePreparationError("source_invalid", source_file) from error + + def _git_status(workspace_root: Path) -> tuple[str, ...]: result = subprocess.run( ["git", "status", "--porcelain"], @@ -581,6 +709,14 @@ def _reject_dirty_authoring_state( raise EvidencePreparationError("authoring_worktree_dirty") +def _reject_dirty_worktree( + workspace_root: Path, + git_status: Callable[[Path], tuple[str, ...]], +) -> None: + if git_status(workspace_root): + raise EvidencePreparationError("worktree_dirty") + + def _load_source_texts(evidence_root: Path) -> dict[str, str]: source_root = evidence_root / "source" if not source_root.is_dir():