expr-tracker 0.2.0__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/PKG-INFO +1 -1
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/docs/design.md +10 -2
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/docs/guide/alerts.md +3 -2
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/docs/reference/expressions.md +38 -3
- expr_tracker-0.2.1/outputs/hello/test/metrics.jsonl +5 -0
- expr_tracker-0.2.1/outputs/hello/test/metrics.meta.json +1 -0
- expr_tracker-0.2.1/outputs/hello/test/summary.json +6 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/alerts/expr/lexer.py +24 -5
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/alerts/expr/nodes.py +20 -2
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_expr_builder.py +2 -1
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_expr_parser.py +16 -7
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_expr_properties.py +7 -3
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_features.py +119 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/.github/workflows/docs.yaml +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/.github/workflows/release.yaml +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/.gitignore +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/README.md +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/docs/architecture.md +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/docs/getting-started.md +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/docs/guide/artifacts.md +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/docs/guide/backends.md +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/docs/guide/cli.md +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/docs/guide/distributed.md +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/docs/guide/history.md +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/docs/guide/logging.md +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/docs/index.md +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/docs/reference/api.md +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/docs/reference/configuration.md +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/mkdocs.yml +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/pyproject.toml +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/__init__.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/_compat.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/alerts/__init__.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/alerts/backends/__init__.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/alerts/backends/base.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/alerts/dispatch.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/alerts/engine.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/alerts/expr/__init__.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/alerts/expr/eval.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/alerts/expr/functions.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/alerts/expr/parser.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/alerts/expr/rule.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/alerts/models.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/artifacts.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/cli.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/encoders.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/history/__init__.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/history/codec.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/history/frame.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/history/reader.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/history/series.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/history/store.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/history/writer.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/run.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/summary.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/tracker.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/types.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/conftest.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_alert_backends.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_alert_delivery.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_alert_dispatch.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_alert_engine.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_alert_models.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_alert_routing.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_artifacts.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_benchmark.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_cache.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_cli.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_correctness.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_distributed.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_e2e.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_expr_eval.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_expr_functions.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_failure_modes.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_frame_codec_summary.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_history.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_hot_paths.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_integration.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_lark_live.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_perf.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_public_surfaces.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_review_regressions.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_rule_lifecycle.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_run_backends.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_scenarios.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_step_commit.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_stress.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_trackio.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_value_encoding.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_wandb.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_writer_buffer.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_writer_durability.py +0 -0
- {expr_tracker-0.2.0 → expr_tracker-0.2.1}/uv.lock +0 -0
|
@@ -199,15 +199,23 @@ unary := ("-"|"+") unary | postfix
|
|
|
199
199
|
postfix := primary ("[" window "]")?
|
|
200
200
|
primary := NUMBER | DURATION | ident | ident "(" args ")" | "(" expr ")"
|
|
201
201
|
window := INT (points) | DURATION (30s / 5m / 2h)
|
|
202
|
-
ident := [A-Za-z_][A-Za-z0-9_
|
|
202
|
+
ident := [A-Za-z_][A-Za-z0-9_.@]* ("/" [A-Za-z0-9_.@]+)* # train/loss, val/m1/acc@16
|
|
203
|
+
| "quoted" | 'quoted' | `quoted` # anything else
|
|
203
204
|
```
|
|
204
205
|
|
|
206
|
+
A `/` joins an identifier only when a name character follows it immediately, so
|
|
207
|
+
division has to be spaced: `a/b` is one metric, `a / b` divides. That makes the
|
|
208
|
+
common case (`train/loss`) free of ceremony at the cost of `loss/2` naming a metric
|
|
209
|
+
rather than halving `loss`. Quoted forms produce names, never string literals — the
|
|
210
|
+
grammar has no strings, and the function table is a closed whitelist.
|
|
211
|
+
|
|
205
212
|
A hand-written lexer and Pratt parser, **not Python's `ast`**: Python reads
|
|
206
213
|
`diff(m1)>50 | m1>5` as `diff(m1) > (50|m1) > 5`. Precedence, low to high:
|
|
207
214
|
`or < and < not < comparison < +- < */% < unary < call/window`.
|
|
208
215
|
|
|
209
216
|
**Metric resolution**: exact name → `.` replaced by `/` → error with close-match
|
|
210
|
-
suggestions. So `eval.acc` finds `eval/acc`;
|
|
217
|
+
suggestions. So `eval.acc` finds `eval/acc`; quote anything the bare grammar cannot
|
|
218
|
+
express.
|
|
211
219
|
|
|
212
220
|
### C.2 Three-valued logic (Kleene)
|
|
213
221
|
|
|
@@ -29,8 +29,9 @@ Key points, covered fully in [Alert expressions](../reference/expressions.md):
|
|
|
29
29
|
`loss[5m]` the last 5 minutes.
|
|
30
30
|
- Precedence is `or < and < not < comparison < arithmetic` — **not** Python's. So
|
|
31
31
|
`diff(m1) > 50 | m1 > 5` parses the way it reads, as `(diff(m1) > 50) or (m1 > 5)`.
|
|
32
|
-
-
|
|
33
|
-
|
|
32
|
+
- Metric names may contain `/` and `@` directly (`train/loss`, `val/m1/acc@16`), so
|
|
33
|
+
division has to be spaced: `a/b` is one metric, `a / b` divides. Quote anything
|
|
34
|
+
else with `"`, `'` or backticks: `"train loss"`.
|
|
34
35
|
- Missing data, too few points, NaN and division by zero all evaluate to UNKNOWN,
|
|
35
36
|
which never fires and never changes state. Warm-up cannot produce false alarms.
|
|
36
37
|
|
|
@@ -30,9 +30,44 @@ Precedence is `or < and < not < comparison < +- < */%`.
|
|
|
30
30
|
|
|
31
31
|
## Metric names
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
Bare names may contain letters, digits, `_`, `.`, `@` and `/`, so the names ML code
|
|
34
|
+
actually uses need no ceremony:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
train/loss > 1
|
|
38
|
+
val/m1/acc@16 < 0.5
|
|
39
|
+
mean(train/loss[20]) > 1
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`.` also resolves to `/`, so `eval.acc` finds `eval/acc` if no metric is literally
|
|
43
|
+
called `eval.acc`.
|
|
44
|
+
|
|
45
|
+
### `/` is part of a name unless it is spaced
|
|
46
|
+
|
|
47
|
+
A `/` joins the name when a name character follows it immediately. Division must
|
|
48
|
+
therefore be written with spaces:
|
|
49
|
+
|
|
50
|
+
| Written | Means |
|
|
51
|
+
| --- | --- |
|
|
52
|
+
| `a/b` | the metric `a/b` |
|
|
53
|
+
| `a / b` | `a` divided by `b` |
|
|
54
|
+
| `a /b`, `a/ b` | `a` divided by `b` (any space makes it division) |
|
|
55
|
+
| `loss/2` | the metric `loss/2` |
|
|
56
|
+
| `loss / 2` | `loss` divided by 2 |
|
|
57
|
+
| `1/2` | division — a name cannot start with a digit |
|
|
58
|
+
|
|
59
|
+
### Quote anything else
|
|
60
|
+
|
|
61
|
+
A name with spaces or other characters must be quoted, with `"`, `'` or `` ` ``:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
"train loss" > 1
|
|
65
|
+
'val acc@16' < 0.5
|
|
66
|
+
`a-b` > 1
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Quotes only ever produce a metric name; the language has no string literals.
|
|
70
|
+
`to_source()` renders with `"` (or another quote if the name contains one).
|
|
36
71
|
|
|
37
72
|
## Windows
|
|
38
73
|
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{"_step": 0, "_time": 1785984521.3747878, "val/loss": 10, "train/loss": 20}
|
|
2
|
+
{"_step": 1, "_time": 1785984578.8073661, "val/loss": 10, "train/loss": 25}
|
|
3
|
+
{"_step": 2, "_time": 1785984751.5897822, "val/loss": 10, "train/loss": 25}
|
|
4
|
+
{"_step": 3, "_time": 1785984760.5739543, "val/loss": 10, "train/loss": 25}
|
|
5
|
+
{"_step": 4, "_time": 1785984823.973783, "train": 30, "loss": 1}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"schema": 2, "size": 369, "lines": 5, "last_step": 4, "max_step": 4, "sorted": true, "has_duplicate_steps": false, "index": [[0, 0, 0]], "run": {"project": "hello", "name": "test", "started_at": 1785984737.2126129}}
|
|
@@ -11,6 +11,7 @@ OP = "OP"
|
|
|
11
11
|
EOF = "EOF"
|
|
12
12
|
|
|
13
13
|
KEYWORDS = {"and", "or", "not", "true", "false"}
|
|
14
|
+
QUOTES = "\"'`"
|
|
14
15
|
TWO_CHAR_OPS = {">=", "<=", "==", "!=", "&&", "||"}
|
|
15
16
|
ONE_CHAR_OPS = set("><+-*/%()[],|&!~")
|
|
16
17
|
DURATION_UNITS = (
|
|
@@ -51,10 +52,10 @@ def tokenize(source: str) -> list[Token]:
|
|
|
51
52
|
i += 1
|
|
52
53
|
continue
|
|
53
54
|
start = i
|
|
54
|
-
if ch
|
|
55
|
-
end = source.find(
|
|
55
|
+
if ch in QUOTES:
|
|
56
|
+
end = source.find(ch, i + 1)
|
|
56
57
|
if end < 0:
|
|
57
|
-
raise ExprSyntaxError("Unterminated
|
|
58
|
+
raise ExprSyntaxError(f"Unterminated {ch}quoted name{ch}", source, i)
|
|
58
59
|
tokens.append(Token(NAME, source[i + 1 : end], start))
|
|
59
60
|
i = end + 1
|
|
60
61
|
continue
|
|
@@ -69,8 +70,7 @@ def tokenize(source: str) -> list[Token]:
|
|
|
69
70
|
tokens.append(Token(NUMBER, float(text), start))
|
|
70
71
|
continue
|
|
71
72
|
if ch.isalpha() or ch == "_":
|
|
72
|
-
|
|
73
|
-
i += 1
|
|
73
|
+
i = scan_name(source, i)
|
|
74
74
|
word = source[start:i]
|
|
75
75
|
tokens.append(
|
|
76
76
|
Token(
|
|
@@ -110,6 +110,25 @@ def parse_duration(text: str) -> float:
|
|
|
110
110
|
) from e
|
|
111
111
|
|
|
112
112
|
|
|
113
|
+
def is_name_char(ch: str) -> bool:
|
|
114
|
+
return ch.isalnum() or ch in "_.@"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def scan_name(source: str, i: int) -> int:
|
|
118
|
+
"""Consume a metric name, which may contain ``/`` as in ``val/m1/acc@16``.
|
|
119
|
+
|
|
120
|
+
A ``/`` only joins the name when a name character follows it immediately, so
|
|
121
|
+
division has to be spaced: ``a / b`` divides, ``a/b`` is one metric.
|
|
122
|
+
"""
|
|
123
|
+
n = len(source)
|
|
124
|
+
while i < n and (
|
|
125
|
+
is_name_char(source[i])
|
|
126
|
+
or (source[i] == "/" and i + 1 < n and is_name_char(source[i + 1]))
|
|
127
|
+
):
|
|
128
|
+
i += 1
|
|
129
|
+
return i
|
|
130
|
+
|
|
131
|
+
|
|
113
132
|
def _scan_number(source: str, i: int) -> int:
|
|
114
133
|
n = len(source)
|
|
115
134
|
while i < n and (source[i].isdigit() or source[i] == "."):
|
|
@@ -113,7 +113,7 @@ class MetricRef(Node):
|
|
|
113
113
|
name: str
|
|
114
114
|
|
|
115
115
|
def to_source(self) -> str:
|
|
116
|
-
return self.name if _is_plain(self.name) else
|
|
116
|
+
return self.name if _is_plain(self.name) else _quote(self.name)
|
|
117
117
|
|
|
118
118
|
def metrics(self) -> set[str]:
|
|
119
119
|
return {self.name}
|
|
@@ -342,9 +342,27 @@ def _wrap(node: Node, parent_op: str | None = None) -> str:
|
|
|
342
342
|
|
|
343
343
|
|
|
344
344
|
def _is_plain(name: str) -> bool:
|
|
345
|
+
"""Whether the name can be written bare and lex back to exactly itself."""
|
|
346
|
+
from .lexer import is_name_char
|
|
347
|
+
|
|
345
348
|
if not name or not (name[0].isalpha() or name[0] == "_"):
|
|
346
349
|
return False
|
|
347
|
-
|
|
350
|
+
for index, ch in enumerate(name):
|
|
351
|
+
if is_name_char(ch):
|
|
352
|
+
continue
|
|
353
|
+
# A `/` only joins the name when a name character follows it
|
|
354
|
+
if ch == "/" and index + 1 < len(name) and is_name_char(name[index + 1]):
|
|
355
|
+
continue
|
|
356
|
+
return False
|
|
357
|
+
return True
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _quote(name: str) -> str:
|
|
361
|
+
"""Wrap a name in whichever quote it does not already contain."""
|
|
362
|
+
for quote in ('"', "'", "`"):
|
|
363
|
+
if quote not in name:
|
|
364
|
+
return f"{quote}{name}{quote}"
|
|
365
|
+
return f'"{name}"' # pragma: no cover - a name with all three quote styles
|
|
348
366
|
|
|
349
367
|
|
|
350
368
|
def _format_duration(seconds: float | None) -> str:
|
|
@@ -31,7 +31,8 @@ def context(**series):
|
|
|
31
31
|
|
|
32
32
|
def test_metric_reference_forms():
|
|
33
33
|
assert M.loss.to_source() == "loss"
|
|
34
|
-
assert M["train/loss"].to_source() == "
|
|
34
|
+
assert M["train/loss"].to_source() == "train/loss"
|
|
35
|
+
assert M["odd name"].to_source() == '"odd name"'
|
|
35
36
|
assert M.train.loss.to_source() == "train.loss" # dotted path becomes one name
|
|
36
37
|
|
|
37
38
|
|
|
@@ -49,10 +49,17 @@ def test_window_selector():
|
|
|
49
49
|
assert parse("mean(loss[5m])").args[0].duration == 300.0
|
|
50
50
|
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
@pytest.mark.parametrize("quote", ["`", '"', "'"])
|
|
53
|
+
def test_quoted_metric_names(quote):
|
|
54
|
+
node = parse(f"{quote}train loss{quote} > 1")
|
|
55
|
+
assert isinstance(node.left, MetricRef) and node.left.name == "train loss"
|
|
56
|
+
assert node.to_source() == '"train loss" > 1' # rendered with the plain quote
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_a_slashed_name_needs_no_quoting():
|
|
60
|
+
node = parse("train/loss > 1")
|
|
54
61
|
assert isinstance(node.left, MetricRef) and node.left.name == "train/loss"
|
|
55
|
-
assert node.to_source() == "
|
|
62
|
+
assert node.to_source() == "train/loss > 1"
|
|
56
63
|
|
|
57
64
|
|
|
58
65
|
def test_dotted_metric_names_are_plain():
|
|
@@ -100,9 +107,11 @@ def test_no_python_injection_surface():
|
|
|
100
107
|
"""The grammar is a closed whitelist: no string literals, attributes or free calls."""
|
|
101
108
|
from expr_tracker.alerts.expr import ExprError, validate
|
|
102
109
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
110
|
+
# Quoted tokens are metric names, never strings, and the function table is a
|
|
111
|
+
# closed whitelist, so nothing here survives validation.
|
|
112
|
+
for source in ["__import__('os')", "open('x')", 'eval("1")', "os.system('ls')"]:
|
|
113
|
+
with pytest.raises((ExprSyntaxError, ExprError)):
|
|
114
|
+
validate(parse(source))
|
|
106
115
|
with pytest.raises(ExprError):
|
|
107
116
|
validate(parse("open(1)"))
|
|
108
117
|
# A dot is just part of a metric name, never attribute access
|
|
@@ -162,7 +171,7 @@ def test_parse_rule_from_builder():
|
|
|
162
171
|
|
|
163
172
|
def test_builder_round_trip():
|
|
164
173
|
node = M["train/loss"][50].zscore() > 4
|
|
165
|
-
assert node.to_source() == "zscore(
|
|
174
|
+
assert node.to_source() == "zscore(train/loss[50]) > 4"
|
|
166
175
|
assert parse(node.to_source()).to_source() == node.to_source()
|
|
167
176
|
|
|
168
177
|
|
|
@@ -76,8 +76,11 @@ EXPRESSIONS = [
|
|
|
76
76
|
"step() > 100",
|
|
77
77
|
# names
|
|
78
78
|
"train.loss > 1",
|
|
79
|
-
"
|
|
80
|
-
"
|
|
79
|
+
'"odd name" > 1',
|
|
80
|
+
"'a-b' + 'c d' > 1",
|
|
81
|
+
"train/loss > 1",
|
|
82
|
+
"val/m1/acc@16 > 2",
|
|
83
|
+
"mean(train/loss[20]) > 1",
|
|
81
84
|
# literals
|
|
82
85
|
"a > 1e-8",
|
|
83
86
|
"a > .5",
|
|
@@ -222,7 +225,8 @@ BUILT = {
|
|
|
222
225
|
"mean(a[5]) > 1": lambda: M.a[5].mean() > 1,
|
|
223
226
|
"mean(a[30s]) > 1": lambda: M.a["30s"].mean() > 1,
|
|
224
227
|
"train.loss > 1": lambda: M.train.loss > 1,
|
|
225
|
-
"
|
|
228
|
+
'"odd name" > 1': lambda: M["odd name"] > 1,
|
|
229
|
+
"train/loss > 1": lambda: M["train/loss"] > 1,
|
|
226
230
|
"diff(a[2]) > 1": lambda: M.a[2].diff() > 1,
|
|
227
231
|
"zscore(a[20]) > 3": lambda: M.a[20].zscore() > 3,
|
|
228
232
|
}
|
|
@@ -415,3 +415,122 @@ def test_a_missing_config_file_is_an_error(tmp_path):
|
|
|
415
415
|
def test_an_unsupported_config_source_is_rejected():
|
|
416
416
|
with pytest.raises(TypeError, match="Unsupported alert config"):
|
|
417
417
|
load_config(12345)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
# ====================================================================== names
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
@pytest.mark.parametrize(
|
|
424
|
+
"name",
|
|
425
|
+
[
|
|
426
|
+
"train/loss",
|
|
427
|
+
"val/m1/acc@16",
|
|
428
|
+
"a/b/c/d",
|
|
429
|
+
"acc@16",
|
|
430
|
+
"loss@ep1/step",
|
|
431
|
+
"train/loss.avg",
|
|
432
|
+
"_private/metric",
|
|
433
|
+
"a1/b2",
|
|
434
|
+
],
|
|
435
|
+
)
|
|
436
|
+
def test_slashed_and_at_names_need_no_quoting(name):
|
|
437
|
+
node = parse(f"{name} > 1")
|
|
438
|
+
assert node.metrics() == {name}
|
|
439
|
+
assert node.to_source() == f"{name} > 1"
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@pytest.mark.parametrize("source", ["a / b", "a /b", "a/ b", "a / b"])
|
|
443
|
+
def test_a_spaced_slash_is_division(source):
|
|
444
|
+
node = parse(f"{source} > 1")
|
|
445
|
+
assert node.metrics() == {"a", "b"}
|
|
446
|
+
assert node.to_source() == "(a / b) > 1"
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def test_an_unspaced_slash_is_one_metric():
|
|
450
|
+
node = parse("a/b > 1")
|
|
451
|
+
assert node.metrics() == {"a/b"}
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def test_dividing_by_a_number_needs_spaces():
|
|
455
|
+
"""`loss/2` is a metric name; the division has to be written `loss / 2`."""
|
|
456
|
+
assert parse("loss/2 > 1").metrics() == {"loss/2"}
|
|
457
|
+
assert parse("loss / 2 > 1").metrics() == {"loss"}
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def test_numbers_still_divide_without_spaces():
|
|
461
|
+
"""A name cannot start with a digit, so `1/2` is unambiguous."""
|
|
462
|
+
from expr_tracker.alerts.expr import EvalContext, evaluate
|
|
463
|
+
from expr_tracker.history import MetricSeries
|
|
464
|
+
|
|
465
|
+
assert evaluate(parse("1/2"), EvalContext(MetricSeries())) == 0.5
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
@pytest.mark.parametrize("quote", ['"', "'", "`"])
|
|
469
|
+
def test_every_quote_style_names_a_metric(quote):
|
|
470
|
+
node = parse(f"{quote}train loss{quote} > 1")
|
|
471
|
+
assert node.metrics() == {"train loss"}
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
@pytest.mark.parametrize(
|
|
475
|
+
"name", ["train loss", "a-b", "a b/c", "100loss", "with spaces", "中文 指标"]
|
|
476
|
+
)
|
|
477
|
+
def test_quoting_carries_names_the_bare_grammar_cannot(name):
|
|
478
|
+
node = parse(f'"{name}" > 1')
|
|
479
|
+
assert node.metrics() == {name}
|
|
480
|
+
assert parse(node.to_source()).metrics() == {name}
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def test_a_name_containing_a_quote_uses_another_one():
|
|
484
|
+
assert parse('"a\'b" > 1').to_source() == '"a\'b" > 1'
|
|
485
|
+
assert parse('`a"b` > 1').to_source() == "'a\"b' > 1"
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
@pytest.mark.parametrize("source", ['"unterminated', "'unterminated", "`unterminated"])
|
|
489
|
+
def test_an_unterminated_quote_is_reported(source):
|
|
490
|
+
with pytest.raises(ExprSyntaxError, match="Unterminated"):
|
|
491
|
+
tokenize(f"{source} > 1")
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
@pytest.mark.parametrize("name", ["a/", "a//b", "/a"])
|
|
495
|
+
def test_names_the_lexer_cannot_read_back_are_quoted(name):
|
|
496
|
+
"""to_source must stay re-parseable, so these keep their quotes."""
|
|
497
|
+
from expr_tracker.alerts.expr.nodes import MetricRef
|
|
498
|
+
|
|
499
|
+
rendered = MetricRef(name).to_source()
|
|
500
|
+
assert rendered != name
|
|
501
|
+
assert parse(rendered).metrics() == {name}
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def test_a_slashed_name_works_in_a_window_and_a_call():
|
|
505
|
+
node = parse("mean(val/m1/acc@16[20]) > 0.5")
|
|
506
|
+
assert node.metrics() == {"val/m1/acc@16"}
|
|
507
|
+
assert node.to_source() == "mean(val/m1/acc@16[20]) > 0.5"
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def test_a_slashed_name_fires_a_real_rule(tmp_path):
|
|
511
|
+
import expr_tracker as et
|
|
512
|
+
|
|
513
|
+
received: list = []
|
|
514
|
+
run = et.init(
|
|
515
|
+
project="names",
|
|
516
|
+
name="r",
|
|
517
|
+
dir=str(tmp_path),
|
|
518
|
+
backends=[],
|
|
519
|
+
max_open_seconds=None,
|
|
520
|
+
alert={
|
|
521
|
+
"channels": [
|
|
522
|
+
{
|
|
523
|
+
"type": "callable",
|
|
524
|
+
"name": "c",
|
|
525
|
+
"options": {"handler": received.append},
|
|
526
|
+
"policy": {"async_send": False, "dedup_window": 0},
|
|
527
|
+
}
|
|
528
|
+
]
|
|
529
|
+
},
|
|
530
|
+
alert_rules=["val/m1/acc@16 < 0.5 => warning: low accuracy"],
|
|
531
|
+
)
|
|
532
|
+
try:
|
|
533
|
+
run.log({"val/m1/acc@16": 0.1})
|
|
534
|
+
assert [m.text for m in received] == ["low accuracy"]
|
|
535
|
+
finally:
|
|
536
|
+
et.finish()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|