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.
Files changed (93) hide show
  1. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/PKG-INFO +1 -1
  2. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/docs/design.md +10 -2
  3. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/docs/guide/alerts.md +3 -2
  4. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/docs/reference/expressions.md +38 -3
  5. expr_tracker-0.2.1/outputs/hello/test/metrics.jsonl +5 -0
  6. expr_tracker-0.2.1/outputs/hello/test/metrics.meta.json +1 -0
  7. expr_tracker-0.2.1/outputs/hello/test/summary.json +6 -0
  8. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/alerts/expr/lexer.py +24 -5
  9. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/alerts/expr/nodes.py +20 -2
  10. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_expr_builder.py +2 -1
  11. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_expr_parser.py +16 -7
  12. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_expr_properties.py +7 -3
  13. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_features.py +119 -0
  14. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/.github/workflows/docs.yaml +0 -0
  15. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/.github/workflows/release.yaml +0 -0
  16. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/.gitignore +0 -0
  17. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/README.md +0 -0
  18. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/docs/architecture.md +0 -0
  19. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/docs/getting-started.md +0 -0
  20. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/docs/guide/artifacts.md +0 -0
  21. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/docs/guide/backends.md +0 -0
  22. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/docs/guide/cli.md +0 -0
  23. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/docs/guide/distributed.md +0 -0
  24. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/docs/guide/history.md +0 -0
  25. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/docs/guide/logging.md +0 -0
  26. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/docs/index.md +0 -0
  27. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/docs/reference/api.md +0 -0
  28. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/docs/reference/configuration.md +0 -0
  29. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/mkdocs.yml +0 -0
  30. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/pyproject.toml +0 -0
  31. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/__init__.py +0 -0
  32. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/_compat.py +0 -0
  33. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/alerts/__init__.py +0 -0
  34. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/alerts/backends/__init__.py +0 -0
  35. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/alerts/backends/base.py +0 -0
  36. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/alerts/dispatch.py +0 -0
  37. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/alerts/engine.py +0 -0
  38. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/alerts/expr/__init__.py +0 -0
  39. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/alerts/expr/eval.py +0 -0
  40. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/alerts/expr/functions.py +0 -0
  41. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/alerts/expr/parser.py +0 -0
  42. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/alerts/expr/rule.py +0 -0
  43. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/alerts/models.py +0 -0
  44. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/artifacts.py +0 -0
  45. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/cli.py +0 -0
  46. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/encoders.py +0 -0
  47. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/history/__init__.py +0 -0
  48. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/history/codec.py +0 -0
  49. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/history/frame.py +0 -0
  50. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/history/reader.py +0 -0
  51. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/history/series.py +0 -0
  52. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/history/store.py +0 -0
  53. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/history/writer.py +0 -0
  54. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/run.py +0 -0
  55. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/summary.py +0 -0
  56. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/tracker.py +0 -0
  57. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/src/expr_tracker/types.py +0 -0
  58. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/conftest.py +0 -0
  59. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_alert_backends.py +0 -0
  60. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_alert_delivery.py +0 -0
  61. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_alert_dispatch.py +0 -0
  62. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_alert_engine.py +0 -0
  63. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_alert_models.py +0 -0
  64. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_alert_routing.py +0 -0
  65. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_artifacts.py +0 -0
  66. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_benchmark.py +0 -0
  67. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_cache.py +0 -0
  68. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_cli.py +0 -0
  69. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_correctness.py +0 -0
  70. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_distributed.py +0 -0
  71. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_e2e.py +0 -0
  72. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_expr_eval.py +0 -0
  73. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_expr_functions.py +0 -0
  74. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_failure_modes.py +0 -0
  75. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_frame_codec_summary.py +0 -0
  76. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_history.py +0 -0
  77. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_hot_paths.py +0 -0
  78. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_integration.py +0 -0
  79. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_lark_live.py +0 -0
  80. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_perf.py +0 -0
  81. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_public_surfaces.py +0 -0
  82. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_review_regressions.py +0 -0
  83. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_rule_lifecycle.py +0 -0
  84. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_run_backends.py +0 -0
  85. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_scenarios.py +0 -0
  86. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_step_commit.py +0 -0
  87. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_stress.py +0 -0
  88. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_trackio.py +0 -0
  89. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_value_encoding.py +0 -0
  90. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_wandb.py +0 -0
  91. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_writer_buffer.py +0 -0
  92. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/tests/test_writer_durability.py +0 -0
  93. {expr_tracker-0.2.0 → expr_tracker-0.2.1}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: expr_tracker
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Add your description here
5
5
  Author-email: HSPK <whxway@whu.edu.cn>
6
6
  Requires-Python: >=3.10
@@ -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_.]* | `anything`
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`; use backticks for exotic names.
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
- - `.` in a metric name resolves to `/`, so `eval.acc` finds `eval/acc`. Use backticks
33
- for anything exotic: `` `a-b c` ``.
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
- - Bare names: `loss`, `grad_norm`.
34
- - `.` resolves to `/`: `eval.acc` finds the metric `eval/acc`.
35
- - Backticks for anything else: `` `a-b c` ``, `` `中文指标` ``.
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}}
@@ -0,0 +1,6 @@
1
+ {
2
+ "val/loss": 10,
3
+ "train/loss": 25,
4
+ "train": 30,
5
+ "loss": 1
6
+ }
@@ -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("`", i + 1)
55
+ if ch in QUOTES:
56
+ end = source.find(ch, i + 1)
56
57
  if end < 0:
57
- raise ExprSyntaxError("Unterminated `identifier`", source, i)
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
- while i < n and (source[i].isalnum() or source[i] in "_."):
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 f"`{self.name}`"
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
- return all(ch.isalnum() or ch in "_." for ch in name)
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() == "`train/loss`"
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
- def test_backtick_metric_names():
53
- node = parse("`train/loss` > 1")
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() == "`train/loss` > 1"
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
- for source in ["__import__('os')", "open('x')", 'eval("1")']:
104
- with pytest.raises(ExprSyntaxError):
105
- parse(source)
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(`train/loss`[50]) > 4"
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
- "`odd name` > 1",
80
- "`a-b` + `c d` > 1",
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
- "`odd name` > 1": lambda: M["odd name"] > 1,
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