extropy-run 0.2.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.
Files changed (99) hide show
  1. extropy/__init__.py +3 -0
  2. extropy/cli/__init__.py +23 -0
  3. extropy/cli/app.py +74 -0
  4. extropy/cli/commands/__init__.py +29 -0
  5. extropy/cli/commands/config_cmd.py +210 -0
  6. extropy/cli/commands/estimate.py +216 -0
  7. extropy/cli/commands/extend.py +271 -0
  8. extropy/cli/commands/network.py +287 -0
  9. extropy/cli/commands/persona.py +344 -0
  10. extropy/cli/commands/results.py +60 -0
  11. extropy/cli/commands/sample.py +324 -0
  12. extropy/cli/commands/scenario.py +256 -0
  13. extropy/cli/commands/simulate.py +339 -0
  14. extropy/cli/commands/spec.py +253 -0
  15. extropy/cli/commands/validate.py +287 -0
  16. extropy/cli/display.py +233 -0
  17. extropy/cli/utils.py +317 -0
  18. extropy/config.py +311 -0
  19. extropy/core/__init__.py +16 -0
  20. extropy/core/llm.py +178 -0
  21. extropy/core/models/__init__.py +200 -0
  22. extropy/core/models/network.py +188 -0
  23. extropy/core/models/population.py +532 -0
  24. extropy/core/models/results.py +125 -0
  25. extropy/core/models/sampling.py +35 -0
  26. extropy/core/models/scenario.py +326 -0
  27. extropy/core/models/simulation.py +393 -0
  28. extropy/core/models/validation.py +219 -0
  29. extropy/core/pricing.py +78 -0
  30. extropy/core/providers/__init__.py +97 -0
  31. extropy/core/providers/base.py +231 -0
  32. extropy/core/providers/claude.py +370 -0
  33. extropy/core/providers/logging.py +69 -0
  34. extropy/core/providers/openai.py +504 -0
  35. extropy/core/rate_limiter.py +564 -0
  36. extropy/core/rate_limits.py +125 -0
  37. extropy/population/__init__.py +79 -0
  38. extropy/population/network/__init__.py +93 -0
  39. extropy/population/network/config.py +179 -0
  40. extropy/population/network/config_generator.py +566 -0
  41. extropy/population/network/generator.py +1006 -0
  42. extropy/population/network/metrics.py +236 -0
  43. extropy/population/network/similarity.py +238 -0
  44. extropy/population/persona/__init__.py +48 -0
  45. extropy/population/persona/config.py +234 -0
  46. extropy/population/persona/generator.py +823 -0
  47. extropy/population/persona/renderer.py +409 -0
  48. extropy/population/persona/stats.py +77 -0
  49. extropy/population/sampler/__init__.py +57 -0
  50. extropy/population/sampler/core.py +364 -0
  51. extropy/population/sampler/distributions.py +354 -0
  52. extropy/population/sampler/modifiers.py +246 -0
  53. extropy/population/spec_builder/__init__.py +64 -0
  54. extropy/population/spec_builder/binder.py +221 -0
  55. extropy/population/spec_builder/hydrator.py +229 -0
  56. extropy/population/spec_builder/hydrators/__init__.py +18 -0
  57. extropy/population/spec_builder/hydrators/conditional.py +448 -0
  58. extropy/population/spec_builder/hydrators/derived.py +161 -0
  59. extropy/population/spec_builder/hydrators/independent.py +196 -0
  60. extropy/population/spec_builder/hydrators/prompts.py +250 -0
  61. extropy/population/spec_builder/parsers.py +194 -0
  62. extropy/population/spec_builder/schemas.py +293 -0
  63. extropy/population/spec_builder/selector.py +320 -0
  64. extropy/population/spec_builder/sufficiency.py +92 -0
  65. extropy/population/validator/__init__.py +55 -0
  66. extropy/population/validator/llm_response.py +646 -0
  67. extropy/population/validator/semantic.py +250 -0
  68. extropy/population/validator/spec.py +42 -0
  69. extropy/population/validator/structural.py +762 -0
  70. extropy/scenario/__init__.py +100 -0
  71. extropy/scenario/compiler.py +303 -0
  72. extropy/scenario/exposure.py +285 -0
  73. extropy/scenario/interaction.py +293 -0
  74. extropy/scenario/outcomes.py +141 -0
  75. extropy/scenario/parser.py +210 -0
  76. extropy/scenario/validator.py +565 -0
  77. extropy/simulation/__init__.py +147 -0
  78. extropy/simulation/aggregation.py +298 -0
  79. extropy/simulation/engine.py +1334 -0
  80. extropy/simulation/estimator.py +289 -0
  81. extropy/simulation/persona.py +381 -0
  82. extropy/simulation/progress.py +105 -0
  83. extropy/simulation/propagation.py +344 -0
  84. extropy/simulation/reasoning.py +916 -0
  85. extropy/simulation/state.py +1268 -0
  86. extropy/simulation/stopping.py +297 -0
  87. extropy/simulation/timeline.py +259 -0
  88. extropy/utils/__init__.py +72 -0
  89. extropy/utils/callbacks.py +81 -0
  90. extropy/utils/distributions.py +227 -0
  91. extropy/utils/eval_safe.py +273 -0
  92. extropy/utils/expressions.py +339 -0
  93. extropy/utils/graphs.py +117 -0
  94. extropy/utils/paths.py +67 -0
  95. extropy_run-0.2.2.dist-info/METADATA +200 -0
  96. extropy_run-0.2.2.dist-info/RECORD +99 -0
  97. extropy_run-0.2.2.dist-info/WHEEL +4 -0
  98. extropy_run-0.2.2.dist-info/entry_points.txt +2 -0
  99. extropy_run-0.2.2.dist-info/licenses/LICENSE +21 -0
