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.
Files changed (44) hide show
  1. {rote_cli-0.6.0 → rote_cli-0.7.0}/PKG-INFO +26 -20
  2. {rote_cli-0.6.0 → rote_cli-0.7.0}/README.md +25 -19
  3. {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/__init__.py +1 -1
  4. {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/adapters/_common.py +8 -4
  5. {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/cli.py +273 -10
  6. {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/eval/__init__.py +4 -1
  7. {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/eval/empirical.py +18 -1
  8. {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/eval/estimate.py +35 -1
  9. {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/eval/priors.py +73 -1
  10. {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/graduator/__init__.py +46 -0
  11. rote_cli-0.7.0/src/rote/graduator/drivers/codex.py +285 -0
  12. rote_cli-0.6.0/src/rote/graduator/drivers/codex.py +0 -84
  13. {rote_cli-0.6.0 → rote_cli-0.7.0}/.gitignore +0 -0
  14. {rote_cli-0.6.0 → rote_cli-0.7.0}/LICENSE +0 -0
  15. {rote_cli-0.6.0 → rote_cli-0.7.0}/pyproject.toml +0 -0
  16. {rote_cli-0.6.0 → rote_cli-0.7.0}/skills/rote-graduate/SKILL.md +0 -0
  17. {rote_cli-0.6.0 → rote_cli-0.7.0}/skills/rote-graduate/references/crystallization-heuristics.md +0 -0
  18. {rote_cli-0.6.0 → rote_cli-0.7.0}/skills/rote-graduate/references/eval-estimates.md +0 -0
  19. {rote_cli-0.6.0 → rote_cli-0.7.0}/skills/rote-graduate/references/ir-schema.md +0 -0
  20. {rote_cli-0.6.0 → rote_cli-0.7.0}/skills/rote-graduate/references/llm-judge-extraction.md +0 -0
  21. {rote_cli-0.6.0 → rote_cli-0.7.0}/skills/rote-graduate/references/node-kinds.md +0 -0
  22. {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/adapters/__init__.py +0 -0
  23. {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/adapters/_py_common.py +0 -0
  24. {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/adapters/_ts_common.py +0 -0
  25. {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/adapters/cloudflare.py +0 -0
  26. {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/adapters/dbos.py +0 -0
  27. {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/adapters/dbos_ts.py +0 -0
  28. {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/adapters/inngest.py +0 -0
  29. {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/adapters/python.py +0 -0
  30. {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/adapters/temporal.py +0 -0
  31. {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/eval/pricing.py +0 -0
  32. {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/eval/scorecard.py +0 -0
  33. {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/eval/sidecar.py +0 -0
  34. {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/eval/tokens.py +0 -0
  35. {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/graduator/drivers/__init__.py +0 -0
  36. {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/graduator/drivers/anthropic_api.py +0 -0
  37. {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/graduator/drivers/claude.py +0 -0
  38. {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/graduator/update.py +0 -0
  39. {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/ir.py +0 -0
  40. {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/serve/__init__.py +0 -0
  41. {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/serve/backends.py +0 -0
  42. {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/serve/registry.py +0 -0
  43. {rote_cli-0.6.0 → rote_cli-0.7.0}/src/rote/serve/server.py +0 -0
  44. {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.6.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, and
330
- all six adapters are validated by a `slow`-marked e2e suite that runs the
331
- emitted code against real runtimes (DBOS over SQLite, Temporal's
332
- time-skipping server, the TypeScript targets compiled with `tsc --noEmit`
333
- and driven through both HITL gates on live dev servers, the plain-Python
334
- script as a subprocess, and the MCP server over real stdio). The fast
335
- suite (`pytest tests/`) makes no real API calls; the integration suite is
336
- `pytest tests/ -m slow`.
337
-
338
- Known gaps: the `codex` driver is a stub (`is_available` works, `run`
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/bdr-outreach/ source skill · hand-drafted IR baseline · run snapshots
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. **`CodexDriver` implementation** same shape as `ClaudeDriver` but
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
- 3. **Pre-filter as a `pure_function` node** — today hard thresholds are
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
- 4. **More example skills** — BDR is one shape; research-heavy,
401
+ 3. **More example skills** — BDR is one shape; research-heavy,
396
402
  retrieval-heavy, and code-review skills stress the IR differently.
397
- 5. **`fan_out` per-element dispatch** — currently the whole upstream list
403
+ 4. **`fan_out` per-element dispatch** — currently the whole upstream list
398
404
  arrives in one invocation.
399
- 6. **The graduator graduating itself** — `rote-graduate` is a SKILL.md;
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, and
284
- all six adapters are validated by a `slow`-marked e2e suite that runs the
285
- emitted code against real runtimes (DBOS over SQLite, Temporal's
286
- time-skipping server, the TypeScript targets compiled with `tsc --noEmit`
287
- and driven through both HITL gates on live dev servers, the plain-Python
288
- script as a subprocess, and the MCP server over real stdio). The fast
289
- suite (`pytest tests/`) makes no real API calls; the integration suite is
290
- `pytest tests/ -m slow`.
291
-
292
- Known gaps: the `codex` driver is a stub (`is_available` works, `run`
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/bdr-outreach/ source skill · hand-drafted IR baseline · run snapshots
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. **`CodexDriver` implementation** same shape as `ClaudeDriver` but
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
- 3. **Pre-filter as a `pure_function` node** — today hard thresholds are
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
- 4. **More example skills** — BDR is one shape; research-heavy,
355
+ 3. **More example skills** — BDR is one shape; research-heavy,
350
356
  retrieval-heavy, and code-review skills stress the IR differently.
351
- 5. **`fan_out` per-element dispatch** — currently the whole upstream list
357
+ 4. **`fan_out` per-element dispatch** — currently the whole upstream list
352
358
  arrives in one invocation.
353
- 6. **The graduator graduating itself** — `rote-graduate` is a SKILL.md;
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
 
@@ -1,3 +1,3 @@
1
1
  """rote — graduate fuzzy AI skills into deterministic workflows."""
2
2
 
3
- __version__ = "0.6.0"
3
+ __version__ = "0.7.0"
@@ -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 (``Node.source``) is excluded: it's tooling metadata, and
227
- # annotating where a node came from must not re-version in-flight
228
- # workflows whose behavior didn't change.
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
- print("rote analyze: not yet implemented.", file=sys.stderr)
382
- return 70
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 Priors, estimate_pipeline, estimate_skill, load_eval_estimates
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
- skill_estimate = estimate_skill(skill_dir, counter, priors, sidecar=sidecar)
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
- scorecard = _build_scorecard_for(pipeline, pipeline_yaml, skill_dir, args.provider)
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 (stub)
1160
+ # rote analyze
946
1161
  analyze = subparsers.add_parser(
947
1162
  "analyze",
948
- help="Run the graduator against a skill and print a report only",
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(skill_runs: tuple[MeasuredRun, ...]) -> dict[str, float]:
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 = counter.count(corpus) + priors.system_overhead_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()