world-model-optimizer 0.2.0__py3-none-any.whl
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.
- llm_waterfall/LICENSE +21 -0
- llm_waterfall/__init__.py +53 -0
- llm_waterfall/adapters/__init__.py +36 -0
- llm_waterfall/adapters/anthropic.py +105 -0
- llm_waterfall/adapters/aws_mantle.py +47 -0
- llm_waterfall/adapters/azure_openai.py +71 -0
- llm_waterfall/adapters/base.py +51 -0
- llm_waterfall/adapters/bedrock.py +309 -0
- llm_waterfall/adapters/openai.py +130 -0
- llm_waterfall/classify.py +184 -0
- llm_waterfall/pricing.py +110 -0
- llm_waterfall/py.typed +0 -0
- llm_waterfall/types.py +295 -0
- llm_waterfall/waterfall.py +255 -0
- wmo/__init__.py +38 -0
- wmo/agents/__init__.py +7 -0
- wmo/agents/default.py +29 -0
- wmo/agents/meta.py +55 -0
- wmo/agents/optimizer.py +55 -0
- wmo/agents/project.py +928 -0
- wmo/cli/__init__.py +5 -0
- wmo/cli/agent_session.py +1123 -0
- wmo/cli/app.py +2489 -0
- wmo/cli/e2b_cmds.py +212 -0
- wmo/cli/eval_closed_loop.py +207 -0
- wmo/cli/harness_app.py +1147 -0
- wmo/cli/harness_distill.py +659 -0
- wmo/cli/hosted_session.py +880 -0
- wmo/cli/ingest_cmd.py +165 -0
- wmo/cli/model_roles.py +82 -0
- wmo/cli/platform_cmds.py +372 -0
- wmo/cli/route_app.py +274 -0
- wmo/cli/session_state.py +243 -0
- wmo/cli/ui.py +1107 -0
- wmo/cli/workspace_sync.py +504 -0
- wmo/config/__init__.py +60 -0
- wmo/config/card.py +129 -0
- wmo/config/config.py +367 -0
- wmo/config/dotenv.py +67 -0
- wmo/config/settings.py +128 -0
- wmo/config/store.py +177 -0
- wmo/conftest.py +19 -0
- wmo/connect/__init__.py +88 -0
- wmo/connect/apps.py +78 -0
- wmo/connect/brave.py +284 -0
- wmo/connect/connector.py +79 -0
- wmo/connect/credentials.py +164 -0
- wmo/connect/github.py +321 -0
- wmo/connect/google.py +627 -0
- wmo/connect/notion.py +790 -0
- wmo/connect/oauth.py +461 -0
- wmo/connect/slack.py +555 -0
- wmo/connect/store.py +199 -0
- wmo/connect/types.py +156 -0
- wmo/core/__init__.py +21 -0
- wmo/core/parsing.py +281 -0
- wmo/core/render.py +271 -0
- wmo/core/text.py +40 -0
- wmo/core/types.py +116 -0
- wmo/distill/__init__.py +14 -0
- wmo/distill/agents.py +140 -0
- wmo/distill/config.py +1006 -0
- wmo/distill/cost.py +437 -0
- wmo/distill/data.py +921 -0
- wmo/distill/deadlines.py +254 -0
- wmo/distill/fake_tinker.py +734 -0
- wmo/distill/gate.py +122 -0
- wmo/distill/loop.py +3499 -0
- wmo/distill/renderers.py +399 -0
- wmo/distill/rendering.py +620 -0
- wmo/distill/rollouts.py +726 -0
- wmo/distill/samples.py +195 -0
- wmo/distill/store.py +829 -0
- wmo/distill/teacher.py +714 -0
- wmo/distill/tokens.py +535 -0
- wmo/distill/tracking.py +552 -0
- wmo/distill/tripwire.py +411 -0
- wmo/distill/xtoken/byte_offsets.py +152 -0
- wmo/distill/xtoken/chunks.py +457 -0
- wmo/distill/xtoken/prompt_logprobs.py +475 -0
- wmo/distill/xtoken/teacher_render.py +346 -0
- wmo/engine/__init__.py +28 -0
- wmo/engine/autoconfig.py +367 -0
- wmo/engine/build.py +346 -0
- wmo/engine/demo.py +77 -0
- wmo/engine/eval_suites.py +245 -0
- wmo/engine/grounding.py +491 -0
- wmo/engine/knowledge.py +291 -0
- wmo/engine/loader.py +36 -0
- wmo/engine/play.py +92 -0
- wmo/engine/prompts.py +99 -0
- wmo/engine/replay.py +443 -0
- wmo/engine/reporting.py +58 -0
- wmo/engine/workspace.py +468 -0
- wmo/engine/world_model.py +568 -0
- wmo/env/__init__.py +22 -0
- wmo/env/base.py +121 -0
- wmo/env/closed_loop.py +229 -0
- wmo/env/episode.py +107 -0
- wmo/env/llm_agent.py +93 -0
- wmo/env/scenarios.py +73 -0
- wmo/evals/__init__.py +52 -0
- wmo/evals/agreement.py +110 -0
- wmo/evals/base.py +45 -0
- wmo/evals/closed_loop.py +480 -0
- wmo/evals/failover.py +96 -0
- wmo/evals/gold.py +127 -0
- wmo/evals/grid.py +394 -0
- wmo/evals/grid_plot.py +205 -0
- wmo/evals/harbor/__init__.py +27 -0
- wmo/evals/harbor/agent.py +573 -0
- wmo/evals/harbor/ctrf.py +171 -0
- wmo/evals/harbor/e2b_environment.py +587 -0
- wmo/evals/harbor/e2b_template_policy.py +144 -0
- wmo/evals/harbor/scorer.py +875 -0
- wmo/evals/harbor/tasks.py +140 -0
- wmo/evals/open_loop.py +194 -0
- wmo/evals/tasks.py +53 -0
- wmo/harness/__init__.py +51 -0
- wmo/harness/code_runtime.py +288 -0
- wmo/harness/create.py +1191 -0
- wmo/harness/delta.py +220 -0
- wmo/harness/doc.py +556 -0
- wmo/harness/e2b_ledger.py +342 -0
- wmo/harness/e2b_reap.py +476 -0
- wmo/harness/e2b_sandbox.py +350 -0
- wmo/harness/environment.py +35 -0
- wmo/harness/live_session.py +543 -0
- wmo/harness/mutate.py +343 -0
- wmo/harness/pi_e2b.py +1710 -0
- wmo/harness/pi_entry/entry.ts +268 -0
- wmo/harness/pi_entry/runner_frames.ts +92 -0
- wmo/harness/pi_entry/runner_live.ts +587 -0
- wmo/harness/pi_entry/runner_service.ts +270 -0
- wmo/harness/pi_entry/runner_stdio.ts +374 -0
- wmo/harness/pi_entry/runner_termination.ts +142 -0
- wmo/harness/pi_local.py +262 -0
- wmo/harness/pi_runtime.py +495 -0
- wmo/harness/pi_vendor.py +65 -0
- wmo/harness/population.py +509 -0
- wmo/harness/project_proposer.py +569 -0
- wmo/harness/proposer.py +977 -0
- wmo/harness/runner_link.py +619 -0
- wmo/harness/runtime.py +389 -0
- wmo/harness/scoring.py +247 -0
- wmo/harness/skills.py +116 -0
- wmo/harness/source_tree.py +319 -0
- wmo/harness/store.py +176 -0
- wmo/harness/tools.py +105 -0
- wmo/harness/vendor/manifest.sha256 +58 -0
- wmo/harness/vendor/pi-agent/CHANGELOG.md +556 -0
- wmo/harness/vendor/pi-agent/LICENSE +21 -0
- wmo/harness/vendor/pi-agent/README.md +488 -0
- wmo/harness/vendor/pi-agent/VENDOR.md +39 -0
- wmo/harness/vendor/pi-agent/docs/agent-harness.md +486 -0
- wmo/harness/vendor/pi-agent/docs/durable-harness.md +212 -0
- wmo/harness/vendor/pi-agent/docs/hooks.md +445 -0
- wmo/harness/vendor/pi-agent/docs/models.md +966 -0
- wmo/harness/vendor/pi-agent/docs/observability.md +376 -0
- wmo/harness/vendor/pi-agent/package.json +60 -0
- wmo/harness/vendor/pi-agent/src/agent-loop.ts +748 -0
- wmo/harness/vendor/pi-agent/src/agent.ts +575 -0
- wmo/harness/vendor/pi-agent/src/harness/agent-harness.ts +1029 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/branch-summarization.ts +261 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/compaction.ts +747 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/utils.ts +144 -0
- wmo/harness/vendor/pi-agent/src/harness/env/nodejs.ts +550 -0
- wmo/harness/vendor/pi-agent/src/harness/messages.ts +164 -0
- wmo/harness/vendor/pi-agent/src/harness/prompt-templates.ts +267 -0
- wmo/harness/vendor/pi-agent/src/harness/session/jsonl-repo.ts +177 -0
- wmo/harness/vendor/pi-agent/src/harness/session/jsonl-storage.ts +293 -0
- wmo/harness/vendor/pi-agent/src/harness/session/memory-repo.ts +50 -0
- wmo/harness/vendor/pi-agent/src/harness/session/memory-storage.ts +131 -0
- wmo/harness/vendor/pi-agent/src/harness/session/repo-utils.ts +51 -0
- wmo/harness/vendor/pi-agent/src/harness/session/session.ts +267 -0
- wmo/harness/vendor/pi-agent/src/harness/session/uuid.ts +54 -0
- wmo/harness/vendor/pi-agent/src/harness/skills.ts +375 -0
- wmo/harness/vendor/pi-agent/src/harness/system-prompt.ts +34 -0
- wmo/harness/vendor/pi-agent/src/harness/types.ts +836 -0
- wmo/harness/vendor/pi-agent/src/harness/utils/shell-output.ts +135 -0
- wmo/harness/vendor/pi-agent/src/harness/utils/truncate.ts +344 -0
- wmo/harness/vendor/pi-agent/src/index.ts +44 -0
- wmo/harness/vendor/pi-agent/src/node.ts +2 -0
- wmo/harness/vendor/pi-agent/src/proxy.ts +367 -0
- wmo/harness/vendor/pi-agent/src/types.ts +428 -0
- wmo/harness/vendor/pi-agent/test/agent-loop.test.ts +1351 -0
- wmo/harness/vendor/pi-agent/test/agent.test.ts +699 -0
- wmo/harness/vendor/pi-agent/test/e2e.test.ts +404 -0
- wmo/harness/vendor/pi-agent/test/harness/agent-harness-stream.test.ts +213 -0
- wmo/harness/vendor/pi-agent/test/harness/agent-harness.test.ts +608 -0
- wmo/harness/vendor/pi-agent/test/harness/compaction.test.ts +655 -0
- wmo/harness/vendor/pi-agent/test/harness/nodejs-env.test.ts +321 -0
- wmo/harness/vendor/pi-agent/test/harness/prompt-templates.test.ts +90 -0
- wmo/harness/vendor/pi-agent/test/harness/repo.test.ts +68 -0
- wmo/harness/vendor/pi-agent/test/harness/resource-formatting.test.ts +24 -0
- wmo/harness/vendor/pi-agent/test/harness/session-test-utils.ts +55 -0
- wmo/harness/vendor/pi-agent/test/harness/session-uuid.test.ts +50 -0
- wmo/harness/vendor/pi-agent/test/harness/session.test.ts +156 -0
- wmo/harness/vendor/pi-agent/test/harness/skills.test.ts +116 -0
- wmo/harness/vendor/pi-agent/test/harness/storage.test.ts +299 -0
- wmo/harness/vendor/pi-agent/test/harness/system-prompt.test.ts +66 -0
- wmo/harness/vendor/pi-agent/test/harness/truncate.test.ts +169 -0
- wmo/harness/vendor/pi-agent/test/scratch/simple.ts +72 -0
- wmo/harness/vendor/pi-agent/test/utils/calculate.ts +32 -0
- wmo/harness/vendor/pi-agent/test/utils/get-current-time.ts +46 -0
- wmo/harness/vendor/pi-agent/tsconfig.build.json +13 -0
- wmo/harness/vendor/pi-agent/vitest.config.ts +19 -0
- wmo/harness/vendor/pi-agent/vitest.harness.config.ts +28 -0
- wmo/harness/vendor/vendor_pi.sh +59 -0
- wmo/harness/workspace_patch.py +270 -0
- wmo/ingest/__init__.py +47 -0
- wmo/ingest/adapter.py +72 -0
- wmo/ingest/base.py +114 -0
- wmo/ingest/braintrust.py +339 -0
- wmo/ingest/detect.py +126 -0
- wmo/ingest/langfuse.py +291 -0
- wmo/ingest/langsmith.py +444 -0
- wmo/ingest/mastra.py +330 -0
- wmo/ingest/messages.py +170 -0
- wmo/ingest/normalize.py +679 -0
- wmo/ingest/otel_genai.py +69 -0
- wmo/ingest/otel_writer.py +100 -0
- wmo/ingest/phoenix.py +150 -0
- wmo/ingest/postgres.py +246 -0
- wmo/ingest/posthog.py +320 -0
- wmo/ingest/quality.py +28 -0
- wmo/ingest/stream.py +209 -0
- wmo/ingest/testdata/sample_otlp.json +60 -0
- wmo/ingest/testdata/sample_spans.jsonl +3 -0
- wmo/optimize/__init__.py +25 -0
- wmo/optimize/base.py +143 -0
- wmo/optimize/gepa.py +806 -0
- wmo/optimize/judge.py +262 -0
- wmo/optimize/judge_quality.py +359 -0
- wmo/optimize/knn.py +468 -0
- wmo/optimize/numeric.py +152 -0
- wmo/optimize/outcomes.py +103 -0
- wmo/optimize/policy.py +669 -0
- wmo/optimize/report.py +231 -0
- wmo/optimize/reward.py +129 -0
- wmo/optimize/routing.py +373 -0
- wmo/platform/__init__.py +6 -0
- wmo/platform/auth.py +115 -0
- wmo/platform/client.py +551 -0
- wmo/platform/credentials.py +126 -0
- wmo/platform/transfer.py +158 -0
- wmo/providers/__init__.py +40 -0
- wmo/providers/_bedrock_chat.py +155 -0
- wmo/providers/_openai_common.py +182 -0
- wmo/providers/_responses_common.py +472 -0
- wmo/providers/anthropic.py +134 -0
- wmo/providers/azure_openai.py +296 -0
- wmo/providers/base.py +300 -0
- wmo/providers/bedrock.py +312 -0
- wmo/providers/models.py +205 -0
- wmo/providers/openai.py +143 -0
- wmo/providers/openai_responses.py +240 -0
- wmo/providers/pool.py +170 -0
- wmo/providers/registry.py +73 -0
- wmo/providers/retry.py +151 -0
- wmo/providers/tinker.py +936 -0
- wmo/providers/waterfall.py +336 -0
- wmo/research/__init__.py +81 -0
- wmo/research/ablation.py +133 -0
- wmo/research/concurrency_plot.py +523 -0
- wmo/research/concurrency_run.py +240 -0
- wmo/research/concurrency_scaling.py +270 -0
- wmo/research/gepa_scaling.py +274 -0
- wmo/research/pipeline.py +198 -0
- wmo/research/scaling_split.py +82 -0
- wmo/research/scenario_fidelity.py +198 -0
- wmo/research/scenario_recovery.py +92 -0
- wmo/research/seed_stability.py +90 -0
- wmo/research/trace_scaling.py +348 -0
- wmo/retrieval/__init__.py +6 -0
- wmo/retrieval/embedders.py +105 -0
- wmo/retrieval/leakfree.py +52 -0
- wmo/retrieval/retriever.py +173 -0
- wmo/scenarios/__init__.py +58 -0
- wmo/scenarios/builder.py +152 -0
- wmo/scenarios/mining/__init__.py +27 -0
- wmo/scenarios/mining/clustering.py +171 -0
- wmo/scenarios/mining/facets.py +226 -0
- wmo/scenarios/mining/selection.py +220 -0
- wmo/scenarios/synthesis/__init__.py +6 -0
- wmo/scenarios/synthesis/scenario_set.py +63 -0
- wmo/scenarios/synthesis/synthesizer.py +85 -0
- wmo/scenarios/verification/__init__.py +17 -0
- wmo/scenarios/verification/judge.py +97 -0
- wmo/scenarios/verification/verify.py +135 -0
- wmo/serving/__init__.py +5 -0
- wmo/serving/builds.py +451 -0
- wmo/serving/chat.py +878 -0
- wmo/serving/endpoint_config.py +64 -0
- wmo/serving/savings.py +250 -0
- wmo/serving/server.py +553 -0
- wmo/serving/traces_source.py +206 -0
- wmo/telemetry.py +213 -0
- wmo/tracking/__init__.py +36 -0
- wmo/tracking/clock.py +24 -0
- wmo/tracking/metered.py +125 -0
- wmo/tracking/pricing.py +99 -0
- wmo/tracking/store.py +31 -0
- wmo/tracking/tracker.py +149 -0
- world_model_optimizer-0.2.0.dist-info/METADATA +203 -0
- world_model_optimizer-0.2.0.dist-info/RECORD +308 -0
- world_model_optimizer-0.2.0.dist-info/WHEEL +4 -0
- world_model_optimizer-0.2.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
"""Render a concurrency scaling-law report to a brand-styled matplotlib figure.
|
|
2
|
+
|
|
3
|
+
`render_report` draws one benchmark's report (`wmo research concurrency --out`) as three panels:
|
|
4
|
+
batch wall-clock per side (endpoint seconds labelled), the headline T_real/T_world differential
|
|
5
|
+
(parity crossing shaded), and how each side parallelizes (speed-up vs ideal, log-log). The
|
|
6
|
+
cross-benchmark story is split into two standalone figures: `render_speedup` (the "what" — how many
|
|
7
|
+
times faster the world model is, per benchmark) and `render_cost` (the "why" — reconstruction vs.
|
|
8
|
+
real-setup cost). Each is a single clean panel with one left-aligned title (no subtitle). Styling
|
|
9
|
+
follows the brand system (AGENTS.md rule 15): white background, near-black ink, hairline grid,
|
|
10
|
+
brand-palette accents, left-aligned titles — matching plot_trace_scaling.py.
|
|
11
|
+
|
|
12
|
+
Needs the `viz` extra (matplotlib/pandas); it is imported lazily by the CLI so the harness runtime
|
|
13
|
+
has no plotting dependency. Kept out of `concurrency_scaling.py` so the core experiment stays
|
|
14
|
+
deployment-free and fake-testable.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import matplotlib # noqa: E402 - grouped with the other plotting deps below
|
|
22
|
+
from pydantic import BaseModel, ValidationError
|
|
23
|
+
|
|
24
|
+
from wmo.research.concurrency_scaling import ConcurrencyScalingReport
|
|
25
|
+
|
|
26
|
+
matplotlib.use("Agg") # headless: write a file, never open a window
|
|
27
|
+
import matplotlib.pyplot as plt # noqa: E402 - must follow the Agg backend selection
|
|
28
|
+
import matplotlib.ticker as mticker # noqa: E402
|
|
29
|
+
import pandas as pd # noqa: E402
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _PlotRow(BaseModel):
|
|
33
|
+
"""One tidy row for seaborn: a (level, side) timing point of a single report."""
|
|
34
|
+
|
|
35
|
+
level: int
|
|
36
|
+
side: str
|
|
37
|
+
wall: float
|
|
38
|
+
wall_std: float
|
|
39
|
+
speedup: float
|
|
40
|
+
efficiency: float
|
|
41
|
+
differential: float
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _load_points(path: str) -> pd.DataFrame:
|
|
45
|
+
"""Flatten one report JSON into a tidy long DataFrame (one row per level×side).
|
|
46
|
+
|
|
47
|
+
Parses the file through `ConcurrencyScalingReport`, so a malformed or truncated report raises a
|
|
48
|
+
clean `ValueError` (the CLI maps it to a friendly error) rather than a raw KeyError, and missing
|
|
49
|
+
optional fields fall back to their model defaults.
|
|
50
|
+
"""
|
|
51
|
+
report = _load_report(path)
|
|
52
|
+
rows: list[_PlotRow] = []
|
|
53
|
+
for point in report.points:
|
|
54
|
+
if point.world_wall_mean:
|
|
55
|
+
rows.append(
|
|
56
|
+
_PlotRow(
|
|
57
|
+
level=point.level,
|
|
58
|
+
side="world model",
|
|
59
|
+
wall=point.world_wall_mean,
|
|
60
|
+
wall_std=point.world_wall_std,
|
|
61
|
+
speedup=point.speedup,
|
|
62
|
+
efficiency=point.efficiency,
|
|
63
|
+
differential=point.differential,
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
if point.real_wall_mean:
|
|
67
|
+
rows.append(
|
|
68
|
+
_PlotRow(
|
|
69
|
+
level=point.level,
|
|
70
|
+
side="real sandbox",
|
|
71
|
+
wall=point.real_wall_mean,
|
|
72
|
+
wall_std=point.real_wall_std,
|
|
73
|
+
speedup=0.0,
|
|
74
|
+
efficiency=0.0,
|
|
75
|
+
differential=point.differential,
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
if not rows:
|
|
79
|
+
raise ValueError(f"no timed points in {path}")
|
|
80
|
+
return pd.DataFrame([r.model_dump() for r in rows])
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# Brand system (AGENTS.md rule 15): white bg, near-black ink, hairline grid, brand-palette accents.
|
|
84
|
+
# Ref: scripts/plot_trace_scaling.py.
|
|
85
|
+
_INK = "#0a0a0a"
|
|
86
|
+
_MUTED = "#8a8a8a"
|
|
87
|
+
_GRID = "#ececec"
|
|
88
|
+
_WORLD_COLOR = "#0070f3" # world model — primary blue
|
|
89
|
+
_REAL_COLOR = "#7928ca" # real sandbox — purple
|
|
90
|
+
_DIFF_COLOR = "#e00" # differential — red
|
|
91
|
+
_IDEAL_COLOR = "#8a8a8a" # ideal / parity reference — muted grey
|
|
92
|
+
# Region tints for the differential panel — light washes of the brand teal (positive) and red.
|
|
93
|
+
_FASTER_FILL = "#e8faf6" # region where the world model is faster (light teal, tint of #50e3c2)
|
|
94
|
+
_SLOWER_FILL = "#fdeaea" # region where the real sandbox is faster (light red, tint of #ee0000)
|
|
95
|
+
# Distinct per-benchmark accents for the cross-benchmark combined figure — brand palette, in order.
|
|
96
|
+
_BENCH_COLORS = ("#0070f3", "#f5a623", "#ee0000", "#7928ca", "#50e3c2")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _bench_color(index: int) -> str:
|
|
100
|
+
"""Stable brand accent for benchmark `index` in the combined overlay."""
|
|
101
|
+
return _BENCH_COLORS[index % len(_BENCH_COLORS)]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _fmt_secs(seconds: float) -> str:
|
|
105
|
+
"""Compact seconds label for point annotations (e.g. 3.6s, 27.6s, 1224s)."""
|
|
106
|
+
return f"{seconds:.0f}s" if seconds >= 100 else f"{seconds:.1f}s"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _load_report(path: str) -> ConcurrencyScalingReport:
|
|
110
|
+
"""Parse a report JSON, mapping a schema failure to a clean ValueError the CLI can surface."""
|
|
111
|
+
text = Path(path).read_text(encoding="utf-8")
|
|
112
|
+
try:
|
|
113
|
+
return ConcurrencyScalingReport.model_validate_json(text)
|
|
114
|
+
except ValidationError as exc:
|
|
115
|
+
raise ValueError(f"{path} is not a valid concurrency-scaling report: {exc}") from exc
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _styled_line(ax: plt.Axes, xs: list[int], ys: list[float], *, color: str, label: str) -> None:
|
|
119
|
+
"""Plot one series in the shared brand marker/line style (used by every panel and figure)."""
|
|
120
|
+
ax.plot(
|
|
121
|
+
xs,
|
|
122
|
+
ys,
|
|
123
|
+
"-o",
|
|
124
|
+
color=color,
|
|
125
|
+
label=label,
|
|
126
|
+
linewidth=2.2,
|
|
127
|
+
markersize=6,
|
|
128
|
+
markerfacecolor="white",
|
|
129
|
+
markeredgecolor=color,
|
|
130
|
+
markeredgewidth=1.6,
|
|
131
|
+
zorder=3,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _style_panel(ax: plt.Axes, levels: list[int], *, title: str, xlabel: str, ylabel: str) -> None:
|
|
136
|
+
"""Apply the shared brand chrome to a panel: hairline grid, no top/right spine, muted ticks."""
|
|
137
|
+
ax.set_title(title, fontsize=13, color=_INK, fontweight="bold", loc="left", pad=12)
|
|
138
|
+
ax.set_xlabel(xlabel, fontsize=11, color=_INK)
|
|
139
|
+
ax.set_ylabel(ylabel, fontsize=11, color=_INK)
|
|
140
|
+
for side in ("top", "right"):
|
|
141
|
+
ax.spines[side].set_visible(False)
|
|
142
|
+
for side in ("left", "bottom"):
|
|
143
|
+
ax.spines[side].set_color(_GRID)
|
|
144
|
+
ax.grid(axis="y", color=_GRID, linewidth=1)
|
|
145
|
+
ax.set_axisbelow(True)
|
|
146
|
+
ax.tick_params(colors=_MUTED, labelsize=10, length=0)
|
|
147
|
+
_style_level_axis(ax, levels)
|
|
148
|
+
leg = ax.get_legend()
|
|
149
|
+
if leg is not None:
|
|
150
|
+
leg.set_frame_on(False)
|
|
151
|
+
for text in leg.get_texts():
|
|
152
|
+
text.set_color(_INK)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _style_level_axis(ax: plt.Axes, levels: list[int]) -> None:
|
|
156
|
+
"""Log2 x-axis with plain integer concurrency ticks (1, 2, 4, ...), shared by every panel."""
|
|
157
|
+
ax.set_xscale("log", base=2)
|
|
158
|
+
ax.set_xticks(levels)
|
|
159
|
+
ax.get_xaxis().set_major_formatter(mticker.ScalarFormatter())
|
|
160
|
+
ax.set_xlim(levels[0] * 0.85, levels[-1] * 1.18)
|
|
161
|
+
ax.margins(y=0.12)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def render_report(path: str, out: str, *, title: str = "Concurrency scaling law") -> str:
|
|
165
|
+
"""Render the report JSON at `path` to an image at `out`; return `out`.
|
|
166
|
+
|
|
167
|
+
Three panels, all comparing the SAME two sides so the world-model-vs-real-sandbox story threads
|
|
168
|
+
through every one:
|
|
169
|
+
1. batch wall-clock per side (absolute time, log y, endpoint seconds labelled),
|
|
170
|
+
2. the headline time differential T_real/T_world (how many times faster, with the parity
|
|
171
|
+
crossing shaded green=world-faster / red=real-faster),
|
|
172
|
+
3. how each side parallelizes (speed-up vs W=1 against ideal-linear, log-log).
|
|
173
|
+
Fixed benchmark-agnostic styling so all benchmarks line up. When only the world side was timed
|
|
174
|
+
(`--side world`), the real-sandbox series are simply absent and panel 2 says so.
|
|
175
|
+
"""
|
|
176
|
+
df = _load_points(path)
|
|
177
|
+
has_real = bool((df["side"] == "real sandbox").any())
|
|
178
|
+
has_diff = bool((df["differential"] > 0).any())
|
|
179
|
+
levels = sorted(df["level"].unique())
|
|
180
|
+
fig, axes = plt.subplots(1, 3, figsize=(19, 5.5), dpi=200)
|
|
181
|
+
fig.patch.set_facecolor("white")
|
|
182
|
+
|
|
183
|
+
def line(ax: plt.Axes, xs: list[int], ys: list[float], color: str, label: str) -> None:
|
|
184
|
+
_styled_line(ax, xs, ys, color=color, label=label)
|
|
185
|
+
|
|
186
|
+
# 1) Batch wall-clock vs. concurrency (log y), one line per side, mean±std band, endpoint labels
|
|
187
|
+
# so the absolute seconds (and the gap between the two sides) read at a glance.
|
|
188
|
+
ax = axes[0]
|
|
189
|
+
ax.set_facecolor("white")
|
|
190
|
+
for side, color in (("world model", _WORLD_COLOR), ("real sandbox", _REAL_COLOR)):
|
|
191
|
+
grp = df[df["side"] == side].sort_values("level")
|
|
192
|
+
if grp.empty:
|
|
193
|
+
continue
|
|
194
|
+
xs, ys = list(grp["level"]), list(grp["wall"])
|
|
195
|
+
line(ax, xs, ys, color, side)
|
|
196
|
+
lo = [w - s for w, s in zip(grp["wall"], grp["wall_std"], strict=True)]
|
|
197
|
+
hi = [w + s for w, s in zip(grp["wall"], grp["wall_std"], strict=True)]
|
|
198
|
+
ax.fill_between(grp["level"], lo, hi, color=color, alpha=0.12, linewidth=0, zorder=2)
|
|
199
|
+
for x, y in ((xs[0], ys[0]), (xs[-1], ys[-1])): # label the W=1 and W=max endpoints
|
|
200
|
+
ax.annotate(
|
|
201
|
+
_fmt_secs(y),
|
|
202
|
+
(x, y),
|
|
203
|
+
textcoords="offset points",
|
|
204
|
+
xytext=(0, 10),
|
|
205
|
+
ha="center",
|
|
206
|
+
fontsize=8.5,
|
|
207
|
+
color=color,
|
|
208
|
+
fontweight="bold",
|
|
209
|
+
)
|
|
210
|
+
ax.set_yscale("log")
|
|
211
|
+
ax.legend(loc="best", fontsize=10)
|
|
212
|
+
_style_panel(
|
|
213
|
+
ax,
|
|
214
|
+
levels,
|
|
215
|
+
title="Wall-clock to obtain the batch (log scale, lower = faster)",
|
|
216
|
+
xlabel="concurrency (scenarios at once)",
|
|
217
|
+
ylabel="batch wall-clock (s)",
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# 2) The headline: time differential T_real / T_world — how many times faster the world model.
|
|
221
|
+
# Shade the world-faster (>1) and real-faster (<1) regions so the parity crossing reads at a
|
|
222
|
+
# glance (tau dips below 1 at high W; terminal/swe stay above). Log y when the range is wide.
|
|
223
|
+
ax = axes[1]
|
|
224
|
+
ax.set_facecolor("white")
|
|
225
|
+
diff = df[(df["side"] == "real sandbox") & (df["differential"] > 0)].sort_values("level")
|
|
226
|
+
if has_diff and not diff.empty:
|
|
227
|
+
dl, dvals = list(diff["level"]), list(diff["differential"])
|
|
228
|
+
line(ax, dl, dvals, _DIFF_COLOR, "T_real / T_world")
|
|
229
|
+
for lvl, d in zip(dl, dvals, strict=True):
|
|
230
|
+
ax.annotate(
|
|
231
|
+
f"{d:.2f}×",
|
|
232
|
+
(lvl, d),
|
|
233
|
+
textcoords="offset points",
|
|
234
|
+
xytext=(6, 6),
|
|
235
|
+
fontsize=9,
|
|
236
|
+
color=_INK,
|
|
237
|
+
fontweight="bold",
|
|
238
|
+
)
|
|
239
|
+
# Log y only when the curve's OWN dynamic range is wide (max/min >= 8) — keying on absolute
|
|
240
|
+
# magnitude would log a high-but-flat curve (e.g. swe ~70-138×, ~2× spread) and cram it into
|
|
241
|
+
# the top of the panel with empty decades below. These curves read best on a linear axis.
|
|
242
|
+
if max(dvals) / min(dvals) >= 8:
|
|
243
|
+
ax.set_yscale("log")
|
|
244
|
+
ax.yaxis.set_major_formatter(mticker.ScalarFormatter())
|
|
245
|
+
lo = min(0.9, min(dvals) * 0.85)
|
|
246
|
+
hi = max(dvals) * 1.25
|
|
247
|
+
ax.set_ylim(lo, hi)
|
|
248
|
+
ax.axhspan(1.0, hi, color=_FASTER_FILL, zorder=0) # world model faster
|
|
249
|
+
if lo < 1.0:
|
|
250
|
+
ax.axhspan(lo, 1.0, color=_SLOWER_FILL, zorder=0) # real sandbox faster
|
|
251
|
+
ax.axhline(1.0, linestyle=(0, (4, 3)), color=_IDEAL_COLOR, linewidth=1.6, label="parity")
|
|
252
|
+
ax.legend(loc="best", fontsize=10)
|
|
253
|
+
else:
|
|
254
|
+
msg = (
|
|
255
|
+
"world-model side only\n(run `--side both` for\nthe sandbox differential)"
|
|
256
|
+
if not has_real
|
|
257
|
+
else "no real-sandbox timings"
|
|
258
|
+
)
|
|
259
|
+
ax.text(0.5, 0.5, msg, ha="center", va="center", transform=ax.transAxes, color=_MUTED)
|
|
260
|
+
_style_panel(
|
|
261
|
+
ax,
|
|
262
|
+
levels,
|
|
263
|
+
title="World model speed-up over the real sandbox",
|
|
264
|
+
xlabel="concurrency",
|
|
265
|
+
ylabel="T_real / T_world (>1 = world model faster)",
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
# 3) Mechanism: how each side parallelizes — speed-up vs W=1 against the ideal-linear diagonal,
|
|
269
|
+
# log-log so ideal is a straight 45° reference and the sub-linear curves stay legible.
|
|
270
|
+
ax = axes[2]
|
|
271
|
+
ax.set_facecolor("white")
|
|
272
|
+
ax.plot(
|
|
273
|
+
levels,
|
|
274
|
+
levels,
|
|
275
|
+
linestyle=(0, (4, 3)),
|
|
276
|
+
color=_IDEAL_COLOR,
|
|
277
|
+
linewidth=1.6,
|
|
278
|
+
label="ideal (linear)",
|
|
279
|
+
zorder=1,
|
|
280
|
+
)
|
|
281
|
+
baseline_level = levels[0]
|
|
282
|
+
for side, color in (("world model", _WORLD_COLOR), ("real sandbox", _REAL_COLOR)):
|
|
283
|
+
grp = df[df["side"] == side].sort_values("level")
|
|
284
|
+
base_rows = grp[grp["level"] == baseline_level]
|
|
285
|
+
if grp.empty or base_rows.empty:
|
|
286
|
+
# No timing at the baseline level -> can't normalize this side to the same W as the
|
|
287
|
+
# other, so skip it rather than plot a curve on a different (higher) baseline.
|
|
288
|
+
continue
|
|
289
|
+
base = float(base_rows.iloc[0]["wall"])
|
|
290
|
+
speedup = [base / w if w else 0.0 for w in grp["wall"]]
|
|
291
|
+
line(ax, list(grp["level"]), speedup, color, side)
|
|
292
|
+
ax.set_yscale("log", base=2)
|
|
293
|
+
ax.yaxis.set_major_formatter(mticker.ScalarFormatter())
|
|
294
|
+
ax.legend(loc="upper left", fontsize=10)
|
|
295
|
+
_style_panel(
|
|
296
|
+
ax,
|
|
297
|
+
levels,
|
|
298
|
+
title="How each side parallelizes (speed-up vs W=1)",
|
|
299
|
+
xlabel="concurrency",
|
|
300
|
+
ylabel="speed-up vs. W=1 (log)",
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
fig.suptitle(title, fontsize=15, color=_INK, fontweight="bold", x=0.02, ha="left")
|
|
304
|
+
fig.tight_layout()
|
|
305
|
+
fig.savefig(out, bbox_inches="tight", facecolor="white")
|
|
306
|
+
plt.close(fig)
|
|
307
|
+
return out
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _load_reports(paths: list[str]) -> tuple[list[tuple[str, ConcurrencyScalingReport]], list[int]]:
|
|
311
|
+
"""Load each report JSON (labelled by its `benchmark` field, else file stem) + shared levels.
|
|
312
|
+
|
|
313
|
+
Raises a clean `ValueError` (surfaced by the CLI as a friendly error) when the list is empty or
|
|
314
|
+
no report has a single timed point.
|
|
315
|
+
"""
|
|
316
|
+
reports: list[tuple[str, ConcurrencyScalingReport]] = []
|
|
317
|
+
for path in paths:
|
|
318
|
+
report = _load_report(path)
|
|
319
|
+
reports.append((report.benchmark or Path(path).stem, report))
|
|
320
|
+
if not reports:
|
|
321
|
+
raise ValueError("no reports to plot")
|
|
322
|
+
levels = sorted({p.level for _, r in reports for p in r.points})
|
|
323
|
+
if not levels:
|
|
324
|
+
raise ValueError("reports have no timed points to plot")
|
|
325
|
+
return reports, levels
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _header(fig: plt.Figure, title: str) -> None:
|
|
329
|
+
"""One clean left-aligned title at the top of a single-panel figure (Vercel/Notion minimal).
|
|
330
|
+
|
|
331
|
+
No subtitle or caption: the title carries the message, the chart carries the rest.
|
|
332
|
+
`subplots_adjust` reserves a generous top band so the title breathes above the axes.
|
|
333
|
+
"""
|
|
334
|
+
fig.text(0.02, 0.955, title, fontsize=14.5, fontweight="bold", color=_INK, ha="left", va="top")
|
|
335
|
+
fig.subplots_adjust(top=0.83, left=0.1, right=0.93, bottom=0.13)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def render_speedup(
|
|
339
|
+
paths: list[str],
|
|
340
|
+
out: str,
|
|
341
|
+
*,
|
|
342
|
+
title: str = "How much faster is the world model than the real environment?",
|
|
343
|
+
) -> str:
|
|
344
|
+
"""Render the cross-benchmark speed-up figure (T_real/T_world per benchmark) to `out`.
|
|
345
|
+
|
|
346
|
+
One line per benchmark on a log y-axis: how many times faster the world model is than standing
|
|
347
|
+
up the real environment, at each concurrency level. The region above the parity line is tinted
|
|
348
|
+
(world model faster); a line dipping below it (e.g. tau-bench's cheap in-process env) is where
|
|
349
|
+
the real sandbox wins. Lines are directly labelled at their right end, so the figure needs no
|
|
350
|
+
legend lookup. This is the "what"; `render_cost` is the "why".
|
|
351
|
+
"""
|
|
352
|
+
reports, levels = _load_reports(paths)
|
|
353
|
+
fig, ax = plt.subplots(figsize=(8.6, 5.6), dpi=200)
|
|
354
|
+
fig.patch.set_facecolor("white")
|
|
355
|
+
ax.set_facecolor("white")
|
|
356
|
+
|
|
357
|
+
all_diffs: list[float] = []
|
|
358
|
+
for idx, (label, report) in enumerate(reports):
|
|
359
|
+
pts = [
|
|
360
|
+
(p.level, p.differential)
|
|
361
|
+
for p in report.points
|
|
362
|
+
if p.differential and p.differential > 0
|
|
363
|
+
]
|
|
364
|
+
if not pts:
|
|
365
|
+
continue
|
|
366
|
+
xs = [lvl for lvl, _ in pts]
|
|
367
|
+
ys = [v for _, v in pts]
|
|
368
|
+
all_diffs.extend(ys)
|
|
369
|
+
color = _bench_color(idx)
|
|
370
|
+
_styled_line(ax, xs, ys, color=color, label=label)
|
|
371
|
+
for x, y in ((xs[0], ys[0]), (xs[-1], ys[-1])): # value-label both endpoints
|
|
372
|
+
ax.annotate(
|
|
373
|
+
f"{y:.1f}×",
|
|
374
|
+
(x, y),
|
|
375
|
+
textcoords="offset points",
|
|
376
|
+
xytext=(0, 9),
|
|
377
|
+
ha="center",
|
|
378
|
+
fontsize=8.5,
|
|
379
|
+
color=color,
|
|
380
|
+
fontweight="bold",
|
|
381
|
+
)
|
|
382
|
+
# Direct-label the line at its right end (replaces a legend the reader has to cross-check).
|
|
383
|
+
ax.annotate(
|
|
384
|
+
label,
|
|
385
|
+
(xs[-1], ys[-1]),
|
|
386
|
+
textcoords="offset points",
|
|
387
|
+
xytext=(12, -1),
|
|
388
|
+
ha="left",
|
|
389
|
+
va="center",
|
|
390
|
+
fontsize=10,
|
|
391
|
+
color=color,
|
|
392
|
+
fontweight="bold",
|
|
393
|
+
annotation_clip=False,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
ax.set_yscale("log")
|
|
397
|
+
ax.yaxis.set_major_formatter(mticker.ScalarFormatter())
|
|
398
|
+
if all_diffs:
|
|
399
|
+
hi = max(all_diffs) * 1.7
|
|
400
|
+
lo = min(0.9, min(all_diffs) * 0.82)
|
|
401
|
+
ax.set_ylim(lo, hi)
|
|
402
|
+
# Colour alone carries the meaning (no labels): teal above parity = world model faster,
|
|
403
|
+
# red below = real environment faster.
|
|
404
|
+
ax.axhspan(1.0, hi, color=_FASTER_FILL, zorder=0)
|
|
405
|
+
if lo < 1.0:
|
|
406
|
+
ax.axhspan(lo, 1.0, color=_SLOWER_FILL, zorder=0)
|
|
407
|
+
ax.axhline(1.0, linestyle=(0, (4, 3)), color=_IDEAL_COLOR, linewidth=1.4)
|
|
408
|
+
ax.annotate(
|
|
409
|
+
"parity (1×)",
|
|
410
|
+
(levels[-1], 1.0),
|
|
411
|
+
textcoords="offset points",
|
|
412
|
+
xytext=(0, 6),
|
|
413
|
+
ha="right",
|
|
414
|
+
fontsize=8.5,
|
|
415
|
+
color=_MUTED,
|
|
416
|
+
)
|
|
417
|
+
_style_panel(
|
|
418
|
+
ax,
|
|
419
|
+
levels,
|
|
420
|
+
title="",
|
|
421
|
+
xlabel="concurrency (scenarios reconstructed at once)",
|
|
422
|
+
ylabel="times faster (T_real / T_world)",
|
|
423
|
+
)
|
|
424
|
+
ax.set_xlim(levels[0] * 0.85, levels[-1] * 1.75) # room for the right-end benchmark labels
|
|
425
|
+
_header(fig, title)
|
|
426
|
+
fig.savefig(out, bbox_inches="tight", facecolor="white")
|
|
427
|
+
plt.close(fig)
|
|
428
|
+
return out
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def render_cost(
|
|
432
|
+
paths: list[str],
|
|
433
|
+
out: str,
|
|
434
|
+
*,
|
|
435
|
+
title: str = "Why: reconstructing vs. standing up the real environment",
|
|
436
|
+
) -> str:
|
|
437
|
+
"""Render the cross-benchmark cost figure (world vs. real wall-clock at W=1) to `out`.
|
|
438
|
+
|
|
439
|
+
Grouped log-y bars per benchmark: the world-model reconstruction cost (blue) beside the real
|
|
440
|
+
environment's standup cost (purple), at the lowest concurrency level. Where the purple bar
|
|
441
|
+
towers over the blue one the world model wins; where it is shorter it loses. The per-benchmark
|
|
442
|
+
speed-up (×) is annotated above each pair to tie this mechanism back to `render_speedup`.
|
|
443
|
+
"""
|
|
444
|
+
reports, levels = _load_reports(paths)
|
|
445
|
+
base_level = levels[0]
|
|
446
|
+
names: list[str] = []
|
|
447
|
+
w_costs: list[float] = []
|
|
448
|
+
r_costs: list[float] = []
|
|
449
|
+
for label, report in reports:
|
|
450
|
+
pt = next((p for p in report.points if p.level == base_level), None)
|
|
451
|
+
if pt is None:
|
|
452
|
+
continue
|
|
453
|
+
names.append(label)
|
|
454
|
+
w_costs.append(pt.world_wall_mean or 0.0)
|
|
455
|
+
r_costs.append(pt.real_wall_mean or 0.0)
|
|
456
|
+
if not names:
|
|
457
|
+
raise ValueError(f"no report has a point at the baseline concurrency W={base_level}")
|
|
458
|
+
|
|
459
|
+
fig, ax = plt.subplots(figsize=(8.6, 5.6), dpi=200)
|
|
460
|
+
fig.patch.set_facecolor("white")
|
|
461
|
+
ax.set_facecolor("white")
|
|
462
|
+
xs = list(range(len(names)))
|
|
463
|
+
width = 0.36
|
|
464
|
+
ax.bar(
|
|
465
|
+
[i - width / 2 for i in xs],
|
|
466
|
+
w_costs,
|
|
467
|
+
width,
|
|
468
|
+
color=_WORLD_COLOR,
|
|
469
|
+
label="world model (reconstruct)",
|
|
470
|
+
zorder=3,
|
|
471
|
+
)
|
|
472
|
+
ax.bar(
|
|
473
|
+
[i + width / 2 for i in xs],
|
|
474
|
+
r_costs,
|
|
475
|
+
width,
|
|
476
|
+
color=_REAL_COLOR,
|
|
477
|
+
label="real environment (stand up)",
|
|
478
|
+
zorder=3,
|
|
479
|
+
)
|
|
480
|
+
ax.set_yscale("log")
|
|
481
|
+
|
|
482
|
+
def _bar_label(x: float, value: float, color: str) -> None:
|
|
483
|
+
if value <= 0:
|
|
484
|
+
return
|
|
485
|
+
ax.annotate(
|
|
486
|
+
_fmt_secs(value),
|
|
487
|
+
(x, value),
|
|
488
|
+
textcoords="offset points",
|
|
489
|
+
xytext=(0, 3),
|
|
490
|
+
ha="center",
|
|
491
|
+
fontsize=8.5,
|
|
492
|
+
color=color,
|
|
493
|
+
fontweight="bold",
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
top = max([*w_costs, *r_costs, 1.0])
|
|
497
|
+
for i, (w, r) in enumerate(zip(w_costs, r_costs, strict=True)):
|
|
498
|
+
_bar_label(i - width / 2, w, _WORLD_COLOR)
|
|
499
|
+
_bar_label(i + width / 2, r, _REAL_COLOR)
|
|
500
|
+
if w > 0 and r > 0: # speed-up (×) just above the pair's taller bar, tying back to fig 1
|
|
501
|
+
ax.annotate(
|
|
502
|
+
f"{r / w:.1f}× faster" if r >= w else f"{r / w:.2f}× (slower)",
|
|
503
|
+
(i, max(w, r) * 1.6),
|
|
504
|
+
ha="center",
|
|
505
|
+
fontsize=9.5,
|
|
506
|
+
color=_INK,
|
|
507
|
+
fontweight="bold",
|
|
508
|
+
)
|
|
509
|
+
ax.set_ylim(top=top * 3.2) # headroom so the tallest pair's ×-label clears the top
|
|
510
|
+
_style_panel(ax, levels, title="", xlabel="", ylabel="batch wall-clock at W=1 (s, log)")
|
|
511
|
+
ax.set_xscale("linear") # categorical x (benchmarks), overriding the shared log-level axis
|
|
512
|
+
ax.set_xticks(xs)
|
|
513
|
+
ax.set_xticklabels(names, fontsize=11, color=_INK)
|
|
514
|
+
ax.set_xlim(-0.6, len(names) - 0.4)
|
|
515
|
+
# Upper-left: the empty band above the shortest (leftmost) pair, clear of every ×-label.
|
|
516
|
+
ax.legend(loc="upper left", fontsize=10, frameon=False, labelcolor=_INK)
|
|
517
|
+
_header(fig, title)
|
|
518
|
+
fig.savefig(out, bbox_inches="tight", facecolor="white")
|
|
519
|
+
plt.close(fig)
|
|
520
|
+
return out
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
__all__ = ["render_cost", "render_report", "render_speedup"]
|