extropy/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Extropy: Simulate how populations respond to scenarios."""
2
+
3
+ __version__ = "0.2.2"
@@ -0,0 +1,23 @@
1
+ """CLI package for Extropy.
2
+
3
+ Supports dual-mode output:
4
+ - Human mode (default): Rich formatting with colors, tables, progress bars
5
+ - Machine mode (--json): Structured JSON output for AI coding tools
6
+
7
+ Exit codes:
8
+ 0 = Success
9
+ 1 = Validation error
10
+ 2 = File not found
11
+ 3 = Sampling error
12
+ 4 = Network error
13
+ 5 = Simulation error
14
+ 6 = Scenario error
15
+ 10 = User cancelled
16
+ """
17
+
18
+ from .app import app
19
+
20
+ # Import commands to register them with the app
21
+ from . import commands # noqa: F401
22
+
23
+ __all__ = ["app"]
extropy/cli/app.py ADDED
@@ -0,0 +1,74 @@
1
+ """Core CLI app definition and global state."""
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ app = typer.Typer(
9
+ name="extropy",
10
+ help="Generate population specs for agent-based simulation.",
11
+ no_args_is_help=True,
12
+ )
13
+
14
+ console = Console()
15
+
16
+ # Global state for JSON mode (set by callback)
17
+ _json_mode = False
18
+
19
+
20
+ def get_json_mode() -> bool:
21
+ """Get current JSON mode state."""
22
+ return _json_mode
23
+
24
+
25
+ def _version_callback(value: bool) -> None:
26
+ if value:
27
+ from .. import __version__
28
+
29
+ print(f"extropy {__version__}")
30
+ raise typer.Exit()
31
+
32
+
33
+ @app.callback()
34
+ def main_callback(
35
+ json_output: Annotated[
36
+ bool,
37
+ typer.Option(
38
+ "--json",
39
+ help="Output machine-readable JSON instead of human-friendly text",
40
+ is_eager=True,
41
+ ),
42
+ ] = False,
43
+ version: Annotated[
44
+ bool,
45
+ typer.Option(
46
+ "--version",
47
+ help="Show version and exit",
48
+ callback=_version_callback,
49
+ is_eager=True,
50
+ ),
51
+ ] = False,
52
+ ):
53
+ """Extropy: Population simulation engine for agent-based modeling.
54
+
55
+ Use --json for machine-readable output suitable for scripting and AI tools.
56
+ """
57
+ global _json_mode
58
+ _json_mode = json_output
59
+
60
+
61
+ # Import commands to register them with the app
62
+ from .commands import ( # noqa: E402, F401
63
+ validate,
64
+ extend,
65
+ spec,
66
+ sample,
67
+ network,
68
+ persona,
69
+ scenario,
70
+ simulate,
71
+ estimate,
72
+ results,
73
+ config_cmd,
74
+ )
@@ -0,0 +1,29 @@
1
+ """CLI commands for Extropy."""
2
+
3
+ from . import (
4
+ validate,
5
+ extend,
6
+ spec,
7
+ sample,
8
+ network,
9
+ persona,
10
+ scenario,
11
+ simulate,
12
+ estimate,
13
+ results,
14
+ config_cmd,
15
+ )
16
+
17
+ __all__ = [
18
+ "validate",
19
+ "extend",
20
+ "spec",
21
+ "sample",
22
+ "network",
23
+ "persona",
24
+ "scenario",
25
+ "simulate",
26
+ "estimate",
27
+ "results",
28
+ "config_cmd",
29
+ ]
@@ -0,0 +1,210 @@
1
+ """Config command for viewing and managing extropy configuration."""
2
+
3
+ import typer
4
+
5
+ from ..app import app, console
6
+ from ...config import (
7
+ get_config,
8
+ reset_config,
9
+ CONFIG_FILE,
10
+ get_api_key,
11
+ get_azure_config,
12
+ )
13
+
14
+
15
+ VALID_KEYS = {
16
+ "pipeline.provider",
17
+ "pipeline.model_simple",
18
+ "pipeline.model_reasoning",
19
+ "pipeline.model_research",
20
+ "simulation.provider",
21
+ "simulation.model",
22
+ "simulation.pivotal_model",
23
+ "simulation.routine_model",
24
+ "simulation.max_concurrent",
25
+ "simulation.rate_tier",
26
+ "simulation.rpm_override",
27
+ "simulation.tpm_override",
28
+ "simulation.api_format",
29
+ }
30
+
31
+ INT_FIELDS = {"max_concurrent", "rate_tier", "rpm_override", "tpm_override"}
32
+
33
+
34
+ @app.command("config")
35
+ def config_command(
36
+ action: str = typer.Argument(
37
+ ...,
38
+ help="Action: show, set, reset",
39
+ ),
40
+ key: str | None = typer.Argument(
41
+ None,
42
+ help="Config key (e.g. pipeline.provider, simulation.model)",
43
+ ),
44
+ value: str | None = typer.Argument(
45
+ None,
46
+ help="Value to set",
47
+ ),
48
+ ):
49
+ """View or modify extropy configuration.
50
+
51
+ Examples:
52
+ extropy config show
53
+ extropy config set pipeline.provider claude
54
+ extropy config set simulation.provider openai
55
+ extropy config set simulation.model gpt-5-mini
56
+ extropy config reset
57
+ """
58
+ if action == "show":
59
+ _show_config()
60
+ elif action == "set":
61
+ if not key or value is None:
62
+ console.print("[red]Usage:[/red] extropy config set <key> <value>")
63
+ console.print()
64
+ console.print("Available keys:")
65
+ for k in sorted(VALID_KEYS):
66
+ console.print(f" {k}")
67
+ raise typer.Exit(1)
68
+ _set_config(key, value)
69
+ elif action == "reset":
70
+ _reset_config()
71
+ else:
72
+ console.print(f"[red]Unknown action:[/red] {action}")
73
+ console.print("Valid actions: show, set, reset")
74
+ raise typer.Exit(1)
75
+
76
+
77
+ def _show_config():
78
+ """Display current resolved configuration."""
79
+ config = get_config()
80
+
81
+ console.print()
82
+ console.print("[bold]Extropy Configuration[/bold]")
83
+ console.print("─" * 40)
84
+
85
+ # Pipeline zone
86
+ console.print()
87
+ console.print("[bold cyan]Pipeline[/bold cyan] (spec, extend, persona, scenario)")
88
+ console.print(f" provider = {config.pipeline.provider}")
89
+ console.print(
90
+ f" model_simple = {config.pipeline.model_simple or '[dim](provider default)[/dim]'}"
91
+ )
92
+ console.print(
93
+ f" model_reasoning = {config.pipeline.model_reasoning or '[dim](provider default)[/dim]'}"
94
+ )
95
+ console.print(
96
+ f" model_research = {config.pipeline.model_research or '[dim](provider default)[/dim]'}"
97
+ )
98
+
99
+ # Simulation zone
100
+ console.print()
101
+ console.print("[bold cyan]Simulation[/bold cyan] (agent reasoning)")
102
+ console.print(f" provider = {config.simulation.provider}")
103
+ console.print(
104
+ f" model = {config.simulation.model or '[dim](provider default)[/dim]'}"
105
+ )
106
+ console.print(
107
+ f" pivotal_model = {config.simulation.pivotal_model or '[dim](same as model)[/dim]'}"
108
+ )
109
+ console.print(
110
+ f" routine_model = {config.simulation.routine_model or '[dim](provider default)[/dim]'}"
111
+ )
112
+ console.print(
113
+ f" api_format = {config.simulation.api_format or '[dim](auto)[/dim]'}"
114
+ )
115
+ console.print(f" max_concurrent = {config.simulation.max_concurrent}")
116
+ console.print(
117
+ f" rate_tier = {config.simulation.rate_tier or '[dim](tier 1)[/dim]'}"
118
+ )
119
+ if config.simulation.rpm_override:
120
+ console.print(f" rpm_override = {config.simulation.rpm_override}")
121
+ if config.simulation.tpm_override:
122
+ console.print(f" tpm_override = {config.simulation.tpm_override}")
123
+
124
+ # API keys status
125
+ console.print()
126
+ console.print("[bold cyan]API Keys[/bold cyan] (from env vars)")
127
+ _show_key_status("openai", "OPENAI_API_KEY")
128
+ _show_key_status("claude", "ANTHROPIC_API_KEY")
129
+ _show_key_status("azure_openai", "AZURE_OPENAI_API_KEY")
130
+
131
+ # Azure-specific config (show when Azure provider is in use)
132
+ active_providers = {config.pipeline.provider, config.simulation.provider}
133
+ if "azure_openai" in active_providers:
134
+ azure_cfg = get_azure_config("azure_openai")
135
+ console.print()
136
+ console.print("[bold cyan]Azure OpenAI[/bold cyan]")
137
+ console.print(
138
+ f" endpoint = {azure_cfg['azure_endpoint'] or '[dim]not set[/dim]'}"
139
+ )
140
+ console.print(f" api_version = {azure_cfg['api_version']}")
141
+ if azure_cfg["azure_deployment"]:
142
+ console.print(f" deployment = {azure_cfg['azure_deployment']}")
143
+
144
+ # Config file
145
+ console.print()
146
+ if CONFIG_FILE.exists():
147
+ console.print(f"Config file: {CONFIG_FILE}")
148
+ else:
149
+ console.print(f"Config file: [dim]not created yet[/dim] ({CONFIG_FILE})")
150
+ console.print()
151
+
152
+
153
+ def _show_key_status(provider: str, env_var_label: str):
154
+ """Show whether an API key is configured."""
155
+ key = get_api_key(provider)
156
+ if key:
157
+ masked = key[:8] + "..." + key[-4:] if len(key) > 16 else "***"
158
+ console.print(f" {env_var_label}: [green]{masked}[/green]")
159
+ else:
160
+ console.print(f" {env_var_label}: [dim]not set[/dim]")
161
+
162
+
163
+ def _set_config(key: str, value: str):
164
+ """Set a config value and save."""
165
+ if key not in VALID_KEYS:
166
+ console.print(f"[red]Unknown key:[/red] {key}")
167
+ console.print()
168
+ console.print("Available keys:")
169
+ for k in sorted(VALID_KEYS):
170
+ console.print(f" {k}")
171
+ raise typer.Exit(1)
172
+
173
+ # Load current config (or defaults if no file)
174
+ config = get_config()
175
+
176
+ zone, field = key.split(".", 1)
177
+ if zone == "pipeline":
178
+ target = config.pipeline
179
+ elif zone == "simulation":
180
+ target = config.simulation
181
+ else:
182
+ console.print(f"[red]Unknown zone:[/red] {zone}")
183
+ raise typer.Exit(1)
184
+
185
+ # Type coercion
186
+ if field in INT_FIELDS:
187
+ try:
188
+ setattr(target, field, int(value))
189
+ except ValueError:
190
+ console.print(f"[red]Invalid integer value:[/red] {value}")
191
+ raise typer.Exit(1)
192
+ else:
193
+ setattr(target, field, value)
194
+
195
+ config.save()
196
+ reset_config() # Clear cached singleton so next get_config() reloads
197
+
198
+ console.print(f"[green]✓[/green] Set {key} = {value}")
199
+ console.print(f" Saved to {CONFIG_FILE}")
200
+
201
+
202
+ def _reset_config():
203
+ """Reset config to defaults."""
204
+ if CONFIG_FILE.exists():
205
+ CONFIG_FILE.unlink()
206
+ reset_config()
207
+ console.print("[green]✓[/green] Config reset to defaults")
208
+ console.print(f" Removed {CONFIG_FILE}")
209
+ else:
210
+ console.print("Config already at defaults (no config file exists)")
@@ -0,0 +1,216 @@
1
+ """Estimate command for predicting simulation costs before running."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import typer
7
+
8
+ from ..app import app, console
9
+
10
+
11
+ @app.command("estimate")
12
+ def estimate_command(
13
+ scenario_file: Path = typer.Argument(..., help="Scenario spec YAML file"),
14
+ model: str = typer.Option(
15
+ "",
16
+ "--model",
17
+ "-m",
18
+ help="LLM model for agent reasoning (empty = use config default)",
19
+ ),
20
+ pivotal_model: str = typer.Option(
21
+ "",
22
+ "--pivotal-model",
23
+ help="Model for pivotal/first-pass reasoning (default: same as --model)",
24
+ ),
25
+ routine_model: str = typer.Option(
26
+ "",
27
+ "--routine-model",
28
+ help="Cheap model for classification pass (default: provider cheap tier)",
29
+ ),
30
+ threshold: int = typer.Option(
31
+ 3, "--threshold", "-t", help="Multi-touch threshold for re-reasoning"
32
+ ),
33
+ verbose: bool = typer.Option(
34
+ False, "--verbose", "-v", help="Show per-timestep breakdown"
35
+ ),
36
+ ):
37
+ """
38
+ Estimate simulation cost without running it.
39
+
40
+ Loads the scenario and population files, runs a simplified propagation
41
+ model, and predicts LLM calls, tokens, and USD cost. No API keys required.
42
+
43
+ Example:
44
+ extropy estimate scenario.yaml
45
+ extropy estimate scenario.yaml --model gpt-5-mini
46
+ extropy estimate scenario.yaml --pivotal-model gpt-5 --routine-model gpt-5-mini -v
47
+ """
48
+ from ...config import get_config
49
+ from ...core.models import ScenarioSpec, PopulationSpec
50
+ from ...population.network import load_agents_json
51
+ from ...simulation.estimator import estimate_simulation_cost
52
+
53
+ # Validate input file
54
+ if not scenario_file.exists():
55
+ console.print(f"[red]x[/red] Scenario file not found: {scenario_file}")
56
+ raise typer.Exit(1)
57
+
58
+ # Load scenario
59
+ try:
60
+ scenario = ScenarioSpec.from_yaml(scenario_file)
61
+ except Exception as e:
62
+ console.print(f"[red]x[/red] Failed to load scenario: {e}")
63
+ raise typer.Exit(1)
64
+
65
+ # Load population spec
66
+ pop_path = Path(scenario.meta.population_spec)
67
+ if not pop_path.is_absolute():
68
+ pop_path = scenario_file.parent / pop_path
69
+ if not pop_path.exists():
70
+ console.print(f"[red]x[/red] Population spec not found: {pop_path}")
71
+ raise typer.Exit(1)
72
+ population_spec = PopulationSpec.from_yaml(pop_path)
73
+
74
+ # Load agents
75
+ agents_path = Path(scenario.meta.agents_file)
76
+ if not agents_path.is_absolute():
77
+ agents_path = scenario_file.parent / agents_path
78
+ if not agents_path.exists():
79
+ console.print(f"[red]x[/red] Agents file not found: {agents_path}")
80
+ raise typer.Exit(1)
81
+ agents = load_agents_json(agents_path)
82
+
83
+ # Load network
84
+ network_path = Path(scenario.meta.network_file)
85
+ if not network_path.is_absolute():
86
+ network_path = scenario_file.parent / network_path
87
+ if not network_path.exists():
88
+ console.print(f"[red]x[/red] Network file not found: {network_path}")
89
+ raise typer.Exit(1)
90
+ with open(network_path) as f:
91
+ network = json.load(f)
92
+
93
+ # Resolve config
94
+ config = get_config()
95
+ provider = config.simulation.provider
96
+
97
+ eff_model = model or config.simulation.model
98
+ eff_pivotal = pivotal_model or config.simulation.pivotal_model or eff_model
99
+ eff_routine = routine_model or config.simulation.routine_model
100
+
101
+ # Run estimation
102
+ est = estimate_simulation_cost(
103
+ scenario=scenario,
104
+ population_spec=population_spec,
105
+ agents=agents,
106
+ network=network,
107
+ provider=provider,
108
+ pivotal_model=eff_pivotal,
109
+ routine_model=eff_routine,
110
+ multi_touch_threshold=threshold,
111
+ )
112
+
113
+ # Display results
114
+ console.print()
115
+ console.print("[bold]Simulation Cost Estimate[/bold]")
116
+ console.print()
117
+
118
+ early_stop = (
119
+ f" (early stop at ~{est.per_timestep[-1]['exposure_rate']:.0%} exposure)"
120
+ if est.effective_timesteps < est.max_timesteps and est.per_timestep
121
+ else ""
122
+ )
123
+ console.print(
124
+ f"Population: {est.population_size} agents | "
125
+ f"Avg degree: {est.avg_degree:.1f} | "
126
+ f"Max timesteps: {est.max_timesteps}"
127
+ )
128
+ console.print(f"Effective timesteps: ~{est.effective_timesteps}{early_stop}")
129
+ console.print()
130
+
131
+ # Models section
132
+ console.print("[bold]Models[/bold]")
133
+ _print_model_line(
134
+ console, "Pass 1 (pivotal)", est.pivotal_model, est.pivotal_pricing
135
+ )
136
+ _print_model_line(
137
+ console, "Pass 2 (routine)", est.routine_model, est.routine_pricing
138
+ )
139
+ console.print()
140
+
141
+ # Calls table
142
+ console.print("[bold]Estimated LLM Calls[/bold]")
143
+ console.print(
144
+ f" {'':16s} {'Calls':>8s} {'Input Tok':>12s} {'Output Tok':>12s}"
145
+ )
146
+ console.print(
147
+ f" {'Pass 1':16s} {est.pass1_calls:>8,} "
148
+ f"{'~' + f'{est.pass1_input_tokens:,}':>12s} "
149
+ f"{'~' + f'{est.pass1_output_tokens:,}':>12s}"
150
+ )
151
+ console.print(
152
+ f" {'Pass 2':16s} {est.pass2_calls:>8,} "
153
+ f"{'~' + f'{est.pass2_input_tokens:,}':>12s} "
154
+ f"{'~' + f'{est.pass2_output_tokens:,}':>12s}"
155
+ )
156
+ total_input = est.pass1_input_tokens + est.pass2_input_tokens
157
+ total_output = est.pass1_output_tokens + est.pass2_output_tokens
158
+ console.print(
159
+ f" {'Total':16s} {est.pass1_calls + est.pass2_calls:>8,} "
160
+ f"{'~' + f'{total_input:,}':>12s} "
161
+ f"{'~' + f'{total_output:,}':>12s}"
162
+ )
163
+ console.print()
164
+
165
+ # Cost section
166
+ console.print("[bold]Estimated Cost[/bold]")
167
+ if est.pass1_cost is not None:
168
+ console.print(f" Pass 1: ${est.pass1_cost:.2f}")
169
+ else:
170
+ console.print(
171
+ f" Pass 1: [dim]pricing not available for {est.pivotal_model}[/dim]"
172
+ )
173
+
174
+ if est.pass2_cost is not None:
175
+ console.print(f" Pass 2: ${est.pass2_cost:.2f}")
176
+ else:
177
+ console.print(
178
+ f" Pass 2: [dim]pricing not available for {est.routine_model}[/dim]"
179
+ )
180
+
181
+ if est.total_cost is not None:
182
+ console.print(f" [bold]Total: ${est.total_cost:.2f}[/bold]")
183
+ console.print()
184
+
185
+ console.print(
186
+ "[dim]Estimates are approximate. Actual costs vary with prompt length "
187
+ "and simulation dynamics.[/dim]"
188
+ )
189
+
190
+ # Verbose per-timestep breakdown
191
+ if verbose and est.per_timestep:
192
+ console.print()
193
+ console.print("[bold]Per-Timestep Breakdown[/bold]")
194
+ console.print(
195
+ f" {'Step':>6s} {'Exposure':>9s} {'New Exp':>8s} {'Reasoning':>10s}"
196
+ )
197
+ for row in est.per_timestep:
198
+ if row["new_exposures"] > 0 or row["reasoning_calls"] > 0:
199
+ console.print(
200
+ f" {row['timestep']:>6d} "
201
+ f"{row['exposure_rate']:>8.1%} "
202
+ f"{row['new_exposures']:>8d} "
203
+ f"{row['reasoning_calls']:>10d}"
204
+ )
205
+ console.print()
206
+
207
+
208
+ def _print_model_line(console, label: str, model: str, pricing):
209
+ """Print a model info line with optional pricing."""
210
+ if pricing:
211
+ console.print(
212
+ f" {label}: {model} "
213
+ f"(${pricing.input_per_mtok:.2f} / ${pricing.output_per_mtok:.2f} per MTok in/out)"
214
+ )
215
+ else:
216
+ console.print(f" {label}: {model} [dim](pricing not available)[/dim]")