diff --git a/result_server/routes/results_detail_routes.py b/result_server/routes/results_detail_routes.py index d4780b8..bbce3a9 100644 --- a/result_server/routes/results_detail_routes.py +++ b/result_server/routes/results_detail_routes.py @@ -1,5 +1,6 @@ import json import os +import re from flask import abort, current_app, render_template, request, url_for from werkzeug.exceptions import Forbidden, NotFound @@ -27,7 +28,6 @@ get_file_confidential_tags, load_public_result_json, load_permitted_result_json, - padata_matches_public_result, serve_permitted_result_file, serve_public_padata_file, ) @@ -39,6 +39,11 @@ from utils.trigger_display import load_trigger_run_lookup, summarize_execution_trigger +PADATA_ARTIFACT_BASENAME_RE = re.compile( + r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}\.(?:tgz|tar\.gz)" +) + + def register_results_detail_routes(results_bp): def public_surface(): return current_app.config.get("PUBLIC_PORTAL_MODE", False) @@ -75,7 +80,7 @@ def result_detail(filename): ) quality = summarize_result_quality(result) padata_dir = current_app.config.get("RECEIVED_PADATA_DIR", current_app.config["RECEIVED_DIR"]) - padata_filenames = _list_public_padata_filenames(padata_dir) if is_public_surface else [ + padata_filenames = _list_result_padata_filenames(result, padata_dir) if is_public_surface else [ name for name in os.listdir(padata_dir) if name.endswith(".tgz") ] detail_context = build_result_detail_context( @@ -281,7 +286,7 @@ def show_result(filename): def _build_public_reuse_manifest_for_route(result, filename): padata_dir = current_app.config.get("RECEIVED_PADATA_DIR", current_app.config["RECEIVED_DIR"]) - padata_filenames = _list_public_padata_filenames(padata_dir) + padata_filenames = _list_result_padata_filenames(result, padata_dir) padata_urls = { name: url_for("results.show_result", filename=name) for name in padata_filenames @@ -296,10 +301,52 @@ def _build_public_reuse_manifest_for_route(result, filename): padata_url_by_filename=padata_urls, ) - def _list_public_padata_filenames(padata_dir): - return [ - name - for name in os.listdir(padata_dir) - if name.endswith(".tgz") - and padata_matches_public_result(name, current_app.config["RECEIVED_DIR"]) - ] + +def _list_result_padata_filenames(result, padata_dir): + result_uuid = _clean_result_value(result.get("_server_uuid")) + timestamp = _clean_result_value(result.get("_server_timestamp")) + if not result_uuid or not timestamp: + return [] + + filenames = [] + seen = set() + for artifact_path in _iter_result_padata_artifact_paths(result): + artifact_slug = _padata_artifact_slug(artifact_path) + if not artifact_slug: + continue + filename = f"padata_{timestamp}_{result_uuid}_{artifact_slug}.tgz" + if filename in seen: + continue + seen.add(filename) + if os.path.isfile(os.path.join(padata_dir, filename)): + filenames.append(filename) + return filenames + + +def _iter_result_padata_artifact_paths(result): + breakdown = result.get("fom_breakdown") + if not isinstance(breakdown, dict): + return + for collection_name in ("sections", "overlaps"): + for item in breakdown.get(collection_name) or []: + if not isinstance(item, dict): + continue + for artifact in item.get("artifacts") or []: + if not isinstance(artifact, dict) or artifact.get("type") != "file_reference": + continue + path = _clean_result_value(artifact.get("path")) + if path: + yield path + + +def _padata_artifact_slug(artifact_path): + if not isinstance(artifact_path, str) or not artifact_path.startswith("results/"): + return "" + basename = os.path.basename(artifact_path) + if not PADATA_ARTIFACT_BASENAME_RE.fullmatch(basename): + return "" + return basename[:-7] if basename.endswith(".tar.gz") else basename[:-4] + + +def _clean_result_value(value): + return str(value or "").strip() diff --git a/result_server/templates/_usage_report_evidence_snapshot_section.html b/result_server/templates/_usage_report_evidence_snapshot_section.html index 21cd10a..7344bad 100644 --- a/result_server/templates/_usage_report_evidence_snapshot_section.html +++ b/result_server/templates/_usage_report_evidence_snapshot_section.html @@ -18,7 +18,7 @@

