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):