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.
Files changed (46) hide show
  1. lightcone/cli/__init__.py +16 -0
  2. lightcone/cli/claude/lightcone/agents/lc-extractor.md +114 -0
  3. lightcone/cli/claude/lightcone/guides/astra-reference.md +290 -0
  4. lightcone/cli/claude/lightcone/guides/lightcone-cli-reference.md +75 -0
  5. lightcone/cli/claude/lightcone/guides/ui-brand.md +86 -0
  6. lightcone/cli/claude/lightcone/hooks/langfuse_git_commit_hook.py +303 -0
  7. lightcone/cli/claude/lightcone/hooks/langfuse_hook.py +894 -0
  8. lightcone/cli/claude/lightcone/hooks/langfuse_prepare_commit_msg.py +142 -0
  9. lightcone/cli/claude/lightcone/hooks/langfuse_session_init_hook.py +83 -0
  10. lightcone/cli/claude/lightcone/hooks/langfuse_utils.py +457 -0
  11. lightcone/cli/claude/lightcone/scripts/activate-venv.sh +44 -0
  12. lightcone/cli/claude/lightcone/scripts/check-lc-run.sh +140 -0
  13. lightcone/cli/claude/lightcone/scripts/session-start.sh +140 -0
  14. lightcone/cli/claude/lightcone/scripts/validate-on-save.sh +77 -0
  15. lightcone/cli/claude/lightcone/skills/lc-build/SKILL.md +92 -0
  16. lightcone/cli/claude/lightcone/skills/lc-build/assets/loop-prompt.md +92 -0
  17. lightcone/cli/claude/lightcone/skills/lc-build/scripts/setup-lc-build.sh +240 -0
  18. lightcone/cli/claude/lightcone/skills/lc-feedback/SKILL.md +94 -0
  19. lightcone/cli/claude/lightcone/skills/lc-migrate/SKILL.md +98 -0
  20. lightcone/cli/claude/lightcone/skills/lc-new/SKILL.md +183 -0
  21. lightcone/cli/claude/lightcone/skills/lc-verify/SKILL.md +53 -0
  22. lightcone/cli/claude/lightcone/templates/CLAUDE.md +32 -0
  23. lightcone/cli/commands.py +2327 -0
  24. lightcone/cli/plugin.py +34 -0
  25. lightcone/engine/__init__.py +42 -0
  26. lightcone/engine/assets.py +418 -0
  27. lightcone/engine/container.py +370 -0
  28. lightcone/engine/io_manager.py +27 -0
  29. lightcone/engine/runner.py +1017 -0
  30. lightcone/engine/site_registry.py +142 -0
  31. lightcone/engine/status.py +135 -0
  32. lightcone/engine/targets.py +68 -0
  33. lightcone/engine/tree.py +245 -0
  34. lightcone/eval/__init__.py +25 -0
  35. lightcone/eval/build.py +148 -0
  36. lightcone/eval/cli.py +176 -0
  37. lightcone/eval/graders.py +192 -0
  38. lightcone/eval/harness.py +265 -0
  39. lightcone/eval/models.py +117 -0
  40. lightcone/eval/report.py +214 -0
  41. lightcone/eval/sandbox.py +394 -0
  42. lightcone_cli-0.2.0.dist-info/METADATA +16 -0
  43. lightcone_cli-0.2.0.dist-info/RECORD +46 -0
  44. lightcone_cli-0.2.0.dist-info/WHEEL +4 -0
  45. lightcone_cli-0.2.0.dist-info/entry_points.txt +2 -0
  46. 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
@@ -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
+ ]