Evidence Snapshot

Evidence Snapshot: the roll-up and CSV export source for configured, executed, profiled, estimated, source, input, and build-cache evidence. Configured: yes = enabled and implemented; partial = enabled but script support incomplete; off = configured off; no = not listed. Result Quality: missing = no result; basic = core result only; ready = estimation bindings present; rich = source provenance and artifacts present. - Input Status: None = no input_info; Declared = input_info only; Covered = input fixed by a recorded source commit; Verified = digest-backed input verification. + Input Status: None = no input_info; Declared = input_info only; Covered = input fixed by source commit or self-contained runtime parameters; Verified = digest-backed input verification. Reuse Package: complete = public packet eligible with profile and estimate evidence; public packet eligible = source material is ready for a public Markdown reuse packet. Public Packet: current latest result status; Latest packet links to the newest eligible public reuse packet if available. Next Action: the first practical follow-up suggested by the current evidence state. diff --git a/result_server/tests/test_portal_list_templates.py b/result_server/tests/test_portal_list_templates.py index 4815259..8fe672d 100644 --- a/result_server/tests/test_portal_list_templates.py +++ b/result_server/tests/test_portal_list_templates.py @@ -826,7 +826,7 @@ def test_usage_report_evidence_snapshot_consolidates_coverage_and_quality(): assert "Maturity Gaps" in html assert "Input Status" in html assert "None = no input_info" in html - assert "Covered = input fixed by a recorded source commit" in html + assert "Covered = input fixed by source commit or self-contained runtime parameters" in html assert "no profile; no estimate; source incomplete; input not declared" in html diff --git a/result_server/tests/test_public_result_routes.py b/result_server/tests/test_public_result_routes.py index b331f61..38bc1b3 100644 --- a/result_server/tests/test_public_result_routes.py +++ b/result_server/tests/test_public_result_routes.py @@ -275,6 +275,35 @@ def test_public_portal_detail_does_not_link_download_packets(tmp_path): assert "reuse-manifest.json" not in text +def test_public_portal_detail_links_current_result_padata_without_archive_scan(tmp_path, monkeypatch): + app, received_dir = _build_public_app(tmp_path) + padata_dir = tmp_path / "padata" + padata_dir.mkdir() + app.config["RECEIVED_PADATA_DIR"] = str(padata_dir) + filename = "result_20260824_090000_11111111-2222-3333-4444-555555555555.json" + payload = _eligible_public_result_payload() + archive = "padata_20260824_090000_11111111-2222-3333-4444-555555555555_demo-profile.tgz" + _write_result(received_dir, filename, payload) + (padata_dir / archive).write_bytes(b"public profile archive placeholder") + + original_listdir = os.listdir + + def guarded_listdir(path): + if os.path.abspath(os.fspath(path)) == os.path.abspath(os.fspath(padata_dir)): + raise AssertionError("Result Detail should not scan every PA archive") + return original_listdir(path) + + monkeypatch.setattr(os, "listdir", guarded_listdir) + + with app.test_client() as client: + response = client.get(f"/results/detail/{filename}") + + assert response.status_code == 200 + text = response.get_data(as_text=True) + assert archive in text + assert f'href="/results/{archive}"' in text + + def test_console_detail_links_download_packets(tmp_path): app, received_dir = _build_console_app(tmp_path) filename = "result_20260824_090000_11111111-2222-3333-4444-555555555555.json" @@ -292,6 +321,8 @@ def test_console_detail_links_download_packets(tmp_path): assert "Reuse Package" in text assert "Public packet" in text assert "Review public reuse packet" in text + assert "Profile evidence" in text + assert "recorded; 1 linked artifact" in text assert "Download Evidence Packet" in text assert "evidence-packet.md" in text assert "Download Reuse Packet" in text @@ -313,7 +344,7 @@ def test_public_portal_reuse_packet_routes_are_blocked_until_release_review(tmp_ assert manifest_response.status_code == 404 -def test_console_reuse_packet_exports_only_public_projection(tmp_path): +def test_console_reuse_packet_exports_only_public_projection(tmp_path, monkeypatch): app, received_dir = _build_console_app(tmp_path) padata_dir = tmp_path / "padata" padata_dir.mkdir() @@ -324,6 +355,15 @@ def test_console_reuse_packet_exports_only_public_projection(tmp_path): _write_result(received_dir, filename, payload) (padata_dir / archive).write_bytes(b"public profile archive placeholder") + original_listdir = os.listdir + + def guarded_listdir(path): + if os.path.abspath(os.fspath(path)) == os.path.abspath(os.fspath(padata_dir)): + raise AssertionError("Reuse Packet should not scan every PA archive") + return original_listdir(path) + + monkeypatch.setattr(os, "listdir", guarded_listdir) + with app.test_client() as client: response = client.get(f"/results/detail/{filename}/reuse-packet.md") @@ -364,6 +404,7 @@ def test_console_reuse_manifest_exports_machine_readable_projection(tmp_path): assert manifest["eligibility"]["status"] == "eligible" assert manifest["result"]["experiment"] == "CASE1" assert manifest["source"]["repository_url"] == "https://example.org/repo.git" + assert manifest["input"]["summary"] == "input_info declares input fixed by a public source commit." assert manifest["input"]["items"][0]["public_url"] == "https://example.org/inputs.git" assert manifest["build"]["cache_entry"]["digests"]["artifacts"] == "sha256:artifacts" assert manifest["estimation"]["package_bindings"][0]["estimation_package"] == "demo-kernel-package" @@ -372,6 +413,32 @@ def test_console_reuse_manifest_exports_machine_readable_projection(tmp_path): assert "local-input-placeholder" not in json.dumps(manifest) +def test_console_reuse_packet_omits_placeholder_values(tmp_path): + app, received_dir = _build_console_app(tmp_path) + filename = "result_20260824_090000_11111111-2222-3333-4444-555555555555.json" + payload = _eligible_public_result_payload() + payload["FOM_version"] = "null" + payload["fom_breakdown"] = {"sections": [], "overlaps": []} + payload["build_cache"]["stored"] = True + _write_result(received_dir, filename, payload) + + with app.test_client() as client: + packet_response = client.get(f"/results/detail/{filename}/reuse-packet.md") + manifest_response = client.get(f"/results/detail/{filename}/reuse-manifest.json") + + assert packet_response.status_code == 200 + packet_text = packet_response.get_data(as_text=True) + assert "FOM version" not in packet_text + assert "| Stored fresh entry | yes |" in packet_text + assert "| Stored fresh entry | True |" not in packet_text + assert "| Sections | 0 |" not in packet_text + assert "0/0 sections" not in packet_text + + manifest = manifest_response.get_json() + assert "fom_version" not in manifest["result"] + assert manifest["estimation"] == {"status": "not recorded"} + + def test_console_reuse_manifest_accepts_scoped_runtime_parameters(tmp_path): app, received_dir = _build_console_app(tmp_path) filename = "result_20260824_090000_11111111-2222-3333-4444-555555555555.json" @@ -417,6 +484,11 @@ def test_console_reuse_manifest_accepts_scoped_runtime_parameters(tmp_path): assert manifest_response.status_code == 200 manifest = manifest_response.get_json() assert manifest["eligibility"]["status"] == "eligible" + assert manifest["input"]["summary"] == "input_info declares self-contained runtime parameters." + assert ( + "Use the recorded source commit and runtime parameters as the starting point for reuse." + in manifest["reuse"]["notes"] + ) assert manifest["input"]["items"] == [ { "dataset_id": "qws-case1-parameters", @@ -435,6 +507,9 @@ def test_console_reuse_manifest_accepts_scoped_runtime_parameters(tmp_path): packet_text = packet_response.get_data(as_text=True) assert packet_response.status_code == 200 + assert "input_info declares self-contained runtime parameters." in packet_text + assert "source commit and runtime parameters" in packet_text + assert "source and input commits" not in packet_text assert "dataset_id: qws-case1-parameters" in packet_text assert "dataset_id: qws-case0-parameters" not in packet_text diff --git a/result_server/utils/public_reuse.py b/result_server/utils/public_reuse.py index 21a52be..1bb993c 100644 --- a/result_server/utils/public_reuse.py +++ b/result_server/utils/public_reuse.py @@ -34,6 +34,7 @@ ".private", ".test", ) +_EMPTY_PUBLIC_STRINGS = {"n/a", "nan", "null"} def evaluate_public_reuse_packet( @@ -93,16 +94,8 @@ def build_reuse_detail_rows( ("Public source", "yes" if eligibility["public_source"] else "no"), ("Public input", "yes" if eligibility["public_input"] else "no"), ("Input status", input_summary["label"]), - ( - "Estimation bindings", - _format_binding_count( - stats.get("section_package_count", 0), - stats.get("section_count", 0), - stats.get("overlap_package_count", 0), - stats.get("overlap_count", 0), - ), - ), - ("Profile artifacts", stats.get("artifact_count", 0)), + ("Estimation bindings", _format_reuse_estimation_bindings(stats)), + ("Profile evidence", _format_reuse_profile_evidence(result, stats)), ] return [{"label": label, "value": value} for label, value in rows] @@ -144,11 +137,7 @@ def build_public_reuse_manifest( "estimation": _estimation_summary(result, quality), "reuse": { "status": "eligible" if eligibility["eligible"] else eligibility["status"], - "notes": [ - "This manifest includes public result, source, and input evidence only.", - "Use the recorded source and input commits as the starting point for reuse.", - "Site access, queue access, and local software setup are outside this packet.", - ], + "notes": _reuse_notes(result), }, } return _strip_empty(manifest) @@ -520,7 +509,7 @@ def _estimation_summary(result: dict[str, Any], quality: dict[str, Any]) -> dict binding_count = stats.get("section_package_count", 0) + stats.get("overlap_package_count", 0) expected_count = section_count + overlap_count if expected_count == 0: - status = "not recorded" + return {"status": "not recorded"} elif binding_count == expected_count: status = "ready" else: @@ -575,6 +564,33 @@ def _format_binding_count( ) +def _format_reuse_estimation_bindings(stats: dict[str, Any]) -> str: + section_count = stats.get("section_count", 0) + overlap_count = stats.get("overlap_count", 0) + if section_count + overlap_count == 0: + return "not recorded" + return _format_binding_count( + stats.get("section_package_count", 0), + section_count, + stats.get("overlap_package_count", 0), + overlap_count, + ) + + +def _format_reuse_profile_evidence(result: dict[str, Any], stats: dict[str, Any]) -> str: + profile_data = result.get("profile_data") + has_profile_data = isinstance(profile_data, dict) and bool(profile_data) + artifact_count = stats.get("artifact_count", 0) + artifact_text = "linked artifact" if artifact_count == 1 else "linked artifacts" + if has_profile_data and artifact_count: + return f"recorded; {artifact_count} {artifact_text}" + if has_profile_data: + return "recorded; no linked artifacts" + if artifact_count: + return f"{artifact_count} {artifact_text}" + return "not recorded" + + def _input_info_items(input_info: Any) -> list[Any]: if not isinstance(input_info, dict) or not input_info: return [] @@ -749,6 +765,50 @@ def _format_profile_artifact(artifact: dict[str, Any]) -> str: return _join_nonempty(artifact.get("section"), archive_text) +def _reuse_notes(result: dict[str, Any]) -> list[str]: + input_items = input_info_items_for_result(result) + notes = [ + "This manifest includes public result, source, and input evidence only.", + _reuse_input_note(input_items), + "Site access, queue access, and local software setup are outside this packet.", + ] + return [note for note in notes if note] + + +def _reuse_input_note(input_items: list[Any]) -> str: + if input_items and all(_is_runtime_parameter_input_item(item) for item in input_items): + return "Use the recorded source commit and runtime parameters as the starting point for reuse." + if input_items and all(_is_repo_local_input_item(item) for item in input_items): + return "Use the recorded source commit; repository-local input paths are fixed by that commit." + return "Use the recorded source and input revisions or digests as the starting point for reuse." + + +def _is_runtime_parameter_input_item(item: Any) -> bool: + if not isinstance(item, dict): + return False + kind = _clean(item.get("kind")).lower() + source = _clean(item.get("source")).lower() + verification_status = _clean(item.get("verification_status")).lower() + return ( + kind in {"runtime-parameters", "inline-parameters"} + and source in {"inline", "self-contained", "self_contained"} + and verification_status in {"self_contained", "self-contained"} + and bool(_clean(item.get("command"))) + and isinstance(item.get("arguments"), list) + ) + + +def _is_repo_local_input_item(item: Any) -> bool: + if not isinstance(item, dict): + return False + source = _clean(item.get("source")).lower() + verification_status = _clean(item.get("verification_status")).lower() + return ( + bool(_clean(item.get("repo_relative_path"))) + and (source == "source_info" or verification_status == "covered_by_source_commit") + ) + + def _format_package_binding(item: dict[str, Any]) -> str: return _join_nonempty( item.get("kind"), @@ -775,8 +835,10 @@ def _escape_link_target(value: str) -> str: def _inline(value: Any) -> str: - if value in (None, "", [], {}): + if _is_empty_public_value(value): return "-" + if isinstance(value, bool): + return "yes" if value else "no" if isinstance(value, (list, tuple, set)): value = ", ".join(str(item) for item in value) text = str(value).replace("\r\n", " ").replace("\n", " ").strip() @@ -788,6 +850,8 @@ def _join_nonempty(*values: Any) -> str: def _clean(value: Any) -> str: + if _is_empty_public_value(value): + return "" return str(value or "").strip() @@ -797,18 +861,24 @@ def _strip_empty(value: Any) -> Any: key: cleaned for key, item in value.items() for cleaned in [_strip_empty(item)] - if cleaned not in (None, "", [], {}) + if not _is_empty_public_value(cleaned) } if isinstance(value, list): return [ cleaned for item in value for cleaned in [_strip_empty(item)] - if cleaned not in (None, "", [], {}) + if not _is_empty_public_value(cleaned) ] return value +def _is_empty_public_value(value: Any) -> bool: + if value in (None, "", [], {}): + return True + return isinstance(value, str) and value.strip().lower() in _EMPTY_PUBLIC_STRINGS + + class _MarkdownBuilder: def __init__(self) -> None: self._lines: list[str] = [] @@ -824,7 +894,11 @@ def paragraph(self, text: str) -> None: self._lines.append("") def table(self, rows: list[tuple[str, Any]]) -> None: - visible_rows = [(label, value) for label, value in rows if value not in (None, "", [], {})] + visible_rows = [ + (label, value) + for label, value in rows + if not _is_empty_public_value(value) + ] if not visible_rows: self._lines.append("_No public evidence recorded._") self._lines.append("") @@ -836,7 +910,7 @@ def table(self, rows: list[tuple[str, Any]]) -> None: self._lines.append("") def bullets(self, values: Any) -> None: - visible_values = [value for value in values if value not in (None, "", [], {})] + visible_values = [value for value in values if not _is_empty_public_value(value)] if not visible_values: self._lines.append("- none") self._lines.append("") diff --git a/result_server/utils/result_records.py b/result_server/utils/result_records.py index ce728f4..ce1f822 100644 --- a/result_server/utils/result_records.py +++ b/result_server/utils/result_records.py @@ -257,16 +257,11 @@ def summarize_input_info(data): "covered": "Covered", "verified": "Verified", } - summaries = { - "declared": "input_info is present, but digest or source-commit coverage is not declared as verified.", - "covered": "input_info declares input covered by a recorded source commit.", - "verified": "input_info declares verified input with digest evidence.", - } return { "present": True, "status": status, "label": labels[status], - "summary": summaries[status], + "summary": _summarize_input_info_status(status, input_items, has_source_commit), } @@ -323,9 +318,6 @@ def _classify_input_info_item(item, has_source_commit): if not isinstance(item, dict): return "declared" - verification_status = str(item.get("verification_status") or "").strip().lower() - source = str(item.get("source") or "").strip().lower() - kind = str(item.get("kind") or "").strip().lower() digest_fields = ( "manifest_digest", "content_digest", @@ -342,42 +334,105 @@ def _classify_input_info_item(item, has_source_commit): "dataset_revision", ) has_input_revision = any(item.get(field) for field in revision_fields) + verification_status = _input_value_lower(item.get("verification_status")) if verification_status == "verified" and has_digest: return "verified" - repo_local_covered = ( + if _is_repo_local_input_covered(item, has_source_commit): + return "covered" + + if _is_runtime_parameter_input_covered(item): + return "covered" + + if _is_public_source_input_covered(item, has_input_revision): + return "covered" + + return "declared" + + +def _summarize_input_info_status(status, input_items, has_source_commit): + if status == "verified": + return "input_info declares verified input with digest evidence." + + if status == "declared": + return "input_info is present, but digest or source-commit coverage is not declared as verified." + + if all(_is_runtime_parameter_input_covered(item) for item in input_items): + return "input_info declares self-contained runtime parameters." + + public_revision_items = [ + item + for item in input_items + if isinstance(item, dict) + and _is_public_source_input_covered(item, _has_input_revision(item)) + ] + if public_revision_items and len(public_revision_items) == len(input_items): + return "input_info declares input fixed by a public source commit." + + if all(_is_repo_local_input_covered(item, has_source_commit) for item in input_items): + return "input_info declares repository-local input fixed by the result source commit." + + return "input_info declares covered input evidence." + + +def _is_repo_local_input_covered(item, has_source_commit): + if not isinstance(item, dict): + return False + verification_status = _input_value_lower(item.get("verification_status")) + source = _input_value_lower(item.get("source")) + return ( has_source_commit - and item.get("repo_relative_path") + and bool(item.get("repo_relative_path")) and (source == "source_info" or verification_status == "covered_by_source_commit") ) - if repo_local_covered: - return "covered" - runtime_parameters_covered = ( + +def _is_runtime_parameter_input_covered(item): + if not isinstance(item, dict): + return False + verification_status = _input_value_lower(item.get("verification_status")) + source = _input_value_lower(item.get("source")) + kind = _input_value_lower(item.get("kind")) + return ( kind in {"runtime-parameters", "inline-parameters"} and source in {"inline", "self-contained", "self_contained"} and verification_status in {"self_contained", "self-contained"} and bool(item.get("command")) and isinstance(item.get("arguments"), list) ) - if runtime_parameters_covered: - return "covered" - public_source_covered = ( + +def _is_public_source_input_covered(item, has_input_revision): + if not isinstance(item, dict): + return False + verification_status = _input_value_lower(item.get("verification_status")) + source = _input_value_lower(item.get("source")) + return ( has_input_revision and verification_status in {"public_source_commit", "covered_by_public_source_commit"} and ( source in {"public_url", "public_git", "public-git"} - or item.get("public_url") - or item.get("source_url") - or item.get("archive_url") + or bool(item.get("public_url")) + or bool(item.get("source_url")) + or bool(item.get("archive_url")) ) ) - if public_source_covered: - return "covered" - return "declared" + +def _has_input_revision(item): + revision_fields = ( + "resolved_commit", + "commit_hash", + "source_commit", + "revision", + "dataset_revision", + ) + return any(item.get(field) for field in revision_fields) + + +def _input_value_lower(value): + return str(value or "").strip().lower() def _dedupe_preserve_order(values):