hepyy-workflows 0.1.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 (42) hide show
  1. hepyy_workflows/__init__.py +3 -0
  2. hepyy_workflows/agent/__init__.py +5 -0
  3. hepyy_workflows/agent/service.py +80 -0
  4. hepyy_workflows/agent/stdio.py +54 -0
  5. hepyy_workflows/analysis/__init__.py +5 -0
  6. hepyy_workflows/analysis/expressions.py +184 -0
  7. hepyy_workflows/analysis/histograms.py +166 -0
  8. hepyy_workflows/analysis/runner.py +738 -0
  9. hepyy_workflows/analysis/views.py +142 -0
  10. hepyy_workflows/cli.py +196 -0
  11. hepyy_workflows/core/__init__.py +28 -0
  12. hepyy_workflows/core/config.py +152 -0
  13. hepyy_workflows/core/contracts.py +188 -0
  14. hepyy_workflows/core/source.py +110 -0
  15. hepyy_workflows/data/particles.parquet +0 -0
  16. hepyy_workflows/examples.py +53 -0
  17. hepyy_workflows/export/__init__.py +5 -0
  18. hepyy_workflows/export/standalone.py +156 -0
  19. hepyy_workflows/processing.py +65 -0
  20. hepyy_workflows/project.py +38 -0
  21. hepyy_workflows/readers/__init__.py +17 -0
  22. hepyy_workflows/readers/formats.py +666 -0
  23. hepyy_workflows/readers/mapped.py +608 -0
  24. hepyy_workflows/readers/scaffold.py +68 -0
  25. hepyy_workflows/readers/service.py +271 -0
  26. hepyy_workflows/reporting/__init__.py +5 -0
  27. hepyy_workflows/reporting/report.py +77 -0
  28. hepyy_workflows/schemas/reader-draft.schema.json +45 -0
  29. hepyy_workflows/schemas/reader.schema.json +31 -0
  30. hepyy_workflows/schemas/recipe.schema.json +87 -0
  31. hepyy_workflows/starter/README.md +22 -0
  32. hepyy_workflows/starter/reader.yaml +20 -0
  33. hepyy_workflows/starter/recipe.yaml +25 -0
  34. hepyy_workflows/ui/__init__.py +5 -0
  35. hepyy_workflows/ui/service.py +65 -0
  36. hepyy_workflows/ui/tui.py +23 -0
  37. hepyy_workflows/ui/web.py +103 -0
  38. hepyy_workflows-0.1.0.dist-info/METADATA +113 -0
  39. hepyy_workflows-0.1.0.dist-info/RECORD +42 -0
  40. hepyy_workflows-0.1.0.dist-info/WHEEL +5 -0
  41. hepyy_workflows-0.1.0.dist-info/entry_points.txt +2 -0
  42. hepyy_workflows-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """Source-neutral contracts and services for HEP workflows."""
