diff --git a/targetintel/functional_dependency/presentation.py b/targetintel/functional_dependency/presentation.py new file mode 100644 index 0000000..48086c1 --- /dev/null +++ b/targetintel/functional_dependency/presentation.py @@ -0,0 +1,176 @@ +"""Pure, read-only presentation of portable DepMap dependency evidence. + +This module consumes only :class:`DependencyReportEvidence`. It does not load +snapshots, profiles, matrices, or upstream workflow state. +""" +from __future__ import annotations + +from html import escape as html_escape +import json +import math +import re +from typing import Any, Mapping + +from .report_contract import DependencyReportEvidence + + +class DependencyPresentationError(ValueError): + """Raised when a report-evidence boundary is unsuitable for rendering.""" + + +_FIXED_LIMITATIONS = ( + "DepMap cell-line dependency is not clinical anti-PD-1 response evidence.", + "Absence of tumor-cell dependency does not invalidate an immune target.", + "Broad dependency may reflect general essentiality.", + "Cell lines do not reproduce the complete tumor microenvironment.", + "Candidate activation requires explicit human review.", +) + + +def _evidence(value: Any) -> DependencyReportEvidence: + if not isinstance(value, DependencyReportEvidence): + raise DependencyPresentationError("invalid_dependency_report_evidence") + if (value.baseline_preserved is not True + or value.production_activation_enabled is not False + or value.approved_authorization_emitted is not False + or value.human_review_required is not True): + raise DependencyPresentationError("invalid_dependency_activation_state") + return value + + +def _release_name(identifier: str) -> str: + """Convert a machine identifier to the deliberately small display form.""" + return identifier.replace("_", " ") + + +def _markdown_text(value: Any) -> str: + """Keep untrusted text to one Markdown line and out of Markdown syntax.""" + text = str(value).replace("\r", " ").replace("\n", " ") + return (text.replace("\\", "\\\\").replace("`", "\\`") + .replace("<", "<").replace(">", ">")) + + +def _json(value: Any) -> str: + """Produce a stable representation of contract-approved structured values.""" + try: + return json.dumps(_plain(value), sort_keys=True, separators=(",", ":"), + ensure_ascii=False, allow_nan=False) + except (TypeError, ValueError) as exc: + raise DependencyPresentationError("unsupported_structured_value") from exc + + +def _plain(value: Any) -> Any: + """Convert immutable contract containers to JSON-compatible containers.""" + if isinstance(value, Mapping): + return {key: _plain(item) for key, item in value.items()} + if isinstance(value, tuple): + return [_plain(item) for item in value] + return value + + +def _number(value: int | float | None, *, unavailable: bool = False) -> str: + if value is None: + return "not available" if unavailable else "not reported" + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise DependencyPresentationError("unsupported_structured_value") + if isinstance(value, float) and not math.isfinite(value): + raise DependencyPresentationError("unsupported_structured_value") + return _json(value) + + +def _text(value: str | None, *, unavailable: bool = False) -> str: + if value is None: + return "not available" if unavailable else "not reported" + return _markdown_text(value) + + +def _markdown_mapping(value: Mapping[str, Any] | None) -> str: + return "not reported" if value is None else _markdown_text(_json(value)) + + +def _limitations(evidence: DependencyReportEvidence) -> tuple[str, ...]: + retained = tuple(sorted(evidence.limitations)) + return retained + tuple(item for item in _FIXED_LIMITATIONS if item not in retained) + + +def render_dependency_markdown(evidence: DependencyReportEvidence) -> str: + """Render deterministic Markdown without modifying or deriving evidence.""" + evidence = _evidence(evidence) + unavailable = not evidence.profile_available + title = _release_name(evidence.release_identifier) + lines = [ + f"## Functional dependency — {_markdown_text(title)}", "", + "### Coverage", "", + f"- **Profile available:** {'yes' if evidence.profile_available else 'no'}", + f"- **Coverage status:** {_markdown_text(evidence.coverage_status)}", + f"- **Total model count:** {_number(evidence.model_count, unavailable=unavailable)}", + f"- **Context model count:** {_number(evidence.context_model_count, unavailable=unavailable)}", + f"- **Reference model count:** {_number(evidence.reference_model_count, unavailable=unavailable)}", + f"- **Available context observations:** {_number(evidence.available_context_observations, unavailable=unavailable)}", + f"- **Available reference observations:** {_number(evidence.available_reference_observations, unavailable=unavailable)}", + f"- **Coverage fraction:** {_number(evidence.coverage_fraction, unavailable=unavailable)}", + f"- **Missing-value state:** {_text(evidence.missing_value_state, unavailable=unavailable)}", + f"- **Unavailable reason:** {_text(evidence.unavailable_reason, unavailable=unavailable)}", + "", "### Dependency profile", "", + ] + if unavailable: + lines.extend([ + "No validated DepMap profile is available for this target in the portable evidence bundle. " + "No dependency conclusion is drawn.", + ]) + else: + lines.extend([ + f"- **Gene-effect summary:** {_markdown_mapping(evidence.gene_effect)}", + f"- **Dependency-probability summary:** {_markdown_mapping(evidence.dependency_probability)}", + f"- **Context-versus-reference comparison:** {_markdown_mapping(evidence.context_reference_comparison)}", + f"- **Selectivity:** {_markdown_mapping(evidence.selectivity)}", + f"- **Dependency interpretation state:** {_text(evidence.dependency_interpretation_state)}", + ]) + lines.extend([ + "", "### Integration", "", + f"- **Baseline rank:** {_number(evidence.baseline_rank)}", + f"- **Dependency-aware candidate rank:** {_number(evidence.dependency_aware_candidate_rank)}", + f"- **Rank delta:** {_number(evidence.rank_delta)}", + "- **Rank-delta convention:** dependency-aware candidate rank minus baseline rank.", + "- **Negative rank delta:** movement toward a lower numerical rank.", + f"- **Integration state:** {_text(evidence.integration_state)}", + "- **Baseline preserved:** yes", + "- **Production activation enabled:** disabled", + "- **Approved authorization emitted:** not emitted", + f"- **Candidate activation readiness:** {_text(evidence.candidate_activation_readiness)}", + "- **Human review required:** required", + "", "### Release provenance", "", + f"- **Evidence ID:** `{_markdown_text(evidence.evidence_id)}`", + f"- **Release identifier:** `{_markdown_text(evidence.release_identifier)}`", + f"- **Release manifest ID:** `{_markdown_text(evidence.release_manifest_id)}`", + f"- **Configuration ID:** `{_markdown_text(evidence.configuration_id)}`", + f"- **Scientific closure identity:** `{_markdown_text(evidence.scientific_closure_identity)}`", + f"- **Context identity:** `{_markdown_text(evidence.context_identity)}`", + f"- **Canonical gene identity:** `{_text(evidence.canonical_gene_identity, unavailable=unavailable)}`", + f"- **Contract format version:** `{_markdown_text(evidence.format_version)}`", + "- **Portable source artifacts:** " + ", ".join( + f"`{_markdown_text(name)}`" for name in evidence.provenance["source_artifact_names"] + ), + "", "### Limitations", "", + ]) + lines.extend(f"- {_markdown_text(item)}" for item in _limitations(evidence)) + return "\n".join(lines) + "\n" + + +def render_dependency_html(evidence: DependencyReportEvidence) -> str: + """Render the same escaped content in a semantic dedicated HTML section.""" + markdown = render_dependency_markdown(evidence) + _, body = markdown.split("\n\n", 1) + sections = re.split(r"\n(?=### )", body.rstrip("\n")) + rendered: list[str] = [ + '
', + f'

Functional dependency — {html_escape(_release_name(evidence.release_identifier), quote=True)}

', + ] + for section in sections: + heading, content = section.split("\n", 1) + if not heading.startswith("### "): + raise DependencyPresentationError("invalid_dependency_presentation_structure") + rendered.append(f"

{html_escape(heading[4:], quote=True)}

") + rendered.append(f'
{html_escape(content, quote=True)}
') + rendered.append("
") + return "\n".join(rendered) + "\n" diff --git a/targetintel/html_reports.py b/targetintel/html_reports.py index 2f4b3e4..50c8809 100644 --- a/targetintel/html_reports.py +++ b/targetintel/html_reports.py @@ -22,6 +22,11 @@ make_feasibility_report_section, render_feasibility_html, ) +from targetintel.functional_dependency.presentation import ( + DependencyPresentationError, + render_dependency_html, +) +from targetintel.functional_dependency.report_contract import DependencyReportEvidence DEFAULT_HTML_REPORT_DIR = Path("results/html_reports") @@ -374,6 +379,7 @@ def make_target_html_report( evidence_card: EvidenceCard | None = None, feasibility_annotations: tuple[object, ...] | list[object] | None = None, feasibility_target_identifier_type: str | None = None, + dependency_evidence: DependencyReportEvidence | None = None, ) -> str: """ Generate one standalone HTML target report. @@ -401,6 +407,13 @@ def make_target_html_report( annotations=feasibility_annotations, )) feasibility_suffix = f"\n\n{feasibility_html}" if feasibility_html else "" + dependency_suffix = "" + if dependency_evidence is not None: + if not isinstance(dependency_evidence, DependencyReportEvidence): + raise DependencyPresentationError("invalid_dependency_report_evidence") + if dependency_evidence.gene_symbol != symbol: + raise DependencyPresentationError("dependency_evidence_target_mismatch") + dependency_suffix = f"\n\n{render_dependency_html(dependency_evidence)}" html = f""" @@ -443,7 +456,7 @@ def make_target_html_report( -{_make_evidence_html(evidence_card)}{feasibility_suffix} +{_make_evidence_html(evidence_card)}{feasibility_suffix}{dependency_suffix}

Stable TargetIntel-IO classification

@@ -588,6 +601,7 @@ def write_target_html_report( evidence_card: EvidenceCard | None = None, feasibility_annotations: tuple[object, ...] | list[object] | None = None, feasibility_target_identifier_type: str | None = None, + dependency_evidence: DependencyReportEvidence | None = None, ) -> Path: """ Write one standalone HTML report. @@ -603,6 +617,7 @@ def write_target_html_report( row, evidence_card=evidence_card, feasibility_annotations=feasibility_annotations, feasibility_target_identifier_type=feasibility_target_identifier_type, + dependency_evidence=dependency_evidence, ), encoding="utf-8", ) @@ -779,6 +794,7 @@ def write_top_html_reports( evidence_cards: Mapping[str, EvidenceCard] | None = None, feasibility_annotations: Mapping[str, tuple[object, ...] | list[object]] | None = None, feasibility_target_identifier_type: str | None = None, + dependency_evidence_by_symbol: Mapping[str, DependencyReportEvidence] | None = None, ) -> list[Path]: """ Write HTML reports for the union of top-N targets across all modes. @@ -828,6 +844,7 @@ def write_top_html_reports( evidence_card=(evidence_cards or {}).get(str(row["target_symbol"])), feasibility_annotations=target_annotations, feasibility_target_identifier_type=feasibility_target_identifier_type, + dependency_evidence=(dependency_evidence_by_symbol or {}).get(str(row["target_symbol"])), )) index_path = write_html_index( diff --git a/targetintel/hypothesis_cards.py b/targetintel/hypothesis_cards.py index 9d0d8f5..5879315 100644 --- a/targetintel/hypothesis_cards.py +++ b/targetintel/hypothesis_cards.py @@ -20,6 +20,11 @@ make_feasibility_report_section, render_feasibility_markdown, ) +from targetintel.functional_dependency.presentation import ( + DependencyPresentationError, + render_dependency_markdown, +) +from targetintel.functional_dependency.report_contract import DependencyReportEvidence DEFAULT_CARD_DIR = Path("results/target_cards") @@ -201,6 +206,7 @@ def make_target_card( evidence_card: EvidenceCard | None = None, feasibility_annotations: tuple[object, ...] | list[object] | None = None, feasibility_target_identifier_type: str | None = None, + dependency_evidence: DependencyReportEvidence | None = None, ) -> str: """ Generate one Markdown target hypothesis card. @@ -226,6 +232,13 @@ def make_target_card( ) ) feasibility_suffix = f"\n{feasibility_section}" if feasibility_section else "" + dependency_suffix = "" + if dependency_evidence is not None: + if not isinstance(dependency_evidence, DependencyReportEvidence): + raise DependencyPresentationError("invalid_dependency_report_evidence") + if dependency_evidence.gene_symbol != symbol: + raise DependencyPresentationError("dependency_evidence_target_mismatch") + dependency_suffix = f"\n{render_dependency_markdown(dependency_evidence)}" card = f"""# Target hypothesis card: {symbol} @@ -280,7 +293,7 @@ def make_target_card( This card is generated by TargetIntel-IO as a transparent, rule-based target triage summary. It is intended for hypothesis generation and portfolio demonstration only. It does not represent clinical advice or validated therapeutic evidence. -{make_evidence_card_section(evidence_card)}{feasibility_suffix} +{make_evidence_card_section(evidence_card)}{feasibility_suffix}{dependency_suffix} """ return card @@ -292,6 +305,7 @@ def write_target_card( evidence_card: EvidenceCard | None = None, feasibility_annotations: tuple[object, ...] | list[object] | None = None, feasibility_target_identifier_type: str | None = None, + dependency_evidence: DependencyReportEvidence | None = None, ) -> Path: """ Write one target card to Markdown. @@ -307,6 +321,7 @@ def write_target_card( row, evidence_card=evidence_card, feasibility_annotations=feasibility_annotations, feasibility_target_identifier_type=feasibility_target_identifier_type, + dependency_evidence=dependency_evidence, ), encoding="utf-8", ) @@ -321,6 +336,7 @@ def write_top_target_cards( evidence_cards: Mapping[str, EvidenceCard] | None = None, feasibility_annotations: Mapping[str, tuple[object, ...] | list[object]] | None = None, feasibility_target_identifier_type: str | None = None, + dependency_evidence_by_symbol: Mapping[str, DependencyReportEvidence] | None = None, ) -> list[Path]: """ Write Markdown target cards for the top targets across all modes. @@ -370,6 +386,7 @@ def write_top_target_cards( evidence_card=(evidence_cards or {}).get(str(row["target_symbol"])), feasibility_annotations=target_annotations, feasibility_target_identifier_type=feasibility_target_identifier_type, + dependency_evidence=(dependency_evidence_by_symbol or {}).get(str(row["target_symbol"])), )) return written_paths diff --git a/tests/test_depmap_report_rendering.py b/tests/test_depmap_report_rendering.py new file mode 100644 index 0000000..3a8e3a9 --- /dev/null +++ b/tests/test_depmap_report_rendering.py @@ -0,0 +1,131 @@ +"""Synthetic tests for read-only DepMap report presentation.""" +from __future__ import annotations + +import pandas as pd +import pytest + +from targetintel.functional_dependency.presentation import ( + DependencyPresentationError, + render_dependency_html, + render_dependency_markdown, +) +from targetintel.hypothesis_cards import make_target_card, write_top_target_cards +from targetintel.html_reports import make_target_html_report, write_top_html_reports +from test_depmap_report_contract import available, unavailable +from test_feasibility_presentation import _annotation, _observation + + +def _row(symbol: str = "BRAF") -> pd.Series: + return pd.Series({"target_symbol": symbol, "target_name": "B-Raf"}) + + +def _ranked() -> pd.DataFrame: + return pd.DataFrame([ + {**_row("BRAF").to_dict(), "antibody_io_rank": 1, "biomarker_rank": 1, "small_molecule_rank": 1}, + {**_row("NRAS").to_dict(), "antibody_io_rank": 2, "biomarker_rank": 2, "small_molecule_rank": 2}, + ]) + + +def test_markdown_is_deterministic_and_retains_required_available_fields() -> None: + evidence = available() + rendered = render_dependency_markdown(evidence) + assert rendered == render_dependency_markdown(evidence) + for text in ( + "## Functional dependency — DepMap Public 26Q1", "### Coverage", + "### Dependency profile", "### Integration", "### Release provenance", + "### Limitations", "**Gene-effect summary:** {\"measured_model_count\":4,\"median\":0.0}", + "**Dependency-probability summary:** {\"measured_model_count\":4,\"median\":null}", + "**Context-versus-reference comparison:**", "**Selectivity:**", + "**Dependency interpretation state:** valid", "**Baseline rank:** 7", + "**Dependency-aware candidate rank:** 5", "**Rank delta:** -2", + "dependency-aware candidate rank minus baseline rank", "Baseline preserved:** yes", + "Production activation enabled:** disabled", "Approved authorization emitted:** not emitted", + "Human review required:** required", evidence.evidence_id, "manifest", + "configuration", "closure", "melanoma_anti_pd1:v1", "BRAF:673", + "release_summary.json", "candidate_overlay.tsv", + ): + assert text in rendered + assert "- **Gene-effect summary:**" in rendered + assert "not reported" in rendered + assert "0.0" in rendered + assert "score" not in rendered.lower() + assert "recommend" not in rendered.lower() + + +def test_unavailable_profile_is_calibrated_and_distinct_from_missing() -> None: + rendered = render_dependency_markdown(unavailable()) + assert "**Profile available:** no" in rendered + assert "**Total model count:** not available" in rendered + assert "No validated DepMap profile is available" in rendered + assert "No dependency conclusion is drawn." in rendered + assert "Gene-effect summary" not in rendered + + +def test_html_is_deterministic_semantic_and_escapes_values() -> None: + evidence = available( + gene_effect={"text": "&\"'", "nested": {"x": 0}}, + provenance={"source_artifact_names": ["release_summary.json"]}, + ) + rendered = render_dependency_html(evidence) + assert rendered == render_dependency_html(evidence) + assert '
' in rendered + for heading in ("Coverage", "Dependency profile", "Integration", "Release provenance", "Limitations"): + assert f"

