syncade 0.6.2__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.
- syncade/__init__.py +3 -0
- syncade/__main__.py +6 -0
- syncade/adapters/__init__.py +0 -0
- syncade/adapters/anthropic.py +457 -0
- syncade/adapters/base.py +221 -0
- syncade/adapters/fake.py +73 -0
- syncade/adapters/fake_common.py +29 -0
- syncade/adapters/fake_producer_audit_draft.py +460 -0
- syncade/adapters/fake_reviewer_synth.py +310 -0
- syncade/adapters/openai.py +484 -0
- syncade/adapters/openai_parsing.py +119 -0
- syncade/adapters/producer.py +221 -0
- syncade/adapters/producer_anthropic.py +300 -0
- syncade/adapters/producer_openai.py +226 -0
- syncade/adapters/registry.py +81 -0
- syncade/auth_check.py +554 -0
- syncade/auth_preflight.py +342 -0
- syncade/base_resolution.py +214 -0
- syncade/billing.py +141 -0
- syncade/checks_config.py +113 -0
- syncade/cli/__init__.py +546 -0
- syncade/cli/auth_gate.py +59 -0
- syncade/cli/config_keys.py +135 -0
- syncade/cli/config_list.py +82 -0
- syncade/cli/config_menu_rows.py +166 -0
- syncade/cli/config_mode.py +609 -0
- syncade/cli/config_overrides.py +122 -0
- syncade/cli/config_tui.py +476 -0
- syncade/cli/doctor_mode.py +72 -0
- syncade/cli/gc_mode.py +109 -0
- syncade/cli/install_skill.py +514 -0
- syncade/cli/metrics_mode.py +363 -0
- syncade/cli/modes.py +573 -0
- syncade/cli/parser.py +450 -0
- syncade/cli/parser_types.py +137 -0
- syncade/cli/paths.py +38 -0
- syncade/cli/preflight_paths.py +90 -0
- syncade/cli/resolve.py +116 -0
- syncade/cli/resume_mode.py +324 -0
- syncade/cli/toml_writer.py +410 -0
- syncade/cli/validate.py +421 -0
- syncade/config.py +478 -0
- syncade/config_auth.py +310 -0
- syncade/config_cold.py +209 -0
- syncade/config_gc.py +55 -0
- syncade/config_loader.py +182 -0
- syncade/config_loop.py +282 -0
- syncade/config_producer.py +222 -0
- syncade/config_retry.py +49 -0
- syncade/config_types.py +59 -0
- syncade/diff_filter.py +437 -0
- syncade/dispatcher.py +571 -0
- syncade/doctor.py +425 -0
- syncade/doctor_env.py +218 -0
- syncade/doctor_preview.py +524 -0
- syncade/doctor_types.py +28 -0
- syncade/exit_codes.py +82 -0
- syncade/findings.py +242 -0
- syncade/findings_json.py +456 -0
- syncade/gc.py +211 -0
- syncade/gc_execute.py +372 -0
- syncade/gc_protection.py +129 -0
- syncade/gc_types.py +50 -0
- syncade/gc_worktrees.py +200 -0
- syncade/git_object_id.py +12 -0
- syncade/git_preconditions.py +389 -0
- syncade/logging.py +289 -0
- syncade/metrics/__init__.py +32 -0
- syncade/metrics/aggregate.py +550 -0
- syncade/metrics/schema.py +221 -0
- syncade/orchestrator/__init__.py +61 -0
- syncade/orchestrator/_runs_dir.py +24 -0
- syncade/orchestrator/branch_advance.py +165 -0
- syncade/orchestrator/branch_guard.py +98 -0
- syncade/orchestrator/budget.py +107 -0
- syncade/orchestrator/escalation_coverage.py +81 -0
- syncade/orchestrator/loop.py +611 -0
- syncade/orchestrator/loop_dispatch_check.py +112 -0
- syncade/orchestrator/loop_finalize.py +404 -0
- syncade/orchestrator/loop_preflight.py +131 -0
- syncade/orchestrator/loop_resume.py +91 -0
- syncade/orchestrator/loop_rmtree.py +70 -0
- syncade/orchestrator/loop_round_step.py +599 -0
- syncade/orchestrator/prior_round.py +336 -0
- syncade/orchestrator/producer_phase.py +169 -0
- syncade/orchestrator/results.py +306 -0
- syncade/orchestrator/resume.py +96 -0
- syncade/orchestrator/resume_load.py +483 -0
- syncade/orchestrator/resume_plan.py +554 -0
- syncade/orchestrator/resume_target.py +215 -0
- syncade/orchestrator/resume_types.py +182 -0
- syncade/orchestrator/reviewer_template_failure.py +99 -0
- syncade/orchestrator/round.py +573 -0
- syncade/orchestrator/round_checks.py +91 -0
- syncade/orchestrator/round_no_changes.py +369 -0
- syncade/orchestrator/round_predispatch.py +212 -0
- syncade/orchestrator/verdict.py +279 -0
- syncade/persistence/__init__.py +189 -0
- syncade/persistence/_atomic.py +33 -0
- syncade/persistence/_clusters.py +70 -0
- syncade/persistence/_findings_verdict.py +201 -0
- syncade/persistence/_markdown.py +286 -0
- syncade/persistence/_validation.py +37 -0
- syncade/persistence/checks.py +249 -0
- syncade/persistence/decision_needed.py +289 -0
- syncade/persistence/findings_md.py +389 -0
- syncade/persistence/handoff.py +389 -0
- syncade/persistence/handoff_classify.py +196 -0
- syncade/persistence/last_reviewed.py +67 -0
- syncade/persistence/loop_manifest.py +165 -0
- syncade/persistence/loop_summary.py +352 -0
- syncade/persistence/loop_summary_text.py +428 -0
- syncade/persistence/producer.py +250 -0
- syncade/persistence/reviewer.py +198 -0
- syncade/persistence/round_manifest.py +238 -0
- syncade/persistence/run_init.py +153 -0
- syncade/persistence/run_summary.py +585 -0
- syncade/persistence/run_summary_next_steps.py +443 -0
- syncade/persistence/synth.py +242 -0
- syncade/persistence/test_run.py +152 -0
- syncade/presets.py +36 -0
- syncade/pricing_config.py +72 -0
- syncade/process.py +600 -0
- syncade/producer.py +189 -0
- syncade/producer_attempt.py +463 -0
- syncade/producer_escalation.py +146 -0
- syncade/producer_git.py +199 -0
- syncade/producer_result.py +205 -0
- syncade/prompts.py +448 -0
- syncade/prompts_loader.py +238 -0
- syncade/retry.py +159 -0
- syncade/run_inputs.py +40 -0
- syncade/run_status.py +198 -0
- syncade/selfcheck.py +471 -0
- syncade/skills/claude/README.md +221 -0
- syncade/skills/claude/SKILL.md +625 -0
- syncade/skills/codex/README.md +116 -0
- syncade/skills/codex/SKILL.md +574 -0
- syncade/snapshot.py +598 -0
- syncade/spec_audit.py +437 -0
- syncade/spec_audit_schema.py +190 -0
- syncade/spec_draft.py +423 -0
- syncade/spec_source.py +135 -0
- syncade/synthesis.py +428 -0
- syncade/synthesis_clusters.py +203 -0
- syncade/synthesis_repair.py +230 -0
- syncade/synthesis_schema.py +65 -0
- syncade/synthesizer/__init__.py +38 -0
- syncade/synthesizer/constants.py +33 -0
- syncade/synthesizer/driver.py +531 -0
- syncade/synthesizer/rendering.py +63 -0
- syncade/synthesizer/result.py +73 -0
- syncade/synthesizer/validation.py +421 -0
- syncade/synthesizer/workspace.py +208 -0
- syncade/templates/presets/balanced.toml +13 -0
- syncade/templates/presets/cheap.toml +12 -0
- syncade/templates/presets/thorough.toml +9 -0
- syncade/templates/producer.md +231 -0
- syncade/templates/reviewer.md +279 -0
- syncade/templates/reviewer_adversarial.md +164 -0
- syncade/templates/reviewer_codex.md +165 -0
- syncade/templates/spec_audit.md +168 -0
- syncade/templates/spec_draft.md +62 -0
- syncade/templates/synthesizer.md +204 -0
- syncade/test_runner.py +476 -0
- syncade/test_runner_classify.py +98 -0
- syncade/transcript.py +150 -0
- syncade/usage.py +407 -0
- syncade/worktree.py +497 -0
- syncade/worktree_env.py +133 -0
- syncade/worktree_paths.py +139 -0
- syncade-0.6.2.dist-info/METADATA +314 -0
- syncade-0.6.2.dist-info/RECORD +177 -0
- syncade-0.6.2.dist-info/WHEEL +5 -0
- syncade-0.6.2.dist-info/entry_points.txt +2 -0
- syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
- syncade-0.6.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
"""Aggregate the ``.syncade/runs/`` artifact corpus into metrics rows.
|
|
2
|
+
|
|
3
|
+
Read-only over the artifacts: reads ``loop-manifest.json`` + ``run-init.json``
|
|
4
|
+
and the presence of ``handoff.md`` / ``decision-needed.md``, mapping each run to
|
|
5
|
+
one :class:`RunRow` plus per-reviewer :class:`ReviewerStatRow`s. Legacy/partial
|
|
6
|
+
runs (missing fields or whole files) degrade to defaults rather than crashing —
|
|
7
|
+
older runs in a real corpus predate current fields.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import sqlite3
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from syncade.metrics.schema import (
|
|
19
|
+
ActorStatRow,
|
|
20
|
+
ReviewerStatRow,
|
|
21
|
+
RunRow,
|
|
22
|
+
upsert_actor_stat,
|
|
23
|
+
upsert_reviewer_stat,
|
|
24
|
+
upsert_run,
|
|
25
|
+
)
|
|
26
|
+
from syncade.synthesizer.constants import (
|
|
27
|
+
SYNTHESIZER_NAME,
|
|
28
|
+
SYNTHESIZER_PROVIDER,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Reasons a coarse exit code cannot distinguish, so the reason wins where one is recorded.
|
|
32
|
+
#
|
|
33
|
+
# no_changes_to_review / producer_emptied_diff exit 0 but are NOT SHIPs — the final round
|
|
34
|
+
# dispatched no reviewers. producer_emptied_diff additionally had prior rounds spend model
|
|
35
|
+
# work, but the final verdict is still no-review.
|
|
36
|
+
#
|
|
37
|
+
# provider_usage_limit shares exit 25 with the operator's OWN budget ceiling. Reporting it as
|
|
38
|
+
# "BUDGET" blames their config for someone else's quota window — and the two want opposite
|
|
39
|
+
# responses: raise the cap, versus wait for the window to reset.
|
|
40
|
+
_VERDICT_BY_REASON = {
|
|
41
|
+
"no_changes_to_review": "NO-CHANGE",
|
|
42
|
+
"producer_emptied_diff": "NO-CHANGE",
|
|
43
|
+
"provider_usage_limit": "QUOTA",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# syncade exit code -> coarse verdict label (see CLAUDE.md "Exit Codes").
|
|
47
|
+
_VERDICT_BY_EXIT = {
|
|
48
|
+
0: "SHIP",
|
|
49
|
+
10: "DECISION-NEEDED",
|
|
50
|
+
20: "MAX-ROUNDS",
|
|
51
|
+
25: "BUDGET",
|
|
52
|
+
30: "NO-SHIP",
|
|
53
|
+
40: "ERROR",
|
|
54
|
+
50: "CONFIG-ERROR",
|
|
55
|
+
60: "ENV-ERROR",
|
|
56
|
+
70: "PARSE-ERROR",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _int(value: object) -> int:
|
|
61
|
+
"""Coerce to int, defaulting non-ints (bool/str/list/…) to 0 — a malformed
|
|
62
|
+
historical artifact must not crash arithmetic or sqlite binding."""
|
|
63
|
+
return value if type(value) is int else 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _float(value: object) -> float:
|
|
67
|
+
return float(value) if type(value) in (int, float) else 0.0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _float_or_none(value: object) -> float | None:
|
|
71
|
+
return float(value) if type(value) in (int, float) else None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _int_or_none(value: object) -> int | None:
|
|
75
|
+
return value if type(value) is int else None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _usage_int_or_none(value: object) -> int | None:
|
|
79
|
+
return value if type(value) is int and value >= 0 else None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _usage_float_or_none(value: object) -> float | None:
|
|
83
|
+
if type(value) not in (int, float):
|
|
84
|
+
return None
|
|
85
|
+
cost = float(value)
|
|
86
|
+
return cost if math.isfinite(cost) and cost >= 0 else None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _usage_pair(tokens_value: object, cost_value: object) -> tuple[int | None, float | None]:
|
|
90
|
+
"""Validate persisted usage fields from manifests.
|
|
91
|
+
|
|
92
|
+
Tokens are the anchor for a usage block. If tokens are present but malformed
|
|
93
|
+
(wrong type or negative), ignore the whole usage entry; if tokens are valid
|
|
94
|
+
but cost is malformed, keep tokens and mark cost unknown downstream.
|
|
95
|
+
"""
|
|
96
|
+
if tokens_value is None and cost_value is None:
|
|
97
|
+
return None, None
|
|
98
|
+
tokens = _usage_int_or_none(tokens_value)
|
|
99
|
+
if tokens is None:
|
|
100
|
+
return None, None
|
|
101
|
+
return tokens, _usage_float_or_none(cost_value)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _text_or_none(value: object) -> str | None:
|
|
105
|
+
return value if type(value) is str else None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _reviewer_models(init: dict) -> dict[str, str]:
|
|
109
|
+
config = init.get("config_snapshot")
|
|
110
|
+
roster = config.get("reviewers") if isinstance(config, dict) else None
|
|
111
|
+
models: dict[str, str] = {}
|
|
112
|
+
if isinstance(roster, list):
|
|
113
|
+
for r in roster:
|
|
114
|
+
if isinstance(r, dict) and isinstance(r.get("name"), str):
|
|
115
|
+
models[r["name"]] = _text_or_none(r.get("model")) or ""
|
|
116
|
+
return models
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _load_init(run_dir: Path) -> dict:
|
|
120
|
+
"""Load ``run-init.json`` as a dict; warn (don't silently blank) when it is
|
|
121
|
+
present but unreadable — a broken model-join source should be visible, not
|
|
122
|
+
quietly yield blank-model reviewer stats. A *missing* run-init is normal for
|
|
123
|
+
partial/legacy runs and returns ``{}`` silently."""
|
|
124
|
+
path = run_dir / "run-init.json"
|
|
125
|
+
if not path.exists():
|
|
126
|
+
return {}
|
|
127
|
+
try:
|
|
128
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
129
|
+
except (OSError, ValueError):
|
|
130
|
+
data = None
|
|
131
|
+
if not isinstance(data, dict):
|
|
132
|
+
print(
|
|
133
|
+
f"[syncade] metrics: {run_dir.name} has an unreadable run-init.json "
|
|
134
|
+
"— model join skipped for this run",
|
|
135
|
+
file=sys.stderr,
|
|
136
|
+
)
|
|
137
|
+
return {}
|
|
138
|
+
return data
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def read_run(run_dir: Path | str) -> tuple[RunRow, list[ReviewerStatRow]] | None:
|
|
142
|
+
"""Map one run directory to a :class:`RunRow` + per-reviewer stats.
|
|
143
|
+
|
|
144
|
+
Returns ``None`` when ``loop-manifest.json`` is present but unparseable — a
|
|
145
|
+
*corrupt* run, which the caller skips with a warning rather than counting it as
|
|
146
|
+
a bogus UNKNOWN run. A *missing* or *parseable-but-partial* manifest is still
|
|
147
|
+
tolerated — scalar fields are coerced and non-list rounds/reviewers ignored, so
|
|
148
|
+
a single malformed historical artifact never crashes the whole aggregation.
|
|
149
|
+
"""
|
|
150
|
+
run_dir = Path(run_dir)
|
|
151
|
+
try:
|
|
152
|
+
manifest = json.loads((run_dir / "loop-manifest.json").read_text(encoding="utf-8"))
|
|
153
|
+
except FileNotFoundError:
|
|
154
|
+
manifest = {}
|
|
155
|
+
except (OSError, ValueError):
|
|
156
|
+
return None
|
|
157
|
+
if not isinstance(manifest, dict):
|
|
158
|
+
return None
|
|
159
|
+
init = _load_init(run_dir)
|
|
160
|
+
rounds = manifest.get("rounds")
|
|
161
|
+
rounds = rounds if isinstance(rounds, list) else []
|
|
162
|
+
exit_code = _int_or_none(manifest.get("final_exit_code"))
|
|
163
|
+
|
|
164
|
+
def _sum(key: str) -> int:
|
|
165
|
+
total = 0
|
|
166
|
+
for r in rounds:
|
|
167
|
+
synth = r.get("synthesizer") if isinstance(r, dict) else None
|
|
168
|
+
if isinstance(synth, dict):
|
|
169
|
+
total += _int(synth.get(key, 0))
|
|
170
|
+
return total
|
|
171
|
+
|
|
172
|
+
run_id = manifest.get("run_id")
|
|
173
|
+
run_id = run_id if isinstance(run_id, str) and run_id else run_dir.name
|
|
174
|
+
|
|
175
|
+
def _producer_outcome_count(outcome: str) -> int:
|
|
176
|
+
return sum(
|
|
177
|
+
1
|
|
178
|
+
for r in rounds
|
|
179
|
+
if isinstance(r, dict)
|
|
180
|
+
and isinstance(r.get("producer"), dict)
|
|
181
|
+
and r["producer"].get("outcome") == outcome
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def _sum_usage() -> tuple[int | None, float | None]:
|
|
185
|
+
# Sum usage across every actor — reviewers, synthesizer, producer — in
|
|
186
|
+
# every round. None when NO actor reported valid usage, so legacy runs
|
|
187
|
+
# stay NULL rather than showing a misleading 0.
|
|
188
|
+
tokens_total = 0
|
|
189
|
+
cost_total = 0.0
|
|
190
|
+
tokens_present = False
|
|
191
|
+
cost_present = False
|
|
192
|
+
for r in rounds:
|
|
193
|
+
if not isinstance(r, dict):
|
|
194
|
+
continue
|
|
195
|
+
reviewers = r.get("reviewers")
|
|
196
|
+
actors = list(reviewers) if isinstance(reviewers, list) else []
|
|
197
|
+
for key in ("synthesizer", "producer"):
|
|
198
|
+
a = r.get(key)
|
|
199
|
+
if isinstance(a, dict):
|
|
200
|
+
actors.append(a)
|
|
201
|
+
for a in actors:
|
|
202
|
+
if not isinstance(a, dict):
|
|
203
|
+
continue
|
|
204
|
+
tokens, cost_usd = _usage_pair(a.get("tokens"), a.get("cost_usd"))
|
|
205
|
+
if tokens is not None:
|
|
206
|
+
tokens_total += tokens
|
|
207
|
+
tokens_present = True
|
|
208
|
+
if cost_usd is not None:
|
|
209
|
+
cost_total += cost_usd
|
|
210
|
+
cost_present = True
|
|
211
|
+
return (
|
|
212
|
+
tokens_total if tokens_present else None,
|
|
213
|
+
cost_total if cost_present else None,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
_tok, _cost = _sum_usage()
|
|
217
|
+
_termination_reason = _text_or_none(manifest.get("termination_reason"))
|
|
218
|
+
_verdict = _VERDICT_BY_REASON.get(
|
|
219
|
+
_termination_reason or "", _VERDICT_BY_EXIT.get(exit_code, "UNKNOWN")
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
row = RunRow(
|
|
223
|
+
run_id=run_id,
|
|
224
|
+
verdict=_verdict,
|
|
225
|
+
rounds_executed=len(rounds),
|
|
226
|
+
blockers=_sum("active_blocker_count"),
|
|
227
|
+
minors=_sum("active_minor_count"),
|
|
228
|
+
nits=_sum("active_nit_count"),
|
|
229
|
+
dismissed=_sum("dismissed_count"),
|
|
230
|
+
final_exit_code=exit_code,
|
|
231
|
+
termination_reason=_termination_reason,
|
|
232
|
+
operator_branch=_text_or_none(init.get("operator_branch")),
|
|
233
|
+
handoff=1 if (run_dir / "handoff.md").exists() else 0,
|
|
234
|
+
decision_needed=1 if (run_dir / "decision-needed.md").exists() else 0,
|
|
235
|
+
producer_commits=_producer_outcome_count("committed"),
|
|
236
|
+
producer_stalled=_producer_outcome_count("stalled"),
|
|
237
|
+
producer_errors=_producer_outcome_count("subprocess_error"),
|
|
238
|
+
producer_escalated=_producer_outcome_count("escalated"),
|
|
239
|
+
tokens=int(_tok) if _tok is not None else None,
|
|
240
|
+
cost_usd=_cost,
|
|
241
|
+
)
|
|
242
|
+
return row, _reviewer_stats(run_id, rounds, init)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def read_actor_stats(run_dir: Path | str) -> list[ActorStatRow]:
|
|
246
|
+
"""Return per-actor usage stats for one run directory.
|
|
247
|
+
|
|
248
|
+
This is intentionally separate from :func:`read_run` so the long-standing
|
|
249
|
+
``read_run() -> (RunRow, reviewer_stats)`` contract remains stable.
|
|
250
|
+
"""
|
|
251
|
+
run_dir = Path(run_dir)
|
|
252
|
+
try:
|
|
253
|
+
manifest = json.loads((run_dir / "loop-manifest.json").read_text(encoding="utf-8"))
|
|
254
|
+
except FileNotFoundError:
|
|
255
|
+
manifest = {}
|
|
256
|
+
except (OSError, ValueError):
|
|
257
|
+
return []
|
|
258
|
+
if not isinstance(manifest, dict):
|
|
259
|
+
return []
|
|
260
|
+
rounds = manifest.get("rounds")
|
|
261
|
+
rounds = rounds if isinstance(rounds, list) else []
|
|
262
|
+
run_id = manifest.get("run_id")
|
|
263
|
+
run_id = run_id if isinstance(run_id, str) and run_id else run_dir.name
|
|
264
|
+
return _actor_stats(run_id, rounds, _load_init(run_dir))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _merge_cost_source(
|
|
268
|
+
current: str,
|
|
269
|
+
incoming: str | None,
|
|
270
|
+
*,
|
|
271
|
+
tokens: int | None,
|
|
272
|
+
cost_usd: float | None,
|
|
273
|
+
) -> str:
|
|
274
|
+
source = incoming or ""
|
|
275
|
+
if tokens is not None and (cost_usd is None or source == "unknown"):
|
|
276
|
+
return "unknown"
|
|
277
|
+
if not source:
|
|
278
|
+
return current
|
|
279
|
+
if current in ("", source):
|
|
280
|
+
return source
|
|
281
|
+
if current == "unknown":
|
|
282
|
+
return "unknown"
|
|
283
|
+
return "estimated"
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _merge_sources(current: str, incoming: str) -> str:
|
|
287
|
+
if not incoming:
|
|
288
|
+
return current
|
|
289
|
+
if current == "unknown" or incoming == "unknown":
|
|
290
|
+
return "unknown"
|
|
291
|
+
if current in ("", incoming):
|
|
292
|
+
return incoming
|
|
293
|
+
return "estimated"
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _actor_stats(run_id: str, rounds: list, init: dict) -> list[ActorStatRow]:
|
|
297
|
+
"""Aggregate usage across reviewers, synthesizer, and producer."""
|
|
298
|
+
models = _reviewer_models(init)
|
|
299
|
+
agg: dict[tuple[str, str, str, str], dict[str, object]] = {}
|
|
300
|
+
|
|
301
|
+
def remember(
|
|
302
|
+
*,
|
|
303
|
+
role: str,
|
|
304
|
+
name: str,
|
|
305
|
+
provider: str,
|
|
306
|
+
model: str,
|
|
307
|
+
tokens_value: object,
|
|
308
|
+
cost_value: object,
|
|
309
|
+
cost_source_value: object,
|
|
310
|
+
auth_mode_value: object = None,
|
|
311
|
+
) -> None:
|
|
312
|
+
tokens, cost_usd = _usage_pair(tokens_value, cost_value)
|
|
313
|
+
cost_source = _text_or_none(cost_source_value)
|
|
314
|
+
auth_mode = _text_or_none(auth_mode_value)
|
|
315
|
+
if tokens is None and cost_usd is None:
|
|
316
|
+
return
|
|
317
|
+
entry = agg.setdefault(
|
|
318
|
+
(role, name, provider, model, auth_mode or ""),
|
|
319
|
+
{
|
|
320
|
+
"provider": provider,
|
|
321
|
+
"model": model,
|
|
322
|
+
"tokens": None,
|
|
323
|
+
"cost_usd": None,
|
|
324
|
+
"cost_incomplete_tokens": 0,
|
|
325
|
+
"cost_source": "",
|
|
326
|
+
"auth_mode": "",
|
|
327
|
+
"rounds_with_usage": 0,
|
|
328
|
+
},
|
|
329
|
+
)
|
|
330
|
+
# No merging: auth_mode is part of the aggregation key, so two modes for one actor
|
|
331
|
+
# produce two rows and each keeps its own dollars. Merging them to "mixed" made
|
|
332
|
+
# real API spend unclassified.
|
|
333
|
+
entry["auth_mode"] = auth_mode or ""
|
|
334
|
+
entry["rounds_with_usage"] = int(entry["rounds_with_usage"]) + 1
|
|
335
|
+
if tokens is not None:
|
|
336
|
+
entry["tokens"] = (entry["tokens"] or 0) + tokens
|
|
337
|
+
if cost_usd is not None:
|
|
338
|
+
entry["cost_usd"] = (entry["cost_usd"] or 0.0) + cost_usd
|
|
339
|
+
if tokens is not None and (cost_usd is None or cost_source == "unknown"):
|
|
340
|
+
entry["cost_incomplete_tokens"] = int(entry["cost_incomplete_tokens"]) + tokens
|
|
341
|
+
entry["cost_source"] = _merge_cost_source(
|
|
342
|
+
str(entry["cost_source"]),
|
|
343
|
+
cost_source,
|
|
344
|
+
tokens=tokens,
|
|
345
|
+
cost_usd=cost_usd,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
for rnd in rounds:
|
|
349
|
+
if not isinstance(rnd, dict):
|
|
350
|
+
continue
|
|
351
|
+
reviewers = rnd.get("reviewers")
|
|
352
|
+
if isinstance(reviewers, list):
|
|
353
|
+
for rev in reviewers:
|
|
354
|
+
if not isinstance(rev, dict):
|
|
355
|
+
continue
|
|
356
|
+
name = _text_or_none(rev.get("name"))
|
|
357
|
+
if not name:
|
|
358
|
+
continue
|
|
359
|
+
remember(
|
|
360
|
+
role="reviewer",
|
|
361
|
+
name=name,
|
|
362
|
+
provider=_text_or_none(rev.get("provider")) or "",
|
|
363
|
+
model=_text_or_none(rev.get("model")) or models.get(name, ""),
|
|
364
|
+
tokens_value=rev.get("tokens"),
|
|
365
|
+
cost_value=rev.get("cost_usd"),
|
|
366
|
+
cost_source_value=rev.get("cost_source"),
|
|
367
|
+
auth_mode_value=rev.get("auth_mode"),
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
synth = rnd.get("synthesizer")
|
|
371
|
+
if isinstance(synth, dict):
|
|
372
|
+
remember(
|
|
373
|
+
role="synthesizer",
|
|
374
|
+
name=SYNTHESIZER_NAME,
|
|
375
|
+
provider=_text_or_none(synth.get("provider")) or SYNTHESIZER_PROVIDER,
|
|
376
|
+
# "" (unknown), NOT the current SYNTHESIZER_MODEL: a synth
|
|
377
|
+
# artifact that recorded no model predates model provenance, and
|
|
378
|
+
# attributing it to today's pin would silently rewrite historical
|
|
379
|
+
# spend-by-model every time the pin moves.
|
|
380
|
+
model=_text_or_none(synth.get("model")) or "",
|
|
381
|
+
tokens_value=synth.get("tokens"),
|
|
382
|
+
cost_value=synth.get("cost_usd"),
|
|
383
|
+
cost_source_value=synth.get("cost_source"),
|
|
384
|
+
auth_mode_value=synth.get("auth_mode"),
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
producer = rnd.get("producer")
|
|
388
|
+
if isinstance(producer, dict):
|
|
389
|
+
remember(
|
|
390
|
+
role="producer",
|
|
391
|
+
name="producer",
|
|
392
|
+
provider=_text_or_none(producer.get("provider")) or "",
|
|
393
|
+
model=_text_or_none(producer.get("model")) or "",
|
|
394
|
+
tokens_value=producer.get("tokens"),
|
|
395
|
+
cost_value=producer.get("cost_usd"),
|
|
396
|
+
cost_source_value=producer.get("cost_source"),
|
|
397
|
+
auth_mode_value=producer.get("auth_mode"),
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
return [
|
|
401
|
+
ActorStatRow(
|
|
402
|
+
run_id=run_id,
|
|
403
|
+
role=role,
|
|
404
|
+
name=name,
|
|
405
|
+
provider=str(e["provider"]),
|
|
406
|
+
model=str(e["model"]),
|
|
407
|
+
tokens=e["tokens"] if type(e["tokens"]) is int else None,
|
|
408
|
+
cost_usd=e["cost_usd"] if type(e["cost_usd"]) in (int, float) else None,
|
|
409
|
+
cost_incomplete_tokens=int(e["cost_incomplete_tokens"]),
|
|
410
|
+
cost_source=str(e["cost_source"]),
|
|
411
|
+
auth_mode=str(e["auth_mode"]),
|
|
412
|
+
rounds_with_usage=int(e["rounds_with_usage"]),
|
|
413
|
+
)
|
|
414
|
+
for (role, name, _provider, _model, _auth), e in agg.items()
|
|
415
|
+
]
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _reviewer_stats(run_id: str, rounds: list, init: dict) -> list[ReviewerStatRow]:
|
|
419
|
+
"""Aggregate per-reviewer finding counts across rounds by artifact model.
|
|
420
|
+
|
|
421
|
+
New manifests carry ``model`` beside reviewer usage. Legacy manifests fall
|
|
422
|
+
back to the run-init config snapshot keyed by reviewer name.
|
|
423
|
+
"""
|
|
424
|
+
models = _reviewer_models(init)
|
|
425
|
+
agg: dict[tuple[str, str, str], dict] = {}
|
|
426
|
+
for rnd in rounds:
|
|
427
|
+
if not isinstance(rnd, dict):
|
|
428
|
+
continue
|
|
429
|
+
reviewers = rnd.get("reviewers")
|
|
430
|
+
if not isinstance(reviewers, list):
|
|
431
|
+
continue
|
|
432
|
+
for rev in reviewers:
|
|
433
|
+
if not isinstance(rev, dict):
|
|
434
|
+
continue
|
|
435
|
+
name = rev.get("name")
|
|
436
|
+
if not isinstance(name, str) or not name:
|
|
437
|
+
continue
|
|
438
|
+
provider = _text_or_none(rev.get("provider")) or ""
|
|
439
|
+
model = _text_or_none(rev.get("model")) or models.get(name, "")
|
|
440
|
+
entry = agg.setdefault(
|
|
441
|
+
(name, provider, model),
|
|
442
|
+
{
|
|
443
|
+
"provider": provider,
|
|
444
|
+
"model": model,
|
|
445
|
+
"finding_count": 0,
|
|
446
|
+
"duration_s": 0.0,
|
|
447
|
+
"tokens": None,
|
|
448
|
+
"cost_usd": None,
|
|
449
|
+
"cost_source": "",
|
|
450
|
+
},
|
|
451
|
+
)
|
|
452
|
+
entry["finding_count"] += _int(rev.get("finding_count", 0))
|
|
453
|
+
entry["duration_s"] += _float(rev.get("duration_seconds", 0))
|
|
454
|
+
tokens, cost_usd = _usage_pair(rev.get("tokens"), rev.get("cost_usd"))
|
|
455
|
+
if tokens is not None:
|
|
456
|
+
entry["tokens"] = (entry["tokens"] or 0) + tokens
|
|
457
|
+
if cost_usd is not None:
|
|
458
|
+
entry["cost_usd"] = (entry["cost_usd"] or 0.0) + cost_usd
|
|
459
|
+
if tokens is not None or cost_usd is not None:
|
|
460
|
+
if tokens is not None and cost_usd is None:
|
|
461
|
+
source = "unknown"
|
|
462
|
+
else:
|
|
463
|
+
source = _text_or_none(rev.get("cost_source")) or ""
|
|
464
|
+
entry["cost_source"] = _merge_sources(str(entry["cost_source"]), source)
|
|
465
|
+
return [
|
|
466
|
+
ReviewerStatRow(
|
|
467
|
+
run_id=run_id,
|
|
468
|
+
name=name,
|
|
469
|
+
provider=e["provider"],
|
|
470
|
+
model=e["model"],
|
|
471
|
+
finding_count=e["finding_count"],
|
|
472
|
+
duration_s=e["duration_s"],
|
|
473
|
+
tokens=e["tokens"],
|
|
474
|
+
cost_usd=e["cost_usd"],
|
|
475
|
+
cost_source=e["cost_source"],
|
|
476
|
+
)
|
|
477
|
+
for (name, _provider, _model), e in agg.items()
|
|
478
|
+
]
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _candidate_run_dirs(runs_root: Path | str) -> list[Path]:
|
|
482
|
+
runs_root = Path(runs_root)
|
|
483
|
+
if not runs_root.is_dir():
|
|
484
|
+
return []
|
|
485
|
+
return [
|
|
486
|
+
d
|
|
487
|
+
for d in sorted(runs_root.iterdir())
|
|
488
|
+
if d.is_dir() and ((d / "loop-manifest.json").exists() or (d / "run-init.json").exists())
|
|
489
|
+
]
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def read_runs(runs_root: Path | str) -> list[tuple[RunRow, list[ReviewerStatRow]]]:
|
|
493
|
+
"""Read every run directory that looks like a syncade run.
|
|
494
|
+
|
|
495
|
+
A directory qualifies if it has ``loop-manifest.json`` OR ``run-init.json``;
|
|
496
|
+
partial/interrupted runs with only ``run-init.json`` degrade to UNKNOWN rows
|
|
497
|
+
rather than being silently omitted. Corrupt runs (manifest present but
|
|
498
|
+
unparseable) are skipped with a stderr warning.
|
|
499
|
+
"""
|
|
500
|
+
results: list[tuple[RunRow, list[ReviewerStatRow]]] = []
|
|
501
|
+
for d in _candidate_run_dirs(runs_root):
|
|
502
|
+
parsed = read_run(d)
|
|
503
|
+
if parsed is None:
|
|
504
|
+
print(
|
|
505
|
+
f"[syncade] metrics: skipping {d.name} — unparseable loop-manifest.json",
|
|
506
|
+
file=sys.stderr,
|
|
507
|
+
)
|
|
508
|
+
continue
|
|
509
|
+
results.append(parsed)
|
|
510
|
+
return results
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def backfill(conn: sqlite3.Connection, runs_root: Path | str) -> None:
|
|
514
|
+
"""Sync the DB to the current corpus (idempotent).
|
|
515
|
+
|
|
516
|
+
Upserts every present run, refreshes each run's reviewer set (so a changed
|
|
517
|
+
roster drops stale rows), and prunes runs no longer in ``.syncade/runs/`` — so
|
|
518
|
+
the DB stays a faithful derived view even after ``--gc`` slims runs.
|
|
519
|
+
"""
|
|
520
|
+
present: list[str] = []
|
|
521
|
+
for d in _candidate_run_dirs(runs_root):
|
|
522
|
+
parsed = read_run(d)
|
|
523
|
+
if parsed is None:
|
|
524
|
+
print(
|
|
525
|
+
f"[syncade] metrics: skipping {d.name} — unparseable loop-manifest.json",
|
|
526
|
+
file=sys.stderr,
|
|
527
|
+
)
|
|
528
|
+
continue
|
|
529
|
+
row, stats = parsed
|
|
530
|
+
upsert_run(conn, row)
|
|
531
|
+
conn.execute("DELETE FROM reviewer_stats WHERE run_id = ?", (row.run_id,))
|
|
532
|
+
conn.execute("DELETE FROM actor_stats WHERE run_id = ?", (row.run_id,))
|
|
533
|
+
for stat in stats:
|
|
534
|
+
upsert_reviewer_stat(conn, stat)
|
|
535
|
+
for stat in read_actor_stats(d):
|
|
536
|
+
upsert_actor_stat(conn, stat)
|
|
537
|
+
present.append(row.run_id)
|
|
538
|
+
_prune_absent(conn, present)
|
|
539
|
+
conn.commit()
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _prune_absent(conn: sqlite3.Connection, present_ids: list[str]) -> None:
|
|
543
|
+
"""Delete rows for runs no longer present in the corpus (row-at-a-time, so
|
|
544
|
+
there is no SQL parameter-count ceiling on corpus size)."""
|
|
545
|
+
present = set(present_ids)
|
|
546
|
+
for (run_id,) in conn.execute("SELECT run_id FROM runs").fetchall():
|
|
547
|
+
if run_id not in present:
|
|
548
|
+
conn.execute("DELETE FROM runs WHERE run_id = ?", (run_id,))
|
|
549
|
+
conn.execute("DELETE FROM reviewer_stats WHERE run_id = ?", (run_id,))
|
|
550
|
+
conn.execute("DELETE FROM actor_stats WHERE run_id = ?", (run_id,))
|