rote-cli 0.6.0__tar.gz → 0.7.0__tar.gz
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.
- {rote_cli-0.6.0 → rote_cli-0.7.0}/PKG-INFO +26 -20
- {rote_cli-0.6.0 → rote_cli-0.7.0}/README.md +25 -19
- {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/__init__.py +1 -1
- {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/adapters/_common.py +8 -4
- {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/cli.py +273 -10
- {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/eval/__init__.py +4 -1
- {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/eval/empirical.py +18 -1
- {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/eval/estimate.py +35 -1
- {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/eval/priors.py +73 -1
- {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/graduator/__init__.py +46 -0
- rote_cli-0.7.0/src/rote/graduator/drivers/codex.py +285 -0
- rote_cli-0.6.0/src/rote/graduator/drivers/codex.py +0 -84
- {rote_cli-0.6.0 → rote_cli-0.7.0}/.gitignore +0 -0
- {rote_cli-0.6.0 → rote_cli-0.7.0}/LICENSE +0 -0
- {rote_cli-0.6.0 → rote_cli-0.7.0}/pyproject.toml +0 -0
- {rote_cli-0.6.0 → rote_cli-0.7.0}/skills/rote-graduate/SKILL.md +0 -0
- {rote_cli-0.6.0 → rote_cli-0.7.0}/skills/rote-graduate/references/crystallization-heuristics.md +0 -0
- {rote_cli-0.6.0 → rote_cli-0.7.0}/skills/rote-graduate/references/eval-estimates.md +0 -0
- {rote_cli-0.6.0 → rote_cli-0.7.0}/skills/rote-graduate/references/ir-schema.md +0 -0
- {rote_cli-0.6.0 → rote_cli-0.7.0}/skills/rote-graduate/references/llm-judge-extraction.md +0 -0
- {rote_cli-0.6.0 → rote_cli-0.7.0}/skills/rote-graduate/references/node-kinds.md +0 -0
- {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/adapters/__init__.py +0 -0
- {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/adapters/_py_common.py +0 -0
- {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/adapters/_ts_common.py +0 -0
- {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/adapters/cloudflare.py +0 -0
- {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/adapters/dbos.py +0 -0
- {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/adapters/dbos_ts.py +0 -0
- {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/adapters/inngest.py +0 -0
- {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/adapters/python.py +0 -0
- {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/adapters/temporal.py +0 -0
- {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/eval/pricing.py +0 -0
- {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/eval/scorecard.py +0 -0
- {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/eval/sidecar.py +0 -0
- {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/eval/tokens.py +0 -0
- {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/graduator/drivers/__init__.py +0 -0
- {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/graduator/drivers/anthropic_api.py +0 -0
- {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/graduator/drivers/claude.py +0 -0
- {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/graduator/update.py +0 -0
- {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/ir.py +0 -0
- {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/serve/__init__.py +0 -0
- {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/serve/backends.py +0 -0
- {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/serve/registry.py +0 -0
- {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/serve/server.py +0 -0
- {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/skill_source.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rote-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Graduate fuzzy AI skills into deterministic, reliable workflows
|
|
5
5
|
Project-URL: Homepage, https://github.com/trevhud/rote
|
|
6
6
|
Project-URL: Repository, https://github.com/trevhud/rote
|
|
@@ -326,17 +326,16 @@ subscription path.
|
|
|
326
326
|
|
|
327
327
|
## Status
|
|
328
328
|
|
|
329
|
-
`rote` is **pre-1.0**. The end-to-end flow works on the BDR example
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
`pytest tests/ -m slow
|
|
337
|
-
|
|
338
|
-
Known gaps: the
|
|
339
|
-
isn't implemented), the extracted modules are `NotImplementedError` stubs
|
|
329
|
+
`rote` is **pre-1.0**. The end-to-end flow works on the BDR example. The
|
|
330
|
+
fast suite (`pytest tests/`) makes no real API calls and is what CI runs
|
|
331
|
+
on every push, alongside a Python e2e (DBOS over SQLite + the MCP server
|
|
332
|
+
over real stdio). Each adapter also has a `slow`-marked e2e that runs its
|
|
333
|
+
emitted code against the real runtime (Temporal's time-skipping server,
|
|
334
|
+
the TypeScript targets via `tsc --noEmit` and live dev servers, the
|
|
335
|
+
plain-Python subprocess); those need a Node toolchain / Docker, so they
|
|
336
|
+
run locally with `pytest tests/ -m slow`, not in CI.
|
|
337
|
+
|
|
338
|
+
Known gaps: the extracted modules are `NotImplementedError` stubs
|
|
340
339
|
you fill in with real API-client code, a Restate adapter is planned, and
|
|
341
340
|
`fan_out` nodes currently receive the whole upstream list in one
|
|
342
341
|
invocation (per-element dispatch is a planned enhancement). Published on
|
|
@@ -357,7 +356,10 @@ rote/
|
|
|
357
356
|
│ ├── graduator/ orchestrator + drivers/ (claude · codex · anthropic_api)
|
|
358
357
|
│ └── adapters/ dbos · temporal · python · cloudflare · dbos_ts · inngest
|
|
359
358
|
│ (+ _common / _py_common / _ts_common emit helpers)
|
|
360
|
-
├── examples/
|
|
359
|
+
├── examples/
|
|
360
|
+
│ ├── bdr-outreach/ canonical: all 5 node kinds · IR baseline · run snapshots
|
|
361
|
+
│ ├── ops-report/ 100% roteness: zero LLM nodes + a HITL gate
|
|
362
|
+
│ └── deal-monitor/ data-heavy: parallel waves · fan-out judges · template render
|
|
361
363
|
└── tests/ fast + slow suites (pytest -m slow)
|
|
362
364
|
```
|
|
363
365
|
|
|
@@ -377,6 +379,12 @@ rote/
|
|
|
377
379
|
heuristics, IR schema, LLM-judge extraction)
|
|
378
380
|
- [`examples/bdr-outreach/`](examples/bdr-outreach/) — the canonical
|
|
379
381
|
skill, its ground-truth IR, and snapshotted real graduator runs
|
|
382
|
+
- [`examples/ops-report/`](examples/ops-report/) — the 100%-roteness
|
|
383
|
+
archetype: every step deterministic, one durable HITL gate, zero LLM
|
|
384
|
+
nodes after graduation
|
|
385
|
+
- [`examples/deal-monitor/`](examples/deal-monitor/) — the data-heavy
|
|
386
|
+
archetype: parallel entry waves, fan-out judges, and a template render
|
|
387
|
+
replacing per-run LLM-generated HTML
|
|
380
388
|
|
|
381
389
|
---
|
|
382
390
|
|
|
@@ -384,19 +392,17 @@ rote/
|
|
|
384
392
|
|
|
385
393
|
In rough priority order:
|
|
386
394
|
|
|
387
|
-
1.
|
|
388
|
-
spawning `codex exec`; unlocks ChatGPT subscribers.
|
|
389
|
-
2. **Re-graduate BDR end-to-end with `signature_spec`** — the bundled IR
|
|
395
|
+
1. **Re-graduate BDR end-to-end with `signature_spec`** — the bundled IR
|
|
390
396
|
was hand-extended with structured schemas; the rubric now teaches the
|
|
391
397
|
field, but no real run has produced one yet.
|
|
392
|
-
|
|
398
|
+
2. **Pre-filter as a `pure_function` node** — today hard thresholds are
|
|
393
399
|
lifted into a judge's `forward()`, which works for Temporal but not
|
|
394
400
|
Cloudflare; a separate node makes the short-circuit uniform.
|
|
395
|
-
|
|
401
|
+
3. **More example skills** — BDR is one shape; research-heavy,
|
|
396
402
|
retrieval-heavy, and code-review skills stress the IR differently.
|
|
397
|
-
|
|
403
|
+
4. **`fan_out` per-element dispatch** — currently the whole upstream list
|
|
398
404
|
arrives in one invocation.
|
|
399
|
-
|
|
405
|
+
5. **The graduator graduating itself** — `rote-graduate` is a SKILL.md;
|
|
400
406
|
pointing `rote graduate` at it should crystallize its rubric-grade
|
|
401
407
|
pieces and leave only the genuinely fuzzy judgments in the loop.
|
|
402
408
|
|
|
@@ -280,17 +280,16 @@ subscription path.
|
|
|
280
280
|
|
|
281
281
|
## Status
|
|
282
282
|
|
|
283
|
-
`rote` is **pre-1.0**. The end-to-end flow works on the BDR example
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
`pytest tests/ -m slow
|
|
291
|
-
|
|
292
|
-
Known gaps: the
|
|
293
|
-
isn't implemented), the extracted modules are `NotImplementedError` stubs
|
|
283
|
+
`rote` is **pre-1.0**. The end-to-end flow works on the BDR example. The
|
|
284
|
+
fast suite (`pytest tests/`) makes no real API calls and is what CI runs
|
|
285
|
+
on every push, alongside a Python e2e (DBOS over SQLite + the MCP server
|
|
286
|
+
over real stdio). Each adapter also has a `slow`-marked e2e that runs its
|
|
287
|
+
emitted code against the real runtime (Temporal's time-skipping server,
|
|
288
|
+
the TypeScript targets via `tsc --noEmit` and live dev servers, the
|
|
289
|
+
plain-Python subprocess); those need a Node toolchain / Docker, so they
|
|
290
|
+
run locally with `pytest tests/ -m slow`, not in CI.
|
|
291
|
+
|
|
292
|
+
Known gaps: the extracted modules are `NotImplementedError` stubs
|
|
294
293
|
you fill in with real API-client code, a Restate adapter is planned, and
|
|
295
294
|
`fan_out` nodes currently receive the whole upstream list in one
|
|
296
295
|
invocation (per-element dispatch is a planned enhancement). Published on
|
|
@@ -311,7 +310,10 @@ rote/
|
|
|
311
310
|
│ ├── graduator/ orchestrator + drivers/ (claude · codex · anthropic_api)
|
|
312
311
|
│ └── adapters/ dbos · temporal · python · cloudflare · dbos_ts · inngest
|
|
313
312
|
│ (+ _common / _py_common / _ts_common emit helpers)
|
|
314
|
-
├── examples/
|
|
313
|
+
├── examples/
|
|
314
|
+
│ ├── bdr-outreach/ canonical: all 5 node kinds · IR baseline · run snapshots
|
|
315
|
+
│ ├── ops-report/ 100% roteness: zero LLM nodes + a HITL gate
|
|
316
|
+
│ └── deal-monitor/ data-heavy: parallel waves · fan-out judges · template render
|
|
315
317
|
└── tests/ fast + slow suites (pytest -m slow)
|
|
316
318
|
```
|
|
317
319
|
|
|
@@ -331,6 +333,12 @@ rote/
|
|
|
331
333
|
heuristics, IR schema, LLM-judge extraction)
|
|
332
334
|
- [`examples/bdr-outreach/`](examples/bdr-outreach/) — the canonical
|
|
333
335
|
skill, its ground-truth IR, and snapshotted real graduator runs
|
|
336
|
+
- [`examples/ops-report/`](examples/ops-report/) — the 100%-roteness
|
|
337
|
+
archetype: every step deterministic, one durable HITL gate, zero LLM
|
|
338
|
+
nodes after graduation
|
|
339
|
+
- [`examples/deal-monitor/`](examples/deal-monitor/) — the data-heavy
|
|
340
|
+
archetype: parallel entry waves, fan-out judges, and a template render
|
|
341
|
+
replacing per-run LLM-generated HTML
|
|
334
342
|
|
|
335
343
|
---
|
|
336
344
|
|
|
@@ -338,19 +346,17 @@ rote/
|
|
|
338
346
|
|
|
339
347
|
In rough priority order:
|
|
340
348
|
|
|
341
|
-
1.
|
|
342
|
-
spawning `codex exec`; unlocks ChatGPT subscribers.
|
|
343
|
-
2. **Re-graduate BDR end-to-end with `signature_spec`** — the bundled IR
|
|
349
|
+
1. **Re-graduate BDR end-to-end with `signature_spec`** — the bundled IR
|
|
344
350
|
was hand-extended with structured schemas; the rubric now teaches the
|
|
345
351
|
field, but no real run has produced one yet.
|
|
346
|
-
|
|
352
|
+
2. **Pre-filter as a `pure_function` node** — today hard thresholds are
|
|
347
353
|
lifted into a judge's `forward()`, which works for Temporal but not
|
|
348
354
|
Cloudflare; a separate node makes the short-circuit uniform.
|
|
349
|
-
|
|
355
|
+
3. **More example skills** — BDR is one shape; research-heavy,
|
|
350
356
|
retrieval-heavy, and code-review skills stress the IR differently.
|
|
351
|
-
|
|
357
|
+
4. **`fan_out` per-element dispatch** — currently the whole upstream list
|
|
352
358
|
arrives in one invocation.
|
|
353
|
-
|
|
359
|
+
5. **The graduator graduating itself** — `rote-graduate` is a SKILL.md;
|
|
354
360
|
pointing `rote graduate` at it should crystallize its rubric-grade
|
|
355
361
|
pieces and leave only the genuinely fuzzy judgments in the loop.
|
|
356
362
|
|
|
@@ -223,13 +223,17 @@ def _pipeline_hash(pipeline: Pipeline) -> str:
|
|
|
223
223
|
# Hash the full validated contents: node wiring, inputs, retries, and
|
|
224
224
|
# edges all participate, so any regeneration that changes behavior gets
|
|
225
225
|
# a new workflow type — name/version/counts alone miss rewires.
|
|
226
|
-
# Provenance
|
|
227
|
-
#
|
|
228
|
-
#
|
|
226
|
+
# Provenance is excluded — it's tooling metadata, and annotating where
|
|
227
|
+
# a pipeline came from must not re-version in-flight workflows whose
|
|
228
|
+
# behavior didn't change. That covers ``Node.source`` (which SKILL.md
|
|
229
|
+
# section produced a node) and ``source_skill`` (where the skill bundle
|
|
230
|
+
# lived): the latter is a filesystem path the graduator re-points per
|
|
231
|
+
# output location, so hashing it would mint a new workflow type on
|
|
232
|
+
# every re-graduation to a different directory.
|
|
229
233
|
payload = pipeline.model_dump_json(
|
|
230
234
|
by_alias=True,
|
|
231
235
|
exclude_none=True,
|
|
232
|
-
exclude={"nodes": {"__all__": {"source"}}},
|
|
236
|
+
exclude={"source_skill": True, "nodes": {"__all__": {"source"}}},
|
|
233
237
|
)
|
|
234
238
|
return hashlib.sha256(payload.encode()).hexdigest()[:8]
|
|
235
239
|
|
|
@@ -28,7 +28,7 @@ import asyncio
|
|
|
28
28
|
import sys
|
|
29
29
|
from collections.abc import Sequence
|
|
30
30
|
from pathlib import Path
|
|
31
|
-
from typing import TYPE_CHECKING
|
|
31
|
+
from typing import TYPE_CHECKING, Any
|
|
32
32
|
|
|
33
33
|
from rote import __version__
|
|
34
34
|
from rote.adapters import ADAPTERS, get_adapter
|
|
@@ -36,6 +36,7 @@ from rote.graduator import Graduator, GraduatorError
|
|
|
36
36
|
from rote.ir import Pipeline, load_pipeline
|
|
37
37
|
|
|
38
38
|
if TYPE_CHECKING:
|
|
39
|
+
from rote.eval.priors import Priors
|
|
39
40
|
from rote.eval.scorecard import Scorecard
|
|
40
41
|
|
|
41
42
|
# ───────── Subcommand: emit ─────────
|
|
@@ -377,9 +378,201 @@ def _cmd_serve(args: argparse.Namespace) -> int:
|
|
|
377
378
|
return 0
|
|
378
379
|
|
|
379
380
|
|
|
381
|
+
def _build_analysis(
|
|
382
|
+
pipeline: Pipeline,
|
|
383
|
+
driver_name: str,
|
|
384
|
+
driver_metadata: dict[str, object],
|
|
385
|
+
skill_path: Path,
|
|
386
|
+
) -> dict[str, object]:
|
|
387
|
+
"""Derive a structural report from a graduated pipeline's IR.
|
|
388
|
+
|
|
389
|
+
Pure function of the IR — no model, no network. Step counting mirrors
|
|
390
|
+
the eval scorecard exactly (top-level execution-wave nodes; loop_body
|
|
391
|
+
sub-nodes are costed inside their parent loop, so they're excluded
|
|
392
|
+
from the top-level count) so ``analyze``'s roteness equals ``eval``'s.
|
|
393
|
+
"""
|
|
394
|
+
from rote.adapters import ADAPTERS
|
|
395
|
+
from rote.adapters._common import _execution_waves
|
|
396
|
+
from rote.ir import NodeKind
|
|
397
|
+
|
|
398
|
+
top_level = [n for wave in _execution_waves(pipeline) for n in wave]
|
|
399
|
+
total = len(top_level)
|
|
400
|
+
|
|
401
|
+
by_kind = {k.value: 0 for k in NodeKind}
|
|
402
|
+
for n in top_level:
|
|
403
|
+
by_kind[n.kind.value] += 1
|
|
404
|
+
|
|
405
|
+
sampled = by_kind[NodeKind.LLM_JUDGE.value] + by_kind[NodeKind.AGENT_LOOP.value]
|
|
406
|
+
deterministic = total - sampled
|
|
407
|
+
roteness = (deterministic / total) if total else 0.0
|
|
408
|
+
|
|
409
|
+
loop_body_ids = sorted({b for n in pipeline.nodes if n.loop_body for b in n.loop_body})
|
|
410
|
+
|
|
411
|
+
untargetable: dict[str, str] = {}
|
|
412
|
+
if pipeline.requires_durable_execution:
|
|
413
|
+
untargetable["python"] = (
|
|
414
|
+
"pipeline parks on a human gate (hitl_gate); needs durable execution"
|
|
415
|
+
)
|
|
416
|
+
targetable = [name for name in sorted(ADAPTERS) if name not in untargetable]
|
|
417
|
+
|
|
418
|
+
return {
|
|
419
|
+
"pipeline": pipeline.name,
|
|
420
|
+
"version": pipeline.version,
|
|
421
|
+
"description": pipeline.description,
|
|
422
|
+
"driver": driver_name,
|
|
423
|
+
"driver_metadata": driver_metadata,
|
|
424
|
+
"source_skill": str(skill_path),
|
|
425
|
+
"nodes": {
|
|
426
|
+
"total": total,
|
|
427
|
+
"by_kind": by_kind,
|
|
428
|
+
"loop_body_subnodes": loop_body_ids,
|
|
429
|
+
},
|
|
430
|
+
"roteness": roteness,
|
|
431
|
+
"deterministic_steps": deterministic,
|
|
432
|
+
"sampled_steps": sampled,
|
|
433
|
+
"mandatory": [n.id for n in top_level if n.mandatory],
|
|
434
|
+
"hitl_gates": [
|
|
435
|
+
{"id": n.id, "signal": n.signal} for n in top_level if n.kind is NodeKind.HITL_GATE
|
|
436
|
+
],
|
|
437
|
+
"agent_loops": [
|
|
438
|
+
{
|
|
439
|
+
"id": n.id,
|
|
440
|
+
"max_iterations": (n.termination.max_iterations if n.termination else None),
|
|
441
|
+
}
|
|
442
|
+
for n in top_level
|
|
443
|
+
if n.kind is NodeKind.AGENT_LOOP
|
|
444
|
+
],
|
|
445
|
+
"targetable_runtimes": targetable,
|
|
446
|
+
"untargetable_runtimes": untargetable,
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _render_analysis_text(report: dict[str, object]) -> str:
|
|
451
|
+
"""Render an :func:`_build_analysis` report as a scannable text block."""
|
|
452
|
+
nodes = report["nodes"]
|
|
453
|
+
assert isinstance(nodes, dict)
|
|
454
|
+
by_kind: dict[str, int] = nodes["by_kind"]
|
|
455
|
+
total = nodes["total"]
|
|
456
|
+
|
|
457
|
+
lines = [
|
|
458
|
+
f"rote analyze: {report['pipeline']} v{report['version']}",
|
|
459
|
+
f" driver: {report['driver']}",
|
|
460
|
+
f" source: {report['source_skill']}",
|
|
461
|
+
]
|
|
462
|
+
description = report["description"]
|
|
463
|
+
if isinstance(description, str) and description.strip():
|
|
464
|
+
# Collapse internal whitespace/newlines to one line, then clip.
|
|
465
|
+
flat = " ".join(description.split())
|
|
466
|
+
lines.append(f" {flat[:200] + '…' if len(flat) > 200 else flat}")
|
|
467
|
+
|
|
468
|
+
lines.append("")
|
|
469
|
+
lines.append(f"Nodes ({total} executed step{'s' if total != 1 else ''})")
|
|
470
|
+
# Stable, readable kind order; only show kinds that occur.
|
|
471
|
+
for kind in ("pure_function", "external_call", "llm_judge", "agent_loop", "hitl_gate"):
|
|
472
|
+
count = by_kind.get(kind, 0)
|
|
473
|
+
if count:
|
|
474
|
+
lines.append(f" {kind:<15} {count:>2} {'█' * count}")
|
|
475
|
+
subnodes = nodes["loop_body_subnodes"]
|
|
476
|
+
assert isinstance(subnodes, list)
|
|
477
|
+
if subnodes:
|
|
478
|
+
lines.append(f" (+{len(subnodes)} loop-body sub-node(s), costed inside their loop)")
|
|
479
|
+
|
|
480
|
+
roteness = report["roteness"]
|
|
481
|
+
assert isinstance(roteness, float)
|
|
482
|
+
lines.append("")
|
|
483
|
+
lines.append(
|
|
484
|
+
f"Roteness: {roteness:.0%} deterministic "
|
|
485
|
+
f"({report['deterministic_steps']} of {total} steps run as code, not inference)"
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
mandatory = report["mandatory"]
|
|
489
|
+
assert isinstance(mandatory, list)
|
|
490
|
+
if mandatory:
|
|
491
|
+
lines.append(f"Mandatory checks (cannot be skipped): {', '.join(mandatory)}")
|
|
492
|
+
|
|
493
|
+
hitl = report["hitl_gates"]
|
|
494
|
+
assert isinstance(hitl, list)
|
|
495
|
+
if hitl:
|
|
496
|
+
gates = ", ".join(f"{g['id']} (signal: {g['signal']})" for g in hitl)
|
|
497
|
+
lines.append(f"HITL gates: {gates}")
|
|
498
|
+
|
|
499
|
+
loops = report["agent_loops"]
|
|
500
|
+
assert isinstance(loops, list)
|
|
501
|
+
if loops:
|
|
502
|
+
rendered = ", ".join(
|
|
503
|
+
f"{loop['id']} (max {loop['max_iterations']} iterations)"
|
|
504
|
+
if loop["max_iterations"] is not None
|
|
505
|
+
else str(loop["id"])
|
|
506
|
+
for loop in loops
|
|
507
|
+
)
|
|
508
|
+
lines.append(f"Agent loops: {rendered}")
|
|
509
|
+
|
|
510
|
+
targetable = report["targetable_runtimes"]
|
|
511
|
+
assert isinstance(targetable, list)
|
|
512
|
+
lines.append("")
|
|
513
|
+
lines.append(f"Targetable runtimes: {', '.join(targetable)}")
|
|
514
|
+
untargetable = report["untargetable_runtimes"]
|
|
515
|
+
assert isinstance(untargetable, dict)
|
|
516
|
+
for name, reason in untargetable.items():
|
|
517
|
+
lines.append(f" {name}: unavailable — {reason}")
|
|
518
|
+
|
|
519
|
+
return "\n".join(lines)
|
|
520
|
+
|
|
521
|
+
|
|
380
522
|
def _cmd_analyze(args: argparse.Namespace) -> int:
|
|
381
|
-
|
|
382
|
-
|
|
523
|
+
"""Dry-run the graduator: report a skill's graduated shape, emit nothing.
|
|
524
|
+
|
|
525
|
+
The ``plan`` to ``graduate``'s ``apply`` — runs the same graduator
|
|
526
|
+
agent to produce a validated IR, then prints a structural report
|
|
527
|
+
instead of emitting runtime code. With ``--out`` the graduated IR +
|
|
528
|
+
stubs are kept (ready for a later ``rote emit``); without it, they're
|
|
529
|
+
produced in a temp dir and discarded after reporting.
|
|
530
|
+
"""
|
|
531
|
+
import json
|
|
532
|
+
import tempfile
|
|
533
|
+
from contextlib import nullcontext
|
|
534
|
+
|
|
535
|
+
skill_path = Path(args.skill_path)
|
|
536
|
+
if not skill_path.is_dir():
|
|
537
|
+
print(f"error: skill path is not a directory: {skill_path}", file=sys.stderr)
|
|
538
|
+
return 2
|
|
539
|
+
|
|
540
|
+
graduator = Graduator(agent=args.agent, model=args.model)
|
|
541
|
+
|
|
542
|
+
out_ctx: Any = (
|
|
543
|
+
nullcontext(str(args.out))
|
|
544
|
+
if args.out
|
|
545
|
+
else tempfile.TemporaryDirectory(prefix="rote-analyze-")
|
|
546
|
+
)
|
|
547
|
+
with out_ctx as out_raw:
|
|
548
|
+
graduated_dir = Path(out_raw)
|
|
549
|
+
try:
|
|
550
|
+
result = asyncio.run(graduator.graduate(skill_path, graduated_dir))
|
|
551
|
+
except GraduatorError as e:
|
|
552
|
+
print(f"rote analyze: {e}", file=sys.stderr)
|
|
553
|
+
return 1
|
|
554
|
+
except KeyboardInterrupt:
|
|
555
|
+
print("rote analyze: interrupted", file=sys.stderr)
|
|
556
|
+
return 130
|
|
557
|
+
|
|
558
|
+
report = _build_analysis(
|
|
559
|
+
result.pipeline, result.driver_name, result.driver_metadata, skill_path
|
|
560
|
+
)
|
|
561
|
+
if args.json:
|
|
562
|
+
print(json.dumps(report, indent=2))
|
|
563
|
+
else:
|
|
564
|
+
print(_render_analysis_text(report))
|
|
565
|
+
if args.out:
|
|
566
|
+
print()
|
|
567
|
+
print(f" graduated IR kept at: {graduated_dir / 'pipeline.yaml'}")
|
|
568
|
+
print(
|
|
569
|
+
f" emit a runtime with: rote emit {graduated_dir / 'pipeline.yaml'} "
|
|
570
|
+
f"--runtime dbos --out ./runtime"
|
|
571
|
+
)
|
|
572
|
+
else:
|
|
573
|
+
print()
|
|
574
|
+
print(" (report only — pass --out DIR to keep the pipeline.yaml for `rote emit`)")
|
|
575
|
+
return 0
|
|
383
576
|
|
|
384
577
|
|
|
385
578
|
def _resolve_eval_skill_dir(
|
|
@@ -412,6 +605,7 @@ def _build_scorecard_for(
|
|
|
412
605
|
pipeline_yaml: Path,
|
|
413
606
|
skill_dir: Path | None,
|
|
414
607
|
provider: str,
|
|
608
|
+
priors: Priors | None = None,
|
|
415
609
|
) -> Scorecard:
|
|
416
610
|
"""Shared eval flow: estimate both sides, fetch live prices, assemble.
|
|
417
611
|
|
|
@@ -421,13 +615,19 @@ def _build_scorecard_for(
|
|
|
421
615
|
"""
|
|
422
616
|
from datetime import UTC, datetime
|
|
423
617
|
|
|
424
|
-
from rote.eval import
|
|
618
|
+
from rote.eval import (
|
|
619
|
+
Priors,
|
|
620
|
+
estimate_pipeline,
|
|
621
|
+
estimate_skill,
|
|
622
|
+
external_call_payload_tokens,
|
|
623
|
+
load_eval_estimates,
|
|
624
|
+
)
|
|
425
625
|
from rote.eval.pricing import fetch_catalog
|
|
426
626
|
from rote.eval.scorecard import build_scorecard
|
|
427
627
|
from rote.eval.sidecar import EVAL_SIDECAR_FILENAME
|
|
428
628
|
from rote.eval.tokens import pick_token_counter
|
|
429
629
|
|
|
430
|
-
priors = Priors()
|
|
630
|
+
priors = priors or Priors()
|
|
431
631
|
prices = fetch_catalog(provider=provider).sample(provider=provider)
|
|
432
632
|
# Exact counting needs a live model id (tokenizers are per-model);
|
|
433
633
|
# the small tier's is as good as any and free either way. Only the
|
|
@@ -440,7 +640,12 @@ def _build_scorecard_for(
|
|
|
440
640
|
if skill_dir is not None:
|
|
441
641
|
sidecar_path = pipeline_yaml.parent / EVAL_SIDECAR_FILENAME
|
|
442
642
|
sidecar = load_eval_estimates(sidecar_path) if sidecar_path.is_file() else None
|
|
443
|
-
|
|
643
|
+
# The agent pulls the same sources the pipeline's external_call nodes
|
|
644
|
+
# bind to; use that footprint to size the before-side context payload.
|
|
645
|
+
data_payload = external_call_payload_tokens(pipeline, priors)
|
|
646
|
+
skill_estimate = estimate_skill(
|
|
647
|
+
skill_dir, counter, priors, sidecar=sidecar, data_payload_tokens=data_payload
|
|
648
|
+
)
|
|
444
649
|
|
|
445
650
|
return build_scorecard(
|
|
446
651
|
pipeline_name=pipeline.name,
|
|
@@ -490,8 +695,18 @@ def _cmd_eval(args: argparse.Namespace) -> int:
|
|
|
490
695
|
file=sys.stderr,
|
|
491
696
|
)
|
|
492
697
|
|
|
698
|
+
from rote.eval.priors import priors_from_overrides
|
|
699
|
+
|
|
493
700
|
try:
|
|
494
|
-
|
|
701
|
+
priors = priors_from_overrides(args.prior or [])
|
|
702
|
+
except ValueError as e:
|
|
703
|
+
print(f"error: {e}", file=sys.stderr)
|
|
704
|
+
return 2
|
|
705
|
+
|
|
706
|
+
try:
|
|
707
|
+
scorecard = _build_scorecard_for(
|
|
708
|
+
pipeline, pipeline_yaml, skill_dir, args.provider, priors=priors
|
|
709
|
+
)
|
|
495
710
|
except PricingError as e:
|
|
496
711
|
print(f"error: could not fetch live prices: {e}", file=sys.stderr)
|
|
497
712
|
return 1
|
|
@@ -942,12 +1157,48 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
942
1157
|
)
|
|
943
1158
|
serve.set_defaults(func=_cmd_serve)
|
|
944
1159
|
|
|
945
|
-
# rote analyze
|
|
1160
|
+
# rote analyze
|
|
946
1161
|
analyze = subparsers.add_parser(
|
|
947
1162
|
"analyze",
|
|
948
|
-
help="
|
|
1163
|
+
help="Dry-run the graduator: report a skill's graduated shape, emit no runtime code",
|
|
1164
|
+
description=(
|
|
1165
|
+
"Run the rote-graduate agent against a skill and print what "
|
|
1166
|
+
"graduation would produce — node-kind breakdown, roteness "
|
|
1167
|
+
"(deterministic vs. LLM-sampled steps), mandatory checks, HITL "
|
|
1168
|
+
"gates, agent loops, and which runtimes can target it — without "
|
|
1169
|
+
"emitting any runtime code. This is the `plan` to `graduate`'s "
|
|
1170
|
+
"`apply`. Pass --out to keep the pipeline.yaml for a later `rote emit`."
|
|
1171
|
+
),
|
|
1172
|
+
)
|
|
1173
|
+
analyze.add_argument("skill_path", help="Path to the source skill directory")
|
|
1174
|
+
analyze.add_argument(
|
|
1175
|
+
"--agent",
|
|
1176
|
+
choices=["claude", "codex", "api"],
|
|
1177
|
+
default=None,
|
|
1178
|
+
help=(
|
|
1179
|
+
"Agent runtime for the graduator (default: auto-detect — claude "
|
|
1180
|
+
"CLI first, then codex, then the anthropic API)."
|
|
1181
|
+
),
|
|
1182
|
+
)
|
|
1183
|
+
analyze.add_argument(
|
|
1184
|
+
"--model",
|
|
1185
|
+
default=None,
|
|
1186
|
+
help="Override the LLM model the graduator uses (default: the driver's default).",
|
|
1187
|
+
)
|
|
1188
|
+
analyze.add_argument(
|
|
1189
|
+
"--out",
|
|
1190
|
+
default=None,
|
|
1191
|
+
help=(
|
|
1192
|
+
"Keep the graduated IR + stubs in this directory (ready for "
|
|
1193
|
+
"`rote emit`). Default: produce them in a temp dir and discard "
|
|
1194
|
+
"after reporting."
|
|
1195
|
+
),
|
|
1196
|
+
)
|
|
1197
|
+
analyze.add_argument(
|
|
1198
|
+
"--json",
|
|
1199
|
+
action="store_true",
|
|
1200
|
+
help="Emit the analysis report as JSON instead of text.",
|
|
949
1201
|
)
|
|
950
|
-
analyze.add_argument("skill_path")
|
|
951
1202
|
analyze.set_defaults(func=_cmd_analyze)
|
|
952
1203
|
|
|
953
1204
|
# rote eval
|
|
@@ -978,6 +1229,18 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
978
1229
|
default="anthropic",
|
|
979
1230
|
help="LLM provider whose current model lineup to price (default: anthropic)",
|
|
980
1231
|
)
|
|
1232
|
+
eval_cmd.add_argument(
|
|
1233
|
+
"--prior",
|
|
1234
|
+
action="append",
|
|
1235
|
+
default=None,
|
|
1236
|
+
metavar="KEY=VALUE",
|
|
1237
|
+
help=(
|
|
1238
|
+
"Override an estimator prior (repeatable), e.g. "
|
|
1239
|
+
"--prior transcript_growth_per_turn=5962 — feed back the "
|
|
1240
|
+
"re-fits a previous --run reported. Per-MCP-tool payload: "
|
|
1241
|
+
"--prior payload_tokens_per_tool.<tool>=12000"
|
|
1242
|
+
),
|
|
1243
|
+
)
|
|
981
1244
|
eval_cmd.add_argument(
|
|
982
1245
|
"--json",
|
|
983
1246
|
action="store_true",
|
|
@@ -25,8 +25,9 @@ from rote.eval.estimate import (
|
|
|
25
25
|
SamplingSurface,
|
|
26
26
|
estimate_pipeline,
|
|
27
27
|
estimate_skill,
|
|
28
|
+
external_call_payload_tokens,
|
|
28
29
|
)
|
|
29
|
-
from rote.eval.priors import Priors
|
|
30
|
+
from rote.eval.priors import Priors, priors_from_overrides
|
|
30
31
|
from rote.eval.sidecar import EvalEstimates, load_eval_estimates
|
|
31
32
|
|
|
32
33
|
__all__ = [
|
|
@@ -39,5 +40,7 @@ __all__ = [
|
|
|
39
40
|
"SamplingSurface",
|
|
40
41
|
"estimate_pipeline",
|
|
41
42
|
"estimate_skill",
|
|
43
|
+
"external_call_payload_tokens",
|
|
42
44
|
"load_eval_estimates",
|
|
45
|
+
"priors_from_overrides",
|
|
43
46
|
]
|
|
@@ -38,6 +38,7 @@ from typing import Any
|
|
|
38
38
|
import yaml
|
|
39
39
|
|
|
40
40
|
from rote.eval.pricing import PricingCatalog
|
|
41
|
+
from rote.eval.priors import Priors
|
|
41
42
|
from rote.graduator.drivers.claude import (
|
|
42
43
|
DEFAULT_ALLOWED_TOOLS,
|
|
43
44
|
build_subscription_env,
|
|
@@ -447,9 +448,12 @@ def measured_pipeline_cost_usd(
|
|
|
447
448
|
return (mean(per_run) if per_run else 0.0, sorted(unpriced))
|
|
448
449
|
|
|
449
450
|
|
|
450
|
-
def suggested_priors(
|
|
451
|
+
def suggested_priors(
|
|
452
|
+
skill_runs: tuple[MeasuredRun, ...], priors: Priors | None = None
|
|
453
|
+
) -> dict[str, float]:
|
|
451
454
|
"""Prior values re-fitted from measured agent runs (reported, never
|
|
452
455
|
silently applied — the static model's constants stay explicit)."""
|
|
456
|
+
priors = priors or Priors()
|
|
453
457
|
fitted: dict[str, float] = {}
|
|
454
458
|
timed = [r for r in skill_runs if r.turns and r.wall_seconds]
|
|
455
459
|
if timed:
|
|
@@ -462,6 +466,19 @@ def suggested_priors(skill_runs: tuple[MeasuredRun, ...]) -> dict[str, float]:
|
|
|
462
466
|
mean(r.output_tokens / r.turns for r in with_output if r.turns and r.output_tokens),
|
|
463
467
|
1,
|
|
464
468
|
)
|
|
469
|
+
# Effective transcript growth Δ, inverted from the measured cache-read
|
|
470
|
+
# under the quadratic model: cached ≈ (N−1)·C₀ + Δ·(N−2)(N−1)/2, with
|
|
471
|
+
# C₀ = system_overhead prior. This is *payload-inclusive* — a value far
|
|
472
|
+
# above the text-only default flags a data-heavy skill whose
|
|
473
|
+
# tokens_per_external_call_result should be raised. Needs N ≥ 3.
|
|
474
|
+
c0 = float(priors.system_overhead_tokens)
|
|
475
|
+
deltas = [
|
|
476
|
+
max(0.0, 2.0 * (r.cache_read_tokens - (r.turns - 1) * c0) / ((r.turns - 2) * (r.turns - 1)))
|
|
477
|
+
for r in skill_runs
|
|
478
|
+
if r.turns and r.turns >= 3 and r.cache_read_tokens
|
|
479
|
+
]
|
|
480
|
+
if deltas:
|
|
481
|
+
fitted["transcript_growth_per_turn"] = round(mean(deltas), 1)
|
|
465
482
|
return fitted
|
|
466
483
|
|
|
467
484
|
|
|
@@ -308,6 +308,32 @@ def estimate_pipeline(
|
|
|
308
308
|
|
|
309
309
|
# ───────── Before: raw-skill estimator ─────────
|
|
310
310
|
|
|
311
|
+
|
|
312
|
+
def external_call_payload_tokens(pipeline: Pipeline, priors: Priors | None = None) -> float:
|
|
313
|
+
"""Estimate the data the agent pulls into context, from the pipeline's
|
|
314
|
+
``external_call`` footprint.
|
|
315
|
+
|
|
316
|
+
The graduated pipeline's ``external_call`` nodes name the sources the
|
|
317
|
+
skill fetches (Slack, Gmail, a Drive file …); the *before*-side agent
|
|
318
|
+
pulls the same data, so its transcript carries the same payload. Each
|
|
319
|
+
external_call contributes ``payload_tokens_per_tool[tool]`` when its MCP
|
|
320
|
+
tool is known and pinned, otherwise ``tokens_per_external_call_result``.
|
|
321
|
+
|
|
322
|
+
Uses the same wave decomposition as :func:`estimate_pipeline`, so
|
|
323
|
+
loop-body sub-nodes are not double-counted.
|
|
324
|
+
"""
|
|
325
|
+
priors = priors or Priors()
|
|
326
|
+
total = 0.0
|
|
327
|
+
for wave in _execution_waves(pipeline):
|
|
328
|
+
for node in wave:
|
|
329
|
+
if node.kind is not NodeKind.EXTERNAL_CALL:
|
|
330
|
+
continue
|
|
331
|
+
tool = node.mcp.tool if node.mcp is not None else None
|
|
332
|
+
per_tool = priors.payload_tokens_per_tool.get(tool) if tool else None
|
|
333
|
+
total += per_tool if per_tool is not None else priors.tokens_per_external_call_result
|
|
334
|
+
return total
|
|
335
|
+
|
|
336
|
+
|
|
311
337
|
_STEP_LINE_RE = re.compile(r"^\s{0,3}(?:\d+[.)]\s|#{2,3}\s|[-*]\s+\*\*)", re.MULTILINE)
|
|
312
338
|
|
|
313
339
|
|
|
@@ -339,6 +365,7 @@ def estimate_skill(
|
|
|
339
365
|
counter: TokenCounter,
|
|
340
366
|
priors: Priors | None = None,
|
|
341
367
|
sidecar: EvalEstimates | None = None,
|
|
368
|
+
data_payload_tokens: float = 0.0,
|
|
342
369
|
) -> AgentRunEstimate:
|
|
343
370
|
"""Predict running the skill as raw agent instructions.
|
|
344
371
|
|
|
@@ -348,11 +375,18 @@ def estimate_skill(
|
|
|
348
375
|
|
|
349
376
|
* fresh ≈ C₀ + (N−1)·Δ
|
|
350
377
|
* cached ≈ (N−1)·C₀ + Δ·(N−2)(N−1)/2
|
|
378
|
+
|
|
379
|
+
``data_payload_tokens`` (the sources the skill pulls, from
|
|
380
|
+
:func:`external_call_payload_tokens`) folds into C₀: fetched-once data
|
|
381
|
+
is billed as one cache-write and re-read on every later turn — exactly
|
|
382
|
+
C₀'s billing shape, and the dominant cost on data-heavy skills.
|
|
351
383
|
"""
|
|
352
384
|
priors = priors or Priors()
|
|
353
385
|
skill_path = Path(skill_dir)
|
|
354
386
|
corpus = _skill_corpus(skill_path)
|
|
355
|
-
context_tokens =
|
|
387
|
+
context_tokens = (
|
|
388
|
+
counter.count(corpus) + priors.system_overhead_tokens + round(data_payload_tokens)
|
|
389
|
+
)
|
|
356
390
|
|
|
357
391
|
if sidecar is not None and (sidecar.steps or sidecar.totals is not None):
|
|
358
392
|
tr = sidecar.turn_range()
|