{heading}

" in rendered + assert "&lt;tag&gt;&\\\\"'" in rendered + + +def test_cards_and_html_attach_only_matching_evidence_and_preserve_legacy() -> None: + row = _row() + card, html = make_target_card(row), make_target_html_report(row) + assert make_target_card(row, dependency_evidence=None) == card + assert make_target_html_report(row, dependency_evidence=None) == html + decorated_card = make_target_card(row, dependency_evidence=available()) + decorated_html = make_target_html_report(row, dependency_evidence=available()) + assert decorated_card.startswith(card.rstrip() + "\n") + assert "Functional dependency — DepMap Public 26Q1" in decorated_card + assert '
' in decorated_html + with pytest.raises(DependencyPresentationError, match="target_mismatch"): + make_target_card(row, dependency_evidence=available(gene_symbol="NRAS")) + with pytest.raises(DependencyPresentationError, match="target_mismatch"): + make_target_html_report(row, dependency_evidence=available(gene_symbol="NRAS")) + + +def test_batch_writers_route_evidence_without_mutating_mapping(tmp_path) -> None: + evidence_by_symbol = {"BRAF": available()} + original = dict(evidence_by_symbol) + cards = write_top_target_cards(_ranked(), tmp_path / "cards", top_n_per_mode=2, + dependency_evidence_by_symbol=evidence_by_symbol) + reports = write_top_html_reports(_ranked(), tmp_path / "html", top_n_per_mode=2, + dependency_evidence_by_symbol=evidence_by_symbol) + assert evidence_by_symbol == original + assert "Functional dependency" in cards[0].read_text(encoding="utf-8") + assert "Functional dependency" not in cards[1].read_text(encoding="utf-8") + assert "Functional dependency" in (tmp_path / "html" / "BRAF.html").read_text(encoding="utf-8") + assert "Functional dependency" not in (tmp_path / "html" / "NRAS.html").read_text(encoding="utf-8") + assert reports[0].name == "index.html" + + +def test_renderer_rejects_non_contract_value() -> None: + with pytest.raises(DependencyPresentationError, match="invalid_dependency_report_evidence"): + render_dependency_markdown(object()) # type: ignore[arg-type] + + +def test_dependency_section_follows_feasibility_without_changing_card_content() -> None: + card = make_target_card( + _row(), dependency_evidence=available(), + feasibility_annotations=(_annotation("antibody", (_observation("tractability", "antibody"),)),), + feasibility_target_identifier_type="gene_symbol", + ) + assert card.index("## Target feasibility — research-only") < card.index("## Functional dependency") + + +def test_pure_renderer_does_not_use_file_or_network_boundaries(monkeypatch) -> None: + import builtins + import socket + + def forbidden(*_args, **_kwargs): + raise AssertionError("presentation attempted external access") + + monkeypatch.setattr(builtins, "open", forbidden) + monkeypatch.setattr(socket, "socket", forbidden) + assert "Functional dependency" in render_dependency_markdown(available())