openfactory 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.
- openfactory/__init__.py +7 -0
- openfactory/actions/__init__.py +344 -0
- openfactory/actions/base.py +334 -0
- openfactory/actions/catalog.py +4872 -0
- openfactory/actions/floor_intents.py +431 -0
- openfactory/adapters/__init__.py +0 -0
- openfactory/adapters/agent/__init__.py +71 -0
- openfactory/adapters/agent/base.py +358 -0
- openfactory/adapters/agent/claude_code.py +1023 -0
- openfactory/adapters/agent/codex.py +350 -0
- openfactory/adapters/agent/kimi.py +291 -0
- openfactory/adapters/agent/opencode.py +432 -0
- openfactory/adapters/agent/registry.py +485 -0
- openfactory/adapters/agent/roles.py +263 -0
- openfactory/adapters/agent/routes.py +255 -0
- openfactory/adapters/agent/session_store.py +221 -0
- openfactory/adapters/agent/stream.py +367 -0
- openfactory/adapters/agent/techlead.py +108 -0
- openfactory/adapters/agent/token_pool.py +67 -0
- openfactory/adapters/azure_devops.py +329 -0
- openfactory/adapters/board/__init__.py +10 -0
- openfactory/adapters/board/azure_devops.py +748 -0
- openfactory/adapters/board/base.py +193 -0
- openfactory/adapters/board/columns.py +68 -0
- openfactory/adapters/board/factory.py +218 -0
- openfactory/adapters/board/jira.py +252 -0
- openfactory/adapters/board/local.py +187 -0
- openfactory/adapters/board_db.py +276 -0
- openfactory/adapters/board_setup/__init__.py +8 -0
- openfactory/adapters/board_setup/base.py +49 -0
- openfactory/adapters/board_setup/local.py +63 -0
- openfactory/adapters/board_setup/registry.py +41 -0
- openfactory/adapters/channel/__init__.py +4 -0
- openfactory/adapters/channel/base.py +100 -0
- openfactory/adapters/channel/panel.py +107 -0
- openfactory/adapters/channel/registry.py +112 -0
- openfactory/adapters/credential/__init__.py +8 -0
- openfactory/adapters/credential/registry.py +164 -0
- openfactory/adapters/environment/__init__.py +30 -0
- openfactory/adapters/environment/azure_pipelines.py +412 -0
- openfactory/adapters/environment/base.py +35 -0
- openfactory/adapters/environment/github_actions.py +97 -0
- openfactory/adapters/environment/none.py +53 -0
- openfactory/adapters/environment/registry.py +160 -0
- openfactory/adapters/forge/__init__.py +30 -0
- openfactory/adapters/forge/azure_devops.py +1528 -0
- openfactory/adapters/forge/base.py +515 -0
- openfactory/adapters/forge/github.py +779 -0
- openfactory/adapters/forge/local.py +660 -0
- openfactory/adapters/forge/registry.py +210 -0
- openfactory/adapters/github_app.py +70 -0
- openfactory/adapters/notify/__init__.py +12 -0
- openfactory/adapters/notify/base.py +45 -0
- openfactory/adapters/notify/panel.py +43 -0
- openfactory/adapters/notify/registry.py +301 -0
- openfactory/adapters/reviewer/__init__.py +30 -0
- openfactory/adapters/reviewer/base.py +34 -0
- openfactory/adapters/reviewer/claude_code.py +140 -0
- openfactory/adapters/reviewer/harness.py +154 -0
- openfactory/adapters/sandbox/__init__.py +31 -0
- openfactory/adapters/sandbox/base.py +410 -0
- openfactory/adapters/sandbox/container.py +550 -0
- openfactory/adapters/sandbox/registry.py +490 -0
- openfactory/adapters/sandbox/timeouts.py +76 -0
- openfactory/adapters/sandbox/worktree.py +381 -0
- openfactory/adapters/tracker/__init__.py +30 -0
- openfactory/adapters/tracker/azure_devops.py +904 -0
- openfactory/adapters/tracker/base.py +549 -0
- openfactory/adapters/tracker/github.py +529 -0
- openfactory/adapters/tracker/github_board_setup.py +216 -0
- openfactory/adapters/tracker/github_project.py +733 -0
- openfactory/adapters/tracker/jira.py +575 -0
- openfactory/adapters/tracker/local.py +379 -0
- openfactory/adapters/tracker/parse.py +231 -0
- openfactory/adapters/tracker/registry.py +200 -0
- openfactory/after_merge.py +51 -0
- openfactory/api/__init__.py +4 -0
- openfactory/api/app.py +2627 -0
- openfactory/api/metrics_view.py +337 -0
- openfactory/api/panel.html +3419 -0
- openfactory/approvals.py +86 -0
- openfactory/box_prove.py +1458 -0
- openfactory/cli.py +3298 -0
- openfactory/cli_refusals.py +169 -0
- openfactory/conformance/__init__.py +19 -0
- openfactory/conformance/adapters.py +622 -0
- openfactory/contracts/__init__.py +65 -0
- openfactory/contracts/bot.py +42 -0
- openfactory/contracts/commands.py +183 -0
- openfactory/contracts/decision.py +286 -0
- openfactory/contracts/manifest.py +713 -0
- openfactory/contracts/product.py +95 -0
- openfactory/contracts/profile.py +214 -0
- openfactory/contracts/project.py +353 -0
- openfactory/contracts/refs.py +155 -0
- openfactory/contracts/review.py +52 -0
- openfactory/contracts/run.py +326 -0
- openfactory/contracts/state.py +64 -0
- openfactory/contracts/ticket.py +113 -0
- openfactory/credentials.py +356 -0
- openfactory/doctor.py +1577 -0
- openfactory/doors.py +238 -0
- openfactory/environ.py +251 -0
- openfactory/factory.py +460 -0
- openfactory/floor/__init__.py +31 -0
- openfactory/floor/ladder.py +655 -0
- openfactory/floor/reading.py +302 -0
- openfactory/identity/__init__.py +14 -0
- openfactory/identity/base.py +95 -0
- openfactory/identity/local.py +186 -0
- openfactory/identity/oidc.py +633 -0
- openfactory/identity/people.py +352 -0
- openfactory/identity/registry.py +91 -0
- openfactory/knowledge/__init__.py +76 -0
- openfactory/knowledge/bundle.py +219 -0
- openfactory/knowledge/check.py +167 -0
- openfactory/knowledge/contracts.py +407 -0
- openfactory/knowledge/experiment.py +118 -0
- openfactory/knowledge/gaps.py +118 -0
- openfactory/knowledge/gate.py +330 -0
- openfactory/knowledge/gather.py +156 -0
- openfactory/knowledge/generator.py +822 -0
- openfactory/knowledge/inventory.py +557 -0
- openfactory/knowledge/okf.py +369 -0
- openfactory/knowledge/pipeline.py +333 -0
- openfactory/knowledge/render.py +146 -0
- openfactory/knowledge/service.py +53 -0
- openfactory/knowledge/staleness.py +112 -0
- openfactory/language/__init__.py +15 -0
- openfactory/language/assent.py +130 -0
- openfactory/loader.py +156 -0
- openfactory/memory/__init__.py +29 -0
- openfactory/memory/ledger.py +215 -0
- openfactory/memory/messages.py +406 -0
- openfactory/memory/recall.py +303 -0
- openfactory/memory/store.py +157 -0
- openfactory/memory/transcript.py +267 -0
- openfactory/namespace.py +258 -0
- openfactory/observability/__init__.py +23 -0
- openfactory/observability/events.py +188 -0
- openfactory/observability/metrics.py +191 -0
- openfactory/observability/query.py +80 -0
- openfactory/observability/registry.py +253 -0
- openfactory/observability/sqlite_metrics.py +203 -0
- openfactory/observability/trajectory.py +243 -0
- openfactory/onboarding/__init__.py +64 -0
- openfactory/onboarding/concepts.py +450 -0
- openfactory/onboarding/context.py +2399 -0
- openfactory/onboarding/cover.py +97 -0
- openfactory/onboarding/deployment.py +807 -0
- openfactory/onboarding/firstrun.py +1637 -0
- openfactory/onboarding/history.py +363 -0
- openfactory/onboarding/infer.py +2736 -0
- openfactory/onboarding/live_ci.py +162 -0
- openfactory/onboarding/onboard.py +1070 -0
- openfactory/onboarding/propose_manifest.py +349 -0
- openfactory/onboarding/questions.py +137 -0
- openfactory/onboarding/readiness.py +1073 -0
- openfactory/onboarding/renew.py +239 -0
- openfactory/onboarding/spend.py +82 -0
- openfactory/ops/__init__.py +6 -0
- openfactory/ops/impediment.py +288 -0
- openfactory/orchestrator/__init__.py +26 -0
- openfactory/orchestrator/context.py +231 -0
- openfactory/orchestrator/errors.py +27 -0
- openfactory/orchestrator/machine.py +2617 -0
- openfactory/orchestrator/merge_policy.py +203 -0
- openfactory/orchestrator/promotion.py +249 -0
- openfactory/orchestrator/risk.py +191 -0
- openfactory/orchestrator/validation.py +172 -0
- openfactory/org_defaults/engineering.md +75 -0
- openfactory/org_defaults/floor.yaml +182 -0
- openfactory/org_defaults/profiles/prototype.yaml +16 -0
- openfactory/org_defaults/profiles/regulated.yaml +35 -0
- openfactory/org_defaults/requirements/0000-template.md +39 -0
- openfactory/org_defaults/roles/coordinator.md +48 -0
- openfactory/org_defaults/roles/executor.md +92 -0
- openfactory/org_defaults/roles/planner.md +117 -0
- openfactory/org_defaults/roles/product.md +71 -0
- openfactory/org_defaults/roles/recovery.md +33 -0
- openfactory/org_defaults/roles/sizer.md +62 -0
- openfactory/org_defaults/roles/techlead.md +61 -0
- openfactory/org_defaults/tdd.md +10 -0
- openfactory/own_work.py +41 -0
- openfactory/paths.py +117 -0
- openfactory/plugins.py +208 -0
- openfactory/policy/__init__.py +10 -0
- openfactory/policy/authz.py +105 -0
- openfactory/policy/census.py +161 -0
- openfactory/policy/conformance.py +300 -0
- openfactory/policy/floor.py +42 -0
- openfactory/policy/presets.py +172 -0
- openfactory/policy/profiles.py +275 -0
- openfactory/policy/protected.py +169 -0
- openfactory/policy/test_work.py +70 -0
- openfactory/preflight.py +577 -0
- openfactory/presets/node.yaml +6 -0
- openfactory/presets/python.yaml +9 -0
- openfactory/presets/security-oss.yaml +26 -0
- openfactory/presets/terraform.yaml +8 -0
- openfactory/product/__init__.py +56 -0
- openfactory/product/asked.py +172 -0
- openfactory/product/authoring.py +1791 -0
- openfactory/product/board.py +484 -0
- openfactory/product/brownfield.py +210 -0
- openfactory/product/case.py +409 -0
- openfactory/product/channel.py +1437 -0
- openfactory/product/config.py +364 -0
- openfactory/product/confirm.py +801 -0
- openfactory/product/conversation.py +49 -0
- openfactory/product/corpus.py +462 -0
- openfactory/product/domain.py +242 -0
- openfactory/product/facts.py +209 -0
- openfactory/product/followup.py +647 -0
- openfactory/product/intents.py +641 -0
- openfactory/product/loader.py +198 -0
- openfactory/product/module.py +3100 -0
- openfactory/product/needs_action.py +198 -0
- openfactory/product/onboard.py +331 -0
- openfactory/product/queue.py +273 -0
- openfactory/product/reading.py +130 -0
- openfactory/product/release.py +153 -0
- openfactory/product/requester.py +55 -0
- openfactory/product/role.py +1356 -0
- openfactory/product/staging.py +477 -0
- openfactory/product/triage.py +277 -0
- openfactory/product/voice.py +2080 -0
- openfactory/product/workspace.py +182 -0
- openfactory/registry.py +526 -0
- openfactory/review/__init__.py +11 -0
- openfactory/review/verdict.py +199 -0
- openfactory/runtime/__init__.py +0 -0
- openfactory/runtime/boxed_job.py +399 -0
- openfactory/runtime/card_repo.py +146 -0
- openfactory/runtime/host.py +91 -0
- openfactory/runtime/repo_cache.py +296 -0
- openfactory/runtime/temporal/__init__.py +26 -0
- openfactory/runtime/temporal/activities.py +5081 -0
- openfactory/runtime/temporal/connection.py +89 -0
- openfactory/runtime/temporal/io.py +639 -0
- openfactory/runtime/temporal/poller.py +185 -0
- openfactory/runtime/temporal/schedule.py +465 -0
- openfactory/runtime/temporal/starter.py +54 -0
- openfactory/runtime/temporal/view.py +884 -0
- openfactory/runtime/temporal/worker.py +325 -0
- openfactory/runtime/temporal/workflow.py +2459 -0
- openfactory/runtime/toolbox.py +163 -0
- openfactory/scheduler.py +75 -0
- openfactory/semver.py +35 -0
- openfactory/techlead/__init__.py +38 -0
- openfactory/techlead/classify.py +427 -0
- openfactory/techlead/conversation.py +1401 -0
- openfactory/techlead/diagnosis.py +251 -0
- openfactory/techlead/memory.py +264 -0
- openfactory/techlead/pack.py +169 -0
- openfactory/techlead/voice.py +679 -0
- openfactory/techlead/watch.py +638 -0
- openfactory/testing/__init__.py +1 -0
- openfactory/testing/local_flow.py +272 -0
- openfactory/util/__init__.py +0 -0
- openfactory/util/bounded.py +77 -0
- openfactory/util/causes.py +125 -0
- openfactory/util/scratch.py +80 -0
- openfactory-0.2.0.dist-info/METADATA +41 -0
- openfactory-0.2.0.dist-info/RECORD +270 -0
- openfactory-0.2.0.dist-info/WHEEL +5 -0
- openfactory-0.2.0.dist-info/entry_points.txt +2 -0
- openfactory-0.2.0.dist-info/licenses/LICENSE +202 -0
- openfactory-0.2.0.dist-info/licenses/NOTICE +22 -0
- openfactory-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Cost-dashboard bridge — read the metrics table (observability.metrics) and shape it for the
|
|
2
|
+
panel's PER-PROJECT cost dashboard: filterable rollups (period / model / harness / role) plus a
|
|
3
|
+
per-task table (with wall-clock time). Read-only + best-effort: any failure degrades to empty so
|
|
4
|
+
the panel still renders. Low volume (dozens of jobs/day) → a full Scan + in-process aggregation."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
from collections import defaultdict
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger("openfactory.metrics")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _configured_sink():
|
|
16
|
+
"""The sink this deployment WRITES to, so a reader asks the same place (audit, 2026-08-02).
|
|
17
|
+
|
|
18
|
+
Both readers used to open with `os.environ.get("OPENFACTORY_METRICS_TABLE")` and return `[]`
|
|
19
|
+
when it
|
|
20
|
+
was unset — which on a `docker compose` install is always, because the local answer is SQLite
|
|
21
|
+
and that variable belongs to DynamoDB. So a perfectly configured local deployment wrote metrics
|
|
22
|
+
to disk and every reader answered "nothing recorded": the cost dashboard, the tech-lead's
|
|
23
|
+
memory of what it had already said, recurring-failure detection and the open-loop ledger, all
|
|
24
|
+
silently blind. `SqliteMetricsSink` had the two matching read methods the whole time, tested,
|
|
25
|
+
and called by nothing.
|
|
26
|
+
|
|
27
|
+
Returns None when the sink cannot read, which every caller already treats as "no data" — and
|
|
28
|
+
SAYS SO when that is a gap rather than a choice. The null sink dropped every record it was
|
|
29
|
+
given, so "nothing to read" is its true answer; any other sink that is not a `ReadableSink`
|
|
30
|
+
recorded rows nobody can read back, which is the cost dashboard, the tech-lead's memory and
|
|
31
|
+
the open-loop ledger all silently blind, and that is logged as a WARNING rather than read as
|
|
32
|
+
an empty store.
|
|
33
|
+
|
|
34
|
+
THE CHECK IS ON THE PORT (`ReadableSink`), not `hasattr(sink, "scan")` — and the two readers no
|
|
35
|
+
longer duck-type `table_name` as "this is DynamoDB". A third-party sink with an ordinary
|
|
36
|
+
`table_name` attribute was starved to `[]` by one reader and routed to a vendor's client by
|
|
37
|
+
the other (probes C/D, 2026-08-24).
|
|
38
|
+
"""
|
|
39
|
+
from openfactory.observability.metrics import NullMetricsSink, ReadableSink
|
|
40
|
+
from openfactory.observability.registry import build_metrics_sink, metrics_sink_kind
|
|
41
|
+
|
|
42
|
+
kind = metrics_sink_kind()
|
|
43
|
+
try:
|
|
44
|
+
sink = build_metrics_sink(kind, path=os.environ.get("OPENFACTORY_METRICS_DB"))
|
|
45
|
+
except Exception as exc: # noqa: BLE001 — an unreadable store is empty, never a 500
|
|
46
|
+
log.warning("metrics sink %r could not be built for reading (%s) — reporting no data",
|
|
47
|
+
kind, exc)
|
|
48
|
+
return None
|
|
49
|
+
if isinstance(sink, ReadableSink):
|
|
50
|
+
return sink
|
|
51
|
+
if not isinstance(sink, NullMetricsSink):
|
|
52
|
+
log.warning("the configured metrics sink %r (%s) records and cannot be read back — the "
|
|
53
|
+
"dashboard and the agents' memory report nothing until a readable sink is "
|
|
54
|
+
"configured", kind, type(sink).__name__)
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _opt_int(value) -> int | None:
|
|
59
|
+
"""An int, or None when the value was never measured.
|
|
60
|
+
|
|
61
|
+
NOT `int(x or 0)`, which is what every other numeric here does and is right for them: a cost of
|
|
62
|
+
zero is a cost. A trajectory dimension of zero would say the agent called no tools, and the
|
|
63
|
+
rows that carry None are the rows where nobody could read the stream at all. The two must not
|
|
64
|
+
average together."""
|
|
65
|
+
try:
|
|
66
|
+
return int(float(value))
|
|
67
|
+
except (TypeError, ValueError):
|
|
68
|
+
# None and "" arrive here, and that is deliberate rather than lazy: an explicit
|
|
69
|
+
# `if value is None` clause in front of this was DEAD — the mutation that removed it
|
|
70
|
+
# changed nothing, because `float(None)` raises and lands in exactly this branch. One
|
|
71
|
+
# spelling of "not a number", not two, one of which is never reached.
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def scan_records(table_name: str | None = None, region: str | None = None) -> list[dict]:
|
|
76
|
+
"""Every metric item, numbers parsed back from their stored strings. [] on any trouble
|
|
77
|
+
(table absent, no creds, scan error) — the dashboard shows 'no data yet', never an error.
|
|
78
|
+
|
|
79
|
+
THE DEGRADING DOOR, and it stays that way. A cost dashboard that 500s because the store hiccuped
|
|
80
|
+
is worse than one showing yesterday's shape; nobody decides anything from it in the next thirty
|
|
81
|
+
seconds. `scan_all_or_raise` is the same read for callers that DO — see #126, where this
|
|
82
|
+
function's `[]` reached the panel's human gates through three layers and told an operator their
|
|
83
|
+
question had already been answered."""
|
|
84
|
+
try:
|
|
85
|
+
return scan_all_or_raise(table_name=table_name, region=region)
|
|
86
|
+
except Exception as exc: # noqa: BLE001 — the dashboard degrades to empty, never 500s
|
|
87
|
+
log.warning("metrics scan failed for %s: %s", table_name or "the configured sink", exc)
|
|
88
|
+
return []
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def scan_all_or_raise(table_name: str | None = None, region: str | None = None) -> list[dict]:
|
|
92
|
+
"""`scan_records`, but an unreadable store RAISES rather than reading as an empty one.
|
|
93
|
+
|
|
94
|
+
`[]` here means the store answered and had nothing, or that no readable store is configured —
|
|
95
|
+
which is a supported deployment shape, not a failure.
|
|
96
|
+
|
|
97
|
+
THE SINK THE REGISTRY BUILT IS THE ONLY DOOR. This used to fall through to
|
|
98
|
+
`OPENFACTORY_METRICS_TABLE` whenever that sink could not read, so a deployment on any other
|
|
99
|
+
sink with the table variable set reached for one vendor's client. An explicit `table_name` is
|
|
100
|
+
the operator's override, and it is registry-shaped: the CONFIGURED sink's kind, pointed at that
|
|
101
|
+
table (`configured_metrics_sink`) — never a vendor's kind spelled here. Each sink parses its
|
|
102
|
+
own stored numbers — the DynamoDB one stringifies and parses back; nothing here knows that."""
|
|
103
|
+
if table_name:
|
|
104
|
+
from openfactory.observability.registry import configured_metrics_sink
|
|
105
|
+
|
|
106
|
+
return configured_metrics_sink(table=table_name, region=region).scan()
|
|
107
|
+
sink = _configured_sink()
|
|
108
|
+
return [] if sink is None else sink.scan()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _round_map(d: dict) -> dict:
|
|
112
|
+
return {k: round(v, 4) for k, v in sorted(d.items(), key=lambda kv: -kv[1])}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _median(xs: list[float]) -> float | None:
|
|
116
|
+
if not xs:
|
|
117
|
+
return None
|
|
118
|
+
s = sorted(xs)
|
|
119
|
+
mid = len(s) // 2
|
|
120
|
+
return s[mid] if len(s) % 2 else (s[mid - 1] + s[mid]) / 2
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
#: tickets that ran before the arm was recorded. `knowledge_map` has defaulted to false for every
|
|
124
|
+
#: project since the field existed, so these demonstrably ran WITHOUT the map — they are a real
|
|
125
|
+
#: baseline, not a mystery. What they are not is a *controlled* arm: nobody chose them, and their
|
|
126
|
+
#: ticket mix is whatever the factory happened to do. Kept as their own bucket so the baseline is
|
|
127
|
+
#: visible without being silently merged into the deliberate `off` control.
|
|
128
|
+
PRE_OKF = "pre_okf"
|
|
129
|
+
|
|
130
|
+
#: the treatment arm — the only one where the agent actually received the map
|
|
131
|
+
TREATMENT = "injected"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _stats(xs: list[float]) -> dict:
|
|
135
|
+
"""n / mean / median for one measure. Median matters as much as mean here: ticket sizes vary
|
|
136
|
+
enormously, so one large ticket landing in a small arm moves the mean and proves nothing."""
|
|
137
|
+
n = len(xs)
|
|
138
|
+
return {
|
|
139
|
+
"n": n,
|
|
140
|
+
"avg": round(sum(xs) / n, 4) if n else None,
|
|
141
|
+
"median": round(_median(xs) or 0.0, 4) if n else None,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _knowledge_ab(tasks: list[dict]) -> dict:
|
|
146
|
+
"""The Knowledge Layer's A/B readout (ADR-0017's gate), grouped by which arm each ticket ran
|
|
147
|
+
in: `injected` (the agent had the module map), `off` (the project hasn't opted in),
|
|
148
|
+
`unavailable` (opted in, but the map wasn't trustworthy for that checkout — those tickets ran
|
|
149
|
+
WITHOUT it, so they are controls, not treatment), and `pre_okf` (see above).
|
|
150
|
+
|
|
151
|
+
TOKENS ARE THE HEADLINE MEASURE, not cost. The claim being tested is that the map makes the
|
|
152
|
+
agent read less to find the same code, and tokens measure that directly. Cost is a derived
|
|
153
|
+
number that moves with per-model pricing and is simply absent for a harness that reports no
|
|
154
|
+
price (Codex), so an experiment resting on cost alone would be unreadable the moment the arms
|
|
155
|
+
ran on different engines.
|
|
156
|
+
|
|
157
|
+
The `delta` is a plain arithmetic difference between the treatment and its control, NOT a
|
|
158
|
+
verdict. Comparing arms is only valid if the ticket mix is comparable, and nothing here can
|
|
159
|
+
know that — which is why every bucket reports its own n and the delta names the control it
|
|
160
|
+
used. A human judges."""
|
|
161
|
+
arms: dict[str, dict] = {}
|
|
162
|
+
for t in tasks:
|
|
163
|
+
arm = t.get("knowledge") or PRE_OKF
|
|
164
|
+
# `pre_okf` is a BASELINE, not an arm, and it is kept out of the comparison entirely.
|
|
165
|
+
# Those tickets predate the experiment AND predate changes to what the platform counts
|
|
166
|
+
# (review-repair started being counted 2026-07-26), so putting them beside a chosen arm
|
|
167
|
+
# invites exactly the comparison that would be wrong. Reported separately, below.
|
|
168
|
+
a = arms.setdefault(arm, {"cost": [], "wall": [], "turns": [], "tokens": [],
|
|
169
|
+
"input": [], "output": []})
|
|
170
|
+
a["cost"].append(float(t.get("cost_usd") or 0.0))
|
|
171
|
+
if t.get("wall_s"):
|
|
172
|
+
a["wall"].append(float(t["wall_s"]))
|
|
173
|
+
a["turns"].append(float(t.get("turns") or 0))
|
|
174
|
+
if t.get("tokens"):
|
|
175
|
+
a["tokens"].append(float(t["tokens"]))
|
|
176
|
+
a["input"].append(float(t.get("input_tokens") or 0))
|
|
177
|
+
a["output"].append(float(t.get("output_tokens") or 0))
|
|
178
|
+
|
|
179
|
+
out: dict[str, dict] = {}
|
|
180
|
+
for arm, a in arms.items():
|
|
181
|
+
out[arm] = {
|
|
182
|
+
"tasks": len(a["cost"]),
|
|
183
|
+
"tokens": _stats(a["tokens"]),
|
|
184
|
+
"input_tokens": _stats(a["input"]),
|
|
185
|
+
"output_tokens": _stats(a["output"]),
|
|
186
|
+
"cost_usd": _stats(a["cost"]),
|
|
187
|
+
"wall_s": _stats(a["wall"]),
|
|
188
|
+
"turns": _stats(a["turns"]),
|
|
189
|
+
}
|
|
190
|
+
experiment = {k: v for k, v in out.items() if k != PRE_OKF}
|
|
191
|
+
return {"arms": experiment, "baseline": out.get(PRE_OKF), "delta": _delta(experiment)}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _delta(arms: dict) -> dict | None:
|
|
195
|
+
"""Treatment vs its control, per measure, as a signed percentage. `None` until both exist —
|
|
196
|
+
a one-arm readout has nothing to compare, and inventing a baseline is how a number that means
|
|
197
|
+
nothing ends up in a slide."""
|
|
198
|
+
t = arms.get(TREATMENT)
|
|
199
|
+
# `pre_okf` is deliberately NOT a candidate control: it is history, measured under a different
|
|
200
|
+
# platform, and a delta against it would be a before/after wearing an experiment's clothes.
|
|
201
|
+
control_name = next((c for c in ("off", "unavailable") if arms.get(c)), None)
|
|
202
|
+
if not t or not control_name:
|
|
203
|
+
return None
|
|
204
|
+
c = arms[control_name]
|
|
205
|
+
out = {"control": control_name, "treatment": TREATMENT, "measures": {}}
|
|
206
|
+
for m in ("tokens", "input_tokens", "output_tokens", "cost_usd", "wall_s", "turns"):
|
|
207
|
+
base, got = c[m].get("median"), t[m].get("median")
|
|
208
|
+
if base: # a zero or missing baseline yields no percentage, not a division by zero
|
|
209
|
+
out["measures"][m] = {
|
|
210
|
+
"control_median": base, "treatment_median": got,
|
|
211
|
+
"pct": round((got - base) / base * 100, 1) if got is not None else None,
|
|
212
|
+
}
|
|
213
|
+
return out
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def dashboard(records: list[dict], project: str | None = None) -> dict:
|
|
217
|
+
"""The panel's dashboard payload. `projects` lists every project seen (for the selector);
|
|
218
|
+
everything else is scoped to `project` (or the first project if none given). Cost breakdowns
|
|
219
|
+
come from `agent_run` items (model/harness/role + cost); the per-task table joins each
|
|
220
|
+
ticket's agent_runs (cost/turns/models/harnesses) with its `job` record (wall-clock, state)."""
|
|
221
|
+
projects = sorted({str(r.get("pk") or r.get("project") or "") for r in records
|
|
222
|
+
if (r.get("pk") or r.get("project"))})
|
|
223
|
+
project = project or (projects[0] if projects else "")
|
|
224
|
+
|
|
225
|
+
scoped = [r for r in records if str(r.get("pk") or r.get("project") or "") == project]
|
|
226
|
+
runs = [r for r in scoped if r.get("kind") == "agent_run"]
|
|
227
|
+
jobs = {str(r.get("ticket")): r for r in scoped if r.get("kind") == "job"}
|
|
228
|
+
|
|
229
|
+
def _sum_by(key: str) -> dict:
|
|
230
|
+
out: dict[str, float] = defaultdict(float)
|
|
231
|
+
for r in runs:
|
|
232
|
+
out[str(r.get(key) or "unknown")] += r.get("cost_usd") or 0.0
|
|
233
|
+
return _round_map(out)
|
|
234
|
+
|
|
235
|
+
by_day: dict[str, float] = defaultdict(float)
|
|
236
|
+
for r in runs:
|
|
237
|
+
day = str(r.get("ts") or "")[:10]
|
|
238
|
+
if day:
|
|
239
|
+
by_day[day] += r.get("cost_usd") or 0.0
|
|
240
|
+
|
|
241
|
+
# per-ticket rollup (the table). Group runs by ticket; join the job record for time/state.
|
|
242
|
+
per: dict[str, dict] = defaultdict(lambda: {"cost": 0.0, "turns": 0, "models": set(),
|
|
243
|
+
"harnesses": set(), "ts": "",
|
|
244
|
+
"in_tok": 0, "out_tok": 0,
|
|
245
|
+
"calls": 0, "repeats": 0, "refused": 0,
|
|
246
|
+
"read": 0})
|
|
247
|
+
for r in runs:
|
|
248
|
+
tk = str(r.get("ticket"))
|
|
249
|
+
p = per[tk]
|
|
250
|
+
p["cost"] += r.get("cost_usd") or 0.0
|
|
251
|
+
p["turns"] += int(r.get("num_turns") or 0)
|
|
252
|
+
# tokens were recorded per invocation but never rolled up, so the measure the Knowledge
|
|
253
|
+
# Layer exists to move was invisible on the dashboard that decides its fate
|
|
254
|
+
p["in_tok"] += int(r.get("input_tokens") or 0)
|
|
255
|
+
p["out_tok"] += int(r.get("output_tokens") or 0)
|
|
256
|
+
# WHAT THE PASSES DID, summed across the ticket's invocations. `turns_to_first_edit` is
|
|
257
|
+
# deliberately NOT here: it is a per-pass shape and adding two of them means nothing.
|
|
258
|
+
# `read` counts the passes whose trajectory could be read at all, so a ticket whose
|
|
259
|
+
# harness has no stream reader shows a dash rather than a confident zero.
|
|
260
|
+
if r.get("tool_calls") is not None:
|
|
261
|
+
p["read"] += 1
|
|
262
|
+
p["calls"] += int(r.get("tool_calls") or 0)
|
|
263
|
+
p["repeats"] += int(r.get("repeated_calls") or 0)
|
|
264
|
+
p["refused"] += int(r.get("refused_calls") or 0)
|
|
265
|
+
if r.get("model"):
|
|
266
|
+
p["models"].add(str(r["model"]))
|
|
267
|
+
if r.get("harness"):
|
|
268
|
+
p["harnesses"].add(str(r["harness"]))
|
|
269
|
+
p["ts"] = max(p["ts"], str(r.get("ts") or ""))
|
|
270
|
+
tasks = []
|
|
271
|
+
for tk, p in per.items():
|
|
272
|
+
job = jobs.get(tk, {})
|
|
273
|
+
tasks.append({
|
|
274
|
+
"ticket": tk,
|
|
275
|
+
"date": (p["ts"] or str(job.get("ts") or ""))[:10],
|
|
276
|
+
"model": ", ".join(sorted(p["models"])) or "—",
|
|
277
|
+
"harness": ", ".join(sorted(p["harnesses"])) or "—",
|
|
278
|
+
"turns": p["turns"],
|
|
279
|
+
"input_tokens": p["in_tok"],
|
|
280
|
+
"output_tokens": p["out_tok"],
|
|
281
|
+
"tokens": p["in_tok"] + p["out_tok"],
|
|
282
|
+
"wall_s": job.get("wall_s"),
|
|
283
|
+
"cost_usd": round(p["cost"], 4),
|
|
284
|
+
# None, not 0, when no pass on this ticket had a readable stream — the panel renders a
|
|
285
|
+
# dash, and an operator ranking tickets by waste is never handed a zero nobody measured.
|
|
286
|
+
"tool_calls": p["calls"] if p["read"] else None,
|
|
287
|
+
"repeated_calls": p["repeats"] if p["read"] else None,
|
|
288
|
+
"refused_calls": p["refused"] if p["read"] else None,
|
|
289
|
+
"state": job.get("state") or "",
|
|
290
|
+
"title": job.get("title") or "",
|
|
291
|
+
"knowledge": job.get("knowledge") or "", # the A/B arm (ADR-0017)
|
|
292
|
+
})
|
|
293
|
+
tasks.sort(key=lambda t: (t["date"], t["ticket"]), reverse=True)
|
|
294
|
+
|
|
295
|
+
total_cost = round(sum((r.get("cost_usd") or 0.0) for r in runs), 4)
|
|
296
|
+
walls = [j.get("wall_s") for j in jobs.values() if j.get("wall_s")]
|
|
297
|
+
n = len(per) or len(jobs)
|
|
298
|
+
# raw per-invocation rows so the panel can filter + re-aggregate client-side (instant, no
|
|
299
|
+
# round-trip per filter change). Small (dozens/day).
|
|
300
|
+
runs_out = [{
|
|
301
|
+
"ticket": str(r.get("ticket")), "ts": str(r.get("ts") or ""),
|
|
302
|
+
"date": str(r.get("ts") or "")[:10], "role": str(r.get("role") or ""),
|
|
303
|
+
"model": str(r.get("model") or ""), "harness": str(r.get("harness") or ""),
|
|
304
|
+
"cost_usd": round(r.get("cost_usd") or 0.0, 4), "turns": int(r.get("num_turns") or 0),
|
|
305
|
+
"input_tokens": int(r.get("input_tokens") or 0),
|
|
306
|
+
"output_tokens": int(r.get("output_tokens") or 0),
|
|
307
|
+
# Per invocation these stay None where they were not measured, for the same reason: the
|
|
308
|
+
# client-side re-aggregation must be able to skip them rather than average in zeros.
|
|
309
|
+
"tool_calls": _opt_int(r.get("tool_calls")),
|
|
310
|
+
"repeated_calls": _opt_int(r.get("repeated_calls")),
|
|
311
|
+
"refused_calls": _opt_int(r.get("refused_calls")),
|
|
312
|
+
"turns_to_first_edit": _opt_int(r.get("turns_to_first_edit")),
|
|
313
|
+
} for r in runs]
|
|
314
|
+
return {
|
|
315
|
+
"projects": projects,
|
|
316
|
+
"project": project,
|
|
317
|
+
"runs": runs_out,
|
|
318
|
+
"knowledge_ab": _knowledge_ab(tasks),
|
|
319
|
+
"by_day": {k: round(by_day[k], 4) for k in sorted(by_day)},
|
|
320
|
+
"by_model": _sum_by("model"),
|
|
321
|
+
"by_harness": _sum_by("harness"),
|
|
322
|
+
"by_role": _sum_by("role"),
|
|
323
|
+
"tasks": tasks,
|
|
324
|
+
"totals": {
|
|
325
|
+
"cost_usd": total_cost,
|
|
326
|
+
"tokens": sum(t["tokens"] for t in tasks),
|
|
327
|
+
"tasks": n,
|
|
328
|
+
"avg_cost_per_task": round(total_cost / n, 4) if n else 0.0,
|
|
329
|
+
"avg_wall_s": round(sum(walls) / len(walls), 1) if walls else 0.0,
|
|
330
|
+
"agent_runs": len(runs),
|
|
331
|
+
},
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def cost_dashboard(project: str | None = None, table_name: str | None = None) -> dict:
|
|
336
|
+
"""The full payload for GET /api/metrics — scan + per-project shape. Always returns a dict."""
|
|
337
|
+
return dashboard(scan_records(table_name), project=project)
|