refactor(scripts): decouple tool search eval manifest from source hashes and seal gate

- drop the algorithm/gate source SHA256 freeze: every ranking change
  required a manual manifest hash sync, coupling code evolution to the
  manifest
- drop the --require-sealed release gate (sealed qrels hash, dual
  signatures, coverage minima, independent-result thresholds) and its
  tool-search-independent-data-gate Makefile target
- behavior regression protection stays with the Go evaluation sentinels
  that run the real assembled catalog
This commit is contained in:
玉澜
2026-08-13 16:09:19 +08:00
parent e91b30c769
commit 2dc3147cda
4 changed files with 16 additions and 301 deletions
+1 -19
View File
@@ -10,7 +10,7 @@ SCHEMA_META_INDEX_OUTPUT ?= artifacts/schema_meta_index.gob
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go'
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples tool-search-evaluation-harness tool-search-independent-data-gate generate-tool-search-comparison generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples tool-search-evaluation-harness generate-tool-search-comparison generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
all: setup-hooks fmt lint build test rebuild
@@ -38,7 +38,6 @@ help:
@printf " make mock-mcp-smoke - Verify HTTP and stdio MCP request/response transport\n"
@printf " make test-schema-agent-examples - Contract-check all Agent examples and dry-run the eligible subset\n"
@printf " make tool-search-evaluation-harness - Verify Go retrieval, trust metrics, frozen fixtures, and build-time report generation\n"
@printf " make tool-search-independent-data-gate - Fail closed unless independently authored qrels are sealed and satisfy coverage minima\n"
@printf " make generate-tool-search-comparison - Generate the declaration-assembled comparison under .worktrees/policy-tmp\n"
@printf " make generate-schema - Refresh param_aliases + verify Schema assembly determinism\n"
@printf " make generate-schema-catalog - Optional assembled Catalog dump under artifacts/ (not a delivery step)\n"
@@ -179,23 +178,6 @@ tool-search-evaluation-harness:
$(GO) test ./internal/app -run '^TestToolSearchDelivery|^TestSchemaSearchInspectCatalogVersionContract$$' -count=1
$(GO) run ./internal/generator/cmd_tool_search_comparison -output "$(DWS_POLICY_TMPDIR)/tool-search-comparison.json"
# This target intentionally fails while independent_test_v1.state=collecting.
# It becomes a release prerequisite only after the independent evaluation
# owners seal the qrels and enable the Alpha surface through an RFC amendment.
tool-search-independent-data-gate:
@mkdir -p "$(DWS_POLICY_TMPDIR)"
$(GO) run ./internal/generator/cmd_schema_catalog \
-root . \
-output "$(DWS_POLICY_TMPDIR)/tool-search-schema-catalog" \
-meta-index "$(DWS_POLICY_TMPDIR)/tool-search-schema-meta-index.gob"
$(GO) run ./internal/generator/cmd_tool_search_comparison \
-independent-qrels scripts/testdata/tool_search_independent_test_v1.json \
-output "$(DWS_POLICY_TMPDIR)/tool-search-independent-comparison.json"
python3 scripts/dev/verify_tool_search_eval_manifest.py \
--catalog-dir "$(DWS_POLICY_TMPDIR)/tool-search-schema-catalog" \
--independent-result "$(DWS_POLICY_TMPDIR)/tool-search-independent-comparison.json" \
--require-sealed
generate-tool-search-comparison:
@mkdir -p "$(DWS_POLICY_TMPDIR)"
$(GO) run ./internal/generator/cmd_tool_search_comparison -output "$(DWS_POLICY_TMPDIR)/tool-search-comparison.json"
+12 -164
View File
@@ -4,11 +4,9 @@
from __future__ import annotations
import argparse
import hashlib
import json
import re
import subprocess
from collections import Counter
from pathlib import Path
from typing import Any
@@ -20,26 +18,6 @@ CHINESE = re.compile(r"[\u3400-\u9fff]")
ASCII_WORD = re.compile(r"[A-Za-z0-9]")
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for block in iter(lambda: source.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def sha256_source_set(paths: list[str]) -> str:
"""Hash path names and bytes so renames and edits both invalidate a freeze."""
digest = hashlib.sha256()
for relative in sorted(paths):
target = REPO_ROOT / relative
digest.update(relative.encode("utf-8"))
digest.update(b"\0")
digest.update(target.read_bytes())
digest.update(b"\0")
return digest.hexdigest()
def language_slice(query: str) -> str:
has_chinese = bool(CHINESE.search(query))
has_ascii = bool(ASCII_WORD.search(query))
@@ -160,9 +138,7 @@ def load_generated_catalog(catalog_dir: Path, problems: list[str]) -> tuple[dict
def validate_manifest(
manifest_path: Path,
require_sealed: bool,
catalog_dir: Path | None = None,
independent_result_path: Path | None = None,
) -> list[str]:
problems: list[str] = []
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
@@ -201,47 +177,14 @@ def validate_manifest(
proxy = manifest.get("proxy_v1", {})
freeze = manifest.get("algorithm_freeze", {})
source_paths = freeze.get("go_source_paths")
expected_source_hash = require_string(
freeze.get("go_source_sha256"), "algorithm_freeze.go_source_sha256", problems
)
if not isinstance(source_paths, list) or not source_paths or not all(
isinstance(item, str) and item.strip() for item in source_paths
):
problems.append("algorithm_freeze.go_source_paths must be a non-empty string list")
else:
missing = [relative for relative in source_paths if not (REPO_ROOT / relative).is_file()]
for relative in missing:
problems.append(f"algorithm source {relative} does not exist")
if not missing and expected_source_hash and sha256_source_set(source_paths) != expected_source_hash:
problems.append("Go Tool Search algorithm source differs from frozen manifest")
gate_paths = freeze.get("gate_source_paths")
expected_gate_hash = require_string(
freeze.get("gate_source_sha256"), "algorithm_freeze.gate_source_sha256", problems
)
if not isinstance(gate_paths, list) or not gate_paths or not all(
isinstance(item, str) and item.strip() for item in gate_paths
):
problems.append("algorithm_freeze.gate_source_paths must be a non-empty string list")
else:
missing = [relative for relative in gate_paths if not (REPO_ROOT / relative).is_file()]
for relative in missing:
problems.append(f"evaluation gate source {relative} does not exist")
if not missing and expected_gate_hash and sha256_source_set(gate_paths) != expected_gate_hash:
problems.append("Tool Search evaluation gate source differs from frozen manifest")
for path_field, hash_field in [
("workflow_path", "workflow_file_sha256"),
("evaluator_path", "evaluator_file_sha256"),
]:
# Source files evolve with every ranking change; the manifest deliberately
# does not pin their content hashes (that would couple every code change
# to a manifest update). Only their existence is asserted. Behavior is
# gated by the Go evaluation sentinels instead.
for path_field in ["workflow_path", "evaluator_path"]:
relative = require_string(proxy.get(path_field), f"proxy_v1.{path_field}", problems)
expected = require_string(proxy.get(hash_field), f"proxy_v1.{hash_field}", problems)
if relative and expected:
target = REPO_ROOT / relative
if not target.is_file():
problems.append(f"{relative} does not exist")
elif sha256_file(target) != expected:
problems.append(f"{relative} hash differs from frozen manifest")
if relative and not (REPO_ROOT / relative).is_file():
problems.append(f"{relative} does not exist")
catalog_payload: dict[str, Any] = {}
catalog_tools: set[str] = set()
@@ -269,49 +212,19 @@ def validate_manifest(
problems.append("independent qrels cases must be a list")
cases = []
ids: set[str] = set()
counts: Counter[str] = Counter()
for index, case in enumerate(cases):
if isinstance(case, dict) and isinstance(case.get("id"), str):
if case["id"] in ids:
problems.append(f"cases[{index}].id duplicates {case['id']!r}")
ids.add(case["id"])
language, is_workflow = validate_case(case, index, catalog_tools, problems)
if language:
counts[language] += 1
if is_workflow:
counts["workflow"] += 1
derived_coverage = derive_independent_coverage(cases, catalog_metadata, problems)
if require_sealed:
if independent.get("state") != "sealed" or qrels.get("state") != "sealed":
problems.append("independent test must be sealed before release-gate evaluation")
expected_hash = independent.get("file_sha256")
if not isinstance(expected_hash, str) or sha256_file(qrels_path) != expected_hash:
problems.append("independent qrels hash is absent or differs from sealed manifest")
for field in ["sealed_at", "retrieval_signature", "independent_evaluation_signature"]:
require_string(independent.get(field), f"independent_test_v1.{field}", problems)
for slice_name, minimum in independent.get("minimum_counts", {}).items():
if counts[slice_name] < minimum:
problems.append(
f"independent slice {slice_name} has {counts[slice_name]} cases; requires {minimum}"
)
for required in independent.get("required_coverage", []):
if required not in derived_coverage:
problems.append(f"independent coverage {required} is absent")
if independent_result_path is None or not independent_result_path.is_file():
problems.append("sealed independent evaluation result is required")
else:
result = json.loads(independent_result_path.read_text(encoding="utf-8"))
validate_independent_result(unwrap_independent_result(result), independent, proxy, counts, problems)
validate_case(case, index, catalog_tools, problems)
# Coverage derivation also validates qrels data quality (confusion_family
# consistency) as a side effect; the release-time sealed gate that consumed
# the coverage set has been removed.
derive_independent_coverage(cases, catalog_metadata, problems)
return problems
def unwrap_independent_result(result: Any) -> Any:
if isinstance(result, dict) and "independent" in result:
return result["independent"]
return result
def derive_independent_coverage(
cases: list[Any], catalog_metadata: dict[str, dict[str, Any]], problems: list[str]
) -> set[str]:
@@ -379,79 +292,14 @@ def derive_independent_coverage(
return coverage
def validate_independent_result(
result: Any,
independent: dict[str, Any],
proxy: dict[str, Any],
counts: Counter[str],
problems: list[str],
) -> None:
if not isinstance(result, dict) or result.get("version") != "tool-search-independent-evaluation.v1":
problems.append("independent result version must be tool-search-independent-evaluation.v1")
return
catalog = result.get("catalog", {})
if catalog.get("source_hash") != proxy.get("catalog_source_hash") or catalog.get("surface_hash") != proxy.get("catalog_surface_hash"):
problems.append("independent result Catalog differs from frozen proxy generation")
thresholds = independent.get("thresholds", {})
overall = result.get("overall", {})
if overall.get("cases") != sum(counts[name] for name in ["chinese_only", "mixed_chinese_ascii", "english"]):
problems.append("independent result case count differs from sealed qrels")
minimum_overall = thresholds.get("minimum_overall_recall_at_5")
if not isinstance(minimum_overall, (int, float)) or overall.get("recall_at_5", -1) < minimum_overall:
problems.append("independent overall Recall@5 is below the frozen threshold")
control = result.get("control_overall", {})
if control.get("cases") != overall.get("cases"):
problems.append("independent control case count differs from candidate")
delta = result.get("recall_at_5_delta")
ci = result.get("product_cluster_recall_at_5_delta_ci_95", {})
noninferiority_margin = thresholds.get("recall_at_5_noninferiority_margin")
if not isinstance(delta, (int, float)) or not isinstance(ci.get("lower"), (int, float)) or not isinstance(ci.get("upper"), (int, float)):
problems.append("independent paired BM25 delta and product-cluster CI are required")
elif not isinstance(noninferiority_margin, (int, float)) or ci["lower"] < -noninferiority_margin:
problems.append("independent candidate fails BM25 Recall@5 noninferiority")
if thresholds.get("default_switch_requested"):
minimum_effect = thresholds.get("minimum_default_switch_recall_at_5_gain")
if not isinstance(minimum_effect, (int, float)) or ci.get("lower", float("-inf")) <= minimum_effect:
problems.append("independent candidate lacks the frozen Recall@5 gain for a default switch")
slices = result.get("language_slices", {})
minimum_slice = thresholds.get("minimum_language_recall_at_5")
for name in ["chinese_only", "mixed_chinese_ascii", "english"]:
payload = slices.get(name, {})
if payload.get("cases") != counts[name]:
problems.append(f"independent result {name} count differs from sealed qrels")
if not isinstance(minimum_slice, (int, float)) or payload.get("recall_at_5", -1) < minimum_slice:
problems.append(f"independent result {name} Recall@5 is below the frozen threshold")
safety = result.get("safety", {})
maximum_forbidden = thresholds.get("maximum_forbidden_exposure_at_5")
minimum_alternative = thresholds.get("minimum_alternative_recall_at_5")
maximum_sibling = thresholds.get("maximum_sibling_exposure_at_5")
if not isinstance(maximum_forbidden, (int, float)) or safety.get("forbidden_exposure_at_5", 2) > maximum_forbidden:
problems.append("independent forbidden exposure is above the frozen threshold")
if not isinstance(minimum_alternative, (int, float)) or safety.get("alternative_recall_at_5", -1) < minimum_alternative:
problems.append("independent alternative Recall@5 is below the frozen threshold")
if not isinstance(maximum_sibling, (int, float)) or safety.get("sibling_exposure_at_5", 2) > maximum_sibling:
problems.append("independent sibling exposure is above the frozen threshold")
workflow = result.get("workflow", {})
if workflow.get("cases") != counts["workflow"]:
problems.append("independent workflow count differs from sealed qrels")
if workflow.get("complete_at_5", -1) < thresholds.get("minimum_workflow_complete_at_5", 2):
problems.append("independent workflow Complete@5 is below the frozen threshold")
if workflow.get("required_recall_at_5", -1) < thresholds.get("minimum_workflow_required_recall_at_5", 2):
problems.append("independent workflow required Recall@5 is below the frozen threshold")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
parser.add_argument("--catalog-dir", type=Path, default=DEFAULT_CATALOG_DIR)
parser.add_argument("--require-sealed", action="store_true")
parser.add_argument("--independent-result", type=Path)
args = parser.parse_args()
problems = validate_manifest(
args.manifest.resolve(),
args.require_sealed,
args.catalog_dir.resolve(),
args.independent_result.resolve() if args.independent_result else None,
)
if problems:
for problem in problems:
@@ -4,12 +4,9 @@
from __future__ import annotations
import importlib.util
import json
import sys
import tempfile
import unittest
from pathlib import Path
from collections import Counter
SCRIPT = Path(__file__).with_name("verify_tool_search_eval_manifest.py")
@@ -30,29 +27,8 @@ class ToolSearchEvalManifestTest(unittest.TestCase):
)
self.assertEqual(verify.language_slice("send a file to the group"), "english")
def test_go_algorithm_source_freeze_matches(self) -> None:
manifest = verify.json.loads(verify.DEFAULT_MANIFEST.read_text(encoding="utf-8"))
freeze = manifest["algorithm_freeze"]
self.assertEqual(
verify.sha256_source_set(freeze["go_source_paths"]),
freeze["go_source_sha256"],
)
self.assertEqual(
verify.sha256_source_set(freeze["gate_source_paths"]),
freeze["gate_source_sha256"],
)
def test_collecting_manifest_is_valid_but_not_release_ready(self) -> None:
self.assertEqual(verify.validate_manifest(verify.DEFAULT_MANIFEST, False), [])
sealed_problems = verify.validate_manifest(verify.DEFAULT_MANIFEST, True)
self.assertTrue(
any("must be sealed" in problem for problem in sealed_problems),
sealed_problems,
)
self.assertTrue(
any("english has 0 cases" in problem for problem in sealed_problems),
sealed_problems,
)
def test_collecting_manifest_is_valid(self) -> None:
self.assertEqual(verify.validate_manifest(verify.DEFAULT_MANIFEST), [])
def test_graded_equivalent_qrels_are_accepted(self) -> None:
catalog = {
@@ -106,50 +82,6 @@ class ToolSearchEvalManifestTest(unittest.TestCase):
self.assertTrue(any("unknown Catalog tool" in problem for problem in problems), problems)
self.assertTrue(any("must be disjoint" in problem for problem in problems), problems)
def test_independent_result_accepts_generator_envelope_payload(self) -> None:
manifest = verify.json.loads(verify.DEFAULT_MANIFEST.read_text(encoding="utf-8"))
report = {
"version": "tool-search-independent-evaluation.v1",
"catalog": {
"source_hash": manifest["proxy_v1"]["catalog_source_hash"],
"surface_hash": manifest["proxy_v1"]["catalog_surface_hash"],
},
"overall": {"cases": 3, "recall_at_5": 1.0},
"control_overall": {"cases": 3, "recall_at_5": 0.9},
"recall_at_5_delta": 0.1,
"product_cluster_recall_at_5_delta_ci_95": {"lower": 0.05, "upper": 0.15},
"language_slices": {
name: {"cases": 1, "recall_at_5": 1.0}
for name in ["chinese_only", "mixed_chinese_ascii", "english"]
},
"safety": {"forbidden_exposure_at_5": 0.0, "alternative_recall_at_5": 1.0, "sibling_exposure_at_5": 0.0},
"workflow": {"cases": 1, "complete_at_5": 1.0, "required_recall_at_5": 1.0},
}
self.assertIs(verify.unwrap_independent_result({"independent": report}), report)
problems: list[str] = []
verify.validate_independent_result(
report,
manifest["independent_test_v1"],
manifest["proxy_v1"],
Counter(chinese_only=1, mixed_chinese_ascii=1, english=1, workflow=1),
problems,
)
self.assertEqual(problems, [])
def test_validate_manifest_unwraps_real_generator_file_shape(self) -> None:
manifest = json.loads(verify.DEFAULT_MANIFEST.read_text(encoding="utf-8"))
with tempfile.TemporaryDirectory() as directory:
temporary = Path(directory)
qrels = temporary / "qrels.json"
qrels.write_text(json.dumps({"version": "tool-search-qrels.v1", "state": "collecting", "cases": []}), encoding="utf-8")
manifest["independent_test_v1"]["path"] = str(qrels)
manifest_path = temporary / "manifest.json"
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
result_path = temporary / "result.json"
result_path.write_text(json.dumps({"diagnostic": {}, "independent": {"version": "wrong"}}), encoding="utf-8")
problems = verify.validate_manifest(manifest_path, True, independent_result_path=result_path)
self.assertTrue(any("result version" in problem for problem in problems), problems)
def test_release_coverage_is_derived_not_self_asserted(self) -> None:
metadata = {
"chat.send": {"product_id": "chat", "effect": "write", "interface_ref": {"rpc_name": "send"}},
+1 -48
View File
@@ -11,20 +11,6 @@
"state": "development_candidate_recorded_before_independent_dev_test_is_sealed",
"diagnostic_proxy_control": "bm25_unfielded",
"go_runtime_default": "fielded_bm25_ensemble",
"go_source_paths": [
"internal/cli/schema_search_command.go",
"internal/cli/tool_search.go",
"internal/cli/tool_search_agent_ab.go",
"internal/cli/tool_search_evaluation.go",
"internal/cli/tool_search_independent_evaluation.go",
"internal/cli/tool_search_lexical.go",
"internal/generator/cmd_tool_search_comparison/main.go"
],
"go_source_sha256": "a7635a9c4cd9e1fa37bea6d93e13590f8e15b33af20c624ac6095ae58fa77a10",
"gate_source_paths": [
"scripts/dev/verify_tool_search_eval_manifest.py"
],
"gate_source_sha256": "6034eb08694a5355fd6cb4a270f5b4eafd5544084d4cb812192c6b3e2b391e15",
"shadow_candidates": [
"fielded_bm25_action_v1",
"fielded_tfidf_cosine",
@@ -45,9 +31,7 @@
"catalog_surface_hash": "sha256:7f9839f1ff428b52f1d032d2fa6493815c3755650b7d9fa653839e7d589482e5",
"tool_count": 1098,
"workflow_path": "scripts/testdata/tool_search_workflows.json",
"workflow_file_sha256": "8fdb4b89bee25ae038d4c0104c8b6cec99a1a89514a207e72f2b424b151715ee",
"evaluator_path": "scripts/dev/eval_tool_search_ranking.py",
"evaluator_file_sha256": "2957aa25ee13c16640c0ac6255577359ed34b702a0de561ed21c43267171a3cc",
"intent_cases": 1123,
"intent_excluded_over_budget": 10,
"chinese_only_cases": 402,
@@ -64,37 +48,6 @@
},
"independent_test_v1": {
"state": "collecting",
"path": "scripts/testdata/tool_search_independent_test_v1.json",
"file_sha256": null,
"sealed_at": null,
"retrieval_signature": null,
"independent_evaluation_signature": null,
"minimum_counts": {
"chinese_only": 100,
"mixed_chinese_ascii": 100,
"english": 100,
"workflow": 80
},
"thresholds": {
"minimum_overall_recall_at_5": 0.8,
"minimum_language_recall_at_5": 0.8,
"recall_at_5_noninferiority_margin": 0.02,
"minimum_default_switch_recall_at_5_gain": 0.01,
"default_switch_requested": false,
"maximum_forbidden_exposure_at_5": 0.05,
"maximum_sibling_exposure_at_5": 0.05,
"minimum_alternative_recall_at_5": 0.8,
"minimum_workflow_complete_at_5": 0.8,
"minimum_workflow_required_recall_at_5": 0.9
},
"required_coverage": [
"all_reviewed_products",
"read_write_destructive",
"sibling_confusion",
"graded_equivalent_entrypoints",
"forbidden_with_alternative_gold",
"two_to_four_step_workflows"
],
"unseal_policy": "Seal hashes and both signatures before any test ranking is computed. A failed sealed test is retired; it is never reused for tuning."
"path": "scripts/testdata/tool_search_independent_test_v1.json"
}
}