@salesforce/afv-skills 1.67.0 → 1.68.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -5
- package/package.json +1 -1
- package/skills/agentforce-offline-experimentation-run/SKILL.md +14 -22
- package/skills/agentforce-offline-experimentation-run/scripts/bt_compare.py +81 -0
- package/skills/agentforce-offline-experimentation-run/scripts/bt_fetch.py +358 -0
- package/skills/agentforce-offline-experimentation-run/scripts/bt_patch_agent.py +110 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/__init__.py +34 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/agentpatch.py +483 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/agentsrc.py +565 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/cache.py +95 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/compare.py +617 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/fetch.py +1012 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/judge.py +148 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/messages.py +92 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/patch.py +327 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/redact.py +40 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/results.py +288 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/scoring.py +340 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/sfap.py +238 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/splits.py +136 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/stdm.py +98 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/tokens.py +173 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/units.py +199 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/versions.py +194 -0
- package/skills/agentforce-offline-experimentation-run/scripts/build_report.py +366 -378
- package/skills/agentforce-offline-experimentation-run/scripts/resolve-token.sh +14 -4
- package/skills/agentforce-offline-experimentation-run/scripts/sfap_replay.py +472 -796
- package/skills/dx-lifecycle-center/SKILL.md +5 -2
- package/skills/dx-lifecycle-center/assets/widgets/connect-connected/connect-connected.json +65 -0
- package/skills/dx-lifecycle-center/assets/widgets/connect-connected/schema.json +32 -0
- package/skills/dx-lifecycle-center/assets/widgets/connect-sign-in/connect-sign-in.json +57 -0
- package/skills/dx-lifecycle-center/assets/widgets/connect-sign-in/schema.json +21 -0
- package/skills/dx-lifecycle-center/examples/workflows.md +20 -6
- package/skills/dx-lifecycle-center/references/connect-org.md +21 -2
- package/skills/dx-lifecycle-center/references/deploy.md +2 -2
- package/skills/dx-lifecycle-center/references/list-orgs.md +1 -1
- package/skills/dx-lifecycle-center/scripts/card-policy.jq +71 -0
- package/skills/dx-lifecycle-center/scripts/fill-widget.jq +29 -0
- package/skills/dx-lifecycle-center/scripts/widget.sh +28 -0
- package/skills/dx-org-devhub-configure/SKILL.md +85 -84
- package/skills/dx-org-devhub-configure/examples/status-output.md +25 -2
- package/skills/dx-org-devhub-configure/scripts/devhub.sh +265 -140
- package/skills/dx-org-devhub-configure/scripts/test-devhub.sh +100 -15
- package/skills/dx-packaging-move-to-2gp-migrate/SKILL.md +89 -0
- package/skills/dx-packaging-move-to-2gp-migrate/references/api-operations.md +164 -0
- package/skills/dx-scratch-def-configure/SKILL.md +163 -0
- package/skills/dx-scratch-def-configure/examples/confirm-card.md +53 -0
- package/skills/dx-scratch-def-configure/examples/report-recipes.md +100 -0
- package/skills/dx-scratch-def-configure/references/features.json +3270 -0
- package/skills/dx-scratch-def-configure/references/project-scratch-def.schema.json +1841 -0
- package/skills/dx-scratch-def-configure/references/settings-map.json +682 -0
- package/skills/dx-scratch-def-configure/scripts/lib_scratch_def.py +1306 -0
- package/skills/dx-scratch-def-configure/scripts/requirements.txt +3 -0
- package/skills/dx-scratch-def-configure/scripts/scrape_config_values.py +848 -0
- package/skills/education-cloud-domain-configure/SKILL.md +1 -1
- package/skills/education-cloud-multi-campus-configure/SKILL.md +1 -1
- package/skills/education-cloud-student-recruitment-agent-configure/SKILL.md +1 -1
- package/skills/experience-design-validate/SKILL.md +1 -1
- package/skills/experience-search-coordinate/SKILL.md +18 -12
- package/skills/experience-search-coordinate/references/content-route.md +79 -1
- package/skills/field-service-data-capture-migrate/SKILL.md +1 -1
- package/skills/platform-apex-generate/SKILL.md +14 -11
- package/skills/platform-apex-test-generate/SKILL.md +2 -1
- package/skills/platform-datamask-run/SKILL.md +52 -71
- package/skills/platform-datamask-run/references/api-surface.md +10 -4
- package/skills/platform-datamask-run/references/policy-authoring.md +56 -7
- package/skills/platform-datamask-run/references/run-and-abort.md +17 -10
- package/skills/platform-datamask-run/scripts/poll-job.sh +15 -25
- package/skills/platform-dataspace-access-configure/SKILL.md +19 -1
- package/skills/platform-metadata-discovery-get/SKILL.md +185 -0
- package/skills/platform-metadata-discovery-get/references/api-map.md +25 -0
- package/skills/platform-metadata-discovery-get/references/h360-routes.md +30 -0
- package/skills/platform-metadata-discovery-get/references/scoped-soql.md +52 -0
- package/skills/platform-metadata-grounding-get/SKILL.md +170 -0
- package/skills/platform-policy-rule-generate/SKILL.md +84 -232
- package/skills/platform-policy-rule-generate/references/condition-model.md +47 -0
- package/skills/platform-policy-rule-generate/references/condition-templates.md +89 -0
- package/skills/platform-policy-rule-generate/references/conjunction-compiler.md +25 -0
- package/skills/platform-policy-rule-generate/references/conjunction-expression.md +22 -0
- package/skills/platform-policy-rule-generate/references/consumer-all.md +9 -0
- package/skills/platform-policy-rule-generate/references/consumer-datacloud-ui.md +91 -0
- package/skills/platform-policy-rule-generate/references/deploy-errors.md +24 -36
- package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/policyRuleDefinitionSets/ExistsJoinRLS.policyRuleDefinitionSet +6 -0
- package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/policyRuleDefinitions/ExistsJoinRLS_Rule_1.policyRuleDefinition +91 -0
- package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/policyRuleDefinitionSets/FieldReadDeny.policyRuleDefinitionSet +6 -0
- package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/policyRuleDefinitions/FieldReadDeny_Rule_1.policyRuleDefinition +72 -0
- package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/policyRuleDefinitionSets/LastCharactersFieldMasking.policyRuleDefinitionSet +6 -0
- package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/policyRuleDefinitions/LastCharactersFieldMasking_Rule_1.policyRuleDefinition +77 -0
- package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/policyRuleDefinitionSets/PrincipalFieldMatchRLS.policyRuleDefinitionSet +6 -0
- package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/policyRuleDefinitions/PrincipalFieldMatchRLS_Rule_1.policyRuleDefinition +77 -0
- package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/policyRuleDefinitionSets/TaggedObjectAccess.policyRuleDefinitionSet +4 -0
- package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/policyRuleDefinitions/TaggedObjectAccess_Rule_1.policyRuleDefinition +65 -0
- package/skills/platform-policy-rule-generate/references/headless-transport.md +87 -0
- package/skills/platform-policy-rule-generate/references/policy-schema-full.md +17 -10
- package/skills/platform-policy-rule-generate/references/policy-shapes.md +74 -0
- package/skills/platform-policy-rule-generate/references/principal-expression.md +31 -0
- package/skills/platform-policy-rule-generate/references/reference-resolution.md +51 -0
- package/skills/platform-policy-rule-generate/references/resource-expression.md +48 -0
- package/skills/platform-policy-rule-generate/references/runtime-context.md +9 -0
- package/skills/platform-policy-rule-generate/references/templates-access.md +11 -9
- package/skills/platform-policy-rule-generate/references/templates-advanced.md +4 -2
- package/skills/platform-policy-rule-generate/references/templates-record.md +37 -22
- package/skills/platform-policy-rule-generate/references/templates-transform.md +10 -8
- package/skills/platform-policy-rule-generate/references/templates.md +2 -2
- package/skills/platform-policy-rule-generate/references/ui-compatibility.md +35 -17
- package/skills/platform-report-generate/SKILL.md +9 -4
- package/skills/platform-report-generate/references/chart-types.md +26 -2
- package/skills/platform-report-generate/references/column-names.md +43 -0
- package/skills/platform-report-generate/references/errors-and-troubleshooting.md +1 -1
- package/skills/platform-report-generate/references/verification-checklist.md +10 -2
- package/skills/service-digital-engagement-channel-configure/SKILL.md +7 -5
- package/skills/service-digital-engagement-channel-configure/assets/messaging_channel_template.xml +33 -0
- package/skills/service-digital-engagement-channel-configure/references/channel_settings.md +110 -14
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
"""Paired comparison of two replay runs of the same units (bt_compare.py).
|
|
2
|
+
|
|
3
|
+
A pair is one unit and one repeat, scored in both runs. Repeat r of both runs used the same seed,
|
|
4
|
+
so a unit a patch left alone, answered from a shared --cache-dir, scores the same in both: only
|
|
5
|
+
the units the change touched move.
|
|
6
|
+
|
|
7
|
+
Metrics are in units:
|
|
8
|
+
- a group's passes: the sum of its units' scores in a repeat (overall, per kind, node and golden
|
|
9
|
+
tool); its delta: the sum over its units of cand - base, each averaged over the unit's pairs;
|
|
10
|
+
- a failure bucket's net: the pairs that scored lower and ended in it, minus the pairs that scored
|
|
11
|
+
higher and came from it. A move between buckets at the same score is not counted.
|
|
12
|
+
|
|
13
|
+
Noise: a metric's tolerance is the largest difference on it between two repeats of the noise run
|
|
14
|
+
(default: base), at least 1 unit. A delta beyond its tolerance is a regression or a gain. A
|
|
15
|
+
bootstrap over sessions gives a 95% interval of each group's mean unit delta; it does not gate.
|
|
16
|
+
|
|
17
|
+
A pair where one run failed is dropped from both, except a no answer: the failed run timed out
|
|
18
|
+
(timeout or provider_timeout, on its last attempt) on a request or model that differs from the
|
|
19
|
+
other run's, and the other run answered. A model that runs away on a request it was changed to
|
|
20
|
+
send has failed it; it scores 0 there, in the bucket no_answer. Timeouts on the same request and
|
|
21
|
+
model, like every other failure, say nothing about the change and are dropped.
|
|
22
|
+
|
|
23
|
+
Two runs are comparable when they replayed the same units (requests and goldens) with the same
|
|
24
|
+
judge_model, k, seed, steps, router_nodes, equivalent_tools, arg_defaults, timeout and btlib code,
|
|
25
|
+
and neither has more than 10% infra errors (failed or missing records, no answers aside) or more
|
|
26
|
+
than 10% no answers (an outage looks like that too). The model and the patch may differ: they are what is
|
|
27
|
+
compared. A unit's request is identified as units.jsonl stores it when both runs have that file,
|
|
28
|
+
else by the sha each record carries (units.jsonl is redacted, the record shas are not).
|
|
29
|
+
not_comparable_kinds names the kinds of the reasons (REASON_KINDS), for a caller that acts on one.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import hashlib
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import random
|
|
36
|
+
from collections import Counter, defaultdict
|
|
37
|
+
|
|
38
|
+
from . import SCHEMA_COMPARE, SCHEMA_RESULT, SCHEMA_SUMMARY
|
|
39
|
+
from .results import golden_tools, read_jsonl, unit_kind
|
|
40
|
+
from .scoring import MATCH_BUCKETS
|
|
41
|
+
from .sfap import TIMEOUT_KINDS, request_sha
|
|
42
|
+
|
|
43
|
+
MUST_MATCH = ("judge_model", "k", "seed", "steps", "router_nodes", "equivalent_tools", "arg_defaults", "timeout")
|
|
44
|
+
NOISE_MUST_MATCH = ("judge_model", "steps", "router_nodes", "equivalent_tools",
|
|
45
|
+
"arg_defaults") # its k and seed are its own
|
|
46
|
+
REPORTED = ("model", "patch", "versions", "split", "exclude_sessions", "inputs", "limit", "org", "agent",
|
|
47
|
+
"sfap_base_url")
|
|
48
|
+
MAX_INFRA_RATE = 0.10
|
|
49
|
+
MAX_NO_ANSWER_RATE = 0.10
|
|
50
|
+
NO_ANSWER = "no_answer"
|
|
51
|
+
TOLERANCE_FLOOR = 1.0
|
|
52
|
+
BOOTSTRAP_SAMPLES = 2000
|
|
53
|
+
GROUP_LABELS = (("by_kind", "kind"), ("by_node", "node"), ("by_tool", "tool"))
|
|
54
|
+
LABELS = dict(GROUP_LABELS, overall="overall", buckets="bucket")
|
|
55
|
+
EXIT_CODES = {"no_regression": 0, "regression": 1, "not_comparable": 2}
|
|
56
|
+
# Why two runs are not comparable: other units, other settings or code, too many infra errors, too many no
|
|
57
|
+
# answers, nothing scored in both. bt_gate scores the no answers only when they are the one reason.
|
|
58
|
+
REASON_KINDS = ("units", "config", "infra", "no_answer", "unscored")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class CompareError(Exception):
|
|
62
|
+
"""A dir that is not a finished replay run."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _digest(obj):
|
|
66
|
+
blob = json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
67
|
+
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _int(v):
|
|
71
|
+
return isinstance(v, int) and not isinstance(v, bool)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _golden_problem(golden):
|
|
75
|
+
if not isinstance(golden, dict) or not isinstance(golden.get("tool_calls"), list):
|
|
76
|
+
return "no golden"
|
|
77
|
+
if not all(isinstance(c, dict) and isinstance(c.get("name"), str) for c in golden["tool_calls"]):
|
|
78
|
+
return "a golden tool call without a name"
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _record_problem(rec):
|
|
83
|
+
"""Why a results.jsonl line cannot be compared, or None."""
|
|
84
|
+
for key in ("unit_id", "session_id", "node", "kind", "request_sha", "status"):
|
|
85
|
+
if not isinstance(rec.get(key), str):
|
|
86
|
+
return f"no {key}"
|
|
87
|
+
if not _int(rec.get("repeat")) or rec["repeat"] < 0:
|
|
88
|
+
return "no repeat"
|
|
89
|
+
if rec["status"] == "ok" and not ((_int(rec.get("score")) or isinstance(rec.get("score"), float))
|
|
90
|
+
and isinstance(rec.get("bucket"), str)):
|
|
91
|
+
return "scored, without a score or a bucket"
|
|
92
|
+
return _golden_problem(rec.get("golden"))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _unit_problem(u):
|
|
96
|
+
if not isinstance(u, dict):
|
|
97
|
+
return "not an object"
|
|
98
|
+
for key in ("unit_id", "session_id", "node"):
|
|
99
|
+
if not isinstance(u.get(key), str):
|
|
100
|
+
return f"no {key}"
|
|
101
|
+
req = u.get("request")
|
|
102
|
+
if not isinstance(req, dict) or not isinstance(req.get("messages"), list) or not isinstance(req.get("tools"), list):
|
|
103
|
+
return "no request messages and tools"
|
|
104
|
+
return _golden_problem(u.get("golden"))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _info(session_id, node, kind, tools):
|
|
108
|
+
return {"session_id": session_id, "node": node, "kind": kind, "tools": tools, "digest": None,
|
|
109
|
+
"record_digest": None}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def load_run(path):
|
|
113
|
+
"""A finished run dir: {path, name, config, k, records {(unit_id, repeat): record}, units {unit_id: info},
|
|
114
|
+
units_file}. The units come from units.jsonl (the requests as captured), else from the records."""
|
|
115
|
+
results_path, summary_path = os.path.join(path, "results.jsonl"), os.path.join(path, "summary.json")
|
|
116
|
+
if not os.path.isfile(results_path):
|
|
117
|
+
raise CompareError(f"{path}: no results.jsonl (not a run dir of sfap_replay.py)")
|
|
118
|
+
if not os.path.isfile(summary_path):
|
|
119
|
+
raise CompareError(f"{path}: no summary.json (the run did not finish)")
|
|
120
|
+
units_path = os.path.join(path, "units.jsonl")
|
|
121
|
+
try:
|
|
122
|
+
records = read_jsonl(results_path)
|
|
123
|
+
with open(summary_path, encoding="utf-8") as f:
|
|
124
|
+
summary = json.load(f)
|
|
125
|
+
unit_lines = read_jsonl(units_path) if os.path.isfile(units_path) else None
|
|
126
|
+
except (OSError, ValueError) as e:
|
|
127
|
+
raise CompareError(f"{path}: cannot read the run ({type(e).__name__}: {e})")
|
|
128
|
+
if (not isinstance(summary, dict) or summary.get("schema") != SCHEMA_SUMMARY
|
|
129
|
+
or any(not isinstance(r, dict) or r.get("schema") != SCHEMA_RESULT for r in records)):
|
|
130
|
+
raise CompareError(f"{path}: not a run of the step-level replay ({SCHEMA_RESULT} records, {SCHEMA_SUMMARY})")
|
|
131
|
+
counts = summary.get("counts")
|
|
132
|
+
if not isinstance(counts, dict) or counts.get("records") != len(records):
|
|
133
|
+
raise CompareError(f"{path}: summary.json does not count the {len(records)} records of results.jsonl "
|
|
134
|
+
"(rewritten after the summary, by a re-run that stopped early?): replay it again")
|
|
135
|
+
by_key = {}
|
|
136
|
+
for i, rec in enumerate(records, 1):
|
|
137
|
+
problem = _record_problem(rec)
|
|
138
|
+
if problem:
|
|
139
|
+
raise CompareError(f"{path}: results.jsonl record {i}: {problem}")
|
|
140
|
+
key = (rec["unit_id"], rec["repeat"])
|
|
141
|
+
if key in by_key:
|
|
142
|
+
raise CompareError(f"{path}: results.jsonl holds a unit and repeat twice")
|
|
143
|
+
by_key[key] = rec
|
|
144
|
+
units = {}
|
|
145
|
+
for i, u in enumerate(unit_lines or [], 1):
|
|
146
|
+
problem = _unit_problem(u)
|
|
147
|
+
if problem:
|
|
148
|
+
raise CompareError(f"{path}: units.jsonl line {i}: {problem}")
|
|
149
|
+
info = units[u["unit_id"]] = _info(u["session_id"], u["node"], unit_kind(u), golden_tools(u))
|
|
150
|
+
info["digest"] = _digest([request_sha(u["request"]["messages"], u["request"]["tools"]), u["golden"]])
|
|
151
|
+
for rec in records:
|
|
152
|
+
info = units.get(rec["unit_id"])
|
|
153
|
+
if info is None and unit_lines is None:
|
|
154
|
+
info = units[rec["unit_id"]] = _info(rec["session_id"], rec["node"], rec["kind"], golden_tools(rec))
|
|
155
|
+
if info is not None and info["record_digest"] is None:
|
|
156
|
+
info["record_digest"] = _digest([rec.get("base_request_sha") or rec["request_sha"], rec["golden"]])
|
|
157
|
+
if unit_lines is None:
|
|
158
|
+
info["digest"] = info["record_digest"]
|
|
159
|
+
stray = {uid for uid, _ in by_key} - set(units)
|
|
160
|
+
if stray:
|
|
161
|
+
raise CompareError(f"{path}: results.jsonl has {len(stray)} units that units.jsonl lacks")
|
|
162
|
+
config = summary.get("config") if isinstance(summary.get("config"), dict) else {}
|
|
163
|
+
k = config.get("k")
|
|
164
|
+
if k is not None and (not _int(k) or k < 1):
|
|
165
|
+
raise CompareError(f"{path}: summary.json config k is not a count")
|
|
166
|
+
k = k or 1 + max((rep for _, rep in by_key), default=0)
|
|
167
|
+
return {"path": path, "name": os.path.basename(os.path.normpath(path)), "config": config, "k": k,
|
|
168
|
+
"records": by_key, "units": units, "units_file": unit_lines is not None}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _name_apart(runs):
|
|
172
|
+
"""Different dirs of the same name (iterations/v1/bt_run, iterations/v2/bt_run) are named with their
|
|
173
|
+
parent dir, so that a message tells them apart."""
|
|
174
|
+
real = [os.path.realpath(run["path"]) for run in runs]
|
|
175
|
+
alike = [i for i, run in enumerate(runs)
|
|
176
|
+
if any(other["name"] == run["name"] and real[j] != real[i] for j, other in enumerate(runs))]
|
|
177
|
+
for i in alike:
|
|
178
|
+
runs[i]["name"] = os.path.join(os.path.basename(os.path.dirname(real[i])), os.path.basename(real[i]))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _no_answer(b, c):
|
|
182
|
+
"""The record of the pair (b, c) that is a no answer, or None: it timed out where the other one
|
|
183
|
+
answered, and the two differ in request or model."""
|
|
184
|
+
if (b["status"] == "ok") == (c["status"] == "ok"):
|
|
185
|
+
return None
|
|
186
|
+
failed, answered = (c, b) if b["status"] == "ok" else (b, c)
|
|
187
|
+
if failed.get("error_kind") not in TIMEOUT_KINDS:
|
|
188
|
+
return None
|
|
189
|
+
if failed["request_sha"] == answered["request_sha"] and failed.get("model") == answered.get("model"):
|
|
190
|
+
return None
|
|
191
|
+
return failed
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _health(run, no_answers=()):
|
|
195
|
+
"""Expected records, and of them: failed (no answers aside), missing, no answers."""
|
|
196
|
+
expected = len(run["units"]) * run["k"]
|
|
197
|
+
keys = [(uid, rep) for uid in run["units"] for rep in range(run["k"])]
|
|
198
|
+
missing = sum(1 for key in keys if key not in run["records"])
|
|
199
|
+
failed = [key for key in keys if key in run["records"] and run["records"][key]["status"] != "ok"]
|
|
200
|
+
no_answer = sum(1 for key in failed if key in no_answers)
|
|
201
|
+
infra = len(failed) - no_answer
|
|
202
|
+
return {"records": expected, "infra_error": infra, "missing": missing, "no_answer": no_answer,
|
|
203
|
+
"infra_rate": round((infra + missing) / expected, 4) if expected else 0.0}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _too_unhealthy(health):
|
|
207
|
+
return bool(health["records"]) and (health["infra_error"] + health["missing"]) / health["records"] > MAX_INFRA_RATE
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _too_many_no_answers(health):
|
|
211
|
+
return bool(health["records"]) and health.get("no_answer", 0) / health["records"] > MAX_NO_ANSWER_RATE
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _norm(key, value):
|
|
215
|
+
if key == "router_nodes":
|
|
216
|
+
return sorted(value or [])
|
|
217
|
+
if key == "equivalent_tools":
|
|
218
|
+
return sorted(sorted(g) for g in value or [])
|
|
219
|
+
if key == "arg_defaults":
|
|
220
|
+
return json.dumps(value or {}, sort_keys=True) # a run from before arg_defaults: none
|
|
221
|
+
return value
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _show(value):
|
|
225
|
+
text = json.dumps(value, ensure_ascii=False)
|
|
226
|
+
return text if len(text) <= 60 else text[:57] + "..."
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _code(run):
|
|
230
|
+
return (run["config"].get("code") or {}).get("btlib_sha256")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _config_issues(a, b, keys):
|
|
234
|
+
issues = [f"{key} differs ({_show(a['config'].get(key))} vs {_show(b['config'].get(key))})"
|
|
235
|
+
for key in keys if _norm(key, a["config"].get(key)) != _norm(key, b["config"].get(key))]
|
|
236
|
+
if _code(a) != _code(b):
|
|
237
|
+
issues.append("the btlib code differs (config.code.btlib_sha256): replay both with the same code")
|
|
238
|
+
return issues
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _same_units(a, b):
|
|
242
|
+
"""The units of a and b with the same request and golden in both, by unit id."""
|
|
243
|
+
key = "digest" if a["units_file"] and b["units_file"] else "record_digest"
|
|
244
|
+
out = set()
|
|
245
|
+
for uid in set(a["units"]) & set(b["units"]):
|
|
246
|
+
da, db = a["units"][uid][key], b["units"][uid][key]
|
|
247
|
+
if da is None or db is None or da == db: # None: never replayed, so its pairs are missing anyway
|
|
248
|
+
out.add(uid)
|
|
249
|
+
return out
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _unit_issue(a, b):
|
|
253
|
+
only_a, only_b = set(a["units"]) - set(b["units"]), set(b["units"]) - set(a["units"])
|
|
254
|
+
changed = len(set(a["units"]) & set(b["units"])) - len(_same_units(a, b))
|
|
255
|
+
if not (only_a or only_b or changed):
|
|
256
|
+
return None
|
|
257
|
+
return (f"the units differ: {len(only_a)} only in {a['name']}, {len(only_b)} only in {b['name']}, "
|
|
258
|
+
f"{changed} with another request or golden")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _differs(a, b):
|
|
262
|
+
out = {key: [a["config"].get(key), b["config"].get(key)] for key in REPORTED
|
|
263
|
+
if a["config"].get(key) != b["config"].get(key)}
|
|
264
|
+
git = [(run["config"].get("code") or {}).get("git") for run in (a, b)]
|
|
265
|
+
if git[0] != git[1]:
|
|
266
|
+
out["code.git"] = git
|
|
267
|
+
return out
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _run_meta(run, health):
|
|
271
|
+
c = run["config"]
|
|
272
|
+
meta = {"dir": run["path"], "model": c.get("model"), "patch": c.get("patch"), "k": run["k"],
|
|
273
|
+
"seed": c.get("seed"), "units": len(run["units"]), "interrupted": bool(c.get("interrupted"))}
|
|
274
|
+
meta.update(health)
|
|
275
|
+
return meta
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _keys(info):
|
|
279
|
+
"""The groups a unit counts in."""
|
|
280
|
+
return [("by_kind", info["kind"]), ("by_node", info["node"])] + [("by_tool", t) for t in info["tools"]]
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _unit_row(uid, info, pairs):
|
|
284
|
+
n = len(pairs)
|
|
285
|
+
new, fixed = Counter(), Counter()
|
|
286
|
+
for b, c in pairs:
|
|
287
|
+
if c["score"] < b["score"]:
|
|
288
|
+
new[c["bucket"]] += 1 / n
|
|
289
|
+
elif c["score"] > b["score"]:
|
|
290
|
+
fixed[b["bucket"]] += 1 / n
|
|
291
|
+
base = sum(b["score"] for b, _ in pairs) / n
|
|
292
|
+
cand = sum(c["score"] for _, c in pairs) / n
|
|
293
|
+
return {"unit_id": uid, "session_id": info["session_id"], "node": info["node"], "kind": info["kind"],
|
|
294
|
+
"tools": info["tools"], "pairs": n, "base": base, "cand": cand, "delta": cand - base,
|
|
295
|
+
"base_buckets": [b["bucket"] for b, _ in pairs], "cand_buckets": [c["bucket"] for _, c in pairs],
|
|
296
|
+
"new": new, "fixed": fixed,
|
|
297
|
+
"touched": any(b["request_sha"] != c["request_sha"] for b, c in pairs),
|
|
298
|
+
"moved": any(b["score"] != c["score"] or b["bucket"] != c["bucket"] for b, c in pairs),
|
|
299
|
+
"ops": sorted({op for _, c in pairs for op in c.get("patch_ops") or []})}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _pair(base, cand, unit_ids, repeats):
|
|
303
|
+
"""(one row per unit with a pair, dropped pairs by reason, {role: (unit_id, repeat) of its no answers})."""
|
|
304
|
+
rows, dropped, no_answers = [], Counter(), {"base": set(), "cand": set()}
|
|
305
|
+
for uid in unit_ids:
|
|
306
|
+
pairs = []
|
|
307
|
+
for rep in repeats:
|
|
308
|
+
b, c = base["records"].get((uid, rep)), cand["records"].get((uid, rep))
|
|
309
|
+
failed = _no_answer(b, c) if b is not None and c is not None else None
|
|
310
|
+
if failed is not None:
|
|
311
|
+
role = "base" if failed is b else "cand"
|
|
312
|
+
no_answers[role].add((uid, rep))
|
|
313
|
+
scored = dict(failed, score=0.0, strict=0.0, bucket=NO_ANSWER)
|
|
314
|
+
pairs.append((scored, c) if role == "base" else (b, scored))
|
|
315
|
+
elif b is None or c is None:
|
|
316
|
+
dropped["missing"] += 1
|
|
317
|
+
elif b["status"] != "ok" and c["status"] != "ok":
|
|
318
|
+
dropped["both_infra"] += 1
|
|
319
|
+
elif b["status"] != "ok":
|
|
320
|
+
dropped["base_infra"] += 1
|
|
321
|
+
elif c["status"] != "ok":
|
|
322
|
+
dropped["cand_infra"] += 1
|
|
323
|
+
else:
|
|
324
|
+
pairs.append((b, c))
|
|
325
|
+
if pairs:
|
|
326
|
+
rows.append(_unit_row(uid, base["units"][uid], pairs))
|
|
327
|
+
return rows, dropped, no_answers
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def noise_spread(run):
|
|
331
|
+
"""({(metric, name): spread}, {bucket: spread}, repeat pairs): the largest difference between two
|
|
332
|
+
of the run's repeats, on the units scored in both, per group (passes) and per bucket (net)."""
|
|
333
|
+
groups, buckets = defaultdict(float), defaultdict(float)
|
|
334
|
+
pairs = 0
|
|
335
|
+
for r1 in range(run["k"]):
|
|
336
|
+
for r2 in range(r1 + 1, run["k"]):
|
|
337
|
+
pairs += 1
|
|
338
|
+
diff, net = defaultdict(float), defaultdict(float)
|
|
339
|
+
for uid, info in run["units"].items():
|
|
340
|
+
a, b = run["records"].get((uid, r1)), run["records"].get((uid, r2))
|
|
341
|
+
if a is None or b is None or a["status"] != "ok" or b["status"] != "ok":
|
|
342
|
+
continue
|
|
343
|
+
d = b["score"] - a["score"]
|
|
344
|
+
for key in [("overall", None)] + _keys(info):
|
|
345
|
+
diff[key] += d
|
|
346
|
+
if d < 0:
|
|
347
|
+
net[b["bucket"]] += 1
|
|
348
|
+
elif d > 0:
|
|
349
|
+
net[a["bucket"]] -= 1
|
|
350
|
+
for key, v in diff.items():
|
|
351
|
+
groups[key] = max(groups[key], abs(v))
|
|
352
|
+
for key, v in net.items():
|
|
353
|
+
buckets[key] = max(buckets[key], abs(v))
|
|
354
|
+
return dict(groups), dict(buckets), pairs
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _tolerance(spread):
|
|
358
|
+
return max(TOLERANCE_FLOOR, spread or 0.0)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _beyond(delta, tol):
|
|
362
|
+
"""'gain', 'regression' or None, for a delta where higher is better."""
|
|
363
|
+
delta = round(delta, 6) # means over 3 repeats are not exact in binary
|
|
364
|
+
return "regression" if delta < -tol else "gain" if delta > tol else None
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _resample_ci(rows, seed, samples=BOOTSTRAP_SAMPLES):
|
|
368
|
+
"""95% percentile interval of the mean unit delta, resampling whole sessions; None under 2 sessions."""
|
|
369
|
+
sessions = defaultdict(lambda: [0.0, 0])
|
|
370
|
+
for r in rows:
|
|
371
|
+
s = sessions[r["session_id"]]
|
|
372
|
+
s[0] += r["delta"]
|
|
373
|
+
s[1] += 1
|
|
374
|
+
if len(sessions) < 2 or samples < 1:
|
|
375
|
+
return None
|
|
376
|
+
clusters = [sessions[s] for s in sorted(sessions)]
|
|
377
|
+
if all(d == 0 for d, _ in clusters):
|
|
378
|
+
return [0.0, 0.0]
|
|
379
|
+
rng = random.Random(seed)
|
|
380
|
+
means = []
|
|
381
|
+
for _ in range(samples):
|
|
382
|
+
picked = rng.choices(clusters, k=len(clusters))
|
|
383
|
+
means.append(sum(d for d, _ in picked) / sum(n for _, n in picked))
|
|
384
|
+
means.sort()
|
|
385
|
+
return [round(means[(25 * samples + 999) // 1000 - 1], 4), # nearest rank: 2.5th and 97.5th percentiles
|
|
386
|
+
round(means[(975 * samples + 999) // 1000 - 1], 4)]
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _group(rows, tol, seed, samples):
|
|
390
|
+
n = len(rows)
|
|
391
|
+
base, cand = sum(r["base"] for r in rows), sum(r["cand"] for r in rows)
|
|
392
|
+
return {"n": n, "sessions": len({r["session_id"] for r in rows}),
|
|
393
|
+
"base": round(base, 4), "cand": round(cand, 4), "delta": round(cand - base, 4), "tolerance": tol,
|
|
394
|
+
"beyond_noise": _beyond(cand - base, tol), "undetectable": 0 < round(base, 6) <= tol,
|
|
395
|
+
"base_agreement": round(base / n, 4), "cand_agreement": round(cand / n, 4),
|
|
396
|
+
"delta_agreement": round((cand - base) / n, 4), "ci95": _resample_ci(rows, seed, samples)}
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _buckets(rows, spread):
|
|
400
|
+
base, cand, new, fixed = Counter(), Counter(), Counter(), Counter()
|
|
401
|
+
for r in rows:
|
|
402
|
+
for b in r["base_buckets"]:
|
|
403
|
+
base[b] += 1 / r["pairs"]
|
|
404
|
+
for c in r["cand_buckets"]:
|
|
405
|
+
cand[c] += 1 / r["pairs"]
|
|
406
|
+
new.update(r["new"])
|
|
407
|
+
fixed.update(r["fixed"])
|
|
408
|
+
out = {}
|
|
409
|
+
for name in sorted((set(base) | set(cand) | set(spread)) - set(MATCH_BUCKETS), key=lambda b: (-base[b], b)):
|
|
410
|
+
tol = _tolerance(spread.get(name))
|
|
411
|
+
net = new[name] - fixed[name]
|
|
412
|
+
better = _beyond(-net, tol) # fewer failures is better
|
|
413
|
+
out[name] = {"base": round(base[name], 4), "cand": round(cand[name], 4), "new": round(new[name], 4),
|
|
414
|
+
"fixed": round(fixed[name], 4), "net": round(net, 4), "tolerance": tol,
|
|
415
|
+
"beyond_noise": better}
|
|
416
|
+
return out
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _round_row(r):
|
|
420
|
+
keep = ("unit_id", "node", "kind", "tools", "pairs", "touched", "ops", "base_buckets", "cand_buckets")
|
|
421
|
+
out = {k: r[k] for k in keep}
|
|
422
|
+
out.update(base=round(r["base"], 4), cand=round(r["cand"], 4), delta=round(r["delta"], 4))
|
|
423
|
+
return out
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def compare(base_dir, cand_dir, noise_dir=None, force=False, samples=BOOTSTRAP_SAMPLES):
|
|
427
|
+
"""The comparison of cand with base (bt.compare/1); its verdict maps to an exit code (EXIT_CODES)."""
|
|
428
|
+
base, cand = load_run(base_dir), load_run(cand_dir)
|
|
429
|
+
same_dir = noise_dir and os.path.exists(noise_dir) and os.path.samefile(noise_dir, base_dir)
|
|
430
|
+
noise = load_run(noise_dir) if noise_dir and not same_dir else base
|
|
431
|
+
roles = [("base", base), ("cand", cand)] + ([("noise", noise)] if noise is not base else [])
|
|
432
|
+
_name_apart([run for _, run in roles])
|
|
433
|
+
reasons = [("units", i) for i in [_unit_issue(base, cand)] if i] # (kind, message)
|
|
434
|
+
reasons += [("config", i) for i in _config_issues(base, cand, MUST_MATCH)]
|
|
435
|
+
if noise is not base:
|
|
436
|
+
reasons += [("units", f"noise run: {i}") for i in [_unit_issue(base, noise)] if i]
|
|
437
|
+
reasons += [("config", f"noise run: {i}") for i in _config_issues(base, noise, NOISE_MUST_MATCH)]
|
|
438
|
+
common = sorted(_same_units(base, cand))
|
|
439
|
+
repeats = range(min(base["k"], cand["k"]))
|
|
440
|
+
rows, dropped, no_answers = _pair(base, cand, common, repeats)
|
|
441
|
+
health = {}
|
|
442
|
+
for role, run in roles:
|
|
443
|
+
h = health[role] = _health(run, no_answers.get(role, ())) # the noise run is not paired: no no answers
|
|
444
|
+
if _too_unhealthy(h):
|
|
445
|
+
reasons.append(("infra", f"{run['name']}: {100 * h['infra_rate']:.1f}% of its records are infra errors "
|
|
446
|
+
f"or missing (more than {100 * MAX_INFRA_RATE:.0f}%): fix the cause, then replay "
|
|
447
|
+
"the failed calls again (--cache-dir sends only those)"))
|
|
448
|
+
if _too_many_no_answers(h):
|
|
449
|
+
reasons.append(("no_answer", f"{run['name']}: {100 * h['no_answer'] / h['records']:.1f}% of its records "
|
|
450
|
+
"timed out where the other run answered (more than "
|
|
451
|
+
f"{100 * MAX_NO_ANSWER_RATE:.0f}%): an outage, or a model that runs away on "
|
|
452
|
+
"most of what it was changed to send. Replay the failed calls again "
|
|
453
|
+
"(--cache-dir sends only those): a runaway times out again, and --force "
|
|
454
|
+
"then scores those as no answers"))
|
|
455
|
+
reasons = list(dict.fromkeys(reasons)) # the noise run can be cand
|
|
456
|
+
kinds = [kind for kind in REASON_KINDS if any(k == kind for k, _ in reasons)]
|
|
457
|
+
reasons = [message for _, message in reasons]
|
|
458
|
+
group_spread, bucket_spread, noise_pairs = noise_spread(noise)
|
|
459
|
+
out = {"schema": SCHEMA_COMPARE, "base": _run_meta(base, health["base"]), "cand": _run_meta(cand, health["cand"]),
|
|
460
|
+
"noise": {"dir": noise["path"], "model": noise["config"].get("model"), "k": noise["k"],
|
|
461
|
+
"repeat_pairs": noise_pairs, "infra_rate": health.get("noise", health["base"])["infra_rate"]},
|
|
462
|
+
"differs": _differs(base, cand), "comparable": not reasons, "not_comparable": reasons,
|
|
463
|
+
"not_comparable_kinds": kinds, "forced": bool(reasons) and force, "warnings": []}
|
|
464
|
+
if reasons and not force:
|
|
465
|
+
out["verdict"] = "not_comparable"
|
|
466
|
+
return out
|
|
467
|
+
|
|
468
|
+
out["pairs"] = {"units": len(rows), "pairs": sum(r["pairs"] for r in rows), "repeats": len(repeats),
|
|
469
|
+
"dropped": dict(dropped), "no_answer": {role: len(keys) for role, keys in no_answers.items()},
|
|
470
|
+
"units_dropped": len(common) - len(rows),
|
|
471
|
+
"units_touched": sum(1 for r in rows if r["touched"]),
|
|
472
|
+
"untouched_moved": sum(1 for r in rows if r["moved"] and not r["touched"])}
|
|
473
|
+
if not rows:
|
|
474
|
+
out["not_comparable"] = reasons + ["no unit was scored in both runs"]
|
|
475
|
+
out["not_comparable_kinds"] = kinds + ["unscored"]
|
|
476
|
+
out["comparable"], out["verdict"] = False, "not_comparable"
|
|
477
|
+
return out
|
|
478
|
+
|
|
479
|
+
out["overall"] = _group(rows, _tolerance(group_spread.get(("overall", None))), "overall", samples)
|
|
480
|
+
members = defaultdict(lambda: defaultdict(list))
|
|
481
|
+
for r in rows:
|
|
482
|
+
for metric, name in _keys(r):
|
|
483
|
+
members[metric][name].append(r)
|
|
484
|
+
for metric, _ in GROUP_LABELS:
|
|
485
|
+
out[metric] = {name: _group(members[metric][name], _tolerance(group_spread.get((metric, name))),
|
|
486
|
+
f"{metric}/{name}", samples)
|
|
487
|
+
for name in sorted(members[metric])}
|
|
488
|
+
out["buckets"] = _buckets(rows, bucket_spread)
|
|
489
|
+
out["units"] = [_round_row(r) for r in sorted(rows, key=lambda r: (round(r["delta"], 6), r["unit_id"]))
|
|
490
|
+
if r["moved"]]
|
|
491
|
+
|
|
492
|
+
regressions = []
|
|
493
|
+
if out["overall"]["beyond_noise"] == "regression":
|
|
494
|
+
regressions.append({"metric": "overall", "name": None, "delta": out["overall"]["delta"],
|
|
495
|
+
"tolerance": out["overall"]["tolerance"]})
|
|
496
|
+
for metric, _ in GROUP_LABELS:
|
|
497
|
+
for name, g in out[metric].items():
|
|
498
|
+
if g["beyond_noise"] == "regression":
|
|
499
|
+
regressions.append({"metric": metric, "name": name, "delta": g["delta"], "tolerance": g["tolerance"]})
|
|
500
|
+
for name, b in out["buckets"].items():
|
|
501
|
+
if b["beyond_noise"] == "regression":
|
|
502
|
+
regressions.append({"metric": "buckets", "name": name, "net": b["net"], "tolerance": b["tolerance"]})
|
|
503
|
+
out["regressions"] = regressions
|
|
504
|
+
out["gain_beyond_noise"] = out["overall"]["beyond_noise"] == "gain"
|
|
505
|
+
out["verdict"] = "regression" if regressions else "no_regression"
|
|
506
|
+
out["warnings"] = _warnings(out, base, cand, noise, noise_pairs, reasons)
|
|
507
|
+
return out
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _warnings(out, base, cand, noise, noise_pairs, forced_reasons):
|
|
511
|
+
warns = []
|
|
512
|
+
if forced_reasons:
|
|
513
|
+
warns.append("compared although not comparable (--force), on the units both runs share: "
|
|
514
|
+
+ "; ".join(forced_reasons))
|
|
515
|
+
if not noise_pairs:
|
|
516
|
+
warns.append(f"the noise run {noise['name']} has one repeat: every tolerance is the floor, "
|
|
517
|
+
f"{TOLERANCE_FLOOR:g} unit; replay it with --k 3 for a noise estimate")
|
|
518
|
+
if noise is not base and noise["config"].get("model") != base["config"].get("model"):
|
|
519
|
+
warns.append(f"the noise run's model ({noise['config'].get('model')}) is not base's "
|
|
520
|
+
f"({base['config'].get('model')}): its spread may not be this model's")
|
|
521
|
+
for run in (base, cand):
|
|
522
|
+
if run["config"].get("interrupted"):
|
|
523
|
+
warns.append(f"{run['name']} was interrupted: its missing records are dropped from the pairs")
|
|
524
|
+
na = out["pairs"]["no_answer"]
|
|
525
|
+
if na["base"] or na["cand"]:
|
|
526
|
+
warns.append(f"{na['base'] + na['cand']} pairs score 0 as no answers (base {na['base']}, cand {na['cand']}): "
|
|
527
|
+
"timeouts where the other run answered another request or model. A runaway times out again; "
|
|
528
|
+
"to rule out an outage, replay the failed calls again (--cache-dir sends only those)")
|
|
529
|
+
moved = out["pairs"]["untouched_moved"]
|
|
530
|
+
if moved and base["config"].get("model") == cand["config"].get("model"):
|
|
531
|
+
warns.append(f"{moved} units whose request is the same in both runs scored differently: sampling noise is "
|
|
532
|
+
"in the deltas. Replay both with the same --seed and one --cache-dir, so that what a change "
|
|
533
|
+
"leaves alone scores the same")
|
|
534
|
+
blind = [f"{label} {name}" for metric, label in GROUP_LABELS for name, g in out[metric].items()
|
|
535
|
+
if g["undetectable"]]
|
|
536
|
+
if out["overall"]["undetectable"]:
|
|
537
|
+
blind.insert(0, "overall")
|
|
538
|
+
if blind:
|
|
539
|
+
warns.append(f"a regression cannot be detected on {len(blind)} groups whose base passes are within their "
|
|
540
|
+
"tolerance: " + ", ".join(blind[:8]) + (", ..." if len(blind) > 8 else ""))
|
|
541
|
+
return warns
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def exit_code(result):
|
|
545
|
+
return EXIT_CODES[result["verdict"]]
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _patch_label(patch):
|
|
549
|
+
return f"patch {patch.get('file')} ({str(patch.get('sha256'))[:8]})" if patch else "no patch"
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _cut(label, width=38):
|
|
553
|
+
return label if len(label) <= width else label[:width - 4] + "... "
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _row(label, g):
|
|
557
|
+
ci = "" if g["ci95"] is None else f"[{g['ci95'][0]:+.3f}, {g['ci95'][1]:+.3f}]"
|
|
558
|
+
flag = {"regression": "REGRESSION", "gain": "gain"}.get(g["beyond_noise"]) or (
|
|
559
|
+
"(undetectable)" if g["undetectable"] else "")
|
|
560
|
+
return (f"{_cut(label):<38}{g['n']:>5}{g['base']:>9.2f}{g['cand']:>9.2f}{g['delta']:>+9.2f}{g['tolerance']:>6g}"
|
|
561
|
+
f" {g['base_agreement']:.3f} -> {g['cand_agreement']:.3f} {ci:<18} {flag}").rstrip()
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def report_lines(res):
|
|
565
|
+
"""The comparison as text: counts, node and tool names only (unit ids stay in the JSON)."""
|
|
566
|
+
lines = []
|
|
567
|
+
for role in ("base", "cand"):
|
|
568
|
+
m = res[role]
|
|
569
|
+
lines.append(f"{role:<6} {m['dir']} model {m['model']}, {_patch_label(m['patch'])}, k={m['k']}, "
|
|
570
|
+
f"infra errors {m['infra_error']} + missing {m['missing']} of {m['records']}"
|
|
571
|
+
+ (f", no answers {m['no_answer']}" if m.get("no_answer") else ""))
|
|
572
|
+
n = res["noise"]
|
|
573
|
+
lines.append(f"noise {n['dir']}: " + (
|
|
574
|
+
f"{n['repeat_pairs']} pairs of repeats (k={n['k']}); tolerance = the largest difference between two "
|
|
575
|
+
f"repeats, at least {TOLERANCE_FLOOR:g} unit" if n["repeat_pairs"] else
|
|
576
|
+
f"one repeat: every tolerance is {TOLERANCE_FLOOR:g} unit"))
|
|
577
|
+
lines.append("differs: " + (", ".join(res["differs"]) or "nothing in the config"))
|
|
578
|
+
if res["verdict"] == "not_comparable":
|
|
579
|
+
lines.append("NOT COMPARABLE:")
|
|
580
|
+
lines.extend(f" - {r}" for r in res["not_comparable"])
|
|
581
|
+
lines.append("verdict: not comparable (exit 2)")
|
|
582
|
+
return lines
|
|
583
|
+
p = res["pairs"]
|
|
584
|
+
dropped = ", ".join(f"{k.replace('_', ' ')} {v}" for k, v in sorted(p["dropped"].items())) or "none"
|
|
585
|
+
no_answer = ", ".join(f"{role} {n}" for role, n in p["no_answer"].items() if n) or "none"
|
|
586
|
+
lines.append(f"paired {p['units']} units, {p['pairs']} unit x repeat pairs over {p['repeats']} repeats; "
|
|
587
|
+
f"dropped pairs: {dropped}; no answers (scored 0): {no_answer}; "
|
|
588
|
+
f"units the change touched: {p['units_touched']}")
|
|
589
|
+
lines.append("")
|
|
590
|
+
lines.append(f"{'passes (sum of unit scores)':<38}{'n':>5}{'base':>9}{'cand':>9}{'delta':>9}{'tol':>6}"
|
|
591
|
+
f" {'agreement':<16} {'95% CI of the mean':<18}")
|
|
592
|
+
lines.append(_row("overall", res["overall"]))
|
|
593
|
+
for metric, label in GROUP_LABELS:
|
|
594
|
+
for name, g in res[metric].items():
|
|
595
|
+
lines.append(_row(f"{label} {name}", g))
|
|
596
|
+
if res["buckets"]:
|
|
597
|
+
lines.append("")
|
|
598
|
+
lines.append(f"{'failure bucket (records per repeat)':<38}{'base':>7}{'cand':>7}{'new':>7}{'fixed':>7}"
|
|
599
|
+
f"{'net':>8}{'tol':>6}")
|
|
600
|
+
for name, b in res["buckets"].items():
|
|
601
|
+
flag = {"regression": "REGRESSION", "gain": "fewer"}.get(b["beyond_noise"], "")
|
|
602
|
+
lines.append(f"{_cut(name):<38}{b['base']:>7.2f}{b['cand']:>7.2f}{b['new']:>7.2f}{b['fixed']:>7.2f}"
|
|
603
|
+
f"{b['net']:>+8.2f}{b['tolerance']:>6g} {flag}".rstrip())
|
|
604
|
+
lines.append("")
|
|
605
|
+
lines.append(f"units that moved: {len(res['units'])} (listed in the JSON)")
|
|
606
|
+
for w in res["warnings"]:
|
|
607
|
+
lines.append(f"WARNING: {w}")
|
|
608
|
+
o = res["overall"]
|
|
609
|
+
if res["regressions"]:
|
|
610
|
+
names = [LABELS[r["metric"]] + ("" if r["name"] is None else f" {r['name']}") for r in res["regressions"]]
|
|
611
|
+
lines.append(f"verdict: REGRESSION beyond noise on {len(names)}: " + ", ".join(names) + " (exit 1)")
|
|
612
|
+
else:
|
|
613
|
+
gain = (f"overall {o['delta']:+.2f} units, a gain beyond noise (tolerance {o['tolerance']:g})"
|
|
614
|
+
if res["gain_beyond_noise"] else
|
|
615
|
+
f"overall {o['delta']:+.2f} units, within noise (tolerance {o['tolerance']:g})")
|
|
616
|
+
lines.append(f"verdict: no regression beyond noise; {gain} (exit 0)")
|
|
617
|
+
return lines
|