Skip to content

Commit e86af16

Browse files
authored
fix(bench): break circular import in predictor/__init__.py (Tracer-Cloud#2802)
* fix(bench): break circular import in predictor/__init__.py * refactor(bench): extract taxonomy + vocabulary from predictor; add L0 report panel
1 parent 2d0c80b commit e86af16

10 files changed

Lines changed: 457 additions & 275 deletions

File tree

tests/benchmarks/_framework/reporting.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,16 @@
226226
# opensre-only instrumentation. Useful as internal diagnostics, but NOT present
227227
# in the paper, so they are reported in a separate panel to avoid implying a
228228
# comparison that doesn't exist.
229+
# L0 investigation-native metrics (opensre prose, keyword parser). Distinct from
230+
# L1 ``a1`` which scores the predictor's rank-1 formalization.
231+
_L0_INVESTIGATION_METRICS = [
232+
"investigation_a1",
233+
"investigation_partial_a1",
234+
"investigation_object_a1",
235+
"translation_loss",
236+
]
237+
_L0_CI_METRICS = frozenset({"investigation_a1", "investigation_object_a1"})
238+
229239
_OPENSRE_ONLY_METRICS = [
230240
"partial_a1",
231241
"partial_a3",
@@ -696,6 +706,92 @@ def _render_decomposition_html(
696706
return parts
697707

698708

709+
def _render_l0_investigation_markdown(
710+
by_lm: dict[str, dict[str, list[dict[str, Any]]]],
711+
) -> list[str]:
712+
"""L0 panel: investigation-native metrics from opensre prose (not predictor)."""
713+
if not by_lm:
714+
return []
715+
flat = [c for modes in by_lm.values() for cs in modes.values() for c in cs]
716+
present = [m for m in _L0_INVESTIGATION_METRICS if _per_cell_metric(flat, m)]
717+
if not present:
718+
return []
719+
720+
lines: list[str] = []
721+
lines.append("### Investigation quality — L0 (opensre prose, not paper-comparable)")
722+
lines.append("")
723+
lines.append(
724+
"_L0 scores a keyword-parsed triple from opensre's investigation prose "
725+
"(``report`` / ``root_cause`` / causal chain). L1 ``a1`` in the headline "
726+
"scores the predictor's rank-1 formalization. The gap "
727+
"``a1 − investigation_a1`` is translation loss; "
728+
"``translation_loss`` flags cases where L0 is right but L1 is wrong._"
729+
)
730+
lines.append("")
731+
header = "| LLM | variant | " + " | ".join(present) + " |"
732+
sep = "|" + "|".join(["---"] * (len(present) + 2)) + "|"
733+
lines.append(header)
734+
lines.append(sep)
735+
for llm in sorted(by_lm.keys()):
736+
for mode in sorted(by_lm[llm].keys()):
737+
mode_cells = by_lm[llm][mode]
738+
row = [f"`{llm}`", mode]
739+
for metric in present:
740+
scen_vals = _scenario_means(mode_cells, metric)
741+
mean, lo, hi, n = _mean_with_ci(scen_vals)
742+
if metric in _L0_CI_METRICS and len(scen_vals) >= 2:
743+
row.append(f"{mean:.2f} [{lo:.2f}{hi:.2f}]")
744+
elif n:
745+
row.append(f"{mean:.2f}")
746+
else:
747+
row.append("—")
748+
lines.append("| " + " | ".join(row) + " |")
749+
lines.append("")
750+
return lines
751+
752+
753+
def _render_l0_investigation_html(
754+
by_lm: dict[str, dict[str, list[dict[str, Any]]]],
755+
esc: Any,
756+
) -> list[str]:
757+
"""HTML mirror of :func:`_render_l0_investigation_markdown`."""
758+
if not by_lm:
759+
return []
760+
flat = [c for modes in by_lm.values() for cs in modes.values() for c in cs]
761+
present = [m for m in _L0_INVESTIGATION_METRICS if _per_cell_metric(flat, m)]
762+
if not present:
763+
return []
764+
765+
parts: list[str] = []
766+
parts.append("<h3>Investigation quality — L0 (opensre prose, not paper-comparable)</h3>")
767+
parts.append(
768+
"<p><small>L0 scores a keyword-parsed triple from opensre's investigation "
769+
"prose. L1 <code>a1</code> scores the predictor's rank-1 formalization. "
770+
"The gap <code>a1 − investigation_a1</code> is translation loss.</small></p>"
771+
)
772+
parts.append("<table><thead><tr><th>LLM</th><th>variant</th>")
773+
for m in present:
774+
parts.append(f"<th>{esc(m)}</th>")
775+
parts.append("</tr></thead><tbody>")
776+
for llm in sorted(by_lm.keys()):
777+
for mode in sorted(by_lm[llm].keys()):
778+
mode_cells = by_lm[llm][mode]
779+
parts.append(f"<tr><td><code>{esc(llm)}</code></td><td>{esc(mode)}</td>")
780+
for metric in present:
781+
scen_vals = _scenario_means(mode_cells, metric)
782+
mean, lo, hi, n = _mean_with_ci(scen_vals)
783+
if metric in _L0_CI_METRICS and len(scen_vals) >= 2:
784+
cell_txt = f"{mean:.2f}<br><small>[{lo:.2f}{hi:.2f}]</small>"
785+
elif n:
786+
cell_txt = f"{mean:.2f}"
787+
else:
788+
cell_txt = "—"
789+
parts.append(f'<td class="metric">{cell_txt}</td>')
790+
parts.append("</tr>")
791+
parts.append("</tbody></table>")
792+
return parts
793+
794+
699795
# --------------------------------------------------------------------------- #
700796
# Markdown rendering #
701797
# --------------------------------------------------------------------------- #
@@ -806,6 +902,9 @@ def _render_markdown(
806902
# --- Decomposition: where the accuracy goes (Track 2) ---
807903
lines.extend(_render_decomposition_markdown(cells, by_lm))
808904

905+
# --- L0 investigation quality (opensre prose — not paper-comparable) ---
906+
lines.extend(_render_l0_investigation_markdown(by_lm))
907+
809908
# --- opensre-only diagnostics (NOT in the paper, NOT comparable) ---
810909
if by_lm:
811910
flat = [c for modes in by_lm.values() for cs in modes.values() for c in cs]
@@ -1110,6 +1209,9 @@ def esc(s: Any) -> str:
11101209
# Decomposition (Track 2)
11111210
parts.extend(_render_decomposition_html(cells, by_lm, esc))
11121211

1212+
# L0 investigation quality
1213+
parts.extend(_render_l0_investigation_html(by_lm, esc))
1214+
11131215
# opensre-only diagnostics (segregated — not paper-comparable)
11141216
flat = [c for modes in by_lm.values() for cs in modes.values() for c in cs]
11151217
present = [m for m in _OPENSRE_ONLY_METRICS if _per_cell_metric(flat, m)]

tests/benchmarks/_framework/tests/test_reporting.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,31 @@ def test_html_includes_inline_style_no_external_deps(tmp_path: Path) -> None:
183183
assert "<script src=" not in html
184184

185185

186+
def test_markdown_includes_l0_investigation_panel_when_metrics_present(tmp_path: Path) -> None:
187+
"""L0 investigation metrics surface in report.md when cells record them."""
188+
_write_report_json(tmp_path)
189+
cases_dir = tmp_path / "cases"
190+
cell = {
191+
"case": {"case_id": "case-001", "metadata": {"fault_category": "Runtime"}},
192+
"run": {"mode": "opensre+llm", "llm": "claude_sonnet"},
193+
"score": {
194+
"metrics": {
195+
"a1": 0.0,
196+
"investigation_a1": 0.75,
197+
"investigation_object_a1": 0.80,
198+
"translation_loss": 0.25,
199+
}
200+
},
201+
"ok": True,
202+
}
203+
(cases_dir / "case-001__opensre+llm__claude_sonnet__0.json").write_text(json.dumps(cell))
204+
out = render_report_dir(tmp_path, formats=["markdown"])
205+
md = out["markdown"].read_text()
206+
assert "Investigation quality — L0" in md
207+
assert "investigation_a1" in md
208+
assert "translation_loss" in md
209+
210+
186211
def test_render_works_without_cases_directory(tmp_path: Path) -> None:
187212
"""report.json is the source of truth; cases/ is optional."""
188213
_write_report_json(tmp_path)
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""Closed-vocabulary constants for CloudOpsBench scoring and prediction.
2+
3+
Package-level module (not under ``predictor/``) so ``taxonomy`` and
4+
``scoring`` can import fault-object lists without loading
5+
``predictor/__init__.py``. The predictor subpackage re-exports these
6+
from ``predictor.vocabulary`` for backward compatibility.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
# Declaring ``__all__`` tells CodeQL that these names are the module's
12+
# public surface — they are not "unused globals" but intentional
13+
# re-exports consumed by ``taxonomy``, ``scoring``, and
14+
# ``predictor.vocabulary`` (which re-exports them for legacy callers).
15+
__all__ = [
16+
"_FAULT_OBJECT_NAMESPACES",
17+
"_FAULT_OBJECT_NODES",
18+
"_FAULT_OBJECT_SERVICES",
19+
"_ROOT_CAUSES",
20+
"_TAXONOMY_CATEGORIES",
21+
]
22+
23+
_TAXONOMY_CATEGORIES: tuple[str, ...] = (
24+
"Admission_Fault",
25+
"Scheduling_Fault",
26+
"Infrastructure_Fault",
27+
"Startup_Fault",
28+
"Runtime_Fault",
29+
"Service_Routing_Fault",
30+
"Performance_Fault",
31+
)
32+
33+
_ROOT_CAUSES: tuple[str, ...] = (
34+
# Scheduling
35+
"missing_service_account",
36+
"node_cordon_mismatch",
37+
"node_affinity_mismatch",
38+
"node_selector_mismatch",
39+
"pod_anti_affinity_conflict",
40+
"taint_toleration_mismatch",
41+
"cpu_capacity_mismatch",
42+
"memory_capacity_mismatch",
43+
# Infrastructure
44+
"node_network_delay",
45+
"node_network_packet_loss",
46+
"containerd_unavailable",
47+
"kubelet_unavailable",
48+
"kube_proxy_unavailable",
49+
"kube_scheduler_unavailable",
50+
# Startup
51+
"image_registry_dns_failure",
52+
"incorrect_image_reference",
53+
"missing_image_pull_secret",
54+
"pvc_selector_mismatch",
55+
"pvc_storage_class_mismatch",
56+
"pvc_access_mode_mismatch",
57+
"pvc_capacity_mismatch",
58+
"pv_binding_occupied",
59+
"volume_mount_permission_denied",
60+
# Runtime
61+
"oom_killed",
62+
"liveness_probe_incorrect_protocol",
63+
"liveness_probe_incorrect_port",
64+
"liveness_probe_incorrect_timing",
65+
"readiness_probe_incorrect_protocol",
66+
"readiness_probe_incorrect_port",
67+
"mysql_invalid_credentials",
68+
"mysql_invalid_port",
69+
"missing_secret_binding",
70+
"db_connection_exhaustion",
71+
"db_readonly_mode",
72+
"gateway_misrouted",
73+
"deployment_zero_replicas",
74+
# Service routing
75+
"service_selector_mismatch",
76+
"service_port_mapping_mismatch",
77+
"service_protocol_mismatch",
78+
"service_env_var_address_mismatch",
79+
"service_sidecar_port_conflict",
80+
"service_dns_resolution_failure",
81+
# Performance
82+
"pod_network_delay",
83+
"pod_cpu_overload",
84+
# Admission
85+
"namespace_cpu_quota_exceeded",
86+
"namespace_memory_quota_exceeded",
87+
"namespace_pod_quota_exceeded",
88+
"namespace_service_quota_exceeded",
89+
"namespace_storage_quota_exceeded",
90+
)
91+
92+
_FAULT_OBJECT_SERVICES: tuple[str, ...] = (
93+
# online-boutique
94+
"adservice",
95+
"cartservice",
96+
"checkoutservice",
97+
"currencyservice",
98+
"emailservice",
99+
"frontend",
100+
"paymentservice",
101+
"productcatalogservice",
102+
"recommendationservice",
103+
"redis-cart",
104+
"shippingservice",
105+
# train-ticket
106+
"ts-gateway-service",
107+
"ts-order-service",
108+
"ts-payment-service",
109+
"ts-travel-service",
110+
"ts-user-service",
111+
"ts-auth-service",
112+
"ts-route-service",
113+
"ts-ticket-office-service",
114+
"ts-assurance-service",
115+
"ts-basic-service",
116+
"ts-cancel-service",
117+
"ts-config-service",
118+
"ts-consign-service",
119+
"ts-contacts-service",
120+
"ts-delivery-service",
121+
"ts-food-delivery-service",
122+
"ts-food-service",
123+
"ts-inside-payment-service",
124+
"ts-notification-service",
125+
"ts-order-other-service",
126+
"ts-preserve-service",
127+
"ts-price-service",
128+
"ts-seat-service",
129+
"ts-security-service",
130+
"ts-station-food-service",
131+
"ts-station-service",
132+
"ts-train-food-service",
133+
"ts-train-service",
134+
"ts-travel2-service",
135+
"ts-voucher-service",
136+
"ts-wait-order-service",
137+
"tsdb-mysql",
138+
)
139+
140+
_FAULT_OBJECT_NODES: tuple[str, ...] = ("master", "worker-01", "worker-02", "worker-03")
141+
_FAULT_OBJECT_NAMESPACES: tuple[str, ...] = ("boutique", "train-ticket")

tests/benchmarks/cloudopsbench/predictor/__init__.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,8 @@
2929

3030
from __future__ import annotations
3131

32-
from tests.benchmarks.cloudopsbench.predictor.investigation_handoff import (
33-
align_predictions_to_investigation,
34-
apply_investigation_handoff,
35-
)
32+
from typing import Any
33+
3634
from tests.benchmarks.cloudopsbench.predictor.llm_call import (
3735
_FENCED_JSON,
3836
_build_system_prompt,
@@ -102,3 +100,23 @@
102100
# llm_call_structured_openai
103101
"emit_paper_predictions_structured",
104102
]
103+
104+
_INVESTIGATION_HANDOFF_EXPORTS = frozenset(
105+
{"align_predictions_to_investigation", "apply_investigation_handoff"}
106+
)
107+
108+
109+
def __getattr__(name: str) -> Any:
110+
"""Lazy-load investigation handoff so importing ``vocabulary`` from this
111+
package does not pull in handoff (and its scoring dependencies) at init."""
112+
if name in _INVESTIGATION_HANDOFF_EXPORTS:
113+
from tests.benchmarks.cloudopsbench.predictor.investigation_handoff import (
114+
align_predictions_to_investigation,
115+
apply_investigation_handoff,
116+
)
117+
118+
return {
119+
"align_predictions_to_investigation": align_predictions_to_investigation,
120+
"apply_investigation_handoff": apply_investigation_handoff,
121+
}[name]
122+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

tests/benchmarks/cloudopsbench/predictor/investigation_handoff.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
import re
3232
from typing import Any
3333

34-
from tests.benchmarks.cloudopsbench.scoring import _taxonomy_for_root_cause
34+
from tests.benchmarks.cloudopsbench.taxonomy import taxonomy_for_root_cause
3535

3636
logger = logging.getLogger(__name__)
3737

@@ -231,7 +231,7 @@ def align_predictions_to_investigation(
231231
{
232232
**prediction,
233233
"rank": new_rank + 1,
234-
"fault_taxonomy": _taxonomy_for_root_cause(str(prediction.get("root_cause") or "")),
234+
"fault_taxonomy": taxonomy_for_root_cause(str(prediction.get("root_cause") or "")),
235235
}
236236
for new_rank, prediction in enumerate(new_order)
237237
]

tests/benchmarks/cloudopsbench/predictor/llm_call.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
_ROOT_CAUSES,
4242
_TAXONOMY_CATEGORIES,
4343
)
44-
from tests.benchmarks.cloudopsbench.scoring import _taxonomy_for_root_cause
44+
from tests.benchmarks.cloudopsbench.taxonomy import taxonomy_for_root_cause
4545

4646
logger = logging.getLogger(__name__)
4747

@@ -312,7 +312,7 @@ def _parse_predictions(text: str) -> dict[str, Any] | None:
312312
# Lever A: snap onto the dataset's closed vocabulary before scoring so
313313
# near-miss tokens don't auto-fail the exact-match scorer.
314314
normalized_root_cause = _snap_root_cause(root_cause)
315-
derived_taxonomy = _taxonomy_for_root_cause(normalized_root_cause)
315+
derived_taxonomy = taxonomy_for_root_cause(normalized_root_cause)
316316
llm_taxonomy = (prediction.get("fault_taxonomy") or "").strip()
317317
if llm_taxonomy and llm_taxonomy != derived_taxonomy:
318318
logger.info(

tests/benchmarks/cloudopsbench/predictor/llm_call_structured_openai.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@
7171
_ROOT_CAUSES,
7272
_TAXONOMY_CATEGORIES,
7373
)
74-
from tests.benchmarks.cloudopsbench.scoring import _taxonomy_for_root_cause
74+
from tests.benchmarks.cloudopsbench.taxonomy import taxonomy_for_root_cause
7575

7676
logger = logging.getLogger(__name__)
7777

@@ -209,7 +209,7 @@ def emit_paper_predictions_structured(
209209
# though the schema constrains the LLM's emit, the scorer's mapping
210210
# is the canonical ground truth and may differ from what the LLM
211211
# picked for the same root_cause.
212-
derived_taxonomy = _taxonomy_for_root_cause(prediction.root_cause)
212+
derived_taxonomy = taxonomy_for_root_cause(prediction.root_cause)
213213
cleaned.append(
214214
{
215215
"rank": prediction.rank,

0 commit comments

Comments
 (0)