@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.
Files changed (118) hide show
  1. package/README.md +55 -5
  2. package/package.json +1 -1
  3. package/skills/agentforce-offline-experimentation-run/SKILL.md +14 -22
  4. package/skills/agentforce-offline-experimentation-run/scripts/bt_compare.py +81 -0
  5. package/skills/agentforce-offline-experimentation-run/scripts/bt_fetch.py +358 -0
  6. package/skills/agentforce-offline-experimentation-run/scripts/bt_patch_agent.py +110 -0
  7. package/skills/agentforce-offline-experimentation-run/scripts/btlib/__init__.py +34 -0
  8. package/skills/agentforce-offline-experimentation-run/scripts/btlib/agentpatch.py +483 -0
  9. package/skills/agentforce-offline-experimentation-run/scripts/btlib/agentsrc.py +565 -0
  10. package/skills/agentforce-offline-experimentation-run/scripts/btlib/cache.py +95 -0
  11. package/skills/agentforce-offline-experimentation-run/scripts/btlib/compare.py +617 -0
  12. package/skills/agentforce-offline-experimentation-run/scripts/btlib/fetch.py +1012 -0
  13. package/skills/agentforce-offline-experimentation-run/scripts/btlib/judge.py +148 -0
  14. package/skills/agentforce-offline-experimentation-run/scripts/btlib/messages.py +92 -0
  15. package/skills/agentforce-offline-experimentation-run/scripts/btlib/patch.py +327 -0
  16. package/skills/agentforce-offline-experimentation-run/scripts/btlib/redact.py +40 -0
  17. package/skills/agentforce-offline-experimentation-run/scripts/btlib/results.py +288 -0
  18. package/skills/agentforce-offline-experimentation-run/scripts/btlib/scoring.py +340 -0
  19. package/skills/agentforce-offline-experimentation-run/scripts/btlib/sfap.py +238 -0
  20. package/skills/agentforce-offline-experimentation-run/scripts/btlib/splits.py +136 -0
  21. package/skills/agentforce-offline-experimentation-run/scripts/btlib/stdm.py +98 -0
  22. package/skills/agentforce-offline-experimentation-run/scripts/btlib/tokens.py +173 -0
  23. package/skills/agentforce-offline-experimentation-run/scripts/btlib/units.py +199 -0
  24. package/skills/agentforce-offline-experimentation-run/scripts/btlib/versions.py +194 -0
  25. package/skills/agentforce-offline-experimentation-run/scripts/build_report.py +366 -378
  26. package/skills/agentforce-offline-experimentation-run/scripts/resolve-token.sh +14 -4
  27. package/skills/agentforce-offline-experimentation-run/scripts/sfap_replay.py +472 -796
  28. package/skills/dx-lifecycle-center/SKILL.md +5 -2
  29. package/skills/dx-lifecycle-center/assets/widgets/connect-connected/connect-connected.json +65 -0
  30. package/skills/dx-lifecycle-center/assets/widgets/connect-connected/schema.json +32 -0
  31. package/skills/dx-lifecycle-center/assets/widgets/connect-sign-in/connect-sign-in.json +57 -0
  32. package/skills/dx-lifecycle-center/assets/widgets/connect-sign-in/schema.json +21 -0
  33. package/skills/dx-lifecycle-center/examples/workflows.md +20 -6
  34. package/skills/dx-lifecycle-center/references/connect-org.md +21 -2
  35. package/skills/dx-lifecycle-center/references/deploy.md +2 -2
  36. package/skills/dx-lifecycle-center/references/list-orgs.md +1 -1
  37. package/skills/dx-lifecycle-center/scripts/card-policy.jq +71 -0
  38. package/skills/dx-lifecycle-center/scripts/fill-widget.jq +29 -0
  39. package/skills/dx-lifecycle-center/scripts/widget.sh +28 -0
  40. package/skills/dx-org-devhub-configure/SKILL.md +85 -84
  41. package/skills/dx-org-devhub-configure/examples/status-output.md +25 -2
  42. package/skills/dx-org-devhub-configure/scripts/devhub.sh +265 -140
  43. package/skills/dx-org-devhub-configure/scripts/test-devhub.sh +100 -15
  44. package/skills/dx-packaging-move-to-2gp-migrate/SKILL.md +89 -0
  45. package/skills/dx-packaging-move-to-2gp-migrate/references/api-operations.md +164 -0
  46. package/skills/dx-scratch-def-configure/SKILL.md +163 -0
  47. package/skills/dx-scratch-def-configure/examples/confirm-card.md +53 -0
  48. package/skills/dx-scratch-def-configure/examples/report-recipes.md +100 -0
  49. package/skills/dx-scratch-def-configure/references/features.json +3270 -0
  50. package/skills/dx-scratch-def-configure/references/project-scratch-def.schema.json +1841 -0
  51. package/skills/dx-scratch-def-configure/references/settings-map.json +682 -0
  52. package/skills/dx-scratch-def-configure/scripts/lib_scratch_def.py +1306 -0
  53. package/skills/dx-scratch-def-configure/scripts/requirements.txt +3 -0
  54. package/skills/dx-scratch-def-configure/scripts/scrape_config_values.py +848 -0
  55. package/skills/education-cloud-domain-configure/SKILL.md +1 -1
  56. package/skills/education-cloud-multi-campus-configure/SKILL.md +1 -1
  57. package/skills/education-cloud-student-recruitment-agent-configure/SKILL.md +1 -1
  58. package/skills/experience-design-validate/SKILL.md +1 -1
  59. package/skills/experience-search-coordinate/SKILL.md +18 -12
  60. package/skills/experience-search-coordinate/references/content-route.md +79 -1
  61. package/skills/field-service-data-capture-migrate/SKILL.md +1 -1
  62. package/skills/platform-apex-generate/SKILL.md +14 -11
  63. package/skills/platform-apex-test-generate/SKILL.md +2 -1
  64. package/skills/platform-datamask-run/SKILL.md +52 -71
  65. package/skills/platform-datamask-run/references/api-surface.md +10 -4
  66. package/skills/platform-datamask-run/references/policy-authoring.md +56 -7
  67. package/skills/platform-datamask-run/references/run-and-abort.md +17 -10
  68. package/skills/platform-datamask-run/scripts/poll-job.sh +15 -25
  69. package/skills/platform-dataspace-access-configure/SKILL.md +19 -1
  70. package/skills/platform-metadata-discovery-get/SKILL.md +185 -0
  71. package/skills/platform-metadata-discovery-get/references/api-map.md +25 -0
  72. package/skills/platform-metadata-discovery-get/references/h360-routes.md +30 -0
  73. package/skills/platform-metadata-discovery-get/references/scoped-soql.md +52 -0
  74. package/skills/platform-metadata-grounding-get/SKILL.md +170 -0
  75. package/skills/platform-policy-rule-generate/SKILL.md +84 -232
  76. package/skills/platform-policy-rule-generate/references/condition-model.md +47 -0
  77. package/skills/platform-policy-rule-generate/references/condition-templates.md +89 -0
  78. package/skills/platform-policy-rule-generate/references/conjunction-compiler.md +25 -0
  79. package/skills/platform-policy-rule-generate/references/conjunction-expression.md +22 -0
  80. package/skills/platform-policy-rule-generate/references/consumer-all.md +9 -0
  81. package/skills/platform-policy-rule-generate/references/consumer-datacloud-ui.md +91 -0
  82. package/skills/platform-policy-rule-generate/references/deploy-errors.md +24 -36
  83. package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/package.xml +12 -0
  84. package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/policyRuleDefinitionSets/ExistsJoinRLS.policyRuleDefinitionSet +6 -0
  85. package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/policyRuleDefinitions/ExistsJoinRLS_Rule_1.policyRuleDefinition +91 -0
  86. package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/package.xml +12 -0
  87. package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/policyRuleDefinitionSets/FieldReadDeny.policyRuleDefinitionSet +6 -0
  88. package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/policyRuleDefinitions/FieldReadDeny_Rule_1.policyRuleDefinition +72 -0
  89. package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/package.xml +12 -0
  90. package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/policyRuleDefinitionSets/LastCharactersFieldMasking.policyRuleDefinitionSet +6 -0
  91. package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/policyRuleDefinitions/LastCharactersFieldMasking_Rule_1.policyRuleDefinition +77 -0
  92. package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/package.xml +12 -0
  93. package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/policyRuleDefinitionSets/PrincipalFieldMatchRLS.policyRuleDefinitionSet +6 -0
  94. package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/policyRuleDefinitions/PrincipalFieldMatchRLS_Rule_1.policyRuleDefinition +77 -0
  95. package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/package.xml +12 -0
  96. package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/policyRuleDefinitionSets/TaggedObjectAccess.policyRuleDefinitionSet +4 -0
  97. package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/policyRuleDefinitions/TaggedObjectAccess_Rule_1.policyRuleDefinition +65 -0
  98. package/skills/platform-policy-rule-generate/references/headless-transport.md +87 -0
  99. package/skills/platform-policy-rule-generate/references/policy-schema-full.md +17 -10
  100. package/skills/platform-policy-rule-generate/references/policy-shapes.md +74 -0
  101. package/skills/platform-policy-rule-generate/references/principal-expression.md +31 -0
  102. package/skills/platform-policy-rule-generate/references/reference-resolution.md +51 -0
  103. package/skills/platform-policy-rule-generate/references/resource-expression.md +48 -0
  104. package/skills/platform-policy-rule-generate/references/runtime-context.md +9 -0
  105. package/skills/platform-policy-rule-generate/references/templates-access.md +11 -9
  106. package/skills/platform-policy-rule-generate/references/templates-advanced.md +4 -2
  107. package/skills/platform-policy-rule-generate/references/templates-record.md +37 -22
  108. package/skills/platform-policy-rule-generate/references/templates-transform.md +10 -8
  109. package/skills/platform-policy-rule-generate/references/templates.md +2 -2
  110. package/skills/platform-policy-rule-generate/references/ui-compatibility.md +35 -17
  111. package/skills/platform-report-generate/SKILL.md +9 -4
  112. package/skills/platform-report-generate/references/chart-types.md +26 -2
  113. package/skills/platform-report-generate/references/column-names.md +43 -0
  114. package/skills/platform-report-generate/references/errors-and-troubleshooting.md +1 -1
  115. package/skills/platform-report-generate/references/verification-checklist.md +10 -2
  116. package/skills/service-digital-engagement-channel-configure/SKILL.md +7 -5
  117. package/skills/service-digital-engagement-channel-configure/assets/messaging_channel_template.xml +33 -0
  118. 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