2
+
3
+ __version__ = "0.1.0.dev0"
@@ -0,0 +1,5 @@
1
+ """Optional narrow agent integration."""
2
+
3
+ from .service import dispatch
4
+
5
+ __all__ = ["dispatch"]
@@ -0,0 +1,80 @@
1
+ """Stable JSON operations for agents and ordinary automation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from hepyy_workflows.analysis.runner import prepare_recipe, run_recipe
9
+ from hepyy_workflows.export.standalone import export_recipe
10
+ from hepyy_workflows.readers.scaffold import scaffold_reader
11
+ from hepyy_workflows.readers.service import (
12
+ draft_reader,
13
+ inspect_input,
14
+ preview_reader,
15
+ validate_reader,
16
+ write_draft,
17
+ )
18
+ from hepyy_workflows.reporting.report import build_report
19
+
20
+
21
+ def _paths(values: list[str] | None) -> list[Path]:
22
+ return [Path(value) for value in values or []]
23
+
24
+
25
+ def _error(error: Exception, code: str = "AGENT_FAILED") -> dict[str, Any]:
26
+ return {
27
+ "valid": False,
28
+ "diagnostics": [
29
+ {
30
+ "code": getattr(error, "code", code),
31
+ "path": getattr(error, "path", "/"),
32
+ "message": str(error),
33
+ }
34
+ ],
35
+ }
36
+
37
+
38
+ def dispatch(method: str, params: dict[str, Any]) -> dict[str, Any]:
39
+ try:
40
+ if method == "inspect_input":
41
+ return inspect_input(Path(params["input"]))
42
+ if method == "draft_reader":
43
+ if "output" in params:
44
+ return write_draft(Path(params["input"]), Path(params["output"]))
45
+ draft, inspection = draft_reader(Path(params["input"]))
46
+ return {**inspection, "draft": draft}
47
+ if method == "validate_reader":
48
+ return validate_reader(Path(params["reader"]), _paths(params.get("inputs")))
49
+ if method == "preview_reader":
50
+ return preview_reader(
51
+ Path(params["reader"]), _paths(params.get("inputs")), int(params.get("events", 10))
52
+ )
53
+ if method == "validate_recipe":
54
+ _, _, prepared = prepare_recipe(Path(params["recipe"]), params.get("inputs"))
55
+ return {
56
+ "valid": True,
57
+ "inputs": {k: [str(p) for p in v] for k, v in prepared["paths"].items()},
58
+ }
59
+ if method == "run_analysis":
60
+ return run_recipe(
61
+ Path(params["recipe"]),
62
+ Path(params["output"]),
63
+ params.get("inputs"),
64
+ params.get("max_events"),
65
+ )
66
+ if method == "export_standalone":
67
+ return export_recipe(
68
+ Path(params["recipe"]), Path(params["output"]), params.get("inputs")
69
+ )
70
+ if method == "build_report":
71
+ return build_report(Path(params["results"]), Path(params["output"]))
72
+ if method == "scaffold_reader":
73
+ return scaffold_reader(params["name"], Path(params["output"]))
74
+ if method == "get_status":
75
+ return {"valid": True, "status": "idle", "diagnostics": []}
76
+ if method == "cancel_run":
77
+ return _error(ValueError("no asynchronous run is active"), "NO_ACTIVE_RUN")
78
+ return _error(ValueError(f"unknown method: {method}"), "METHOD_UNKNOWN")
79
+ except Exception as error:
80
+ return _error(error)
@@ -0,0 +1,54 @@
1
+ """Line-delimited JSON agent interface; no shell or arbitrary code endpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+
8
+ from .service import dispatch
9
+
10
+ _METHODS = (
11
+ "inspect_input",
12
+ "draft_reader",
13
+ "validate_reader",
14
+ "preview_reader",
15
+ "validate_recipe",
16
+ "run_analysis",
17
+ "export_standalone",
18
+ "build_report",
19
+ "scaffold_reader",
20
+ "get_status",
21
+ "cancel_run",
22
+ )
23
+
24
+
25
+ def serve_stdio() -> int:
26
+ for line in sys.stdin:
27
+ if not line.strip():
28
+ continue
29
+ try:
30
+ request = json.loads(line)
31
+ if not isinstance(request, dict) or not isinstance(request.get("method"), str):
32
+ raise ValueError("request needs a method")
33
+ method = request["method"]
34
+ if method == "initialize":
35
+ result = {
36
+ "protocolVersion": "2025-03-26",
37
+ "serverInfo": {"name": "hwf", "version": "1"},
38
+ }
39
+ elif method == "tools/list":
40
+ result = {
41
+ "tools": [
42
+ {"name": name, "description": f"HWF {name} operation"} for name in _METHODS
43
+ ]
44
+ }
45
+ elif method == "tools/call":
46
+ params = request.get("params", {})
47
+ result = dispatch(params["name"], params.get("arguments", {}))
48
+ else:
49
+ result = dispatch(method, request.get("params", {}))
50
+ response = {"id": request.get("id"), "result": result}
51
+ except Exception as error:
52
+ response = {"id": None, "error": {"code": "REQUEST_INVALID", "message": str(error)}}
53
+ print(json.dumps(response, default=str), flush=True)
54
+ return 0
@@ -0,0 +1,5 @@
1
+ """Generic recipe analysis services."""
2
+
3
+ from .runner import RecipeError, prepare_recipe, run_recipe
4
+
5
+ __all__ = ["RecipeError", "prepare_recipe", "run_recipe"]
@@ -0,0 +1,184 @@
1
+ """Small, side-effect-free expression evaluator for recipe values."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import math
7
+ import operator
8
+ from functools import lru_cache
9
+ from typing import Any
10
+
11
+
12
+ class ExpressionError(ValueError):
13
+ pass
14
+
15
+
16
+ def wrapped_angle(value: float) -> float:
17
+ return math.atan2(math.sin(value), math.cos(value))
18
+
19
+
20
+ _FUNCTIONS = {
21
+ "abs": abs,
22
+ "sqrt": math.sqrt,
23
+ "log": math.log,
24
+ "log10": math.log10,
25
+ "sin": math.sin,
26
+ "cos": math.cos,
27
+ "tan": math.tan,
28
+ "atan2": math.atan2,
29
+ "hypot": math.hypot,
30
+ "wrapped_angle": wrapped_angle,
31
+ "isfinite": math.isfinite,
32
+ "min": min,
33
+ "max": max,
34
+ }
35
+ _BINARY = {
36
+ ast.Add: operator.add,
37
+ ast.Sub: operator.sub,
38
+ ast.Mult: operator.mul,
39
+ ast.Div: operator.truediv,
40
+ ast.Pow: math.pow,
41
+ ast.Mod: operator.mod,
42
+ }
43
+ _COMPARE = {
44
+ ast.Eq: operator.eq,
45
+ ast.NotEq: operator.ne,
46
+ ast.Lt: operator.lt,
47
+ ast.LtE: operator.le,
48
+ ast.Gt: operator.gt,
49
+ ast.GtE: operator.ge,
50
+ ast.Is: operator.is_,
51
+ ast.IsNot: operator.is_not,
52
+ }
53
+
54
+
55
+ @lru_cache(maxsize=1024)
56
+ def compile_expression(source: str) -> ast.Expression:
57
+ try:
58
+ tree = ast.parse(source, mode="eval")
59
+ except SyntaxError as exc:
60
+ raise ExpressionError(f"invalid expression {source!r}: {exc.msg}") from exc
61
+ _check(tree.body)
62
+ return tree
63
+
64
+
65
+ def referenced_names(source: str) -> set[str]:
66
+ """Return field/parameter roots, excluding whitelisted function names."""
67
+
68
+ tree = compile_expression(source)
69
+ names = {node.id for node in ast.walk(tree) if isinstance(node, ast.Name)}
70
+ calls = {
71
+ node.func.id
72
+ for node in ast.walk(tree)
73
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
74
+ }
75
+ return names - calls
76
+
77
+
78
+ def _path(node: ast.AST) -> str | None:
79
+ if isinstance(node, ast.Name):
80
+ return node.id
81
+ if isinstance(node, ast.Attribute):
82
+ parent = _path(node.value)
83
+ return None if parent is None else parent + "." + node.attr
84
+ return None
85
+
86
+
87
+ def _check(node: ast.AST) -> None:
88
+ if isinstance(node, ast.Constant):
89
+ if not isinstance(node.value, (int, float, str, bool, type(None))):
90
+ raise ExpressionError("unsupported literal")
91
+ elif isinstance(node, (ast.Name, ast.Attribute)):
92
+ if _path(node) is None or any(part.startswith("_") for part in _path(node).split(".")):
93
+ raise ExpressionError("private or dynamic field access is forbidden")
94
+ elif isinstance(node, ast.BinOp) and type(node.op) in _BINARY:
95
+ _check(node.left)
96
+ _check(node.right)
97
+ elif isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd, ast.Not)):
98
+ _check(node.operand)
99
+ elif isinstance(node, ast.BoolOp) and isinstance(node.op, (ast.And, ast.Or)):
100
+ for value in node.values:
101
+ _check(value)
102
+ elif isinstance(node, ast.Compare) and all(type(op) in _COMPARE for op in node.ops):
103
+ _check(node.left)
104
+ for item in node.comparators:
105
+ _check(item)
106
+ elif isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
107
+ if node.func.id not in _FUNCTIONS or node.keywords:
108
+ raise ExpressionError("function is not allowed")
109
+ for item in node.args:
110
+ _check(item)
111
+ else:
112
+ raise ExpressionError(f"unsupported expression element: {type(node).__name__}")
113
+
114
+
115
+ def field(context: dict, path: str) -> Any:
116
+ if path in context:
117
+ return context[path]
118
+ parts = path.split(".")
119
+ for start in range(len(parts), 0, -1):
120
+ prefix = ".".join(parts[:start])
121
+ if prefix in context:
122
+ value = context[prefix]
123
+ for part in parts[start:]:
124
+ if not isinstance(value, dict):
125
+ return None
126
+ value = value.get(part)
127
+ return value
128
+ return None
129
+
130
+
131
+ def evaluate(source: str, context: dict) -> Any:
132
+ tree = compile_expression(source)
133
+
134
+ def visit(node: ast.AST) -> Any:
135
+ if isinstance(node, ast.Constant):
136
+ return node.value
137
+ if isinstance(node, (ast.Name, ast.Attribute)):
138
+ return field(context, _path(node))
139
+ if isinstance(node, ast.BinOp):
140
+ left, right = visit(node.left), visit(node.right)
141
+ if left is None or right is None:
142
+ return None
143
+ if not isinstance(left, (int, float)) or not isinstance(right, (int, float)):
144
+ return None
145
+ try:
146
+ value = _BINARY[type(node.op)](left, right)
147
+ except (ArithmeticError, OverflowError, TypeError, ValueError):
148
+ return None
149
+ return value if not isinstance(value, float) or math.isfinite(value) else None
150
+ if isinstance(node, ast.UnaryOp):
151
+ value = visit(node.operand)
152
+ if isinstance(node.op, ast.Not):
153
+ return not bool(value)
154
+ return None if value is None else (-value if isinstance(node.op, ast.USub) else +value)
155
+ if isinstance(node, ast.BoolOp):
156
+ if isinstance(node.op, ast.And):
157
+ return all(bool(visit(item)) for item in node.values)
158
+ return any(bool(visit(item)) for item in node.values)
159
+ if isinstance(node, ast.Compare):
160
+ left = visit(node.left)
161
+ for op, right_node in zip(node.ops, node.comparators, strict=True):
162
+ right = visit(right_node)
163
+ if left is None or right is None:
164
+ if not isinstance(op, (ast.Is, ast.IsNot, ast.Eq, ast.NotEq)):
165
+ return False
166
+ try:
167
+ if not _COMPARE[type(op)](left, right):
168
+ return False
169
+ except TypeError:
170
+ return False
171
+ left = right
172
+ return True
173
+ if isinstance(node, ast.Call):
174
+ args = [visit(item) for item in node.args]
175
+ if any(item is None for item in args):
176
+ return None
177
+ try:
178
+ value = _FUNCTIONS[node.func.id](*args)
179
+ except (ArithmeticError, OverflowError, TypeError, ValueError):
180
+ return None
181
+ return value if not isinstance(value, float) or math.isfinite(value) else None
182
+ raise AssertionError("expression was not checked")
183
+
184
+ return visit(tree.body)
@@ -0,0 +1,166 @@
1
+ """Streaming weighted histograms with event-clustered covariance."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+
11
+
12
+ class HistogramError(ValueError):
13
+ pass
14
+
15
+
16
+ def axis_edges(spec: dict, observed: tuple[float, float] | None = None) -> np.ndarray:
17
+ bins = spec["bins"]
18
+ if "edges" in bins:
19
+ if len(bins["edges"]) > 4097:
20
+ raise HistogramError("axis has more than 4096 bins")
21
+ edges = np.asarray(bins["edges"], dtype=float)
22
+ else:
23
+ if "automatic" in bins:
24
+ if observed is None:
25
+ raise HistogramError("automatic axis requires a bounded discovery pass")
26
+ definition = bins["automatic"]
27
+ low, high = observed
28
+ if low == high:
29
+ low, high = (
30
+ (low / math.sqrt(10), high * math.sqrt(10))
31
+ if low > 0
32
+ else (low - 0.5, high + 0.5)
33
+ )
34
+ else:
35
+ definition = bins
36
+ low, high = float(definition["low"]), float(definition["high"])
37
+ if definition["count"] > 4096:
38
+ raise HistogramError("axis has more than 4096 bins")
39
+ if definition.get("scale", "linear") == "log":
40
+ if low <= 0:
41
+ raise HistogramError("logarithmic axis requires positive limits")
42
+ edges = np.geomspace(low, high, definition["count"] + 1)
43
+ else:
44
+ edges = np.linspace(low, high, definition["count"] + 1)
45
+ if len(edges) < 2 or not np.all(np.isfinite(edges)) or not np.all(np.diff(edges) > 0):
46
+ raise HistogramError("axis edges must be finite and strictly increasing")
47
+ return edges
48
+
49
+
50
+ @dataclass(slots=True)
51
+ class Flow:
52
+ count: int = 0
53
+ sumw: float = 0.0
54
+ sumw2: float = 0.0
55
+
56
+ def add(self, weight: float) -> None:
57
+ self.count += 1
58
+ self.sumw += weight
59
+ self.sumw2 += weight * weight
60
+
61
+
62
+ class Histogram:
63
+ """One reducer keyed by histogram, categories and optional slice."""
64
+
65
+ def __init__(self, axes: list[np.ndarray]) -> None:
66
+ self.axes = axes
67
+ self.shape = tuple(len(axis) - 1 for axis in axes)
68
+ self.size = math.prod(self.shape)
69
+ if not 1 <= self.size <= 256:
70
+ raise HistogramError("histogram must have 1 to 256 cells for covariance")
71
+ self.count = np.zeros(self.size, dtype=np.int64)
72
+ self.sumw = np.zeros(self.size)
73
+ self.sumw2 = np.zeros(self.size)
74
+ self.event_cross = np.zeros((self.size, self.size))
75
+ self.event_total_cross = np.zeros(self.size)
76
+ self.event_total2 = 0.0
77
+ self.events = 0
78
+ self.entries = 0
79
+ self.flows = {name: Flow() for name in ("underflow", "overflow", "invalid")}
80
+
81
+ def _cell(self, values: tuple[Any, ...]) -> int | str:
82
+ if len(values) != len(self.axes):
83
+ return "invalid"
84
+ if any(not isinstance(value, (int, float)) or not math.isfinite(value) for value in values):
85
+ return "invalid"
86
+ indices = []
87
+ for value, edges in zip(values, self.axes, strict=True):
88
+ if value < edges[0]:
89
+ return "underflow"
90
+ if value > edges[-1]:
91
+ return "overflow"
92
+ indices.append(
93
+ min(int(np.searchsorted(edges, value, side="right") - 1), len(edges) - 2)
94
+ )
95
+ return int(np.ravel_multi_index(tuple(indices), self.shape))
96
+
97
+ def add_event(self, entries: list[tuple[tuple[Any, ...], float]]) -> None:
98
+ contribution = np.zeros(self.size)
99
+ for values, weight in entries:
100
+ self.entries += 1
101
+ if not isinstance(weight, (int, float)) or not math.isfinite(weight):
102
+ self.flows["invalid"].count += 1
103
+ continue
104
+ cell = self._cell(values)
105
+ if isinstance(cell, str):
106
+ self.flows[cell].add(float(weight))
107
+ else:
108
+ self.count[cell] += 1
109
+ self.sumw[cell] += weight
110
+ self.sumw2[cell] += weight * weight
111
+ contribution[cell] += weight
112
+ active = np.flatnonzero(contribution)
113
+ if len(active):
114
+ self.event_cross[np.ix_(active, active)] += np.outer(
115
+ contribution[active], contribution[active]
116
+ )
117
+ total = float(contribution.sum())
118
+ self.event_total_cross += contribution * total
119
+ self.event_total2 += total * total
120
+ self.events += 1
121
+
122
+ def result(self) -> dict:
123
+ total = float(self.sumw.sum())
124
+ widths = np.diff(self.axes[0])
125
+ if len(self.axes) == 2:
126
+ widths = np.outer(widths, np.diff(self.axes[1])).reshape(-1)
127
+ if total == 0:
128
+ normalized = density = normalized_error = density_error = None
129
+ covariance = None
130
+ else:
131
+ normalized_values = self.sumw / total
132
+ normalized = normalized_values.tolist()
133
+ density = (normalized_values / widths).tolist()
134
+ if self.events > 1:
135
+ residual = (
136
+ self.event_cross
137
+ - np.outer(self.event_total_cross, normalized_values)
138
+ - np.outer(normalized_values, self.event_total_cross)
139
+ + self.event_total2 * np.outer(normalized_values, normalized_values)
140
+ )
141
+ covariance_array = self.events / (self.events - 1) * residual / total**2
142
+ covariance = covariance_array.tolist()
143
+ normalized_error = np.sqrt(np.maximum(np.diag(covariance_array), 0)).tolist()
144
+ density_error = (np.asarray(normalized_error) / widths).tolist()
145
+ else:
146
+ covariance = normalized_error = density_error = None
147
+ return {
148
+ "edges": [axis.tolist() for axis in self.axes],
149
+ "shape": self.shape,
150
+ "count": self.count.tolist(),
151
+ "sumw": self.sumw.tolist(),
152
+ "sumw2": self.sumw2.tolist(),
153
+ "error": np.sqrt(np.diag(self.event_cross)).tolist(),
154
+ "normalized": normalized,
155
+ "normalized_error": normalized_error,
156
+ "density": density,
157
+ "density_error": density_error,
158
+ "normalized_covariance": covariance,
159
+ "flows": {
160
+ name: {"count": flow.count, "sumw": flow.sumw, "sumw2": flow.sumw2}
161
+ for name, flow in self.flows.items()
162
+ },
163
+ "entries": self.entries,
164
+ "events": self.events,
165
+ "uncertainty": "event-clustered",
166
+ }