lightcone-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lightcone/cli/__init__.py +16 -0
- lightcone/cli/claude/lightcone/agents/lc-extractor.md +114 -0
- lightcone/cli/claude/lightcone/guides/astra-reference.md +290 -0
- lightcone/cli/claude/lightcone/guides/lightcone-cli-reference.md +75 -0
- lightcone/cli/claude/lightcone/guides/ui-brand.md +86 -0
- lightcone/cli/claude/lightcone/hooks/langfuse_git_commit_hook.py +303 -0
- lightcone/cli/claude/lightcone/hooks/langfuse_hook.py +894 -0
- lightcone/cli/claude/lightcone/hooks/langfuse_prepare_commit_msg.py +142 -0
- lightcone/cli/claude/lightcone/hooks/langfuse_session_init_hook.py +83 -0
- lightcone/cli/claude/lightcone/hooks/langfuse_utils.py +457 -0
- lightcone/cli/claude/lightcone/scripts/activate-venv.sh +44 -0
- lightcone/cli/claude/lightcone/scripts/check-lc-run.sh +140 -0
- lightcone/cli/claude/lightcone/scripts/session-start.sh +140 -0
- lightcone/cli/claude/lightcone/scripts/validate-on-save.sh +77 -0
- lightcone/cli/claude/lightcone/skills/lc-build/SKILL.md +92 -0
- lightcone/cli/claude/lightcone/skills/lc-build/assets/loop-prompt.md +92 -0
- lightcone/cli/claude/lightcone/skills/lc-build/scripts/setup-lc-build.sh +240 -0
- lightcone/cli/claude/lightcone/skills/lc-feedback/SKILL.md +94 -0
- lightcone/cli/claude/lightcone/skills/lc-migrate/SKILL.md +98 -0
- lightcone/cli/claude/lightcone/skills/lc-new/SKILL.md +183 -0
- lightcone/cli/claude/lightcone/skills/lc-verify/SKILL.md +53 -0
- lightcone/cli/claude/lightcone/templates/CLAUDE.md +32 -0
- lightcone/cli/commands.py +2327 -0
- lightcone/cli/plugin.py +34 -0
- lightcone/engine/__init__.py +42 -0
- lightcone/engine/assets.py +418 -0
- lightcone/engine/container.py +370 -0
- lightcone/engine/io_manager.py +27 -0
- lightcone/engine/runner.py +1017 -0
- lightcone/engine/site_registry.py +142 -0
- lightcone/engine/status.py +135 -0
- lightcone/engine/targets.py +68 -0
- lightcone/engine/tree.py +245 -0
- lightcone/eval/__init__.py +25 -0
- lightcone/eval/build.py +148 -0
- lightcone/eval/cli.py +176 -0
- lightcone/eval/graders.py +192 -0
- lightcone/eval/harness.py +265 -0
- lightcone/eval/models.py +117 -0
- lightcone/eval/report.py +214 -0
- lightcone/eval/sandbox.py +394 -0
- lightcone_cli-0.2.0.dist-info/METADATA +16 -0
- lightcone_cli-0.2.0.dist-info/RECORD +46 -0
- lightcone_cli-0.2.0.dist-info/WHEEL +4 -0
- lightcone_cli-0.2.0.dist-info/entry_points.txt +2 -0
- lightcone_cli-0.2.0.dist-info/licenses/LICENSE +29 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Known HPC site defaults for site configuration.
|
|
2
|
+
|
|
3
|
+
When ``lc setup`` detects a known site, it auto-populates scheduler
|
|
4
|
+
settings with site-specific defaults (node types, QOS options, container
|
|
5
|
+
runtimes, etc.). Users can override any value during the wizard.
|
|
6
|
+
|
|
7
|
+
To add a new site, append an entry to ``SITE_DEFAULTS``.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
# Each entry maps a site key to its defaults. The ``hostname_patterns``
|
|
14
|
+
# list is used to auto-detect the site from user-provided hostnames.
|
|
15
|
+
SITE_DEFAULTS: dict[str, dict[str, Any]] = {
|
|
16
|
+
"perlmutter": {
|
|
17
|
+
"hostname_patterns": ["perlmutter", "saul"],
|
|
18
|
+
"display_name": "NERSC Perlmutter",
|
|
19
|
+
"backend": "slurm",
|
|
20
|
+
"connection": {
|
|
21
|
+
"hostname": "perlmutter.nersc.gov",
|
|
22
|
+
},
|
|
23
|
+
"scheduler": {
|
|
24
|
+
"container_runtime": "podman-hpc",
|
|
25
|
+
},
|
|
26
|
+
"node_types": {
|
|
27
|
+
"gpu": {
|
|
28
|
+
"description": "GPU (A100 40GB) — 1,536 nodes, 4 GPUs/node",
|
|
29
|
+
"constraint": "gpu",
|
|
30
|
+
"container_flags": ["--gpu"],
|
|
31
|
+
},
|
|
32
|
+
"gpu_hbm80": {
|
|
33
|
+
"description": "GPU (A100 80GB) — 256 nodes, 4 GPUs/node",
|
|
34
|
+
"constraint": "gpu&hbm80g",
|
|
35
|
+
"container_flags": ["--gpu"],
|
|
36
|
+
},
|
|
37
|
+
"cpu": {
|
|
38
|
+
"description": "CPU only — 3,072 nodes, 128 cores/node",
|
|
39
|
+
"constraint": "cpu",
|
|
40
|
+
"container_flags": [],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
"qos_options": {
|
|
44
|
+
"regular": {"description": "Standard priority, max 48h", "default": True},
|
|
45
|
+
"debug": {"description": "Quick tests, max 30min, 8 nodes max"},
|
|
46
|
+
"shared": {"description": "Fractional GPU (1-2 GPUs), max 48h"},
|
|
47
|
+
"preempt": {"description": "0.25x cost, can be preempted after 2h"},
|
|
48
|
+
},
|
|
49
|
+
"container_runtimes": ["podman-hpc"],
|
|
50
|
+
"resource_limits": {
|
|
51
|
+
"max_nodes": 4,
|
|
52
|
+
"max_walltime_minutes": 360,
|
|
53
|
+
"max_concurrent_jobs": 8,
|
|
54
|
+
},
|
|
55
|
+
"safe_defaults": {
|
|
56
|
+
"node_type": "gpu",
|
|
57
|
+
"constraint": "gpu",
|
|
58
|
+
"qos": "debug",
|
|
59
|
+
"nodes": 1,
|
|
60
|
+
"time_limit": "30m",
|
|
61
|
+
},
|
|
62
|
+
"account_suffixes": {
|
|
63
|
+
"gpu": "_g",
|
|
64
|
+
"gpu&hbm80g": "_g",
|
|
65
|
+
},
|
|
66
|
+
"scratch_paths": [
|
|
67
|
+
"//pscratch/**",
|
|
68
|
+
"//global/cscratch1/**",
|
|
69
|
+
"//global/cfs/cdirs/**",
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
"local": {
|
|
73
|
+
"hostname_patterns": [],
|
|
74
|
+
"display_name": "Local",
|
|
75
|
+
"backend": "local",
|
|
76
|
+
"connection": {},
|
|
77
|
+
"scheduler": {},
|
|
78
|
+
"node_types": {},
|
|
79
|
+
"qos_options": {},
|
|
80
|
+
"container_runtimes": [],
|
|
81
|
+
"resource_limits": {},
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def detect_site(hostname_or_name: str) -> str | None:
|
|
87
|
+
"""Detect a known HPC site from a hostname or site name.
|
|
88
|
+
|
|
89
|
+
Returns the site key (e.g. ``"perlmutter"``) or ``None`` if no match.
|
|
90
|
+
"""
|
|
91
|
+
normalized = hostname_or_name.lower()
|
|
92
|
+
for site_key, site in SITE_DEFAULTS.items():
|
|
93
|
+
if site.get("backend") == "local":
|
|
94
|
+
continue
|
|
95
|
+
if site_key in normalized:
|
|
96
|
+
return site_key
|
|
97
|
+
for pattern in site.get("hostname_patterns", []):
|
|
98
|
+
if pattern in normalized:
|
|
99
|
+
return site_key
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_site_defaults(site_key: str) -> dict[str, Any] | None:
|
|
104
|
+
"""Return defaults for a known site, or ``None``."""
|
|
105
|
+
return SITE_DEFAULTS.get(site_key)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def list_known_sites() -> list[tuple[str, str]]:
|
|
109
|
+
"""Return list of (site_key, display_name) for all known sites."""
|
|
110
|
+
return [
|
|
111
|
+
(key, site.get("display_name", key))
|
|
112
|
+
for key, site in SITE_DEFAULTS.items()
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def resolve_account(site_key: str, account: str, constraint: str | None) -> str:
|
|
117
|
+
"""Apply site-specific account suffix based on constraint.
|
|
118
|
+
|
|
119
|
+
For example, on Perlmutter GPU jobs require ``m4031_g`` instead of
|
|
120
|
+
``m4031``. If the account already has the suffix, it is not added again.
|
|
121
|
+
"""
|
|
122
|
+
site = SITE_DEFAULTS.get(site_key)
|
|
123
|
+
if not site or not constraint:
|
|
124
|
+
return account
|
|
125
|
+
suffixes = site.get("account_suffixes", {})
|
|
126
|
+
suffix = suffixes.get(constraint)
|
|
127
|
+
if suffix and not account.endswith(suffix):
|
|
128
|
+
return account + suffix
|
|
129
|
+
return account
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_site_scratch_deny_rules(site_key: str) -> list[str]:
|
|
133
|
+
"""Return Edit deny rules for a site's scratch/shared filesystem paths.
|
|
134
|
+
|
|
135
|
+
These are used in Claude Code permissions to prevent accidental writes
|
|
136
|
+
to shared HPC filesystems.
|
|
137
|
+
"""
|
|
138
|
+
site = SITE_DEFAULTS.get(site_key)
|
|
139
|
+
if not site:
|
|
140
|
+
return []
|
|
141
|
+
scratch_paths = site.get("scratch_paths", [])
|
|
142
|
+
return [f"Edit({path})" for path in scratch_paths]
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Materialization status queries for ASTRA outputs."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import dagster as dg
|
|
9
|
+
from astra.helpers import load_yaml, resolve_analysis_tree
|
|
10
|
+
|
|
11
|
+
from lightcone.engine.tree import collect_tree_outputs
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _get_dagster_instance(project_path: Path) -> dg.DagsterInstance | None:
|
|
17
|
+
"""Load a DagsterInstance from the project's dagster.yaml.
|
|
18
|
+
|
|
19
|
+
Returns None if dagster.yaml doesn't exist or the instance can't be loaded
|
|
20
|
+
(e.g. corrupted SQLite). Callers treat None as "no events recorded."
|
|
21
|
+
|
|
22
|
+
Temporarily changes to project_path so that relative paths in dagster.yaml
|
|
23
|
+
(e.g. ``base_dir: results/.dagster``) resolve correctly.
|
|
24
|
+
"""
|
|
25
|
+
# Check .lightcone/ first, then root for backwards compat
|
|
26
|
+
dagster_yaml = project_path / ".lightcone" / "dagster.yaml"
|
|
27
|
+
if not dagster_yaml.exists():
|
|
28
|
+
dagster_yaml = project_path / "dagster.yaml"
|
|
29
|
+
if not dagster_yaml.exists():
|
|
30
|
+
return None
|
|
31
|
+
config_dir = dagster_yaml.parent
|
|
32
|
+
old_cwd = os.getcwd()
|
|
33
|
+
try:
|
|
34
|
+
os.chdir(project_path)
|
|
35
|
+
return dg.DagsterInstance.from_config(str(config_dir))
|
|
36
|
+
except Exception:
|
|
37
|
+
logger.warning("Failed to load Dagster instance from %s", project_path, exc_info=True)
|
|
38
|
+
return None
|
|
39
|
+
finally:
|
|
40
|
+
os.chdir(old_cwd)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_output_status(
|
|
44
|
+
project_path: Path,
|
|
45
|
+
universe_id: str,
|
|
46
|
+
instance: dg.DagsterInstance | None = None,
|
|
47
|
+
) -> dict[str, str]:
|
|
48
|
+
"""Get materialization status for all outputs in a universe.
|
|
49
|
+
|
|
50
|
+
Returns dict mapping qualified output_id to status string:
|
|
51
|
+
- "no_recipe": output declared but has no recipe block
|
|
52
|
+
- "pending": has recipe, not yet materialized
|
|
53
|
+
- "materialized": has recipe and Dagster event log confirms materialization
|
|
54
|
+
|
|
55
|
+
For sub-analysis outputs, keys are qualified: "analysis_id/output_id".
|
|
56
|
+
Root-level outputs use just "output_id".
|
|
57
|
+
"""
|
|
58
|
+
spec = load_yaml(project_path / "astra.yaml")
|
|
59
|
+
# Resolve sub-analysis tree
|
|
60
|
+
spec = resolve_analysis_tree(spec, project_path)
|
|
61
|
+
|
|
62
|
+
if instance is None:
|
|
63
|
+
instance = _get_dagster_instance(project_path)
|
|
64
|
+
|
|
65
|
+
# Collect all outputs from the tree
|
|
66
|
+
tree_outputs = collect_tree_outputs(spec)
|
|
67
|
+
|
|
68
|
+
# Build asset keys for outputs with recipes, then batch-query Dagster
|
|
69
|
+
recipe_keys: dict[str, dg.AssetKey] = {} # qualified_id -> asset key
|
|
70
|
+
for tree_out in tree_outputs:
|
|
71
|
+
out_id = tree_out.output_id
|
|
72
|
+
if not out_id or not tree_out.output_def.get("recipe"):
|
|
73
|
+
continue
|
|
74
|
+
if tree_out.analysis_id:
|
|
75
|
+
qualified = f"{tree_out.analysis_id}/{out_id}"
|
|
76
|
+
key = dg.AssetKey([universe_id, tree_out.analysis_id, out_id])
|
|
77
|
+
else:
|
|
78
|
+
qualified = out_id
|
|
79
|
+
key = dg.AssetKey([universe_id, out_id])
|
|
80
|
+
recipe_keys[qualified] = key
|
|
81
|
+
|
|
82
|
+
materialized: set[str] = set()
|
|
83
|
+
if instance is not None and recipe_keys:
|
|
84
|
+
events = instance.get_latest_materialization_events(list(recipe_keys.values()))
|
|
85
|
+
materialized_asset_keys = {k for k, v in events.items() if v is not None}
|
|
86
|
+
for qualified, key in recipe_keys.items():
|
|
87
|
+
if key in materialized_asset_keys:
|
|
88
|
+
materialized.add(qualified)
|
|
89
|
+
|
|
90
|
+
status: dict[str, str] = {}
|
|
91
|
+
for tree_out in tree_outputs:
|
|
92
|
+
out_id = tree_out.output_id
|
|
93
|
+
if not out_id:
|
|
94
|
+
continue
|
|
95
|
+
if tree_out.analysis_id:
|
|
96
|
+
qualified = f"{tree_out.analysis_id}/{out_id}"
|
|
97
|
+
else:
|
|
98
|
+
qualified = out_id
|
|
99
|
+
|
|
100
|
+
if not tree_out.output_def.get("recipe"):
|
|
101
|
+
# Check for alias outputs (from: sub.output)
|
|
102
|
+
from_ref = tree_out.output_def.get("from")
|
|
103
|
+
if from_ref and tree_out.analysis_id is None:
|
|
104
|
+
status[qualified] = "alias"
|
|
105
|
+
continue
|
|
106
|
+
status[qualified] = "no_recipe"
|
|
107
|
+
elif qualified in materialized:
|
|
108
|
+
status[qualified] = "materialized"
|
|
109
|
+
else:
|
|
110
|
+
status[qualified] = "pending"
|
|
111
|
+
|
|
112
|
+
return status
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def get_all_universe_status(
|
|
116
|
+
project_path: Path,
|
|
117
|
+
) -> dict[str, dict[str, str]]:
|
|
118
|
+
"""Get status for all universes.
|
|
119
|
+
|
|
120
|
+
Returns dict mapping universe_id to output status dict.
|
|
121
|
+
"""
|
|
122
|
+
universes_dir = project_path / "universes"
|
|
123
|
+
if not universes_dir.exists():
|
|
124
|
+
return {}
|
|
125
|
+
|
|
126
|
+
# Create instance once and share across all universe checks
|
|
127
|
+
instance = _get_dagster_instance(project_path)
|
|
128
|
+
|
|
129
|
+
result: dict[str, dict[str, str]] = {}
|
|
130
|
+
for universe_file in sorted(universes_dir.glob("*.yaml")):
|
|
131
|
+
universe_data = load_yaml(universe_file)
|
|
132
|
+
universe_id = universe_data.get("id", universe_file.stem)
|
|
133
|
+
result[universe_id] = get_output_status(project_path, universe_id, instance=instance)
|
|
134
|
+
|
|
135
|
+
return result
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Target configuration management for Dagster execution backends."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_targets_dir() -> Path:
|
|
11
|
+
"""Return the user-level targets directory (~/.lightcone/targets/)."""
|
|
12
|
+
return Path.home() / ".lightcone" / "targets"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def list_targets() -> list[str]:
|
|
16
|
+
"""Return names of saved target configurations."""
|
|
17
|
+
targets_dir = get_targets_dir()
|
|
18
|
+
if not targets_dir.exists():
|
|
19
|
+
return []
|
|
20
|
+
return sorted(p.stem for p in targets_dir.glob("*.yaml"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_target(name: str) -> dict[str, Any] | None:
|
|
24
|
+
"""Load a saved target configuration by name. Returns None if missing."""
|
|
25
|
+
config_path = get_targets_dir() / f"{name}.yaml"
|
|
26
|
+
if not config_path.exists():
|
|
27
|
+
return None
|
|
28
|
+
with open(config_path) as f:
|
|
29
|
+
return yaml.safe_load(f)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def save_target(name: str, config: dict[str, Any]) -> Path:
|
|
33
|
+
"""Save a target configuration to ~/.lightcone/targets/{name}.yaml."""
|
|
34
|
+
targets_dir = get_targets_dir()
|
|
35
|
+
targets_dir.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
config_path = targets_dir / f"{name}.yaml"
|
|
37
|
+
with open(config_path, "w") as f:
|
|
38
|
+
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
|
39
|
+
return config_path
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_config_path() -> Path:
|
|
43
|
+
"""Return the user-level config file path (~/.lightcone/config.yaml)."""
|
|
44
|
+
return Path.home() / ".lightcone" / "config.yaml"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_user_config() -> dict[str, Any]:
|
|
48
|
+
"""Load the user-level lightcone-cli configuration.
|
|
49
|
+
|
|
50
|
+
Returns an empty dict if the config file doesn't exist.
|
|
51
|
+
"""
|
|
52
|
+
config_path = get_config_path()
|
|
53
|
+
if not config_path.exists():
|
|
54
|
+
return {}
|
|
55
|
+
with open(config_path) as f:
|
|
56
|
+
return yaml.safe_load(f) or {}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def save_user_config(config: dict[str, Any]) -> Path:
|
|
60
|
+
"""Save user-level lightcone-cli configuration to ~/.lightcone/config.yaml.
|
|
61
|
+
|
|
62
|
+
Returns the path where it was saved.
|
|
63
|
+
"""
|
|
64
|
+
config_path = get_config_path()
|
|
65
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
with open(config_path, "w") as f:
|
|
67
|
+
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
|
68
|
+
return config_path
|
lightcone/engine/tree.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Analysis tree helpers — walk resolved sub-analysis trees.
|
|
2
|
+
|
|
3
|
+
After ``resolve_analysis_tree()`` from astra.helpers expands ``path:``
|
|
4
|
+
references, this module provides utilities to:
|
|
5
|
+
|
|
6
|
+
- Collect all outputs across the tree (with their sub-analysis context)
|
|
7
|
+
- Resolve ``from:`` references on inputs to concrete output paths
|
|
8
|
+
- Resolve ``from:`` references on decisions to parent decision values
|
|
9
|
+
- Build merged decision dicts from composable universe files
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from astra.helpers import get_inputs, get_outputs, load_yaml
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class TreeOutput:
|
|
25
|
+
"""An output from the resolved analysis tree, with its sub-analysis context."""
|
|
26
|
+
|
|
27
|
+
output_id: str
|
|
28
|
+
output_def: dict[str, Any]
|
|
29
|
+
analysis_id: str | None # None for root-level outputs
|
|
30
|
+
analysis_path: str | None # relative path, e.g. "./analyses/hod_fitting"
|
|
31
|
+
analysis_spec: dict[str, Any] # the sub-analysis spec dict
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def collect_tree_outputs(spec: dict[str, Any]) -> list[TreeOutput]:
|
|
35
|
+
"""Walk the resolved tree and collect all outputs with context.
|
|
36
|
+
|
|
37
|
+
Returns outputs from root level (analysis_id=None) and from each
|
|
38
|
+
sub-analysis. Root-level outputs with ``from:`` pointing to a
|
|
39
|
+
sub-analysis output are included but flagged for alias handling.
|
|
40
|
+
"""
|
|
41
|
+
results: list[TreeOutput] = []
|
|
42
|
+
|
|
43
|
+
# Root-level outputs
|
|
44
|
+
for out in get_outputs(spec):
|
|
45
|
+
results.append(TreeOutput(
|
|
46
|
+
output_id=out.get("id", ""),
|
|
47
|
+
output_def=out,
|
|
48
|
+
analysis_id=None,
|
|
49
|
+
analysis_path=None,
|
|
50
|
+
analysis_spec=spec,
|
|
51
|
+
))
|
|
52
|
+
|
|
53
|
+
# Sub-analysis outputs
|
|
54
|
+
for analysis_id, analysis_node in (spec.get("analyses") or {}).items():
|
|
55
|
+
sub_path = analysis_node.get("path")
|
|
56
|
+
for out in get_outputs(analysis_node):
|
|
57
|
+
results.append(TreeOutput(
|
|
58
|
+
output_id=out.get("id", ""),
|
|
59
|
+
output_def=out,
|
|
60
|
+
analysis_id=analysis_id,
|
|
61
|
+
analysis_path=sub_path,
|
|
62
|
+
analysis_spec=analysis_node,
|
|
63
|
+
))
|
|
64
|
+
|
|
65
|
+
return results
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def collect_tree_inputs(spec: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
69
|
+
"""Collect all inputs from root and sub-analyses.
|
|
70
|
+
|
|
71
|
+
Returns {qualified_id: input_def} where qualified_id is:
|
|
72
|
+
- "input_id" for root inputs
|
|
73
|
+
- "analysis_id.input_id" for sub-analysis inputs
|
|
74
|
+
"""
|
|
75
|
+
result: dict[str, dict[str, Any]] = {}
|
|
76
|
+
|
|
77
|
+
for inp in get_inputs(spec):
|
|
78
|
+
inp_id = inp.get("id", "")
|
|
79
|
+
if inp_id:
|
|
80
|
+
result[inp_id] = inp
|
|
81
|
+
|
|
82
|
+
for analysis_id, analysis_node in (spec.get("analyses") or {}).items():
|
|
83
|
+
for inp in get_inputs(analysis_node):
|
|
84
|
+
inp_id = inp.get("id", "")
|
|
85
|
+
if inp_id:
|
|
86
|
+
result[f"{analysis_id}.{inp_id}"] = inp
|
|
87
|
+
|
|
88
|
+
return result
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def resolve_universe_decisions(
|
|
92
|
+
project_path: Path,
|
|
93
|
+
spec: dict[str, Any],
|
|
94
|
+
universe_id: str,
|
|
95
|
+
) -> dict[str, Any]:
|
|
96
|
+
"""Load and merge universe decisions from root and sub-analysis universes.
|
|
97
|
+
|
|
98
|
+
Returns a flat dict of all decisions for execution:
|
|
99
|
+
- Root decisions from ``universes/<universe_id>.yaml``
|
|
100
|
+
- Sub-analysis decisions from ``<sub_path>/universes/<sub_universe_id>.yaml``
|
|
101
|
+
- ``from:`` decisions in sub-analyses are resolved to parent values
|
|
102
|
+
|
|
103
|
+
The returned dict uses qualified keys for sub-analysis decisions:
|
|
104
|
+
``{analysis_id}.{decision_id}`` to avoid collisions.
|
|
105
|
+
"""
|
|
106
|
+
# Load root universe
|
|
107
|
+
root_universe_file = project_path / "universes" / f"{universe_id}.yaml"
|
|
108
|
+
root_decisions: dict[str, Any] = {}
|
|
109
|
+
sub_universe_refs: dict[str, str] = {}
|
|
110
|
+
|
|
111
|
+
if root_universe_file.exists():
|
|
112
|
+
root_data = load_yaml(root_universe_file)
|
|
113
|
+
root_decisions = root_data.get("decisions", {})
|
|
114
|
+
# Parse sub-analysis universe references
|
|
115
|
+
for analysis_id, ref in (root_data.get("analyses") or {}).items():
|
|
116
|
+
if isinstance(ref, dict) and ref.get("universe"):
|
|
117
|
+
sub_universe_refs[analysis_id] = ref["universe"]
|
|
118
|
+
|
|
119
|
+
merged: dict[str, Any] = dict(root_decisions)
|
|
120
|
+
|
|
121
|
+
# Load sub-analysis universes
|
|
122
|
+
for analysis_id, analysis_node in (spec.get("analyses") or {}).items():
|
|
123
|
+
sub_path = analysis_node.get("path")
|
|
124
|
+
if not sub_path:
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
sub_universe_id = sub_universe_refs.get(analysis_id, universe_id)
|
|
128
|
+
sub_dir = (project_path / sub_path).resolve()
|
|
129
|
+
sub_universe_file = sub_dir / "universes" / f"{sub_universe_id}.yaml"
|
|
130
|
+
|
|
131
|
+
if sub_universe_file.exists():
|
|
132
|
+
sub_data = load_yaml(sub_universe_file)
|
|
133
|
+
sub_decisions = sub_data.get("decisions", {})
|
|
134
|
+
else:
|
|
135
|
+
sub_decisions = {}
|
|
136
|
+
|
|
137
|
+
# Resolve from: references in sub-analysis decisions
|
|
138
|
+
for decision_id, decision_def in (analysis_node.get("decisions") or {}).items():
|
|
139
|
+
if isinstance(decision_def, dict) and decision_def.get("from"):
|
|
140
|
+
from_ref = decision_def["from"]
|
|
141
|
+
# ../parent_decision -> look up in root decisions
|
|
142
|
+
if from_ref.startswith("../"):
|
|
143
|
+
parent_key = from_ref[3:]
|
|
144
|
+
if parent_key in root_decisions:
|
|
145
|
+
merged[f"{analysis_id}.{decision_id}"] = root_decisions[parent_key]
|
|
146
|
+
else:
|
|
147
|
+
logger.warning(
|
|
148
|
+
"Decision '%s' in '%s' references '%s' which is not in "
|
|
149
|
+
"root universe decisions",
|
|
150
|
+
decision_id, analysis_id, from_ref,
|
|
151
|
+
)
|
|
152
|
+
elif decision_id in sub_decisions:
|
|
153
|
+
merged[f"{analysis_id}.{decision_id}"] = sub_decisions[decision_id]
|
|
154
|
+
|
|
155
|
+
# Also add un-referenced local decisions from the sub-universe
|
|
156
|
+
for decision_id, value in sub_decisions.items():
|
|
157
|
+
key = f"{analysis_id}.{decision_id}"
|
|
158
|
+
if key not in merged:
|
|
159
|
+
merged[key] = value
|
|
160
|
+
|
|
161
|
+
return merged
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def get_decisions_for_analysis(
|
|
165
|
+
merged_decisions: dict[str, Any],
|
|
166
|
+
analysis_id: str | None,
|
|
167
|
+
) -> dict[str, Any]:
|
|
168
|
+
"""Extract the decisions relevant to a specific analysis from merged dict.
|
|
169
|
+
|
|
170
|
+
For root (analysis_id=None): returns unqualified keys.
|
|
171
|
+
For sub-analysis: returns decisions with matching prefix, stripped to local names.
|
|
172
|
+
Also includes root-level decisions (for from: references).
|
|
173
|
+
"""
|
|
174
|
+
if analysis_id is None:
|
|
175
|
+
# Root analysis: return all unqualified keys
|
|
176
|
+
return {k: v for k, v in merged_decisions.items() if "." not in k}
|
|
177
|
+
|
|
178
|
+
prefix = f"{analysis_id}."
|
|
179
|
+
result: dict[str, Any] = {}
|
|
180
|
+
|
|
181
|
+
# Add qualified decisions with prefix stripped
|
|
182
|
+
for k, v in merged_decisions.items():
|
|
183
|
+
if k.startswith(prefix):
|
|
184
|
+
local_key = k[len(prefix):]
|
|
185
|
+
result[local_key] = v
|
|
186
|
+
|
|
187
|
+
return result
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def resolve_output_path(
|
|
191
|
+
project_path: Path,
|
|
192
|
+
tree_output: TreeOutput,
|
|
193
|
+
universe_id: str,
|
|
194
|
+
) -> Path:
|
|
195
|
+
"""Resolve the results directory for an output.
|
|
196
|
+
|
|
197
|
+
Root outputs: ``results/<universe_id>/``
|
|
198
|
+
Sub-analysis outputs: ``<sub_path>/results/<universe_id>/``
|
|
199
|
+
"""
|
|
200
|
+
if tree_output.analysis_path:
|
|
201
|
+
return (project_path / tree_output.analysis_path).resolve() / "results" / universe_id
|
|
202
|
+
return project_path / "results" / universe_id
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def resolve_input_path(
|
|
206
|
+
project_path: Path,
|
|
207
|
+
spec: dict[str, Any],
|
|
208
|
+
from_ref: str,
|
|
209
|
+
universe_id: str,
|
|
210
|
+
) -> str | None:
|
|
211
|
+
"""Resolve a ``from:`` reference on an input to a concrete filesystem path.
|
|
212
|
+
|
|
213
|
+
Handles:
|
|
214
|
+
- ``../parent_input`` -> root input's source
|
|
215
|
+
- ``../sibling.output_id`` -> sibling sub-analysis's results path
|
|
216
|
+
- ``sibling.output_id`` -> sibling sub-analysis's results path (no ../ needed at root)
|
|
217
|
+
"""
|
|
218
|
+
# Strip leading ../ if present
|
|
219
|
+
ref = from_ref.removeprefix("../")
|
|
220
|
+
ref = ref.removeprefix("/")
|
|
221
|
+
|
|
222
|
+
# Check if it's a root input reference
|
|
223
|
+
for inp in get_inputs(spec):
|
|
224
|
+
if inp.get("id") == ref:
|
|
225
|
+
source = inp.get("source")
|
|
226
|
+
if source and isinstance(source, str) and source.startswith("/"):
|
|
227
|
+
return source
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
# Check if it's a sibling.output_id reference
|
|
231
|
+
if "." in ref:
|
|
232
|
+
analysis_id, output_id = ref.split(".", 1)
|
|
233
|
+
analyses = spec.get("analyses") or {}
|
|
234
|
+
if analysis_id in analyses:
|
|
235
|
+
sub_node = analyses[analysis_id]
|
|
236
|
+
sub_path = sub_node.get("path")
|
|
237
|
+
if sub_path:
|
|
238
|
+
return str(
|
|
239
|
+
(project_path / sub_path).resolve()
|
|
240
|
+
/ "results" / universe_id / output_id
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Lightcone eval harness — quantitative evaluation of the agentic build loop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from lightcone.eval.models import (
|
|
6
|
+
EvalRun,
|
|
7
|
+
EvalRunConfig,
|
|
8
|
+
GraderResult,
|
|
9
|
+
GraderSpec,
|
|
10
|
+
IterationResult,
|
|
11
|
+
TaskSpec,
|
|
12
|
+
TrialResult,
|
|
13
|
+
VersionInfo,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"EvalRun",
|
|
18
|
+
"EvalRunConfig",
|
|
19
|
+
"GraderResult",
|
|
20
|
+
"GraderSpec",
|
|
21
|
+
"IterationResult",
|
|
22
|
+
"TaskSpec",
|
|
23
|
+
"TrialResult",
|
|
24
|
+
"VersionInfo",
|
|
25
|
+
]
|